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
SkeewirtandBrues f30efb29b1 feat(gossip): add GetNumGossipAvailableQuests and GetNumGossipActiveQuests (#95)
* feat(gossip): add GetNumGossipAvailableQuests and GetNumGossipActiveQuests

The 2.0+ globals count the quests GetGossipAvailableQuests /
GetGossipActiveQuests list. Vanilla sizes those lists with two engine
counters (0x004E2430 / 0x004E2580: walk the gossip quest array, split
by status 3|4), so push those counters and the counts always match the
lists. Zygor Guides Viewer's auto-accept/turn-in loops over them.

Not verified in-game yet beyond both functions existing. The testkit
compares the counts with the lists (two values per quest, as 1.12's
GossipFrame reads them) the next time a gossip window with quests opens.

* docs(gossip): move GetNumGossip*Quests to the end of the section

* docs(gossip): reword GetNumGossip*Quests without version or impl notes

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-22 19:40:11 -05:00
SkeewirtandBrues 19b6232e43 feat(addon): backport the Interface Options panel for addon categories (#85)
* feat(addon): backport the Interface Options panel for addon categories

Later clients list addon option panels in InterfaceOptionsFrame; addons
register them with InterfaceOptions_AddCategory (Ace3's AddToBlizOptions
does it for every Ace3 addon) and open them with
InterfaceOptionsFrame_OpenToCategory. Vanilla has neither, so Ace3 addons
fail while initializing - Zygor Guides Viewer 2.0's OnInitialize stopped
there, before its waypoint arrow and taxi data were set up.

FrameXML/InterfaceOptions.lua/.xml in the bundled addon: the frame with
its background as first region, the AddOns category list (buttons /
buttonHeight / update, a FauxScrollFrame named
InterfaceOptionsFrameAddOnsList), an empty Categories list, the panel
container, Okay / Cancel / Defaults, InterfaceOptionsListButtonTemplate,
and the later-client panel protocol (name, parent nesting with collapse,
okay / cancel / default / refresh). Names match later clients because
addons and libraries use them directly. Vanilla's own options stay where
they are.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
Zygor's AceConfigDialog-3.0 panels register under AddOns and open, and
their pages were used throughout testing (with the SetParent level and
scroll-clip fixes, sent separately). Not checked specifically: Okay,
Cancel and Defaults calling each panel's handlers, and collapsing a
category with children.

* feat(addon): open Interface Options from the game menu, Escape cancels

The backported InterfaceOptionsFrame had no way in for the player and
closed on Escape without calling any panel's cancel. Wired the way
3.3.5 does it, with one code path for Stock and Turtle:

- AddOn Options button in GameMenuFrame. It takes over Key Bindings'
  anchor (Interface Options on Stock, Options on Turtle) and Key
  Bindings moves under it, so pfUI's top-of-menu buttons are untouched.
  Not "AddOns": later clients use that for the addon list. The window
  title shares the new ADDON_OPTIONS string.
- Native center panel via UIPanelWindows + Show/HideUIPanel instead of
  UISpecialFrames; closing returns to lastFrame (the game menu), like
  Turtle's own OptionsFrame.
- Escape clicks InterfaceOptionsFrameCancel after static popups, the
  3.3.5 ToggleGameMenu order.
- Shown panel kept in InterfaceOptionsFramePanelContainer.displayedPanel,
  where addons read it.

Verified in-game on Turtle with pfUI: button placement and menu sizing.

* feat(addon): add a ClassicAPI page to AddOn Options

Registers ClassicAPI in the backported InterfaceOptionsFrame with
Controls, Combat and Text To Speech pages under it:

- ClassicAPI: player cache (C_PlayerCache.SetEnabled / SetScanEnabled;
  scan is greyed out while the cache is off).
- Controls: Enable Interact Key, Interact Key Icons, Auto Stand, Auto
  Dismount, Auto Cancel Form.
- Combat: Enable Action Targeting, Enable Friendly Action Targeting,
  Cast action keybinds on key down, Stop Auto Attack.
- Text To Speech: voice, rate, volume.

Where retail's Settings has the option, the label, tooltip and value
mapping are copied from Blizzard_SettingsDefinitions_Frame (soft
targeting toggles 3 = Any / 1 = Gamepad; Interact Key Icons drives the
same four cvars as retail's three presets). The 18 retail strings are
added to every locale from Blizzard's own translations. Player cache,
cast settings and friendly soft targeting have no retail labels, so
those strings are ClassicAPI's own (English).

Changes apply at once; Cancel / Escape restores each page to what it
held when first shown, Defaults resets the shown page via
GetCVarDefault.

Not yet verified in-game.

* docs(readme): drop the InterfaceOptions row from the API table

The table lists engine API; the InterfaceOptions functions are FrameXML.
They stay documented in docs/API.md.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-22 19:20:45 -05:00
Brues bbac6b9cac fix(cvar): match SetCVar and honor staged cvars in SetTempCVar
A value lua_tostring cannot convert now becomes "0", and the read-only
error says "read-only", both as Script_SetCVar does. Staged cvars (gx*,
Sound*) park a set at +0x38 and dirty the config unconditionally, so the
temp value reached Config.wtf; the tracked value and the config-writer
swap now cover the staged slot too.
2026-10-22 18:10:07 -05:00
Brues e48e4e2361 fix(spell): land only the reticle our own cast opened
CastAtCursor, UseAtCursor and UseAtUnit could commit a reticle the
player had armed by hand: the cast dispatcher returns nothing and an
attempt the engine refuses early leaves the old placement up. Placing
calls now cancel any open placement first (0x6e4900, the engine's own
new-cast cancel), and item calls commit only when CGItem::Use reports
the spell began.

The commit now honors 0x6e60f0's result: placed means bound AND no
target left, since the engine sends the cast only when the targeting
word empties. A spell still wanting a target is cancelled.

UseAtUnit gains placeGroundSpell, returns true for a unit-target item
used on the unit, and resolves the token once, matching CastAtUnit.

The shared placement and dispatch code moves to Spell::Placement; item
use goes through one Item::Use::UseItem; one macro extractor tags both
spellIDs and quoted names for CastAtCursor, CastAtUnit and
CastSpellNoToggle. UseAtCursor and UseAtUnit get doc descriptors.
2026-10-22 17:59:11 -05:00
Brues 09cd9040f7 fix(item): open wrapped gifts in IsItemOpenable
The tooltip's <Right Click to Open> test has a second arm we missed:
template flag 0x200 (wrapper) with the instance's wrapped bit 0x08.
Unused wrapping paper carries 0x200 too, so the arm needs the bit.

Also corrects the Offsets.h claim that descriptor bit 0x08 is
"broken" -- GetInventoryItemBroken uses durability and only excludes
wrapped items with that bit.
2026-10-22 17:29:16 -05:00
Brues 8f79084c57 feat(item): add IsEquippableItem and resolve item names
Port the rest of 3.3.5's Script_IsEquippableItem. The global
IsEquippableItem returns 1/nil, and C_Item.IsEquippableItem keeps its bool.
Both share one check (any non-zero inventory type), and both now resolve a
carried item's name through Item::Location::IDForName, like 3.3.5's
GetItemIDByName. Before, a name always read false.

Add doc descriptors for both names and correct the header, which said there
was no name lookup and that inventory types end at 23.
2026-10-22 17:02:53 -05:00
Brues 597b16cb74 fix(item): port 3.3.5's IsConsumableItem
The old test was "item class 0, or ammo/thrown", a rule no client uses.
Explosives and quest items that are used up (Rough Dynamite, Thorium
Grenade) read false, and class-0 items that are not used up read true.
3.3.5's Script_IsConsumableItem, 4.3.4's (minus ammo) and this binary's own
ActionSlotIsConsumable (0x004E5250) all walk the on-use spells instead. An
item is consumable when it is ammo or thrown, or when an on-use spell has
negative charges or costs reagents. Port that.

The global IsConsumableItem now returns 1/nil as in 3.3.5, and
C_Item.IsConsumableItem returns a bool. Both resolve a carried item's name,
like 3.3.5's GetItemIDByName. Move that lookup from item/Count into
Item::Location::IDForName so GetItemCount and both functions share it.

Retail's IsConsumableItem is currently broken (false even for potions), so
3.3.5 is the reference.
2026-10-22 16:56:48 -05:00
Brues 60ef54b4a6 refactor(cvar): register cvars the way the engine registers its own
CVar::Factory::Register now takes a help line and a console category. The
registrar's EDX slot is the help line, not a spare zero, and category 9 (what
Script_RegisterCVar passes) matches none of the nine console categories. So
our cvars had no help and were listed under no category in `help <category>`.
Every backported cvar now passes retail's exact help string and category
(from Ketho's C_Console.GetAllCommands dump). The bag-sort and TTS cvars have
no retail counterpart and get no help line.

GetInt and the new GetFloat read the integer and float the setter stores on
every change, as the engine's own readers do. They no longer re-parse the
string. SoftTarget's atof copy and Nearest's raw read are gone. Info, Bool,
Set and Bitfield now go through the Factory (Find, GetString, the new
IsReadOnly) instead of their own lookups and field reads.

The read-only bit on scriptMemory is no longer a mystery. FUN_0063E030 sets
it on realmList, realmName and scriptMemory from CGGameUI::InitializeGame,
before the interface loads, and ShutdownGame clears it. So
CVar::ScriptMemory's in-game hook could never write. Drop it and keep the
glue one.

Also correct the Offsets.h comments: FUN_SET_CVAR_VALUE is __thiscall, and
the registrar's callback contract is now documented.
2026-10-22 16:27:11 -05:00
Brues 0e8b002af9 fix(action): let a spell replace a macro that casts it
PlaceAction compared the cursor spell against the slot's resolved spell
without checking the slot type, so dropping Dispel Magic on a
"/cast Dispel Magic" macro was taken for a duplicate and only cleared the
cursor. Gate the compare on the slot holding a spell action, as 4.3.4's
CGActionBar::PutActionInSlot does with IsSpell.
2026-10-22 15:58:31 -05:00
Brues 8bc43b455c fix(unit): trace UnitInLineOfSight like the server's LoS check
The server traces one segment with each end at its own unit's collision
height. The UnitXP_SP3-derived level-look pass traced both ends at the
shorter unit's height first and returned "in sight" when that cleared, so
segments slipping under overhangs read clear while casts still failed with
SPELL_FAILED_LINE_OF_SIGHT. Keep only the server's trace.

Also correct the docs: the 0x100111 class word already tests doodads and
game objects.
2026-10-22 15:43:50 -05:00
Brues 923cf943e3 fix(tracking): match 3.3.5 GetTrackingInfo and SetTracking
SetTracking() with no number now cancels the active tracking aura, as
3.3.5 does, through the same CMSG_CANCEL_AURA path as vanilla's
CancelTrackingBuff. The engine's aura rebuild then clears the tracking
word and fires PLAYER_AURAS_CHANGED.

GetTrackingInfo returns active as 1/nil instead of a boolean, and the
active tracker shows its activeIconID when the spell has one.
2026-10-22 15:32:05 -05:00
Brues 3a06590add feat(addons): add C_AddOns.GetAddOnInterfaceVersion
Reads the engine's parsed ## Interface: value (record+0x1C, the field
CanLoadAddOn compares against the client version). TocRewrite now
collapses a comma-list to retail's pick, the closest value at or below
the client version, so the stored value matches retail; loadability is
unchanged since the pick is 11200 iff 11200 is listed.
2026-10-22 15:24:03 -05:00
Brues c1086189f7 fix(loot): match 5.4.8 C_LootHistory and the engine's roll decode
Roll packets now decode as WriteRollLine does: a greed vote was read
as a pass, so greed voters on an item a need voter won stayed passed.
A START_ROLL always opens a new entry and later packets match the
newest one, so a respawned mob or chest no longer merges into its old,
finished roll. The winner's roll and type come from SMSG_LOOT_ROLL_WON
too, and the START_ROLL hook returns the original's result.

GetItem returns 5.4.8's isMasterLoot and isCurrency (both false) in
place of the timestamp, which 5.4.8's LootHistory UI read as master
loot. GetPlayerInfo returns a nil roll when no dice were rolled.
2026-10-22 15:13:32 -05:00
Brues e827e319be fix(unit): match 4.3.4 UnitIsInMyGuild resolution order
Try the engine's by-name search first (party then loaded players, exact,
mode 3, as 4.3.4's ClosestObjectMatch call) and decide a loaded hit on
the guild key, so a nearby guildmate's name works before GuildRoster().
Tokens resolve to a name through the NameCache and go to the roster,
which now compares names ignoring case (SStrCmpI).
2026-10-22 14:58:09 -05:00
Brues d3667797b5 fix(input): gate L/R modifiers on the engine's held-key list
The per-side modifier state was a parallel tracker that could disagree
with IsShiftKeyDown & co.: it stayed down while WoW was in the
background, and MODIFIER_STATE_CHANGED fired before the engine had
seen the key.

Reads are now the side bits gated by EventIsKeyDown for the merged
key. The event fires from handlers on the engine's key-down, key-up,
key-repeat and activation events, after the dispatch has updated the
held-key list, and that pass drops side bits the engine no longer
holds. The GetAsyncKeyState resync and its WH_CALLWNDPROC hook are
gone.
2026-10-22 10:05:22 -05:00
Brues e1ae53488b feat(fontstring): add GetFontHeight and SetFontHeight
FontString and Font get retail's pair. The getters push the height
through the same engine calls as GetFont, so they equal its second
value. The setters call the engine's own SetFont with the current file
and flags. Also map the Font method registry to SimpleFontAPI so Font
methods show in the API browser.
2026-10-22 09:01:31 -05:00
Brues b1a58f0d08 fix(luaenv): match 5.1 setfenv and getfenv
setfenv now returns the function it changed and no longer refuses an
environment marked with __environment; the marker only hides the env from
getfenv, as in 4.3.4/3.3.5 lbaselib. getfenv reads only the metatable
__environment field; the stock 5.0 raw __fenv fallback is removed.
2026-10-22 08:38:48 -05:00
Brues a61ff06e9a feat(unit): add UnitSelectionColor and UnitSelectionType
4.3.4's selection colour is one ladder over a GUID with an extended flag:
the unit's virtual calls it with 1, Script_UnitSelectionColor with 0.
Retail exposes the flag as useExtendedColors and names each rung
(UnitSelectionType 0 Hostile .. 9 Dead). 1.12 inlines the extended
ladder into CGUnit_C::GetSelectionHighlightColor and has no Lua surface.

Both globals walk that ladder rung for rung with the engine's own
predicates (CanAttack, IsPVPUI, CGPartyInfo::IsMember, UnitReaction,
health) plus the friend rung, so the extended colour is the one the
circle and the name draw. A GUID with no unit object takes 4.3.4's
no-object branch, with the PvP bit from the group stats block that
Script_UnitIsPVP reads.

FriendNameColor.cpp becomes SelectionColor.cpp so the engine-side
friend patches and the Lua ladder share one colour table.
2026-10-22 08:12:31 -05:00
Brues 3d2ac4d944 fix(friendlist): gate afk/dnd on connected, scan ignore holes
A logoff (SMSG_FRIEND_STATUS result 3) runs SetFriendStatus (0x5AEB30),
which clears only the online byte and leaves the AFK/DND status byte,
so an offline friend kept reporting afk/dnd = true. Gate both on
`connected`, as the engine's GetFriendInfo does for its status text.

Removing an ignore (0x5AF650) zeroes its slot and re-sorts only when no
ignore name lookup is pending, so the GUID array can have holes. Our
walks stopped at the first zero and missed later ignores. IsGuidIgnored
now calls the engine's FriendList::IsIgnored (0x5AE5A0), which scans
all 25 slots, and the name path skips zero slots instead of stopping.

Cleanup: IsFriend routes GUID input through the engine's IsGuidFriend
(names resolve via GuidForName) and drops the speculative name fallback;
Who.cpp reads ticks through Time::Clock.
2026-10-22 03:53:26 -05:00
Brues 167815f27b feat(unit): color friends' names light blue
4.3.4's selection colour checks the friends list after the party for a
player-controlled unit neither side can attack, and gives it #53C8FF.
1.12's CGUnit_C::GetSelectionHighlightColor has the same ladder without
that rung, so its two not-in-party exits now return the friend colour
for a friend. The name above the head and the selection circle both
take it. FRIENDLIST_UPDATE marks every name record's colour dirty
through the engine's own mark-all, so a change shows at once.

AutoPrepare's 6-byte call patcher moves to Common::ReplaceWithCall,
which takes any instruction length.
2026-10-22 03:31:24 -05:00
Brues 80af00f0c7 fix(softtarget): keep the soft target steady through the whole frame
After a few minutes in game the soft-target icons flickered: an addon's
UnitBuff, polling every ~250 ms, names each aura caster's unit token, which
reads softenemy / softfriend / softinteract. That read ran after the world
tick advanced the frame counter but before the scene drew, when the paint
has reset every node's cull state to culled, so IsEntityDrawn saw nothing
and the empty pick was cached for the frame (probe: every empty pick on
frame = drain frame + 1, stack UnitBuff -> Aura::Data::Push ->
Unit::Identity::TokenFromGUID).

- Take "drawn" from the drain's pass-1 list itself: the cull walk clears
  +0x94 exactly as it moves an entity to the draw list the drain calls with
  pass 1, so the list is the engine's verdict kept until the next drain.
- Key the cached pick on the drain count, not the world frame counter, so
  a read anywhere in the frame gets the same answer.
- InView (the locked target) checks the GUID against that list.
- Drop VAR_WORLD_FRAME_COUNTER, now unused.
2026-10-22 03:18:51 -05:00
Brues 15e1e21c8c fix(frame): read RegisterUnitEvent membership from the frame's event list
RegisterUnitEvent resolved the event-table entry before delegating to
Script_RegisterEvent, but the RegisterEvent hook runs
Event::Custom::RetryClaims inside that call. A custom event claimed there
was registered with its unit filter dropped and returned false, and a
Grow left the saved entry pointer dangling.

Membership now comes from the script object's own registered-event list
(+0x18 count / +0x1C indices), the list Frame::UnregisterEvent walks: a
longer list means a fresh registration and its last index names the
event. This drops every event-table scan and chain walk from
RegisterUnitEvent and IsEventRegistered.

Also check self before the event argument (engine order) and remove the
unreachable non-frame delegate, and test the free %s spec before reading
_G.event on the OnEvent gate.
2026-10-22 03:10:15 -05:00
Brues 8318daa74a fix(tooltip): match SetHyperlinkCompareItem to the 4.3.4 contract
Return 1 or nil instead of a slot count. 0 is truthy, so callers that
test the result (our GameTooltip_ShowCompareItem, retail FrameXML) showed
empty or stale shopping tooltips.

Show stat deltas only when comparisonTooltip is passed, and take the
compared item from that tooltip's live CGItem first. shiftButton no
longer gates deltas (in 4.3.4 it only affects the merchant offset-3 case).

Pick the equipped item with 4.3.4 SetCompareItem's rules: multi-slot
types count filled slots, weapons follow the main/off-hand rules, and an
off-hand item falls back to an equipped two-hander. Slot masks are read
from the engine's own table (0x809200) instead of a hand-written switch.

Require itemLink, add the blank spacer before the delta header, and wrap
the header line.
2026-10-22 02:45:46 -05:00
Brues fc4a983644 fix(nameplate): pass the NAME_PLATE_CREATED frame as a real argument
The frame went out by setting _G.arg1 around an empty-format fire, so a
modern function(self, event, namePlate) handler got nil. Fire it through
Event::Custom::FireValues instead: the argument runner sets arg1 and
passes the frame positionally.
2026-10-22 02:20:06 -05:00
Brues cd5d38d921 perf(lossofcontrol): find control-loss debuffs in one aura walk
The per-frame list asked FindNthSlot for the 1st, 2nd, ... harmful aura,
and each call rescanned all 48 slots. Walk the harmful order once with
the same filter instead: the same slots in UnitDebuff's order, one pass
instead of up to n x 48 checks. Entries and events are unchanged.
2026-10-22 02:11:40 -05:00
Brues 51b8a47d7a perf(nameplate): diff plates with flat arrays
The per-frame diff rebuilt an unordered_map every frame, one node
allocation per visible plate, and looked plates up in three hash
containers. The engine shows a few dozen plates at most, so flat
arrays searched linearly do the same work and allocate nothing once
warm.

Events, slot numbering and the reload behavior are unchanged; plates
that appear in the same frame now fire NAME_PLATE_UNIT_ADDED in the
engine's plate order instead of hash order.
2026-10-22 02:08:49 -05:00
Brues 8801b8ce35 perf(timer): skip C_Timer frames with nothing due
The world tick walked every pending timer and allocated a vector each
frame while any timer existed, due or not, and swept finished timers
with one erase each.

- Track the earliest deadline of a live timer; a frame before it reads
  the clock and returns. Scheduling lowers it, the sweep recomputes it.
- Reuse one vector for the IDs due in a frame.
- Sweep in one compacting pass.

Callback order and timing are unchanged.
2026-10-22 02:08:48 -05:00
Brues 128186288a perf(softtarget): pick from the scene's drawn entities, once a frame
The soft enemy, friend and interact picks scanned every live object
through ClntObjMgrEnumObjects (bag items included, ~870 a scan in a
city) and ran again for every reader in a frame: the circles, the
icons and each Lua token read.

- Pick at most once per side per frame, keyed on the engine's world
  frame counter s_frameCounter (0xC7B2C8), the counter its scene code
  keys per-frame work on. A target change or a soft-target cvar change
  (a cvar change callback) repicks at once.
- Take the candidates from the scene: hook DrawEntityModels (0x683DD0)
  and its per-entity callback WorldObjectRenderCallback (0x481540) to
  collect the pass-1 (in sight) entities, and publish the list when the
  drain returns, so a scan never reads one still filling. Probe in
  Stormwind: ~200 pass-1 and ~360 pass-2 entities a frame, no pass-2
  entity ever drawn, and no drawn object missing from the list.
- Keep each entity's object from AddPickCandidate (0x4815D0) and use it
  while it is certain to be live: until the next incoming packet (the
  destroy message and pool reuse only happen while one is handled)
  and under the same object manager. Outside that window the GUID is
  looked up as before.

Stormwind, all three soft targets and icons on: UI-frame subscriber
time from ~101 to ~27 us a frame, one pick from ~30-38 to ~8-12 us.
2026-10-22 02:08:48 -05:00
Brues b46b0cd503 fix(timing): match the engine's spell, aura and enchant timing math
- C_Item.GetWeaponEnchantInfo / GetItemTempEnchantInfo read the item's
  expiry clock (0x5D9D00) like the stock getter, so time left counts
  down; an enchant is present whenever its id is set.
- GetSpellInfo / C_Spell.GetSpellInfo report the effective cost and cast
  time through the engine helpers, divide by the power display divisor
  (rage in whole points), report per-second cost as isFunnel, and give
  passives a cast time of 0, as 3.3.5 and 4.3.4 do.
- Aura base duration follows the server (abs(base), -1 infinite, no
  per-level term); duration and expirationTime are reported together or
  as 0, 0, matching 4.3.4's PushUnitAura.
- C_Spell.GetSpellCooldown reads the pet's history for a pet-book spell.
- Route the remaining raw tick compares in Aura::Source and Spell::Cast
  through Time::Clock.
- Merge duplicate SpellDuration offsets; name the engine helpers' pet
  parameter consistently.
2026-10-22 00:48:10 -05:00
Brues 1e81acf277 fix(totem): match 3.3.5's totem slot behavior
Track each slot's totem creature by GUID the way 3.3.5's
CGGameUI::m_totems does, instead of rescanning the object manager by
entry every time.

- TargetTotem calls CGGameUI::Target on the stored GUID.
- A slot empties when its creature stops resolving (3.3.5's
  CGUnit_C::Disable -> DestroyTotem); the duration is only a fallback
  for a creature that was never seen. Leaving the world empties every
  slot; a UI reload keeps them.
- GetTotemInfo: haveTotem is true while a totem is up, the name is the
  creature's name (nil until seen, PLAYER_TOTEM_UPDATE on arrival),
  start/duration are whole seconds (3.3.5 divides as integers), and the
  empty icon is "". modRate and spellID stay as returns 6 and 7.
- GetTotemTimeLeft and GetTotemDuration return whole seconds.
- GetTotemInfo, GetTotemTimeLeft, GetTotemDuration and TargetTotem
  raise "Totem slot must be in range of 1 to 4".
- GameTooltip:SetTotem shows the creature name and always adds the
  time line.
2026-10-21 23:58:32 -05:00
Brues a0ee633397 fix(equipment): fire UPDATE_INVENTORY_DURABILITY on every alerts recompute
4.3.4 fires the event unconditionally at the end of
CGCharacterInfo::UpdateInventoryAlerts. Vanilla's recompute (0x004C7EE0)
has the same seven callers, including the engine's own type-wide
ITEM_FIELD_DURABILITY watcher (0x005D97C0), so firing after the original
is enough. Remove the per-slot {guid, durability} diff and its seed
latch: they hid the login fire and missed bag-item durability changes.

Correct the Offsets.h notes. FUN_005D9A90 is the enchantment refresh,
not a handler for every item update, and item-field watchers do work
through the type-wide registrar 0x00468070. Drop the unused
DESC_OBSERVER_BANK_ITEM / OFF_DESC_ITEM_DURABILITY constants.
2026-10-21 23:39:37 -05:00
Brues 792cd4c62b docs(readme): list StopSound and PlaySoundFile under Sound 2026-10-21 23:25:49 -05:00
Brues bdd9e8faad fix(item): port GetItemCount to the engine's inventory search
C_Item.GetItemCount now follows 3.3.5's Script_GetItemCount on 1.12's
own CGBag_C::FindItem ancestors (0x622130 stack count, 0x622180 charge
count, 0x6223F0 walk), which share 3.3.5's search-flag bits.

- Counts equipped bags and the keyring, skips broken items, and reads
  the bank through flag 0x08 without an open bank window.
- includeUses uses 3.3.5's gate (non-stackable, negative base charges)
  and its reagent-uses override.
- A name resolves to the ID of the first carried match, as 3.3.5's
  s_itemByName does.
- Flags are read with the engine's StringToBOOL, so "0" is false.

Item::Count::InInventory uses the same engine count, so IsUsableItem,
the totem tool check and GetSpellCastCount now count what the engine's
reagent and totem gate (0x6E4000) counts. GetSpellCastCount no longer
stops at the first empty reagent slot.
2026-10-21 23:21:06 -05:00
Brues b39e95bc66 fix(addons): match stock arg handling and stack check in GetAddOnOptionalDependencies
Mirror stock GetAddOnDependencies (0x0048E5E0, identical to 4.3.4's
GetAddOnOptionalDependencies): lua_isnumber first so numeric strings are
indices, the engine's own range error with the addon count for an
out-of-range index, and lua_checkstack before pushing the names.
2026-10-21 22:59:44 -05:00
Brues e83307d4c5 docs(readme): move cvars into their own CVars section 2026-10-21 22:49:44 -05:00
Brues 628b1c347e refactor(equipment): fire PLAYER_EQUIPMENT_CHANGED from the engine's slot observer
The engine already watches the equipment GUID fields: AddActiveWatchers
(0x5DD8A0) registers OnInventorySlotChanged (0x5DDCF0) on inventory
slots 0..22. That handler is the ancestor of 4.3.4's InventoryUpdate,
which fires PLAYER_EQUIPMENT_CHANGED right after unlocking the arriving
item. Co-hook it post-original instead of registering 19 of our own
observers from the FUN_004F8CC0 setup.

The engine now owns the node's registration and lifetime, so the
unregister-then-register guard against /reload stacking goes away, and
ITEM_LOCK_CHANGED fires before our event, as in 4.3.4. Like 4.3.4, no
event fires when the player GUID does not resolve.
2026-10-21 22:43:53 -05:00
Brues ed8f0ba851 fix(target): soft enemy and friend arcs default to the camera's front
SoftTargetEnemyArc / SoftTargetFriendArc defaulted to 2 (any direction),
so softenemy / softfriend was just the nearest unit. Default both to 0,
the interact arc's: the unit in front of the camera.
2026-10-21 22:20:48 -05:00
Brues 15a36827cd feat(target): selection circle under softenemy and softfriend
Retail draws a dimmer, smaller selection circle under the soft enemy and
the soft friend. It follows SoftTargetEnemy / SoftTargetFriend and the
engine's ObjectSelectionCircle; a soft unit that is also the target keeps
the engine's own circle.

The circle is drawn at the base per-frame step 0x614A90, where the engine
draws the target's (so it lands before the unit's blob shadow), by a
mirror of the unit's slot 12 (0x608E00) with the radius scaled 0.85 and
the colour's alpha byte at 0x80. Both are from retail screenshots of one
unit as softfriend and as target at the same zoom: width 64 vs 75 px,
brightness over the grass +116 vs +207 (additive blend).

The soft units are picked on the UI frame tick, after the scene: the scan
takes only objects the scene drew, so a pick before it found none.
2026-10-21 22:14:19 -05:00
Brues 52e17d05c1 feat(item): per-school spell damage, penetration and statTable in GetItemStats
Single-school MOD_DAMAGE_DONE equip spells now land on per-school keys
(ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT, ...) instead of reading as
generic spell damage; an all-magic mask stays on
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT. Damage and healing stay split, as the
server sums them from separate auras (13 by school, 135 alone).

Newly decoded equip auras:
- 123 MOD_TARGET_RESISTANCE, split by mask: armor bit ->
  ITEM_MOD_ARMOR_PENETRATION_RATING, magic bits ->
  ITEM_MOD_SPELL_PENETRATION (both positive)
- 158 MOD_SHIELD_BLOCKVALUE -> ITEM_MOD_BLOCK_VALUE
- 161 MOD_HEALTH_REGEN_IN_COMBAT -> ITEM_MOD_HEALTH_REGEN

GetItemStats / GetItemStatDelta take the engine's optional statTable
(filled in place, not wiped), return nothing on a cache miss, and the
delta is now item1 - item2, matching the 3.3.5 and 4.3.4 binaries.

Labels for the nine new keys in all seven locales, plus English
fallbacks for the compare tooltip.
2026-10-21 21:44:16 -05:00
Brues 00bad65103 fix(sortbags): gate bank on the banker, wait on the engine's item lock
- SortBankBags with the bank closed planned moves for the main bank,
  whose GUIDs sit in the player's invMgr from login, and the server
  refused each one. ViewForBag now hides the bank unless a banker is
  open, the same check as the engine's slot reader 0x6228a0.
- Phase two waited for the first BAG_UPDATE_DELAYED. If the server
  refused every merge, none came: later sorts were ignored and the
  placement ran at the next unrelated bag change. Both phases now lock
  the items they relocate and wait on the world tick until the engine
  clears those locks (slot change, refusal, or merge destroying the
  source). A 3 s deadline that restarts on each answer releases any
  stragglers.
- Phase one sized transfers for uncached items as 0 - count, a uint32
  underflow that poured every stack into the first. Uncached items are
  now skipped.
- Locked items (cursor, trade, in-flight move) are pinned in place
  instead of being moved and having the server refuse the move.
- The autosort exclusions passed a stack-local bag list that the
  deferred placement read after it was freed. The active list is now
  owned.
- A login or /reload resets an in-flight sort, so it can't resume on
  the next character's bags.

Also add doc descriptors for all eight C_Container registrations,
report locksReleased from _classicapi_SortBagsStats, rewrite the stale
comments, and update docs/API.md.
2026-10-21 21:12:28 -05:00
Brues 8e800502e1 fix(swap): release a held item before sending a swap
FUN_INVENTORY_SWAP is the drag-drop sender: after sending it clears the
cursor with returnItem 0, which drops a held item but keeps it locked
until the server answers for it. Our swaps never move the held item, so
no answer came and it stayed greyed out and unpickable. This hit every
Item::Swap caller (C_Container.SwapItems, equipment sets, whole-stack
MoveItem, SortBags). All three call sites now go through SendSwapPacket,
which first releases a held item through FUN_CURSOR_CLEAR(1, 1), the
form ClearCursor() uses.
2026-10-21 21:12:28 -05:00
Brues da7be7a50a fix(luasyntax): leave the engine-skipped # first line alone
luaX_setinput skips a first line starting with `#` (0x006FF5CD). The
transpiler read that `#` as the length operator and put its preamble in
front of the line, so the engine no longer skipped it. SkippedHead now
covers the BOM plus that line; only the rest is transpiled, and the
head is put back ahead of the preamble, which starts line 2.

Also drop the 0x1B bytecode pass-through: this client never undumps
(lua_load always takes the parser), so it could never matter.
2026-10-21 20:43:56 -05:00
Brues 9ba66e147c perf(table): read borders through luaH_getnum directly
Each slot probe in Table::Border was lua_rawgeti + lua_type + lua_settop,
three API calls per slot, on every transpiled `#t` and every luaL_getn
heal (table.insert / getn / remove / unpack / `...`). Resolve the Table*
once with luaA_indexAcceptable and probe with the engine's own raw
integer read, luaH_getnum, which is what lua_rawgeti resolves through.

Find now mirrors 5.1's luaH_getn: a nil in the last array slot bisects
inside the array part, otherwise unbound search from the array size.
Holey constructors now match real 5.1 (`#{1,nil,3}` is 3, was 1).
The getn heal's answers are unchanged. 100k `#t` on a 1000-entry table:
0.011s -> 0.004s.
2026-10-21 20:37:19 -05:00
Brues 7614a50aa4 fix(config): register launch switches with the engine parser
1.12's switch parser stops at the first switch it does not know, so any
engine switch written after -config, -gluescript or -gamescript (such as
-console) was ignored. Register ours in the engine's switch list before it
parses, as 4.3.4 does with -config, and read -config and the script file
paths back through the engine. Inline script code still comes from the raw
command line, which now skips quoted text.
2026-10-21 20:13:40 -05:00
Brues 53af083339 build: embed the addon and bindings as RCDATA resources
The embed step hex-encoded every file in a CMake while loop at build
time (7.6 s, a 2.4 MB generated header). Configure now writes an .rc
that points rc.exe at the files directly, plus a path-to-ID header.
Embed::Resource reads the bytes from the DLL image at runtime.

The .toc version stamp moves to a configure-time copy. Editing the toc
reconfigures, and editing any other file recompiles only the .rc.
2026-10-21 15:56:34 -05:00
Brues 718e5a31fe refactor(addons): drop dead embed code and duplicate offsets
- Stop writing DefaultState on the embedded entry: the entry
  constructor (0x5205b9) already stores 1, and the enable-resolver hook
  forces it on.
- Ask the original exists check for the dev marker instead of reading
  and freeing the whole file.
- Remove FUN_STORM_SMEMALLOC/SMEMFREE, which duplicated
  FUN_STORM_SMEM_ALLOC/FREE; Event::Custom uses the main names.
2026-10-21 15:45:44 -05:00
Brues 4554c3a5f5 fix(addons): register the embedded addon before the scan
Pre-hook the add-on scan (0x51c760) instead of post-hooking its caller.
The registry is empty there, so !!!ClassicAPI is the first record and
loads first without a list move, and the scan's LoadWith pass sees it.

The exists check now answers for embedded files the same way the read
hook serves them. Embedded owns the one FUN_FILE_EXISTS hook and chains
FlavorBindings::TryExists.
2026-10-21 15:20:10 -05:00
Brues 7240688008 feat(bindings): try every modifier subset and add GetBindingByKey
A key press now looks up its binding the way 4.3.4's
GetReducedKeyBinding does: every subset of the modifiers in its fixed
order (ALT-SHIFT-Q tries ALT-SHIFT-Q, SHIFT-Q, ALT-Q, Q), an override
before the saved binding for each. 1.12 tried only the pressed key and
the key minus its first modifier.

A binding to a command that does not exist is skipped, as 4.3.4's
GetKeyBinding does; the command-table lookup mirrors the walk the
engine inlines in FUN_BINDING_COMMAND_EXECUTE.

GetBindingByKey(key) returns the command a press of the key runs.
2026-10-21 14:52:52 -05:00
Brues f8e242639b feat(cvar): add C_CVar.SetCVar
Sets a cvar and returns true, or false for a read-only cvar. An unknown
name returns nothing. A missing value sets "0".
2026-10-21 14:38:39 -05:00
Brues 81c4aedb06 feat(target): show icons over the soft enemy and soft friend
SoftTargetIconEnemy and SoftTargetIconFriend draw the hover cursor image
over softenemy and softfriend, beside the soft interact icon. One unit
gets one icon. SoftTargetLowPriorityIcons lets the plain pointer count as
an icon image.

SetUnitCursorTexture now honors includeLowPriority (defaulting to
SoftTargetLowPriorityIcons) and clears the texture when it returns false,
as retail does.
2026-10-21 14:23:12 -05:00
Brues f08e360f10 feat(target): cast at the soft target and add loose targets
With no target, a harmful spell goes to softenemy and a helpful spell to
softfriend, through the engine's own range check, as in retail. A spell
that only the caster can receive still goes to the caster.

A harmful spell that goes off on the soft enemy makes it a loose target.
SoftTargetForce moves a loose target to each new soft enemy (1) or soft
friend (2), and clears it when none is left. It never changes a chosen
target and never selects one from nothing. Add IsTargetLoose().

SoftTargetMatchLocked and SoftTargetWithLocked now count only a chosen
target, and the attack's own selects keep a loose target loose.
2026-10-21 13:52:49 -05:00
Brues 8d1402dc83 feat(target): attack the soft enemy with no target
With no target, AttackTarget, the Attack Target key, /startattack and
StartAttack swing at softenemy, and the target stays empty, as in retail.
With no soft enemy, 1.12's own behavior stays: the nearest enemy becomes
the target. The engine's state checks and errors are unchanged.

Add SoftTargetWithLocked: 0 gives no soft enemy and no soft friend while
there is a target.
2026-10-21 12:46:00 -05:00
Brues 81eaf24748 feat(target): add the soft enemy and soft friend
Add the softenemy, softfriend, anyenemy and anyfriend unit tokens, the
PLAYER_SOFT_ENEMY_CHANGED and PLAYER_SOFT_FRIEND_CHANGED events, and the
SoftTargetEnemy*, SoftTargetFriend* and SoftTargetMatchLocked cvars, with
Classic Era's defaults. anyleftinteract is the same as anyinteract.

The soft-target scan moves to Target::SoftTarget, shared by interact,
enemy and friend. Its arc now follows the camera, not the character.
2026-10-21 02:15:10 -05:00
Brues bac67038ea feat(target): add SoftTargetIconGameObject
A game object's soft-interact icon now also needs SoftTargetIconGameObject,
as in retail. It defaults to 0, the value of retail's Default option.
2026-10-21 00:59:48 -05:00
Brues c8880444f4 docs(readme): list soft interact in the modern client behaviors 2026-10-21 00:51:06 -05:00
Brues 3e4db9279e perf(debugtools): stop the event trace churning memory
The trace kept 1000 entries under an index that only grows, so each of
its 22 column tables rehashed and reallocated every few events. Entries
now go into a ring of fixed slots.

An event no longer redraws the list (OnUpdate redraws it once per frame
while shown), packs its arguments into a table, or formats its time and
marker text; that text is built when a row is drawn. /etrace clear now
rebuilds all 19 argument columns.
2026-10-21 00:26:02 -05:00
brues-code 62cabb2acb Add the combat log: COMBAT_LOG_EVENT, filters and kept entries (#108)
* feat(combatlog): add COMBAT_LOG_EVENT and CombatLogGetCurrentEventInfo

Port 4.3.4's combat log events (UnitCombatLog_C.cpp) in its argument
layout: timestamp, subevent, hideCaster, source and dest GUID, name,
flags and raid flags, then the spell prefix and the subevent suffix.

- Swing, range, spell and periodic damage, misses, heals, energize,
  drain and leech, damage shields, split damage and environmental
  damage, read from the combat log messages through
  Net::PacketDispatch.
- Spell misses from the one function every miss goes through, so a
  missile's miss is sent when it arrives.
- Object flags and raid flags from the fields 4.3.4 reads. Names follow
  the 1.12 combat log namer's steps.
- CombatLogGetCurrentEventInfo returns the values only while the events
  are being sent, as on Classic Era.
- Event::Custom::FireValues sends an event with Lua values, so booleans
  and nils land where 4.3.4 puts them.
- COMBATLOG_OBJECT_* constants in the addon.

Overkill, overhealing and healing absorbs are 0: the server does not
send them.

* feat(combatlog): add casts, auras, dispels, interrupts and deaths

Port 4.3.4's layer 2 combat log subevents: SPELL_CAST_START/SUCCESS/
FAILED, SPELL_AURA_APPLIED/REMOVED and their _DOSE forms, SPELL_DISPEL,
SPELL_DISPEL_FAILED, SPELL_INTERRUPT, SPELL_EXTRA_ATTACKS, SPELL_DRAIN,
SPELL_LEECH, SPELL_RESURRECT, SPELL_DURABILITY_DAMAGE(_ALL),
SPELL_INSTAKILL, UNIT_DIED, UNIT_DESTROYED and PARTY_KILL, plus proc
resists as SPELL_MISSED.

Casts come from SPELL_START / SPELL_GO and the cast-fail log line, effects
from SPELLLOGEXECUTE as the 1.12 client reads it, auras from the engine's
aura handlers through a new Aura::Source change observer that carries the
bound caster, and deaths from the death presentation.

* feat(combatlog): add filters, kept entries and CombatLog_Object_IsA

Port 4.3.4's UnitCombatLog_C_Scripts surface: CombatLogAddFilter /
ResetFilter, the kept-entry functions (GetNumEntries, SetCurrentEntry,
GetCurrentEntry, AdvanceEntry, ClearEntries), the retention time with its
combatLogRetentionTime CVar, and CombatLog_Object_IsA. COMBAT_LOG_EVENT now
fires only for entries that match the filters. Entries and filters clear on
the return to the login screen. Adds the COMBATLOG_FILTER_* masks from
4.3.4's Constants.lua.

* docs(combatlog): move the combat log events into the Events section

* fix(combatlog): bound the execute-log loop and the retention time

- Stop an execute log's target loop at an effect that carries nothing,
  so a huge target count cannot spin.
- Clamp the retention time to what the wrap-safe age test can measure.
- Bound GetCurrentEntry's index by the entry count.
- List ENCHANT_APPLIED and ENCHANT_REMOVED among the subevents that do
  not fire, and note why effect 59 casts log SPELL_CAST_SUCCESS.
2026-10-20 23:01:29 -05:00
Brues 81e1396f81 perf(target): pick the soft-interact target on demand
The pick no longer runs every frame. Reading softinteract or anyinteract
picks fresh, which covers the Interact Key and UnitGUID. The UI frame
tick re-picks only while a frame is registered for
PLAYER_SOFT_INTERACT_CHANGED, and the icon reads the token only while
SoftTargetIconInteract is on. With neither, nothing runs per frame.

SoftTargetInteract now defaults to 3, the value "Enable Interact Key"
writes, since the key does nothing until bound. SoftTargetIconInteract
defaults to 0, so the icon is opted into.
2026-10-20 19:27:38 -05:00
Brues cf5e9c8ee7 feat(target): add SetUnitCursorTexture and the soft-interact icon
SetUnitCursorTexture(texture, unit) sets a texture to the cursor image
the mouse shows over the unit, the Unable form when out of reach. It
takes the engine's own image: the cursor name table, or a game object's
record-named cursor through FormatGameObjectCursorFile.

The softinteract object now shows that image over it
(SoftTargetIconInteract, default 1; SoftTargetNameplateSize, default 19,
is its size). A unit's icon sits where 1.12 seats a nameplate, from
GetNamePlateScreenPos; an object's uses GetNamePosition's no-attachment
rule (1.25 x scale x box height) with the same 2/3-yard lift. The icon
is a texture on the world frame, anchored to its BOTTOMLEFT the way
SeatNamePlate anchors a plate, and hides off screen.
2026-10-20 19:09:19 -05:00
Brues 622478df04 feat(target): add the soft-interact target and Interact Key
Backports the Interact Key: the SoftTargetInteract, SoftTargetInteractArc,
SoftTargetInteractRange and SoftTargetInteractRangeIsHard cvars (Classic
Era defaults, so it is off until set), the softinteract and anyinteract
unit tokens, PLAYER_SOFT_INTERACT_CHANGED, and
C_PlayerInteractionManager.InteractUnit. INTERACTTARGET and
INTERACTMOUSEOVER now use Blizzard's bodies, which interact with
anyinteract.

The pick is the closest object the hover cursor would show an interact
cursor for: the unit and corpse cursor classifiers run with
SetCursorMode captured (Cursor::Hover), and game objects use GetCursor.
It also needs the tab-target collector's drawn test, the arc (arc 1 is
the collector's pi/6 front cone, arc 0 a 1.2-yard strip measured in
Classic Era) and the range capped by targetNearestDistance. It runs on
the UI frame tick, as on the world tick every world node reads as
culled. anyinteract is the target when it can be interacted with, else
softinteract, as probed on retail.

UnitTokenFromGUID now returns softinteract, last, in retail's order.
2026-10-20 19:09:13 -05:00
Brues 362669b071 feat(combat): add stopAutoAttackOnTargetChange
Backports 4.3.4's setting. At 1, a target change stops Auto Shot and
wand shots, and melee no longer moves to the new target. At 0, the
default, 1.12 behavior is unchanged.

Two call redirects in the target setter, at the point where 4.3.4's
CGGameUI::Target runs the test: the PLAYER_TARGET_CHANGED fire also
stops auto-repeat, and the melee restart on the new target is skipped.
2026-10-20 17:02:49 -05:00
Brues 929b6f5c60 feat(spell): add autoStand, autoDismount and autoUnshift
Backports 4.3.4's cast settings. With one set to 1, a cast that 1.12
refuses because the player sits, is mounted, or is in a form that the
spell excludes now stands, dismounts or leaves the form, then casts.

Mirrors 4.3.4's split: the caster check's mount, stand and form rungs
pass when the action is allowed and note it, and the cast request runs
the noted actions once every rung has passed. A form refusal for "only
in form" is never handled, and the usability check honors autoUnshift
so the button stays lit, as Spell_C_IsUsableAction does.

All three default to 0, so 1.12 behavior is unchanged until set.
CancelShapeshiftForm now shares the aura-cancel walk with the dismount.
2026-10-20 17:02:42 -05:00
Brues 3751b31cda fix(spell): read dual wield from the known-spell bitmap
The engine's CGPlayer_C::CanDualWield (0x005EAB70) reads a global that
the learn path sets and only the unlearn of the same spell clears.
Nothing clears it at logout, so after a character that knows Dual
Wield, every later character in the session reads true.

CanDualWield and GetAverageItemLevel now ask whether this character
knows spell 674, the only SPELL_EFFECT_DUAL_WIELD spell in Spell.dbc,
through a new Spell::Known module. Spell::Known::IsKnown replaces the
three copies of the known-spell bitmap read in spell/Info.cpp,
spell/Usable.cpp and aura/Source.cpp.
2026-10-20 16:25:19 -05:00
Brues 20c4312251 fix(item): port GetAverageItemLevel from 4.3.4
Rewrite GetAverageItemLevel as a port of 4.3.4's
CGCharacterInfo::CalculateAverageItemLevel:

- count only items the player can use (CGPlayer_C::CanUseItem)
- keep the best item level per inventory type, plus the second best
  ring, trinket and one-hand weapon
- take the better of the one-hand loadout over 17 slots and the
  two-hand loadout over 16, using the engine's own dual-wield check
  (FUN_PLAYER_CAN_DUAL_WIELD) to pick the off hand
- count what is inside containers, not the containers themselves

While item records load, return the last complete answer, and fire
PLAYER_AVG_ITEM_LEVEL_READY when they arrive. Item::Data gains a
LoadSubscriber for load completions, and WarmCache reports whether it
queued the item. Player::CanUseItem::Check is shared through a header.
2026-10-20 16:12:40 -05:00
Brues 7e6bfd70ad docs(readme): split the Frame and Item reference rows
The Frame row had grown to 124 calls across nine widget types in one
cell. Split it into one row per widget (Region, Frame, Button,
FontString, Texture, EditBox, StatusBar, Slider, Cooldown,
ScrollingMessageFrame, scripts), group related calls, pair Set/Get
methods, and link each row to its first heading in docs/API.md.

Split the Item row into C_Item, item info, inventory and item-ID rows,
and list the registered calls it was missing: C_Item.GetItemData,
C_Item.GetItemDataByID, C_Item.IsUsableItem, IsUsableItem and
IsConsumableItem.
2026-10-20 15:35:20 -05:00
Brues c1ac5109e5 feat(texture): add IsDesaturateSupported
Returns 1 when the engine's stock Desaturate shader is loaded and valid,
as 3.3.5's Script_IsDesaturateSupported does, through the same
FUN_00770620(1) gate that Script_SetDesaturated uses.
2026-10-20 15:29:02 -05:00
Brues 0b7076d330 feat(frame): add GetCurrentKeyBoardFocus, SetMaxAnimFramerate and GetMaxAnimFramerate
GetCurrentKeyBoardFocus returns the focused edit box, or nil.
SetMaxAnimFramerate / GetMaxAnimFramerate set and read the animation
step rate (default 30 per second), which was a fixed constant before.
Both mirror 4.3.4's SimpleFrameScript_Scripts.cpp. GetText moves to the
not-worth-it list.
2026-10-20 15:16:42 -05:00
Brues 1d3ca3db6e feat(statusbar): add reverse fill, rotated texture, fill styles, color fill and desaturation
StatusBar gains SetReverseFill / GetReverseFill, SetRotatesTexture /
GetRotatesTexture, SetFillStyle / GetFillStyle (with
Enum.StatusBarFillStyle), SetColorFill, and SetStatusBarDesaturated /
IsStatusBarDesaturated / SetStatusBarDesaturation /
GetStatusBarDesaturation.

1.12 draws the fill by cropping the bar texture from its bottom-left
corner. A bar with a mode on takes 4.3.4's layout instead: its
OnFrameUpdate co-hook sets the coordinates and moves the texture's four
points, with the crop flag off. A bar with every mode off runs 1.12's
own update. SetColorFill creates a bar texture the way
SetStatusBarTexture does when the bar has none, then fills it through
the engine's Texture:SetTexture handler. The desaturation methods use
the bar texture's Texture::Desaturation state, now exposed in
texture/Desaturation.h.
2026-10-20 15:06:57 -05:00
Brues f112a13695 feat(frame): add the OnMinMaxChanged script to Slider and StatusBar
SetMinMaxValues runs it with (min, max) when the range changes, before
the value is applied to the new range, as 4.3.4 does. The ExtraScripts
resolver now skips a script of the same name on another class instead
of stopping, so both classes can have one.
2026-10-20 14:31:06 -05:00
Brues 986f849b65 feat(slider): add Enable, Disable and IsEnabled
Enable and Disable set or clear frame flag 0x400 and run OnEnable or
OnDisable on every call, as 4.3.4 does. A co-hook on the slider's
mouse-down skips the drag while the flag is set and runs only the base
frame's mouse-down, so OnMouseDown still fires.
2026-10-20 14:11:53 -05:00
Brues 863cb61e8d feat(button): add RegisterForMouse
Sets which mouse buttons run a button's OnMouseDown and OnMouseUp
scripts. The engine fires both for every button through the script
runner, where the ClickEvents gate now skips unregistered ones, so drags
and clicks are unchanged. OnMouseUp also needs its down registered.
2026-10-20 13:54:56 -05:00
Brues 28bc2b3ee2 feat(button): add SetEnabled
Enables or disables the button through its Enable vmethod, the slot the
stock Enable and Disable methods call, so CheckButton's override and the
OnEnable / OnDisable scripts run the same way.
2026-10-20 13:42:11 -05:00
Brues f6ba0ff9a1 docs: mark XML KeyValues done, list KeyValues, mixin and method 2026-10-20 13:42:11 -05:00
Brues 1e2a36d630 fix(bindings): stop logging ModifiedClick nodes as unknown
The engine's Bindings.xml loader tests each root tag inline and logs
"Unknown node type" for anything but <Binding>, so every <ModifiedClick>
added a FrameXML.log line even though the actions load from the same
file. The loader now runs with a status that drops exactly that warning
for ModifiedClick nodes and forwards every other message.
2026-10-20 07:47:48 -05:00
Brues 2cab0dcb12 feat(xml): support mixin= and method= on XML elements
mixin="A, B" on frames, textures, font strings and animation groups
copies each global table onto the element's Lua table, as Mixin() does.
The outermost load of an object applies the whole inherits chain once,
templates first, before the engine loads any node, so KeyValues and
scripts build on the mixins instead of being overwritten by them. A
missing mixin logs a load warning.

Script elements take method="Name", binding self[Name] as the handler,
on frames, animation groups and animations.
2026-10-20 07:43:04 -05:00
Brues a2e3eed0de feat(xml): support <KeyValues> on XML elements
A <KeyValue key= value= type= keyType=/> entry sets object[key] = value
on frames, textures, font strings, animation groups and animations.
Types are nil, boolean, number, string and global, as in the later
clients' schema. Template entries apply first so an element's own
entries win, and all are set before OnLoad. Bad entries log a load
warning and are skipped.

C_XMLUtil.GetTemplateInfo now returns the template's own entries in
keyValues instead of an empty table.
2026-10-20 07:35:53 -05:00
Brues 9b01cca336 refactor(input): drive the global mouse API from the engine's event bus
GLOBAL_MOUSE_DOWN/UP came from a Win32 WH_GETMESSAGE hook with its own
held-button mask, which stayed "held" after an alt-tab and sent no
release when the window lost capture. Register two handlers on the
engine's mouse-down and mouse-up events with EventRegisterEx instead, at
a priority above the interface and below the console and the
loading-screen vetoes, and read IsMouseButtonDown from the engine's own
held-button mask.

Share the engine's button-name lookup as
Frame::ClickEvents::ButtonNameForMask. IsMouseButtonDown(nil) now means
any button, as documented. Add documentation descriptors and rewrite
the stale API.md sections.
2026-10-20 07:22:45 -05:00
Brues 1384160fb0 refactor(events): document quest and bag events, use engine names
Add documentation descriptors for QUEST_ACCEPTED, QUEST_REMOVED,
QUEST_TURNED_IN and BAG_UPDATE_DELAYED.

Quest::TurnedIn now reads its payload with the shared Net::Read and
restores the cursor itself, so FUN_PACKET_READ_UINT32 and
OFF_PACKET_STREAM_CURSOR go. It stays a hook on the engine handler so
the event still fires after the completion lines.

Rename the BAG_UPDATE emitter hooks to the engine's names
(RaiseBagEvents, RaiseBagUpdate, OnBackpackSlotChanged) and correct
their comments: RaiseBagUpdate takes a container GUID, and the
backpack watcher has the standard field-watcher ABI.
2026-10-20 07:14:03 -05:00
Brues 116a3b352c fix(item): stop flagging the half of a split stack as new
A split gives the split-off half a new item GUID, so the seen-set read it
as a fresh acquisition. Record the source stack when CMSG_SPLIT_ITEM is
sent and let the next unseen bag item with the same item ID claim it. The
record retires once the source stack shrinks or the server refuses the
move.

Also add documentation descriptors for C_NewItems and
BAG_NEW_ITEMS_UPDATED, and rewrite the API.md section without
implementation details.
2026-10-20 07:02:06 -05:00
Brues 56871fdebf feat(sound): mute every sound, add StopSound, and map channels
MuteSoundFile hooked Sound::Play2D, which only interface sounds and
files reach. Positional sounds (Play3D: creature voices, spell impacts,
server sounds) and loops (PlayLooped: ambience, emitters) open through
other routes, so they could not be muted and never reached
GetRecentSoundFiles. The hooks now sit on Sound::Play (0x007A5290,
behind both Play2D and Play3D) and Sound::PlayLooped (0x007A54D0).

StopSound(handle [, fadeoutTime]) calls Sound::Stop2 (0x007A5A10). It
stops only handles our play functions returned, because releasing a
record the engine holds in a slot would free it under that slot.

PlaySoundFile makes the engine's own Play2D(3, file) call, adds the
channel's category flag, and returns willPlay, soundHandle.

The channel argument now picks the volume category by name, as 4.3.4's
_PickScriptSoundBus does. This replaces the numeric bus, which exposed
the engine's per-bus concurrency caps. forceNoDuplicates sets the sound
definition's own duplicate bit for the call. SoundKit plays now respect
the interface-sound suppression count, as PlaySound(name) does.
2026-10-20 06:45:02 -05:00
Brues f96e46113e fix(combat): fire cast swing resets only while auto-attacking
The server resets both melee timers on every non-triggered cast with
SPELL_INTERRUPT_FLAG_AUTOATTACK, attacking or not, so a buff such as
Power Word: Fortitude fired PLAYER_SWING(MainHand) and swing bars showed
a swing that never came. The reset is still recorded, but the event now
fires only while CGUnit_C::HasAttackTarget (0x0060ECB0, the engine's own
attacking test) is set. An attack that starts while such a reset is still
running reports the remaining main-hand time.
2026-10-20 06:27:35 -05:00
Brues 0ee594b9e1 fix(frame): match 4.3.4's IsMouseOver edges and self errors
IsMouseOver now treats a cursor exactly on an edge as outside, as
CScriptRegion::IsMouseOver does with strict comparisons. IsMouseOver and
IsDragging raise the engine's "this" errors for a bad self, as
FrameScript_GetObjectThis does, in place of returning false.
2026-10-20 06:17:50 -05:00
Brues 943597ca0d feat(texture): load masks from XML and release mask textures
- <Texture mask="path"> applies texture:SetMask(path).
- <MaskTexture> in a <Layer> builds a MaskTexture, and its
  <MaskedTextures> entries (childKey or target) attach it to textures.
  xml/Layers.cpp replaces the engine's layer loader with the same loop
  plus a MaskTexture branch, so the engine no longer warns on the tag.
- SetMask closes the mask texture's handle on replace, clear, region
  reuse and reload, as SetTexture does with the texture it held.
- SetMask keeps its previous mask when the file does not load, using
  SetTexture's own status test, instead of masking with the opaque
  fallback texture.
- API.md documents the XML forms and that a mask with an opaque edge
  does not clip past its rectangle (the device has no border address
  mode).
2026-10-20 06:08:38 -05:00
Brues a446e4cf8c refactor(item): answer CanUseItem and IsUsableItem with the engine's checks
C_PlayerInfo.CanUseItem now calls CGPlayer_C::CanUseItem (0x5EA930),
the check 1.12 uses for every "can use" answer it gives Lua, instead of
rebuilding its level, mask, proficiency, skill, spell, PvP and
reputation tests by hand.

IsUsableItem / C_Item.IsUsableItem now follow the item leg of 1.12's
action-bar usable check (0x4E5050), which matches 4.3.4's IsUsableItem:
the item must be carried, its on-use spell must not be held, an item
with no on-use spell is usable while the player has control, and the
engine's spell test decides the rest. Level, class and race are no
longer checked here, as in both engines.
2026-10-20 05:48:15 -05:00
Brues a53a08be95 fix(item): resend item queries the server dropped behind a loading screen
The server drops CMSG_ITEM_QUERY_SINGLE while the player is not in the
world, and the item cache sends its query only when it creates the
pending node, so a query sent before login finished or during a transfer
left that item nil for the rest of the session. The settle gate was a
one-way latch, so every loading screen after the first could lose
queries.

Queries now go out only in the world, after a settle delay per entry.
Queries in flight across a transition, or unanswered for 5 s, are sent
again with Cache::Query::Send, which builds the packet as the cache's
GetRecord does. Timeouts are wall-clock instead of frame counts.
2026-10-20 05:34:23 -05:00
Brues 0668c37156 feat(text): add the |4 plural code to the grammar pass
1.12's grammar pass is 4.3.4's LanguageProcess with rules 1 and 2 only,
so |4singular:plural; reached the screen as written. The pass is now
4.3.4's loop: |1 and |2 still run 1.12's own functions, and |4 is
4.3.4's LanguageRule4 with its locale plural index. It applies wherever
1.12 runs the pass: SetText, button text, tooltip lines, chat lines,
chat bubbles and name plates.

A code is matched by its single digit, which is all a rule consumes, so
|01 no longer loops forever.

PushLocalizedFormatInt drops its gsub expansion; AddLine now expands |4.
2026-10-19 22:39:21 -05:00
Brues a37b18bdfd feat(button): add SetFormattedText, shared with the font string's
Frame::FormattedText formats with the engine's str_format under pcall and
hands the result to the type's own SetText, as 4.3.4 does:
CSimpleButton::SetText (0x778DC0) for buttons, which creates a label when
there is none, and the font string's SetText for font strings.

The font string's version moves here from FontString::Metrics and now
matches Lua SetText and 4.3.4: it passes expand = 1, so the |1 / |2
grammar escapes are rewritten, and raises "Font not set" on a string
with no font.
2026-10-19 22:12:29 -05:00
Brues 3d41c8a702 feat(button): add SetMotionScriptsWhileDisabled and GetMotionScriptsWhileDisabled
A disabled button with the flag set runs its OnEnter and OnLeave scripts,
with no highlight and no label refresh, as 4.3.4's
CSimpleButton::OnLayerCursorEnter/Exit do. Co-hooks the button enter and
leave (0x779490 / 0x7794E0). The flag is per button, set from Lua or the
motionScriptsWhileDisabled XML attribute, and cleared when the frame is
destroyed.
2026-10-19 22:00:25 -05:00
Brues 5db35619ca docs(todo): add Button methods and the script-object API sweep
Button: 4.3.4's MotionScriptsWhileDisabled and SetFormattedText, and the
later clients' SetEnabled, state atlases, Clear*Texture and RegisterForMouse.

The sweep diffs every 4.3.4 method table and script handler and retail's
generated API docs against 1.12 and what ClassicAPI registers: the 4.3.4 gaps
(Slider enable, OnMinMaxChanged, StatusBar fill options, ScrollingMessageFrame
message methods, GetCurrentKeyBoardFocus, Set/GetMaxAnimFramerate, GetText)
and the later-client candidates, with the larger pieces listed apart.
2026-10-19 21:41:14 -05:00
Brues 6b95c03888 docs(todo): add XML KeyValues
Record retail's contract (UI.xsd, XMLUtilDocumentation), where it fits in
1.12's frame load order so a parent's values are set before its children's
OnLoad, the plan (a shared region-load observer, a typed-value helper shared
with <Attributes>, GetTemplateInfo's keyValues) and what cannot be verified
without a retail binary.
2026-10-19 21:27:06 -05:00
Brues 1a47cb3215 feat(fontstring): add SetWordWrap, CanWordWrap and GetFieldSize
The FontString methods 4.3.4 has that 1.12 lacked.

SetWordWrap is 1.12's own SetNonSpaceWrap on 4.3.4's style bit 0x40 (set
when the text does not wrap). 1.12's text engine already honours the bit:
TextBlockCreate turns it into text flag 0x2, and the layout and line count
stop after one line on it. A change lays the string out again as SetText
does, shared with SetMaxLines as FontString::Layout::Invalidate; a rebuild
alone left a text-sized string at its old height, so the fit cut at a whole
word with no ellipsis.

GetFieldSize returns what 1.12 holds rather than 4.3.4's constant 8191: n - 1
bytes for a string with XML bytes="n" (SetText's SStrCopy keeps one for the
terminator), and infinity for a string with no fixed size, whose buffer
grows to any length.
2026-10-19 21:17:36 -05:00
Brues d243457ecf docs(todo): add texture tiling from 4.3.4
SetHorizTile / SetVertTile and SetNonBlocking are the texture methods 4.3.4
has that 1.12 lacks. Record how 4.3.4 tiles, what 1.12 already has (the wrap
argument to CSimpleTexture::SetTexture), where the rescale belongs and the
open questions.
2026-10-19 21:17:29 -05:00
SkeewirtandBrues e84496d036 feat(animation): the animation system - AnimationGroup, Animation, Alpha, Scale, Rotation, Translation (#89)
* feat(animation): the animation system - AnimationGroup, Animation, Alpha, Scale, Rotation, Translation

Later clients animate regions in the renderer; vanilla has none of it, and
ported UIs build on it everywhere (Zygor Guides Viewer 2.0: 25 XML groups
plus CreateAnimationGroup in its guide window's OnLoad, which stopped there).

- Util/Animation.lua (bundled addon): the runtime. Groups and animations
  with the later-client API (Play/Stop/Pause/Finish, progress and smooth
  progress, order/startDelay/endDelay/duration timeline, NONE/REPEAT/BOUNCE
  looping, NONE/IN/OUT/IN_OUT smoothing, maxFramerate, the group and
  animation scripts). One driver frame ticks playing groups; effects go
  through the region's own setters and the region's state is restored
  when the group ends, as later clients never persist them.
- animation/Bridge.cpp: CreateAnimationGroup / GetAnimationGroups /
  StopAnimating on the region base registry, forwarding to the runtime the
  bundled addon registers via _classicapi_SetAnimationRuntime.
- xml loaders: an <Animations> child becomes descriptor tables (attributes,
  <Scripts> bodies and function= names, <Origin>) handed to runtime.LoadXML
  before the builder finalizes. Adds OFF_XML_NODE_TEXT. This extends the
  node-loader hooks of the XML attributes change, which it builds on.

Known limits of the 1.12 renderer: Scale pivots on a frame's own anchor
point (exact when anchored by its center) and resizes textures anchored by
one point; Rotation needs texture:SetRotation (textures, font strings).
Group templates (inherits on an AnimationGroup, CreateAnimationGroup's
template argument) are not supported yet and are ignored.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
a probe XML <Animations> group builds with Play(); every AnimationGroup and
animation method exists; a Lua-built group plays through 12 updates to
progress 1.0 and fires OnFinished; Zygor's step flash group (Alpha) plays
and stops, and its guide window finishes its OnLoad. Not checked yet:
Scale, Rotation and Translation on screen (Zygor's own groups with them
load without error, but no one has watched them play), REPEAT and BOUNCE
looping, the smoothing curves, and maxFramerate.

* refactor(animation): port 4.3.4's CSimpleAnimGroup/CSimpleAnim to C++

Replace the Lua animation runtime with a C++ port of the 4.3.4 engine
classes. Groups and animations are real 1.12 script objects (engine
header from ConstructScriptObject, our own vtable), so GetObjectType,
IsObjectType, GetName/$parent globals, SetScript/HookScript and the
shared __index dispatch all go through the engine's mechanisms.

- Group/Anim: the 4.3.4 timeline (orders, delays, looping, cosine
  smoothing, 1/30 s throttle, pause/stop/finish) and every method.
- Region: per-region state ticked from a CSimpleFrame::OnFrameUpdate
  co-hook (shown frames only); 4.3.4's alpha model (store, reset each
  tick, add change, restore when the last alpha group ends, children
  included). CreateAnimationGroup, GetAnimationGroups, StopAnimating.
- Xml: <Animations> with 4.3.4's attribute set, inherits/templates,
  script bodies and function= through SetScriptObjectScript.
- Frame::Destruction: a shared hub over the frame destructor; groups
  die with their frame, and Frame::Alpha moves onto it.

Translation, Scale and Rotation keep their values and timing but do
not move anything yet.

* feat(animation): apply Translation to what regions draw

Port 4.3.4's AddAnimTranslation: a frame hands the offset to every
texture and font string below it (or only the target), a texture moves
the four corners its draw record points at, and a font string moves its
text block's string position. Rects, anchors and mouse areas stay put.

- StoreAnimPosition / PreOnAnimUpdate / ResetPosition mirror 4.3.4,
  with the base taken from the engine's own layout (the corner store
  for textures, inset x layout scale + rect corner for font strings).
- Co-hooks on the texture and font string OnFrameSizeChanged callbacks
  move the base when the layout moves a region mid-animation and keep
  the animation's offset, as 4.3.4's own legs do.
- A frame is marked while it plays a position group, so the end of its
  last one puts everything below it back.

Scale and Rotation still keep their values without drawing.

* feat(animation): apply Scale and Rotation to textures

Port 4.3.4's AddAnimScale / AddAnimRotation with GetOriginOffset,
ApplyScaleAnim and ApplyRotationAnim on the texture's drawn corners: the
origin point on the quad, its offset turned along the bottom edge, and a
counter-clockwise positive angle. Frames hand both to every texture
below them, or only the target. Font strings do not scale or turn yet.

* feat(animation): scale and turn font strings

Port CSimpleFontString's AddAnimScale / AddAnimRotation: the animated
rotation and x scale replace the string's for the update, and the text
is baked again when they change (the engine's node invalidate, as
4.3.4 invalidates the CGxString). After the bake, the glyphs are scaled
and turned about the node origin, where 4.3.4's InitializeTextLine turns
them, with the engine's half-texel UV inset for turned glyphs. The text
is stretched rather than drawn at a larger font size.

* feat(animation): add Path and ControlPoint

Port 4.3.4's CSimplePathAnim and CSimpleControlPoint: a Path moves the
region's drawing through its control points with AddAnimTranslation,
the points spread evenly over the progress, in straight lines (NONE) or
on 4.3.4's Catmull-Rom spline (SMOOTH). ControlPoint is its own script
object with offset, order and SetParent; BuildSpline keeps 4.3.4's
order shuffle when an order is taken. XML reads `curve` and
<ControlPoints>, with templates for CreateControlPoint and `inherits`.

One deviation from the decompiled CreateControlPoint: a point made
without an order takes the highest order + 1 (as SetParent does), so
points made one after another keep their creation order.

* fix(texture): keep shape changes made during a position animation

Texture:SetRotation and SetVertexOffset wrote the drawn corners, which
the next animation update replaced with the corners stored when the
group started. While a position group plays they now write the stored
corners, so the new shape shows with the animation on top and stays
after the group ends. FontString:SetRotation on text an animation has
turned or scaled bakes the glyphs again instead of re-turning them in
place, so the bake applies both.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-19 20:44:31 -05:00
Skeewirt 3ce2880c93 feat(lua): add the strmatch global for string.match (#62)
1.12's startup script makes globals of the 5.0 string functions
(strfind = str.find, strsub = str.sub, ...), so the 5.1 string.match that
ClassicAPI adds had no global form. 3.x clients have strmatch: 3.3.5's
FrameXML calls it 41 times and never defines it, and Zygor Guides Viewer
2.0 calls it in Profession.lua when it scans a trade-skill window.

Register string.match's function as strmatch too.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
strmatch("|Henchant:3275|h", "|H%w+:(%d+)") returns "3275", and Zygor's
trade-skill scan (Profession.lua:142) no longer errors when a trade-skill
window opens.
2026-10-19 18:44:36 -05:00
Brues 9f4e45551b feat(text): backport IndentedWordWrap
Set/GetIndentedWordWrap on Font, FontString, EditBox, MessageFrame,
ScrollingMessageFrame and SimpleHTML (optional "h1".."p" first), as
4.3.4 does them: style bit 0x20000, set on the font string itself (and
cleared from its settable mask) or on the class's font through
CSimpleFont_SetIndentedWordWrap. The font string constructor's settable
mask gains 0x20000 (0x3F -> 0x2003F), as 4.3.4's 0x2107F has it, so a
font's setting reaches the strings that follow it.

The text engine honours it as 4.3.4's does with its flag 0x2000: from
the second line on, every wrap loop narrows the width by 15 / render
width, and the layout places a left-justified line 15 px in and a
right-justified one 15 px further left. All by call-site redirects, no
new hooks: the font string's five calls into the engine note the bit
(and the node gets flag 0x2000), the three measure loops' stepper calls
narrow lines past the text's start, and two thunks in the draw builder
narrow and place each line from the node's line count before the x is
copied into a wrapped link's rect.
2026-10-19 15:55:20 -05:00
Brues 09492782cc feat(editbox): backport 4.3.4's missing EditBox API, fix three caret faults
New, as 4.3.4 does them:
- Enable / Disable / IsEnabled: a disable clears the focus; both run
  CSimpleFrame::RunOnEnableScript (frame flag 0x400, OnEnable/OnDisable).
- OnEnable / OnDisable scripts on every frame. Buttons run them when
  CSimpleButton::Enable changes the state, as 4.3.4's does.
- IsInIMECompositionMode reads the composition flag 1.12 already keeps
  (+0x3CC); OnCharComposition(self, text) runs after the input method's
  composition insert.
- OnTextChanged(self, userInput): 4.3.4's dirty bit 0x8, kept per box at
  the insert and delete points; SetText's clear and insert are the
  script-made changes. The fire block is replaced by a call that runs the
  script with the bit.

Fixes (benilla-re ui/editboxmarkup "Faults"):
- A text ending in a colour escape or an unclosed link froze the client
  on Right or Delete: the forward cursor step had no end test. It now
  stops at the terminator, as 4.3.4's does.
- A single-line box read |n as a line break in its class table but drew
  it as two characters; the table is now built with the decoder flags
  its font string draws with.
- The right-justified click converted glyphs counted from the line end
  by stepping from the line start; it now steps back from the end.
2026-10-19 15:42:15 -05:00
Brues b3955f89df refactor(frame): share the extra-script cells, add slot runner and call redirect
Frame::ExtraScripts now owns the co-hook on the base frame script
resolver (MinHook allows one per address) and hands out per-frame cells
for any script name the engine does not know, filtered by the class's
Lua type id. OnAttributeChanged moves onto it; its cells are dropped on
/reload by the module.

ScriptArgs::RunSlotWithValues runs a {handler, context} slot with Lua
values inside the slot's execution context, as the engine's fire wrapper
0x00702690 does.

Common::RedirectCall repoints one `E8 rel32` call when it still reaches
the expected target, and is idempotent across registration passes.
2026-10-19 15:42:07 -05:00
Brues 1645f6513a feat(frame): add 4.3.4's missing Frame and Region methods
Close the Frame/Region gap against the 4.3.4 decomp (animation methods
excepted):

- SetPropagateKeyboardInput / GetPropagateKeyboardInput: frame flag
  0x200000. The Frame key-down and char slots and the EditBox key-down
  slot run their scripts, then answer "not taken" so the walk goes on to
  the frames below and the bindings, as 4.3.4's slots end. Key up is not
  hooked: the top's key-up handler discards the slot's answer.
- SetDontSavePosition / GetDontSavePosition: frame flag 0x80000, which
  the layout-cache save (0x00490E60) now skips, as 4.3.4's
  PlacedFrameCallback does.
- GetBoundsRect: 4.3.4's walk with its ScrollFrame override and pending
  layout resolve, on the engine's rect reader and frame lists.
- CanChangeProtectedState, CanChangeAttribute, AllowAttributeChanges:
  what 4.3.4 answers with nothing protected and nothing tainted.
  IsProtected moves to Region and returns nil, nil, as 4.3.4's does.

Both flags go through the engine's SetFrameFlag; none of its 14 callers
in 1.12 uses either bit.
2026-10-19 14:59:30 -05:00
Brues 2d2a5b9442 refactor(frame): name the layout resize, share the frame-list walk
0x007680E0 is 4.3.4's CLayoutFrame::Resize(force): force 0 marks the
layout dirty, force 1 resolves it now, as Script GetLeft calls it. It was
named FUN_FONTSTRING_LAYOUT_INVALIDATE, which hid the second use; rename
it FUN_LAYOUT_RESIZE.

Move the walk of a frame's intrusive region and child lists out of
Frame::Alpha into Game::ForEachFrameListObject, so the bounds walk can
use the same code.
2026-10-19 14:59:22 -05:00
Brues 9a2de093b9 docs: list the interruptible cast events in the README events table 2026-10-19 14:07:13 -05:00
Brues ff57450b7c feat(spell): match the engine's cast-failure events, add interruptible events
UNIT_SPELLCAST_INTERRUPTED vs FAILED now follows the failure reason, as
1.12 (0x6E4940, 0x6E2238) and 4.3.4 (HandleSpellFailedOrGo) decide it:
SPELL_FAILED_INTERRUPTED / _COMBAT give INTERRUPTED, every other reason
FAILED. A failure for the in-flight spell (read before the engine's
handler pops it, its own test at 0x6E2201) ends the cast in progress and
is reported with that cast's guid, then STOP. Casting while moving is a
FAILED attempt, and line of sight or a dead target at the end of a cast
is FAILED, not INTERRUPTED. A per-cast "reason reported" flag replaces
the interrupt dedup window.

Channels with SPELL_ATTR_EX3_HIDE_CHANNEL_BAR record no channel and fire
no channel events, as 1.12's MSG_CHANNEL_START handler and 4.3.4 skip
them.

Add UNIT_SPELLCAST_INTERRUPTIBLE / _NOT_INTERRUPTIBLE: Spell::Cast
watches the target and focus and fires when the notInterruptible answer
for the same cast flips, as 4.3.4 does from the unit's aura update.
2026-10-19 14:03:07 -05:00
Brues 05b978df3c feat(aura): report isStealable, as 4.3.4's CanStealAura does
1.12 builds only the friendly dispel list when a spell is learned
(AddSpellToBook); it has no offensive mask. Apply 4.3.4's rule to the
spells the player and pet know: a Dispel effect aimed at an enemy
(TARGET_UNIT_ENEMY, or TARGET_UNIT, which covers Dispel Magic and Devour
Magic) adds its dispel type. The mask is rebuilt when the spellbook
count or the pet changes.

An aura is stealable when the player can attack the unit, the aura is a
buff that is not passive, channeled or an area aura, and its dispel type
is in that mask. Reported as isStealable in AuraData and at UnitAura
position 8.
2026-10-19 13:16:27 -05:00
Brues e2c789510a feat(aura): list the player's own auras first, as 4.3.4 sorts them
C_UnitAuras walks a live unit's auras in 4.3.4's sorted order
(CGUnit_C::InsertSortedAura): auras the player or a unit the player
owns cast, then auras the player could apply, then the rest, each group
in slot order. Every index and slot walk uses it, including the by-ID
and by-name lookups and GameTooltip:SetUnitAura, so indices still agree
across them. The out-of-range group array keeps slot order, as 4.3.4's
does.

The groups are cached per unit and rebuilt when its aura array changes
or a new frame starts. Aura::Source::CastersForSlots finds every slot's
caster in one pass over the cache, and the can-apply answer is kept
until the slot's spell, the spellbook count or the pet changes.
2026-10-19 13:05:46 -05:00
Brues fd7843b8b3 feat(aura): add RAID and CANCELABLE filters, fix stale player timers
Use the engine's own aura tests where C_UnitAuras guessed or gave up:

- RAID calls the two tests behind UnitBuff/UnitDebuff's raid-filter
  argument (castable buff 0x4B3870, known dispel type 0x4B3920);
  isRaid reports the same answer.
- CANCELABLE / NOT_CANCELABLE read the descriptor's cancelable bit.
- PLAYER also counts a caster whose charmer or creator is the player
  (totems, guardians, charmed units), as 4.3.4's IsAuraCaster does.
  The charmer-or-creator read moves to Unit::Identity::OwnerGuid.
- Filter tokens and spell-name lookups ignore case (SStrCmpI).
- castByPlayer (UnitAura position 13) is a player caster only, as in
  4.3.4; isFromPlayerOrPlayerPet keeps pets.
- canApplyAura: the player or pet knows the spell and it applies an aura.
- Player timers look up the buff record by slot and honour its
  until-cancelled flag, so a permanent aura no longer reads the stale
  expiry its slot kept from an earlier aura.

Corrects the player-buff record and visible-aura comments in Offsets.h.
2026-10-19 12:50:54 -05:00
Brues e6f0a077e6 fix(screen): read GetPhysicalScreenSize from the graphics device
GetPhysicalScreenSize parsed the gxResolution CVar, which is the
configured resolution, not what is drawn: a resized window kept
reporting the setting. It now asks the device for its live render-window
rectangle through GxCapsWindowSize (0x58A240), the same source the
engine's font code sizes text from (GxuFontUpdateViewport), so the result
follows the window. An empty rectangle gives the font code's own
640x480 fallback.
2026-10-19 12:23:15 -05:00
Brues bbd78b437f feat(model): backport 4.3.4's PlayerModel and DressUpModel methods
SetDisplayInfo and SetCreature move from Model to PlayerModel, where
4.3.4 registers them, and now ride vanilla's own pane path: SetDisplay
(0x505CB0) stores the record and AttachDisplayModel (0x505A70) builds the
model with the engine's skins (ApplyCreatureDisplay), Stand, the pane
camera and light, and rebuilds it on every show. A co-hook on
AttachDisplayModel adds 4.3.4's SetUpModel step: a display with an Extra
record is dressed through the character compositor. SetCreature passes
the creature-cache record itself; SetDisplayInfo passes a per-pane record
holding the display ID, the only field the pane reads. The hand-built
ApplySkins is removed.

SetPortraitZoom, SetCamDistanceScale and RefreshCamera apply 4.3.4's
ConfigureCamera steps after vanilla's (0x505890); the defaults leave the
camera untouched. The portrait camera id is bounds-checked, since
GetCameraById indexes unchecked.

DressUpModel gains UndressSlot (one step of vanilla's Undress) and a
TryOn that takes item links, recipe links and carried item names in
front of vanilla's worker. PlayerModel:SetRotation takes 4.3.4's animate
flag, and Model:SetGlow exists as the no-op it is on a 4.3.4 Model.
2026-10-19 12:11:21 -05:00
Brues d5c6530b80 docs: move USER_WAYPOINT_UPDATED into the Events section 2026-10-19 11:51:50 -05:00
Brues a47f871adb feat(map): add GetExploredAreaIDsAtPosition and MAP_EXPLORATION_UPDATED
C_MapExplorationInfo.GetExploredAreaIDsAtPosition(uiMapID, pos) returns
the explored areas whose revealed overlay art is under a map point, most
visible first, or nil. It tests the art's alpha, decoded through the
engine's own BLP reader (driven as GetPortraitAlphaMask drives it),
not the overlay hit rects: measured over the 1.12 art, the rects name an
area over bare base map at 16.7% of points and give the wrong first
area at 11.6% where they overlap.

MAP_EXPLORATION_UPDATED co-hooks the engine's explored-bits watcher
(0x4A6460), which EnterWorldUI arms and LeaveWorldUI clears, so it fires
only on a real change and never stacks across /reload.

The overlay exploration filter now calls the engine's IsAreaExplored
(0x4A9A40) instead of re-deriving the bit test; the copy treated a
negative AreaBit as unexplored where the engine's unsigned compare
treats it as explored. Tile geometry is shared between GetMapOverlays
and the new hit test, and tile sizes come from the engine BLP reader.
2026-10-19 11:45:14 -05:00
Brues b9071a37f9 feat(text): render inline icons in edit boxes, as 4.3.4 does
4.3.4's edit box renders |T...|t in its text. Typed markup stays literal
because the edit box stores a typed | as ||, and 1.12 does the same
(0x77c254; Paste goes through InsertChar too). So edit boxes need no
special case once || is read the way both engines read it.

- The span rule is 4.3.4's GxuDetermineQuotedCode CODE_TEXTURESTART
  rule, in one scanner (IconSpanAt). Only |T...|t and |A...|a are spans;
  || is always a literal pipe. The old doubled ||T...||t match is gone.
  Stock 1.12 has no pipe-doubling sanitizer, so the ||T it was built for
  was typed text plus our own marker output.
- The edit-box suppression gates are removed: node bit 6 (really
  non-space-wrap), the focused-buffer test, the render-line content
  compare and the re-entry bracket.
- New co-hook on the fit-to-width function 0x772bc0 (mouse click to
  cursor, single-line visible window), mirroring 4.3.4's
  GxuFontGetMaxCharsWithinWidth.
- Ctrl+C drops icons: GxuStripControlCodes honours 4.3.4's 0x1000
  texture bit, and CopySelection's mask is patched from 0x500 to 4.3.4's
  0x1500. CopyToClipboard(text, true) now calls the same engine strip
  instead of its own parser.
- {rt1} and the other raid tags are expanded in the CHAT_MSG text at the
  event fire, and in the speech bubble after its escape strip (a
  redirected SetText call at 0x4b16c0), as 4.3.4's
  CGChatBubbleFrame::ParseRaidIcons does. The message buffer keeps the
  tags.
- The chat anti-spoof doubles a smuggled |T's pipe, as the edit box does
  for a typed one.
- The tokenizer hook honours the 0x800 all-pipes-literal gate.
2026-10-19 10:11:09 -05:00
Brues 9ab6627d90 feat(unit): add InteractUnit and the INTERACTTARGET / INTERACTMOUSEOVER bindings
InteractUnit(unit [, exactMatch]) is 4.3.4's Script_InteractUnit. It
resolves the argument the way 4.3.4 does:
- "mouseover" reads the hovered-object GUID, which in 1.12 is any
  object type (units, game objects, corpses).
- A unit token resolves to its GUID.
- Any other text runs the engine's by-name search with TYPEMASK_OBJECT.

It then calls 1.12's own OnSpriteRightClick (0x00492820), the function
a world right-click runs, so targeting, the object's right-click and
the autoloot modifier all come from the engine. 4.3.4's taint gate is
dropped because 1.12 has no secure environment.

Both bindings are spliced into the TARGETING group, as in 4.3.4. Their
names are added to every locale, using Blizzard's strings.
2026-10-19 03:33:21 -05:00
Brues 799ab1d3ce fix(target): call the selection setter as __stdcall, not __cdecl
FUN_SET_SELECTION_BY_GUID (0x00493540) returns with `ret 8`, and the
engine's own callers (TargetByName, OnSpriteRightClick) never clean the
stack after it. Target::Nearest declared it __cdecl, so each call from
TargetDirectionEnemy / TargetDirectionFriend popped the two GUID words
a second time and left the stack 8 bytes off.
2026-10-19 03:33:21 -05:00
Brues 77fb074191 feat(tooltip): add SetAnchorType, IsUnit, ANCHOR_TOP/BOTTOM and two getters
Cross-checked against 4.3.4's Tooltip_Scripts:

- GameTooltip:SetAnchorType(type [, x [, y]]) was missing in 1.12, but
  the shipped GameTooltip_ShowCompareItem port (and pfUI) call it.
  It stores the offsets and, with an owner, the anchor, then runs the
  engine's position routine with force.
- GetMinimumWidth and GetPadding read the fields 1.12's SetMinimumWidth
  and SetPadding already write; GetPadding returns the stored internal
  value, as 4.3.4 does.
- IsUnit(unit) compares GUIDs and returns 1 or nil. The token is probed
  first, because 1.12's resolver raises on an unknown name.
- 1.12's SetOwner has nine anchor names and sends every other one to
  ANCHOR_LEFT. ANCHOR_TOP and ANCHOR_BOTTOM get codes of their own; one
  hook on the position routine places them, and SetOwner and
  GetAnchorType are re-registered to know the names. ANCHOR_CURSOR_RIGHT
  is not added.
2026-10-19 02:07:42 -05:00
Brues 5c5c21e2ec fix(action): GetActionInfo no longer reports a dead macro button as an item
A 0x40 action entry is the server's ACTION_BUTTON_MACRO; there is no
bag-item tag (items are 0x80, by itemID). A 0x40 entry whose macroID is
not in the macro-slot map is a button whose macro is gone, which the
engine and 4.3.4 both treat as nothing. GetActionInfo returned
"item", nil for it.

- Empty slots, dead macro buttons and unknown tags now return nothing,
  as 4.3.4 does, instead of a nil.
- Action::Slot::MacroSlotForID calls the engine's own scan
  (FUN_MACRO_ID_TO_SLOT) instead of a copy.
- Offsets notes, Slot.h and API.md no longer describe a bag-item case.
2026-10-19 01:43:47 -05:00
Brues eccf5896cb feat(cursor): GetCursorInfo returns pets, and the right link for dragged items
Cross-checked against 4.3.4's Script_GetCursorInfo:

- Drag-source items (action bar, auction slot) keep only an itemID on
  the cursor, so they return the plain itemID link, as 4.3.4 does.
  The old search of the bags for a matching item could describe the
  wrong copy when two carried different enchants. The duplicate link
  builder in Info.cpp is gone; Item::Link::BasicFromItemID is used.
- A pet action returns "petaction"; a stable pet returns "pet", index.
- The stable cursor slot is 0-based in the engine (ClickStablePet
  stores its argument minus one); the Offsets note said 1-based.
- CURSOR_CHANGED now sees a change between two pets.
2026-10-19 01:34:59 -05:00
Brues fdae686cc9 feat(console): add pitchLimit, AppendLogToFile and 4.3.4's Tab completion
Cross-checked against the 4.3.4 decompilation (wowdecomp15595):

- The handler's first argument is the command name, not unused (the
  shared CVar handler looks its CVar up by it); Game::Console::Write
  takes 4.3.4's COLOR_T, and Game::Console::Printf is the engine's own
  ConsolePrintf (0x0063CD50).
- pitchLimit <deg>: writes the camera pitch clamp, which 1.12 keeps as
  two .rdata floats read only by camera code.
- Ctrl+Tab lists every command containing the typed text; Tab after
  set/cvar_reset/cvar_default completes CVar names (extension). Our key
  handler registers at the console's own priority 7.0, which the
  engine's tie rule runs first, so no hook is needed.
- AppendLogToFile [file] [numLines]: implements the contract 4.3.4's
  help text documents (the shipped command prints "Access Denied").
- The command-list walk moves into console/Registry.h, and
  EnsureParentDirs into Common.
2026-10-19 00:52:28 -05:00
Brues e75e585551 feat(frame): register Cooldown as a real frame type, so XML <Cooldown> works
4.3.4 registers "Cooldown" in the frame-type factory table, beside
GameTooltip and the model types. Vanilla has no such type: the
CreateFrame hook rewrote a "Cooldown" request as a Model with
CooldownFrameTemplate, so XML <Cooldown> elements were still "Unknown
frame type".

The Cooldown module now registers the type through the engine's own
FrameXML_RegisterFactory (0x006EE880), during the game Lua registration
pass, which CGGameUI::Initialize runs before the game-UI factory list
and before FrameXML loads. The table is never cleared, so a /reload's
second registration is a no-op, as for the engine's own types. The
factory builds the engine's Model (0x006EECF0) and marks it, and XML and
CreateFrame both reach it through the builder. CreateFrame drops its
Cooldown special case, and Cooldown.h goes away (nothing outside the
module calls it).

Every 4.3.4 Cooldown draws a sweep without a template; here the sweep is
CooldownFrameTemplate. A Cooldown loads it under its first node, before
the node's own templates and content, through a new
Xml::FrameLoad::AutoSubscribeBefore observer. Xml::LoadNode is the
shared "load this node onto the frame" step that Inherits also uses.
The template is setAllPoints, and a 4.3.4 Cooldown starts unanchored, so
the observer then calls CLayoutFrame::ClearAllPoints (0x00767620). A new
frame has no anchors at that point, so this restores exactly that.
Without it, a Cooldown with its own Size and CENTER anchor kept
TOPLEFT/BOTTOMRIGHT on UIParent and stretched across the screen.

Verified in-game (1.12.1 via VanillaFixes) with a throwaway test addon:
a bare XML <Cooldown>, an XML <Cooldown> child inheriting
CooldownFrameTemplate, and CreateFrame("Cooldown") with no template each
report "Cooldown", stay a Model, load UI-Cooldown-Indicator at scale
0.75, and draw a visible sweep. The bare one sits at its own 36x36
CENTER anchor, the child fills its host, and a plain Model still reports
"Model".
2026-10-19 00:08:55 -05:00
Brues 4ea0223f34 feat(xml): read inherits as a template list, and CreateFrame's id
4.3.4 reads `inherits` as a list. CSimpleFrame::LoadXML tokenizes it
on " ," and loads each template, left to right, before the node's own
content, so later templates override earlier ones; FindParent walks the
same list for a `parent`. Vanilla's frame loader, child-frames builder
and builder parent walk each look the whole string up as one name, so
XML `inherits="A, B"` failed with "Couldn't find inherited node".

Each of the three steps now takes a list the same way: every name in
order through the engine's own step, then the node itself. A single
name keeps the engine's path untouched. Textures and font strings keep
one template, as in 4.3.4.

CreateFrame is now 4.3.4's Script_CreateFrame on vanilla's builder:
- every name in a template list must resolve, or it raises the engine's
  "Couldn't find inherited node" error; the whole list goes on the
  request node, so the frame is built through the list path above with
  all templates applied before OnLoad. The old hook built with the
  first template and grafted the rest after OnLoad, and it skipped
  missing names.
- the 5th argument `id` is set on the node, so GetID() in OnLoad
  returns it.
The id has to be on the request node before the builder runs, and
vanilla builds and frees that node in its own body, so the function is
replaced, with the engine's node constructor, attribute setter,
builder, status object and error strings.

Verified in-game (1.12.1 via VanillaFixes) with a throwaway test addon:
XML and CreateFrame lists apply left to right (B's size over A's), both
templates' regions and children exist when the last template's OnLoad
runs, a missing name is skipped in XML and an error in CreateFrame, a
space-separated list takes its parent from a template, CreateFrame's id
reaches OnLoad, and CreateFrame("Cooldown") still reports "Cooldown".
2026-10-18 23:54:14 -05:00
Brues 6f55df19fa docs(readme): list the CHAT_MSG_* arguments in the Events table
They went into the API category list, which names functions; the Events
table further down is where event payloads are listed.
2026-10-18 23:28:24 -05:00
Brues 889a096963 feat(chat): give CHAT_MSG_* 4.3.4's lineID and sender GUID (arg11, arg12)
4.3.4's SendChatEvent fires every chat event with lineID and the sender's
GUID string after the ten arguments 1.12 already passes. 1.12 fires all ten
from one FUN_FIRE_EVENT call in FUN_0049B0B0, so that call is redirected to
append them:

- arg11 lineID: ++counter per message, before its event fires; 0 for text
  emotes, as 4.3.4's AddTextEmoteMessage passes.
- arg12 guid: the sender's GUID only for a player sender and a message not
  tagged GM, "" otherwise.

The redirect also works under Turtle's .tdata detour on the same function,
which returns to the PUSH just before the CALL. GetCurrentChatGUID() stays
and now reads the same sender, nil where arg12 is "". It moves into the new
Chat::EventArgs module with the redirect.

The sender GUID was wrong or missing on several paths, all fixed here:

- Monster say/yell put the monster's TARGET (often the local player) in the
  pair we published; the packet layout (vmangos BuildChatPacket, matching
  FUN_0049D560's reads) sends sender, name, target. Monster chat now has no
  sender, as in 4.3.4.
- Raid boss whispers are rewritten to a plain whisper carrying the target,
  so they reported the local player as sender. An SMSG_MESSAGECHAT
  subscriber keeps the wire type for the dispatch that follows.
- A message from a sender the name cache did not know was queued and later
  dispatched with both GUID pairs zeroed, by the name-query callback
  FUN_0049CCC0 or the enter-world drain FUN_0049D230. The queue entry keeps
  the GUID, and both callers pass pointers into it.
- Text emotes fire without passing their GUID on; the builder is co-hooked
  to provide it.

Chat::Dispatch's argument labels are corrected too: the pair it called the
target GUID is the speaker the speech bubble is placed on.
2026-10-18 22:47:22 -05:00
Brues 90b4691130 fix(debugtools): show all 19 event arguments in the event trace
The event trace passed only arg1..arg9 from its XML OnEvent and kept nine
argument columns, so it never showed arg10 that 1.12 already sends with
every CHAT_MSG_*, nor anything after it. The engine sets at most
arg1..arg19 for an event, so the trace now passes and stores all 19, with
trailing nils marking the end as before.
2026-10-18 22:47:22 -05:00
brues-codeandJared Reni 99de63755b CreateFrame("Cooldown") (#106)
* feat(frame): CreateFrame("Cooldown") builds vanilla's cooldown Model

Later clients have a native Cooldown frame type; vanilla draws cooldown
sweeps with a Model from FrameXML's CooldownFrameTemplate, driven by
CooldownFrame_SetTimer, and its CreateFrame rejects the type name
("Unknown frame type 'Cooldown'"). Ported code writes
CreateFrame("Cooldown", name, parent, "CooldownFrameTemplate") and then
calls CooldownFrame_SetTimer - Zygor Guides Viewer 2.0 does it while
building its guide window, which stopped there.

In the CreateFrame hook, build a "Cooldown" request as "Model", defaulting
the template to CooldownFrameTemplate when none is given.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
CreateFrame("Cooldown", nil, UIParent, "CooldownFrameTemplate") returns a
Model, and Zygor's guide window builds.

* feat(frame): give the Cooldown frame 4.3.4's methods

4.3.4's CGCooldown_Scripts registers SetCooldown, SetReverse,
GetReverse, SetDrawEdge and GetDrawEdge. The Cooldown frame built by
the CreateFrame hook now has the same five: SetCooldown takes start
and duration in seconds and errors as 4.3.4 does when either is not a
number, the setters read their value as 4.3.4's StringToBOOL does,
and both flags start false.

The hook marks each frame it builds as a Cooldown. The methods keep
their state per marked frame, SetCooldown drives the sweep through
CooldownFrame_SetTimer, and a marked frame reports its type as
"Cooldown" from GetObjectType / GetFrameType and answers
IsObjectType("Cooldown"). Every other frame delegates to the engine's
own type methods.

The reverse and draw-edge flags are stored and reported, but do not
change how the sweep draws: the sweep has no reversed form and no
edge. The five methods sit on the Model registry, so a Model that was
not built as a Cooldown raises the wrong-object-type error when one
is called on it.

---------

Co-authored-by: Jared Reni <jaredreni@gmail.com>
2026-10-18 16:39:34 -05:00
Brues 7cd891e86c fix(bindings): follow 4.3.4's bindings, modified clicks and key state
Modified clicks: IsModifiedClick, GetModifiedClick, SetModifiedClick,
GetNumModifiedClickActions and GetModifiedClickAction, with 4.3.4's
<ModifiedClick> Bindings.xml node and its 18 defaults. Each binding set
loads, saves and copies them through co-hooks on the engine's own
binding-set steps, in 4.3.4's "modifiedclick" file lines. [mod:]
asks IsModifiedClick, as 4.3.4's macro conditions do.

Direct actions run as 4.3.4's ExecKey runs them: SPELL / ITEM / MACRO
on key release through 1.12's spellbook, item and macro functions,
cast at you for SELFCAST and at your focus for FOCUSCAST; CLICK sends
the frame a mouse down and up, with 4.3.4's virtual-click button logic
built from the button's own methods. RunBinding no longer runs them.

While a key binding runs, its own modifiers count as released for
every modifier read, the engine's IsKeyDown included.

Overrides follow 4.3.4: two lists per key, checked inside the key
lookup at both of 1.12's lookup steps; GetBindingAction takes
checkOverride; isPriority is loose; "" removes; the owner must be a
frame.

ActionButtonUseKeyDown (default 0) and 4.3.4's action-button key
handlers, ported onto 1.12's bars.
2026-10-18 15:11:02 -05:00
Brues 6e50577565 fix(target): follow 4.3.4's TargetNearest and TargetDirection
TargetNearest, TargetNearestEnemyPlayer and TargetNearestFriendPlayer
now run the engine's own tab cycle (FUN_00493F60), which is 4.3.4's
TargetNearestEnemy: units in front first, the targetNearestDistance /
targetNearestDistanceRadius ranges, no critters or unseen units, and
the 3 s / 1 s snapshot shared with the native functions. A co-hook on
its predicate adds 4.3.4's types 0, 2 and 4 as three new modes.
TargetNearest now cycles any unit, as in 4.3.4.

TargetDirectionEnemy / TargetDirectionFriend are now 4.3.4's
CGGameUI::TargetDirection, built on 1.12's GetScreenCoordinates, the
camera's forward vector and the tab search's tests: facing is a screen
direction relative to the camera, the search starts at the target when
it is on screen, the cone is at least 22.5 degrees, and a hit locks
further calls until the new TargetDirectionFinished().
2026-10-18 14:20:26 -05:00
Brues 024c6d2021 feat(chat): prefix %t with the target's raid marker, as 4.3.4 does
4.3.4's chat parser prefixes both of its name codes, %f and %t / %n,
with {rtN} when the unit carries a raid marker and a name was found.
1.12's %t already resolves the name the same way, so the hook only
writes the marker in front of the %t pair and the engine still fills
in the name.

Rename Chat::FocusToken to Chat::ChatTokens; %f and %t share the name
lookup and the marker helper.
2026-10-18 13:56:54 -05:00
Brues 4b47059788 feat(debugtools): build /fstack with GameTooltip:SetFrameStack
Replace the Lua EnumerateFrames walk with the engine method, as
Blizzard_DebugTools does. /fstack also accepts "true" for hidden frames.
2026-10-18 13:38:14 -05:00
Brues 1aa5a07823 feat(tooltip): add GameTooltip:SetFrameStack, as 4.3.4 does
Mirror 4.3.4's CGTooltip::SetFrameStack: walk the engine frame list,
hit-test the layout rect against the cursor, name each frame the way
4.3.4 does, sort by strata and level, and fire the new
OnTooltipSetFrameStack script before Show. Every input already exists
in 1.12; the new offsets are verified against the binary.

GetMouseFoci now replays the engine's mouse-focus pass instead of
calling Lua getters: it walks the per-strata mouse input queues and
calls the engine's hit test, so hit-rect insets and scroll clipping
apply as they do for GetMouseFocus.

Also fix the FUN_REGION_GET_RECT comment: the rect is
{bottom, left, top, right}, not {top, left, bottom, right}.
2026-10-18 13:38:14 -05:00
Brues 00f32e0e2f feat(xml): load <Attributes> on frames, as 4.3.4 does
4.3.4's CSimpleFrame::LoadXML reads an <Attributes> child through
LoadXML_Attributes; 1.12's frame loader (FUN_XML_FRAME_LOAD) walks its
child elements and silently skips the unknown one, so ported templates
lost every attribute. Each <Attribute name= type= value=> is now set
the way 4.3.4 sets it: type nil/boolean/number/string (default),
converted with the loader's own helpers (FUN_XML_ATTR_TO_BOOL, new
FUN_SSTR_TO_FLOAT), stored without firing OnAttributeChanged (through
our SetAttributeNoHandler, so unit/type* behave as when set from Lua),
and malformed entries logged through the loader's status sink with
4.3.4's warning texts (VTBL_XML_STATUS_ADD, level 1).

FUN_XML_FRAME_LOAD was already hooked by Xml::ParentKey, and MinHook
takes one hook per address, so Xml::FrameLoad now owns it and fans out
after the engine loads each node (the RunnerHook / WorldTick pattern).
ParentKey's frame binds and the attribute loader subscribe; its node
helpers and loader-safe wrapper push moved there too.
OFF_COBJECT_LUA_REGISTRY_REF duplicated OFF_COBJECT_LUA_REF (both 0x08)
and is folded into it.

Verified in-game with a test addon: template attributes inherited and
overridden by the frame's own, number / boolean / nil types, a mixed-
case name, values present by OnLoad, no OnAttributeChanged on load, and
the three FrameXML.log warnings.
2026-10-18 05:17:22 -05:00
Brues 5537432662 docs(frame): describe 4.3.4's attribute and mouseover behavior
OnAttributeChanged takes (self, name, value) and nested changes fire
their handlers; mouseover follows SecureButton_GetModifiedUnit; the
3-arg GetAttribute and SetAttribute's required value. Drops the
implementation paragraphs.
2026-10-18 05:10:05 -05:00
Brues 5962ccc83d fix(frame): follow 4.3.4's attribute system and unit-frame mouseover
Compared against 4.3.4's CSimpleFrame attribute code and
CGWorldFrame::OnLayerUpdate:

- OnAttributeChanged gets (self, name, value) as real arguments, like
  4.3.4's RunOnAttributeChangedScript, plus the this/arg1/arg2 globals.
  It used to fire through the no-argument invoker with globals only, so
  a modern function(self, name, value) saw name == nil. The engine's
  runners only carry %d/%s values, so Frame::ScriptArgs gains
  RunWithValues for arguments already on the Lua stack.
- No recursion guard: a handler that sets attributes fires their
  handlers too, as in 4.3.4. The guard silenced every nested
  OnAttributeChanged on every frame.
- SetAttribute requires the value argument; the 3-arg GetAttribute form
  needs exactly four arguments and treats a nil prefix/suffix as empty;
  usage errors name the frame like the engine's (GetName ?: <unnamed>).
- The mouseover unit of a hovered frame comes from FrameXML's
  SecureButton_GetModifiedUnit, pcalled under the engine's error
  handler, as 4.3.4 does, so unitsuffix and modifier-qualified unit
  attributes count. 4.3.4 asks protected frames; with no protection in
  1.12 the analog is a frame that has attributes. The mouseover tooltip
  stays (4.3.4 shows it only over the world frame).
- SecureStateDriver.lua is 4.3.4's file again: it drives itself through
  SecureStateDriverManager attributes and reads OnUpdate's elapsed,
  both of which the guard and the old no-argument handlers had ruled
  out.
- Game::Lua::ObjectLuaRef / PushObject / ObjectDisplayName replace the
  copies in ScriptArgs and Modern.

Verified in-game: handler arguments and globals, a nested handler
firing, both usage errors, the nil-prefix lookup, mouseover following
shift-unit / unit, and a visibility state driver.
2026-10-18 05:09:25 -05:00
Brues f5acea6060 fix(coroutine): follow 4.3.4's coroutine library
Compared against 4.3.4's stock Lua 5.1 co_funcs (lbaselib.c), opened by
luaopen_base in FrameScript_Initialize:

- status: a coroutine mid-resume of another one is "normal", not
  "suspended". Test what the engine's own resume body (0x006F67B0)
  tests - ci > base_ci AND CI_YIELD on the CallInfo - instead of
  ci > base_ci alone. Resuming it now fails with 5.1's "cannot resume
  normal coroutine".
- running: push the thread with the exec-context field set, as every
  engine push does; it was left as whatever the stack slot held, which
  lua_pushvalue copies into the script context. The main thread is read
  from G->mainthread (+0x50, set by f_luaopen) instead of an L captured
  at registration.
- Registered on the glue state too.
- 5.1's error texts ("cannot resume %s coroutine"); too many arguments
  or results raise, and the results check reserves the slot for true.
- wrap re-raises a non-string error object unchanged through the raw
  lua_error (LUA_RAW_ERROR, 0x6F4440); a string still gets luaL_where.

Verified in-game: "normal" status and its resume error, the lifecycle
and running(), an error in the body is not read as a yield, and the
wrap error text.
2026-10-18 04:47:53 -05:00
Brues a86f1219c3 fix(frame): follow 4.3.4's HookScript, so a hooked handler keeps its args
HookScript's HookThunk was a C closure, which Frame::ScriptArgs treats as
wanting no positional args, and it called the old handler with none - so
a modern SetScript("OnShow", function(self) ... end) saw self == nil as
soon as anything hooked it. It also padded the hook to 9 args and only
detected OnEvent by exact case.

Follow 4.3.4's FrameScript_Object::HookScript instead:
- an unset script gets the hook itself, so GetScript returns it;
- a set one gets the same wrapper hooksecurefunc installs (4.3.4 uses
  FrameScript_HookWrapper for both), exposed via HookSecureFunc.h: the
  old handler and the hook get the script's own args, and a hook error
  goes through the engine's script error handler.
ScriptArgs' HandlerWantsArgs looks inside that wrapper (C function
compare, upvalues at CClosure +0x10 per lua_pushcclosure), so a hooked
script takes the positional path only when either side declares
parameters. The usage error names the frame as the engine does:
vtable+4 GetName, else its "<unnamed>".

Verified in-game: old and hook both get self; OnEvent gets (self, event)
with no padding; an unset script stores the hook; a three-deep chain;
a hook error is reported after the old handler ran; both error texts.
2026-10-18 04:34:15 -05:00
Brues 338c6777d6 feat(hooksecurefunc): follow 4.3.4's hooksecurefunc
Compared against 4.3.4's Script_hooksecurefunc / FrameScript_HookWrapper
(taint bookkeeping aside, which 1.12 has no counterpart for):

- The hook runs under lua_pcall with the engine's frame-script message
  handler as errfunc, so a broken hook is reported like any script
  error instead of being dropped silently. The original's results still
  reach the caller.
- The wrapper is installed with lua_rawset, so a target's __newindex
  never runs. The original is still read with lua_gettable, so an
  inherited method can be hooked.
- Registered on the glue state too: 4.3.4 registers it from
  FrameScript_Initialize, which runs for both states.
- 4.3.4's error texts, and its lua_isstring name check, so a number
  name works.
- _G is pushed and inserted at index 1, so both call forms share one
  layout. The comment saying pushvalue(GLOBALS_INDEX) crashes was
  written in the commit that fixed LUA_PUSH_VALUE (it pointed at
  lua_remove); the engine's lua_pushvalue resolves the pseudo-index.

The 11.0 unhookable-name list stays. lua_rawgeti moves into Game::Lua
now that ScriptArgs is not its only caller.

Verified in-game: a hook error is reported and T(3) still returns 3 6;
__newindex does not run and an __index method hooks; a number name
hooks; all three error texts.
2026-10-18 04:24:19 -05:00
Brues aeb3bdf07b feat(playercache): resolve GetPlayerInfoByName through the engine's name search
C_PlayerCache.GetPlayerInfoByName read only the persistent NameCache,
so it returned nothing for every name while that cache was off, even
for a player standing next to you.

It now resolves the name to a GUID first (Player::Info::GuidFromName):
the engine's own by-name search behind TargetByName (party and raid
members, including out-of-range ones, then every visible player), then
the friends list, then the persistent cache. The result is built from
that GUID by the same code as GetPlayerInfoByGUID.

The engine search is wrapped once as Unit::Identity::FindGuidByName,
which UnitTokenFromName now uses too.
2026-10-18 03:59:50 -05:00
Brues 1caf9fde18 feat(luasyntax): give strings 4.3.4's shared metatable
4.3.4 ships stock Lua 5.1, whose createmetatable gives every string one
metatable { __index = string } in G(L)->mt[LUA_TSTRING]. 1.12 differs in
one line of each reader: lua_getmetatable returns 0 for a non-table, and
luaT_gettmbyobj returns nil. Co-hook both so a string reads a per-state
metatable instead, and drop the index-continuation hook that answered
with the string table directly.

getmetatable("") now returns the metatable, so libraries that extend it
(StatLogic: getmetatable("").__index.trim = trim) work, and a replaced
or function __index resolves through the engine's own code. A method
lookup costs what 4.3.4's does: luaH_getstr for "__index", then the
lookup in string.

Verified in-game: getmetatable("").__index == string, methods, nil on a
missing key, extending __index, a function __index, the write / call /
arithmetic errors, tostring, and the kill switch.
2026-10-18 03:53:26 -05:00
SkeewirtandBrues d496ff2aa7 feat(string): string.trim and string.join, as later clients expose them (#94)
* feat(string): string.trim and string.join, as later clients expose them

Later clients put strtrim / strjoin on the string table too (string.split
was already registered), so (" x "):trim() works and libraries capture
them at file scope - AceConfigCmd-3.0 does strtrim = string.trim and then
calls it for every toggle/slash command ("attempt to call a nil value").

Not verified in-game yet: these register the existing strtrim / strjoin
functions a second time, under the string table. The testkit check for
them (string methods, the chars argument, the globals agreeing) runs at
the next login, and I'll add the result here.

* docs(string): document string.split and cite 4.3.4's compat.lua

string.split was registered but undocumented. 4.3.4's compat.lua aliases
all three str* globals onto the string table (str.trim / str.split /
str.join), so name it as the source and list the three the same way.
strsplit takes the separator first, so its method form is called on the
separator.

* fix(string): bind the str* globals to their string-table functions

Each Register* call mints its own closure, so registering one C function
as both strtrim and string.trim gave two values and strtrim ~= string.trim.
4.3.4's compat.lua binds each pair by assignment (gsub = str.gsub, strrev
= str.reverse, str.trim = strtrim, str.split = strsplit, str.join =
strjoin), so the globals now alias the string-table entries by value via
RegisterGlobalAlias, as tinsert already does. Verified in-game:
strtrim == string.trim and ClassicAPI.strtrim == strtrim.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-18 03:32:31 -05:00
Brues 8b96fccfe9 refactor(addons): pass an addon file's name and table as real arguments
4.3.4 hands each addon file (addonName, addonTable) as real call
arguments: LoadAddOn puts one table on the stack and
FrameScript_ExecuteBuffer's wrapper path pcalls every file of the addon
with the name and that table. We injected a source preamble instead,
`local arg={"<Name>", __addonns("<Name>"), n=2};`, backed by a gated
global.

An addon file that uses `...` now compiles as a vararg function, and
the file loader gets a C closure that calls it with the name and the
addon's table. The __addonns global and its one-shot gate are gone.
Only the immediate-run file funnel gets the closure, never loadstring,
whose chunkname is forgeable; GetAddOnLocalTable reads the same table.
2026-10-18 03:16:06 -05:00
Brues 9d3bdfb6ad feat(focus): follow 4.3.4's focus, and give every unit event to focus, nameplates and marks
Every unit event the engine fires now reaches focus, nameplateN and
markN, as 4.3.4 does for its focus: the unit gets a script-registration
bit, so the engine watches its fields, and whatever the engine fires
for the unit fires for the token too.

- A co-hook on the registration recompute (FUN_0060c520) keeps bit
  0x8000 on units with one of these tokens. Post-init runs it too, so a
  unit coming back into range is watched again. 0x2000 is SuperWoW's
  and WeirdUtils' sparkle bit on every unit.
- nampower replaces the broadcast (0x00515E50) and the combat feedback
  (0x00494600) with detours that never call the original, so neither
  can be co-hooked. Their 38 + 4 CALL/JMP sites, which no loaded DLL
  touches, are redirected to functions that call the original address
  and then fire for the unit's tokens. Sites are found by a .text scan
  and patched only when the counts match Ghidra's xrefs.
- The tokens gain UNIT_COMBAT, UNIT_NAME_UPDATE, UNIT_PORTRAIT_UPDATE,
  UNIT_MODEL_CHANGED, UNIT_CLASSIFICATION_CHANGED and group-stat
  updates. TokenObserver now serves only focustarget: with SuperWoW
  every unit is engine-watched, so an observer would double-fire.

Focus drop rules follow 4.3.4 where 1.12 has the data: out of range is
kept for you, your pet, a party or raid member or their pet (the
engine's own target rule); death while not friendly, or arrival already
dead and not friendly; becoming not selectable. Death and the flag act
on the change. 1.12 has no duel exception and no SMSG_BREAK_TARGET.

%f in sent chat (4.3.4's ChatParserParseText) becomes the focus's name,
with a {rtN} prefix when marked, or FOCUS_TOKEN_NOT_FOUND.

The .text bounds move into Offsets.h (shared with interface/Export).
The focus still survives /reload; focustarget events stay as a
ClassicAPI extension.
2026-10-18 02:54:02 -05:00
Brues 42d037f080 fix(text): lay out inline-icon lines the way 4.3.4 does
Chat lines with icons ran past the frame edge after 47b258a. An icon
now starts at the previous glyph's ink edge, but every measure counts
that glyph by its advance, so a glyph whose cell is wider than its step
("[" in chat) pushed each following icon and the rest of the line past
the wrap budget. Cap the ink edge at the glyph's base advance.

Match 4.3.4's CGxString layout for the rest:

- Every wrap sizes icons with the fontstring's layout scale, as 4.3.4
  passes m_layoutScale to each CalcWrapPoint. The height getter, the
  rebuild's truncation and the break array set a fontstring scope the
  wrap stepper reads (new co-hook on the break array).
- A line whose tallest icon passes the font height moves itself and the
  later lines down by the overflow (InitializeText), and the origin
  finalize justifies the block with the summed extra
  (InitializeViewTranslation), via a co-hook on FUN_005CDF70.
- GetStringHeight sums each wrapped line's overflow
  (GxuFontGetWrappedTextHeight) instead of adding the tallest icon once.
- A link's hit band grows toward where its icon reaches, by the vertical
  justify.
2026-10-18 01:26:55 -05:00
Brues af4a379311 feat(equipmentset): follow 4.3.4's equipment manager
UseEquipmentSet now behaves like 4.3.4's CGEquipmentManager:
- in combat only the weapon slots swap (one COMBAT_SWAP message);
- missing items and full bags report through UI_ERROR_MESSAGE;
- bank items are used only while the bank is open, and then equip
  straight from the bank;
- a swap in flight or an item on the cursor refuses the call.

EQUIPMENT_SWAP_FINISHED now fires when the swap settles instead of
during the call: success when the paperdoll matches, failure on an
SMSG_INVENTORY_CHANGE_FAILURE for one of the moved items, or when the
paperdoll stops changing short of the target. Its failure value is nil,
not 0. CanUseEquipmentSets and the set buttons are blocked meanwhile.

GetEquipmentSetInfo uses 4.3.4's GetSetStatus counts and strict
IsSetEquipped; GetItemIDs and GetItemLocations return all 19 slots with
the 0/1/-1 markers; set buttons also grey when a replaced item is
locked. Set names go through a port of ScrubString. Item tooltips get
the "Equipment Sets:" line (dontShowEquipmentSetsOnItems hides it).
2026-10-18 00:24:00 -05:00
Brues 2b9d0e7127 fix(player): fire HEARTHSTONE_BOUND on SMSG_PLAYERBOUND
The server re-sends SMSG_BINDPOINTUPDATE at login and on every map
change, which needed two gates to filter out. SMSG_PLAYERBOUND (0x158)
is sent only from the bind spell effect, right after the bind-point
update, so it needs no filtering. 1.12 handles it in the shared player
packet handler FUN_005E38C0.

Subscribe through Net::PacketDispatch instead of hooking the bind-point
handler, which drops one MinHook and the two unused offsets.
2026-10-17 23:46:39 -05:00
SkeewirtandBrues a68fb0a6be fix(frame): keep a scroll child clipped when its ScrollFrame moves (#60)
* fix(frame): keep a scroll child clipped when its ScrollFrame moves

A frame a ScrollFrame draws carries a "scrolled" flag (0x2000 at +0xB4):
the strata pass skips it and the ScrollFrame draws it inside a viewport
cut to its rect. SetScrollChild sets the flag through the setter at
0x0076A400, which also marks the scroll child (0x4000) and recurses into
child frames. SetParent sets a moved frame's flag from its new parent
through the same setter, and the recursion doesn't stop at a ScrollFrame,
so moving a frame that holds a ScrollFrame under an unscrolled parent
cleared the flag on the scroll child and everything in it. The strata
pass then drew them whole, past the ScrollFrame's edges.

AceGUI-3.0 builds each widget under UIParent and then moves it into its
container, so every AceGUI ScrollFrame spilled: AceConfigDialog-3.0's
pages drew below the Interface Options window (61 frames under Zygor
Guides Viewer 2.0's "Display" scroll child had lost the flag).

Hook the setter to keep a frame marked as a scroll child scrolled when a
value is only being passed down (third argument -1). SetScrollChild's
own calls are unchanged.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
AceConfigDialog-3.0's Interface Options pages clip and scroll inside
their box; on the "Display" page no frame under the scroll child lacks
the flag any more (61 did before), and a ScrollFrame built under
UIParent and then moved keeps its content, its content's children and
a child added afterwards scrolled.

* docs(frame): describe ScrollFrame clipping without version history

State what stays clipped and when, with AceGUI-3.0 as the case it
serves. Remove the vanilla comparison and the engine flag explanation;
the comment in src/frame/ScrollChildren.cpp already records them.

* fix(frame): skip scroll children the way 3.3.5's setter does

3.3.5's CSimpleFrame::SetBeingScrolled (0x00490F60) does not pass the
scrolled value into a child frame marked as the scroll child, and its
direct call is not gated: a scroll child that is itself given a new
parent takes the new parent's value. The hook forced any scroll child
scrolled on a pass-down call, which also kept a directly reparented
scroll child clipped to its old ScrollFrame.

Skip a scroll child only inside the setter's own recursion, told apart
by a depth counter (the setter is its only nested caller). SetParent's
and SetScrollChild's direct calls run unchanged.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 20:40:56 -05:00
SkeewirtandBrues 721f32ac77 feat(map): keep the closed world map on the player's zone (#71)
* feat(map): keep the closed world map on the player's zone

GetPlayerMapPosition("player") reads the map the world map is set to,
open or closed, and returns 0,0 off it. The 3.x FrameXML resets that
map to the player's zone in WorldMapFrame_OnHide and in WatchFrame's
PLAYER_ENTERING_WORLD and (map closed) ZONE_CHANGED_NEW_AREA handlers;
vanilla has neither, so after zoning the closed map stays on the old
zone. Do the same resets from the bundled addon. Zygor Guides Viewer's
travel steps read the player's position this way.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
after logging in, the closed world map is on the player's zone (Darkshore)
and GetPlayerMapPosition("player") returns the player's position there.
Not checked yet: changing zones with the map closed (the
ZONE_CHANGED_NEW_AREA reset) and closing the map on another zone (the
OnHide reset).

* docs(map): describe the closed world map without version history

State what the map does and when it goes back to the player's zone,
and add that a zone change does not move the map while it is open.
Remove the 3.x and vanilla comparison and the FrameXML handler names;
the code comment in Util/WorldMap.lua already records them.

* docs(readme): keep Highlights for major features

Single-behavior fixes (faction while loading, <Script file> paths, the
closed world map, the quest completed line, SetParent frame levels,
frame alpha, OnSizeChanged sizes) each had a row in the Modern client
behaviors table. Remove those rows and point to docs/API.md, where each
fix keeps its own section.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 20:26:57 -05:00
Brues fcdc24471c fix(spell): latch remote channels on +0x228 like the player's
The broadcast +0x228 field lags another unit's channel start by up to
~1 s, and UnitChannelInfo returned nothing until it caught up. So the
UNIT_SPELLCAST_CHANNEL_START it fires from the SMSG_SPELL_START stamp
disagreed with the getter, and cast bars that poll on the event (pfUI
nameplates) missed the channel whenever the field was late.

Mirror the player's confirmed latch per remote channel: the packet stamp
stands until the field has shown the spell, and a confirmed field that
clears before endMs is the channel ending early. That drops the entry and
fires CHANNEL_STOP, where it used to run out the full computed duration
(no packet reports a remote interrupt on Turtle).
2026-10-17 20:10:37 -05:00
Brues 58f5b4f78e fix(spell): keep remote channel times through the SPELL_GO cast-slot clear
The engine's SPELL_GO handler calls ClearCastingSpell on the caster for
every spell, including a channel at the moment it starts. Our co-hook
treated that as an abort and evicted the SMSG_SPELL_START cache entry, so
UnitChannelInfo never had start/end times for another unit's instant
channel (bandages). ClearCastingSpell only ends the cast slot; a channel's
lifetime is the +0x228 field UnitChannelInfo already gates on, so that
route no longer evicts channel entries. The failure packets still do.

Fixes #102
2026-10-17 20:08:18 -05:00
Brues 6be4aac9fb feat(macro): resolve every macro's display the way 3.3.5 does
Compared Macro::ShowTooltip against 3.3.5's decompiled MACRONODE::Parse,
FindSpellID and GetFullIconPath (WoW/Source/Ui/UIMacros.cpp) and fixed
the differences:

- Every macro body is parsed, directive or not. A question-mark macro
  with no #show now shows its first action's icon, and its button
  follows that action's cooldown, count and usable state; the tooltip
  stays the engine's (Kind::Implicit). #show counts anywhere in the
  body, not only on line 1.
- "#showtooltip none" stops the parse: the macro keeps its own icon and
  holds no spell, instead of greying out.
- /castrandom and /userandom show their first entry (3.3.5 cuts the
  value at the first comma); they used to grey the button.
- /equip and /equipslot lines are collected and resolve to items only.
- Bare numbers 1..23 are inventory slots, the equipped bags included.

Edits now always end in a repaint, as 3.3.5's EditMacro does: the engine
repaints a macro's buttons straight after its parse, before our rescan,
so a re-parsed body is marked and repainted on the next tick, and a
reader's catch-up defers its repaint instead of firing
ACTIONBAR_SLOT_CHANGED from inside a button handler.

Implicit macros are also parked across the unlearn sweep, so a
condition-picked value can't delete a macro from the bar.
2026-10-17 19:49:39 -05:00
Brues a6270469e1 refactor(chatbubble): move the bubble list offsets into Offsets.h
C_ChatBubbles.GetAllChatBubbles defined the engine's bubble list head
(0x00B6F038) and link offset (+0x318) as file-local constants. Move them
to Offsets.h as VAR_CHAT_BUBBLE_LIST_HEAD / OFF_CHAT_BUBBLE_LINK_NEXT,
documented as 3.3.5's CGChatBubbleFrame list and the engine walks they
mirror. No behavior change.
2026-10-17 19:30:16 -05:00
Brues a8f73623c4 perf(nameplate): enumerate the engine's shown-plate list, not every object
NamePlate::Walk walked the whole object hash table every frame (and on
every C_NamePlate.GetNamePlates call) to find units with a plate. 1.12
keeps the same shown-plate list as 3.3.5's s_namePlates (head
0x00C4D92C, next at plate+0x4E0), each plate carrying its unit's GUID
(+0x4E8). Walk that list and resolve each plate's unit by GUID, as the
engine's own NamePlateClearAll (FUN_0060F890) does; a plate still counts
only while unit+0xE60 points back at it.

PlateReassigned becomes one read of the plate's GUID instead of a scan of
the tick's snapshot. GetNamePlates now returns the engine's order
(farthest from the screen centre first).

The old comments claiming the list was unsafe to read and that release
callers "transiently zero +0xE60" are replaced: the list is re-sorted
only by main-thread engine code, every plate on it is bound, and every
FUN_00608A10 caller is a real release.
2026-10-17 19:27:45 -05:00
Brues 47b258ad95 fix(text): size, space and align inline icons by the engine's own rules
The inline-texture backport placed icons with hand-tuned constants: a
0.18 x font lead/trail pad, a 0.6 x font vertical centre, a -1px region
nudge, and written sizes taken as raw screen pixels. Replace all of them
with the rules from the 3.3.5 / 4.3.4 clients (decompiled; the size
formula's helpers confirmed against the real 4.3.4 binary):

- A written size multiplies by fontPx / (14 x ddcWidthScale), so it
  follows the text's size, the UI scale and the resolution, and then by
  the fontstring's layout scale, as 3.3.5 does. Size defaults follow
  ParseEmbeddedTexture: ":0:2" is a width ratio, ":20:0" is font-wide.
- An icon starts at the previous glyph's ink edge (bearing + cell width)
  plus the face's outline gap, found by replaying the run's placements
  with the emitter's own glyph helpers; the next glyph starts at the
  rounded icon edge.
- Vertical alignment follows the node's justify-V (node+0x50).
- Offsets move only the drawn icon; a positive offsetY now moves it up.

GetCoinTextureString now matches 3.3.5: default height 14, a space
between denominations, and retail's 2:0 coin offset in the strings.

The four Coordinate statics at 0x00832A40 get their real names
(VAR_COORD_DDC_WIDTH_SCALE and siblings). Known gap: the fs-level wrap
callers can't see the fontstring, so they budget icons at layout scale 1.
2026-10-17 19:14:17 -05:00
Brues ac6bd552d5 fix(addons): rebuild the file index with the engine's own clear on /reload
Rescan re-ran the boot indexer on two subtrees, which only adds files, and
skipped its whole run when the index's built latch was 0. The SavedVariables
unload pass clears that latch whenever it writes a new SV file, so on that
/reload no new folder, metadata change or display-array rebuild happened.
Call FUN_VFS_INDEX_CLEAR (0x00646FC0) instead - the clear the engine itself
uses after its own file writes - and drop the latch gate. The next lookup
rebuilds the index from disk, so deleted files drop out too.

AddOns::Toc::FindValue now matches lines the way the TOC parser does: skip
a UTF-8 BOM, break lines at CR or LF, and accept any spaces after "##"
before the key, which must be followed directly by ':'. Callers pass the
bare key.

Also correct the notes that said a first-ever SavedVariables file stays
unreadable until restart; the engine's unload pass already handles it.
2026-10-17 18:42:51 -05:00
SkeewirtandBrues 2a05baee8d feat(cvar): GetCVar returns nil for an unknown cvar (#82)
* feat(cvar): GetCVar returns nil for an unknown cvar

Later clients return nil from GetCVar for a name they do not know; vanilla
raises "Couldn't find CVar named '<name>'". Ported code uses the nil as
a feature probe - LibCamera-1.0 opens with
if not GetCVar("cameraYawE") then return end, which throws at file load
on vanilla.

Co-hook Script_GetCVar: a string name that FUN_FIND_CVAR (the lookup
Script_GetCVar and C_CVar.DoesCVarExist share) does not find yields nil.
Everything else, including non-string arguments, runs the engine function
unchanged. The glue state registers the same function, so both match.

Verified in-game (1.12.1 client via VanillaFixes): GetCVar with an unknown
name returns nil at file load, without an error.

* feat(cvar): GetCVarDefault stops raising for an unknown cvar

Script_GetCVarDefault has the same isstring -> tostring -> FUN_FIND_CVAR
-> raise shape as Script_GetCVar, so it gets the same co-hook. Both now
return no values for an unknown name, matching 1.15.9 (`/dump` prints
"empty result" for each) instead of a single nil.

Docs: describe behavior only, without version or vanilla-gap notes.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 18:26:46 -05:00
SkeewirtandBrues 80ef0e2988 feat(lua): give modern XML handler bodies their implicit parameters (#84)
* feat(lua): give modern XML handler bodies their implicit parameters

Later clients compile an XML handler body as a function whose parameters
name the script's arguments - self, then elapsed / button, down /
event, ... and so on. Vanilla compiles it as a zero-parameter chunk and
publishes the frame and arguments only through the this / argN globals,
so a ported <OnLoad>self:Foo()</OnLoad> indexes a nil global.
Zygor Guides Viewer 2.0 has 85 such bodies; 13 fail at load.

For a chunk compiled by the XML script funnel FUN_00704C70 (return
address 0x00704C8C, engine-built chunkname "<Frame>:<Script>") whose body
reads self - or reads one of its script's parameter names and none of
this / argN - wrap it as return function(self, <names>, ...) ... end,
reusing the vararg path's materialize-the-inner-function shape.
Frame::ScriptArgs already pushes (self, arg1..argN) in that order. Vanilla
bodies are untouched, so FrameXML and 1.12 addons keep their globals and
no names are shadowed.

`event` alone doesn't trigger the wrap. Vanilla sets a global `event`
during OnEvent dispatch, so FrameXML's own
<OnEvent>ActionButton_OnEvent(event)</OnEvent> bodies (about 250) already
work; a body that also reads self still gets (self, event, ...), which
binds the same value.

Adds RET_LUA_SCRIPT_COMPILE (found by scanning FUN_00704C70 for the E8
call to luaL_loadbuffer; the same scan reproduces RET_LUA_FILE_COMPILE),
an XmlHandlerArgs transpile toggle, and a 6th _classicapi_TranspileStats
return counting wrapped handlers.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
probe XML bodies see self in OnLoad, elapsed in OnUpdate, button in
OnClick ("LeftButton") and event in OnEvent; Zygor's bodies load without
an error, and 112 of the 36,627 chunks compiled that session were wrapped.

* fix(lua): match 3.3.5's handler parameters; treat ... as a modern body

OnSizeChanged binds (self, w, h), as 3.3.5's wrapper does, not
(self, width, height); the table now names its 3.3.5 source.

A handler body with a ... expression is wrapped with its named
parameters. Lua 5.0 can't compile one, so only a body written for later
clients has it, and the plain vararg wrap gave an OnEvent body's ...
(self, event, args) instead of the arguments after event.

The docs list each script's names and drop the version history, and say
the values need SetModernScriptArgs on.

* fix(frame): pass OnSizeChanged the size in GetWidth/GetHeight units

FUN_FRAME_ON_SIZE_CHANGED (0x0076B660), the only code that fires
OnSizeChanged, pushed the frame's screen-rect size in raw layout units:
a 20 x 10 frame's handler saw (0.0092, 0.0046). 3.3.5 divides out the
frame's layout scale (OnFrameSizeChanged, 0x0048FA20) and converts with
the chain 1.12's own GetWidth uses (Game::InternalToPixel) before the
script runs.

A co-hook now does both: convert, then divide by the frame's effective
scale (OFF_REGION_EFFECTIVE_SCALE). Verified in game at a 0.91 UI
scale: (19.99993, 9.99997) against GetWidth/GetHeight's (20, 10).
Converting alone gave (18.2, 9.1). The vmethod uses the values for
nothing else, and neither stock FrameXML nor any installed addon reads
OnSizeChanged's arguments.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 16:27:11 -05:00
SkeewirtandBrues 5df5ce00cf fix(xml): resolve <Script file> subdirectory paths relative to the XML file (#80)
* fix(xml): resolve <Script file> subdirectory paths relative to the XML file

The XML file processor joins <Include file> onto the including file's
directory, but <Script file> only when the value has no backslash
(strchr at 0x006EE06E); a value with one is loaded from the game root.
So <Script file="widgets\AceGUIWidget-Button.lua"/> inside
Libs\AceGUI-3.0\AceGUI-3.0.xml fails with "Error loading
widgets\AceGUIWidget-Button.lua" - all 23 AceGUI-3.0 widget files in
Zygor Guides Viewer 2.0, and every other addon that embeds AceGUI-3.0.
Later clients resolve it relative to the XML file.

Co-hook the XML file processor (FUN_XML_LOAD_FILE) to track the file being
processed, and at the <Script> call into FUN_LUA_LOAD_FILE (return address
RET_XML_SCRIPT_FILE_LOAD) retry a backslash path that does not exist as
given against that file's directory, only when the joined file exists.
Root-relative paths that load on vanilla are unchanged. FUN_LUA_LOAD_FILE
is already co-hooked by AddOns::SavedVarsFirst, which now calls
Xml::ScriptPath::Resolve first.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
a probe addon's <Script file="sub\File.lua"/> loads from its XML file's
folder, and Zygor's AceGUI-3.0 option pages, built from those widget
files, work.

* refactor(xml): share the Lua file-loader hook; collapse .. in Script paths

FUN_LUA_LOAD_FILE now has one owner, Script::LoadFile, which runs path
rewriters and then suppressors. Xml::ScriptPath is a rewriter and
AddOns::SavedVarsFirst a suppressor, so neither module reaches into the
other's hook.

Xml::ScriptPath joins onto the XML file's folder with each \..\
collapsed first, as FUN_XML_LOAD_FILE does before its own joins; the
tracked pointer is the caller's uncollapsed path, so an XML file reached
through <Include file="..\x.xml"/> joined onto a path with .. in it.

The user docs no longer name later clients or what 1.12 does.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:52:58 -05:00
SkeewirtandBrues 1297b9522a feat(macro): accept a macro name in GetMacroInfo/EditMacro/PickupMacro/DeleteMacro (#96)
* feat(macro): accept a macro name in GetMacroInfo/EditMacro/PickupMacro/DeleteMacro

2.0+ clients take `index or "name"` in all four; vanilla raises
"Usage: DeleteMacro(index)" (etc.) for a name. Co-hook each script
function: a non-numeric string first argument becomes the macro's
1-based index via FUN_MACRO_NAME_TO_SLOT (the GetMacroIndexByName
resolver), and an unknown name becomes 0, which each original treats
like any out-of-range index (GetMacroInfo returns nils, the others do
nothing). Zygor Guides Viewer clears its scratch macros with
DeleteMacro("ZygorGuidesMacro" .. i) every time a step starts.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
an unknown name is a no-op in all four, and Zygor's DeleteMacro calls on
every step start run without an error. The known-name path hasn't run
in-game yet: the test character has no macros.

* docs(macro): say what EditMacro returns for an unknown macro name

An unknown name leaves EditMacro returning the first empty slot's index
(or 0 when every slot is full), the same as an out-of-range index and as
3.3.5; the section said all three non-GetMacroInfo functions "do
nothing". Also drop the patch-version mention.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:37:48 -05:00
SkeewirtandBrues b3188ced2a feat(tradeskill): add GetTradeSkillRecipeLink and IsTradeSkillLinked (#75)
* feat(tradeskill): add GetTradeSkillRecipeLink and IsTradeSkillLinked

GetTradeSkillRecipeLink(index) links the recipe in a trade-skill row as
an enchant: link carrying the recipe's spell ID, in the form the client's
own GetCraftItemLink (0x004F72A0) gives an enchanting formula
(|cffffffff|Henchant:%d|h[%s]|h|r, format at 0x0084D410), with the
spell's name in the client's locale. Rows are read as the engine's row
functions read them, so a header row (spell ID -1) returns nothing.
Vanilla's window only links the item a recipe makes.

IsTradeSkillLinked() returns nil: on this client the trade-skill window
and its functions only describe the player's own professions, since a
received trade: link opens ClassicAPI's separate viewer.

Zygor Guides Viewer 2.0 calls both on TRADE_SKILL_SHOW to record the
recipes the player knows.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
in the First Aid and Cooking windows, all 15 recipe rows link the row's
own spell (checked against GetSpellInfo) and header rows return nothing;
IsTradeSkillLinked() returns nil.

* fix(tradeskill): match 3.3.5's GetTradeSkillRecipeLink and show its links

GetTradeSkillRecipeLink now builds 3.3.5's link: gold, "Profession:
Recipe" (the profession from the engine's SkillLineAbility lookup
FUN_006de040, for the player's race and class), gated on
SPELL_ATTR_TRADESPELL. Empty names are pushed as the record holds them,
and the index is checked as a double before the cast, the way the
engine truncates and compares unsigned. IsTradeSkillLinked returns
(nil, nil), as 3.3.5 does. Both globals get doc descriptors.

1.12's SetHyperlink accepts enchant: only for records with +0x0C == 3,
which no trade-skill recipe has, and raises "Unknown link type" for the
rest. Tooltip::Hyperlink now shows the spell tooltip for a trade-spell
enchant: link, as 3.3.5 does. That also fixes clicking SuperWoW's
GetTradeSkillItemLink(index, 1) links; its SetHyperlink patch at
0x00532210 runs after this co-hook and handles only quest:.

SPELL_ATTR_TRADESPELL moves to Offsets.h now that two files use it.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:30:20 -05:00
SkeewirtandBrues f55893fdf4 feat(frame): Button:Set/GetNormalFontObject and Frame:IsProtected (#93)
* feat(frame): Button:Set/GetNormalFontObject and Frame:IsProtected

- button:SetNormalFontObject / GetNormalFontObject: 3.0's names for
  vanilla's SetTextFontObject / GetTextFontObject; delegate to them.
  Zygor Guides Viewer 2.0 and AceGUI-3.0 call the new names.
- frame:IsProtected() returns false, false: vanilla has no protected
  frames (the secure system arrived in 2.0). AceHook-3.0 asks before
  hooking scripts.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
Zygor sets its guide window's tab fonts with SetNormalFontObject while it
builds, without an error. Not yet checked: that the font set reads back
through GetTextFontObject and the reverse, and IsProtected's two values;
the testkit check for those runs at the next login.

* docs(frame): drop version and implementation notes from the new methods

The SetNormalFontObject and IsProtected sections named the patches the
APIs came from, said what 1.12 lacks, and described how the methods are
implemented. User docs describe the contract only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 15:06:45 -05:00
SkeewirtandBrues e0b2e56070 feat(frame): multiply frame alpha into child frames, as 3.x does (#70)
* feat(frame): multiply frame alpha into child frames, as 3.x does

A frame now draws at its own alpha times its parent's drawn alpha, and
GetAlpha returns the frame's own alpha. Vanilla keeps one alpha per
frame and copies it into the children that exist when SetAlpha runs:

- an XML frame with alpha="0" drew its child frames at full alpha,
  because the attribute is applied before its <Frames> are built;
- a child's own alpha was overwritten whenever its parent's changed;
- a frame created or reparented under a transparent frame drew opaque.

Zygor Guides Viewer's border glow is the first case. It is an
alpha="0.0" frame whose child holds the glow textures, so on vanilla the
glow covered the guide frame from login until the first step flash.

Co-hooks the engine's alpha setter, SetParent, the frame destructor and
frame GetAlpha. Own alpha lives in a table keyed by frame. The engine's
byte holds the drawn value, so region recolors and the Model family's
model alpha read it unchanged. Children are recomputed through their
own vtable setter, as the engine's copy-down calls it.

Vanilla code that shows a child under a transparent parent
(parent:SetAlpha(0); child:SetAlpha(1)) now leaves the child hidden, as
3.x does.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
Zygor's guide frame no longer shows its border glow from login. With own
alphas 0.5 / 0.8 / 1.0, a child and a grandchild draw at 0.4; the child
of an XML frame with alpha="0" keeps its own 1.0 and draws at 0; a child
keeps its own 0.5 while its parent goes to 0.2 and back to 1; a frame
with its own 0.6 draws at 0.3 under a 0.5 parent and at 0.18 once moved
under a 0.3 one.

* fix(frame): match 3.3.5's effective-alpha math and read it from the byte

- Multiply child alpha by truncating (a*b/255), as 3.3.5's
  CSimpleFrame::UpdateAlpha and GetEffectiveAlpha do, instead of
  rounding.
- GetEffectiveAlpha reads the frame's alpha byte, which Frame::Alpha
  keeps at the drawn value, instead of multiplying GetAlpha up the parent
  chain. This is 3.3.5's Script_GetEffectiveAlpha. Drops the now-unused
  FUN_SCRIPT_REGION_GETPARENT.
- Drop the reload cleanup of the own-alpha table. The frame destructor
  hook already removes each frame's entry, and a frame that outlives a
  /reload would have lost its own alpha.
- Rewrite the API.md frame-alpha and GetEffectiveAlpha sections as
  user-facing behavior.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 14:58:24 -05:00
SkeewirtandBrues 20dc9fc962 fix(frame): move a frame's children along when SetParent sets its level (#61)
* fix(frame): move a frame's children along when SetParent sets its level

Vanilla's SetParent gives the frame its new parent's strata and level + 1,
but calls the level setter without its children flag, so the frames under
it keep the levels they had. A panel built on its own and then moved into a
raised window draws its contents behind that window: Zygor Guides Viewer
2.0's AceConfigDialog-3.0 panel in the Interface Options window had its
widgets at levels 1-4 under a window at level 5.

Hook the level setter (0x0076A4F0) and pass the children flag on
SetParent's two calls, recognized by their return addresses. The setter
then moves each same-strata child by the same amount, recursively.
Script SetFrameLevel and the setter's other callers are unchanged.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
the AceConfigDialog-3.0 panel's widgets draw above the Interface Options
window, and none of its 18 frames sit below the window's container any
more. A frame with a child and a grandchild moved under a level-20 frame
sits at 21/22/23, and at 0/1/2 once moved to no parent.

* refactor(frame): patch SetParent's children flag instead of hooking the setter

The two `push 0` that pass the level setter's children flag from SetParent
become `push 1`, which is what 3.3.5's SetParent passes. This drops the
MinHook on the level setter, which 11 call sites share, and the
return-address checks. Each site's bytes are checked before the write, as in
Quest::CompleteMessage.

Docs: describe only the current behavior in API.md and the README row.

Verified in-game: a frame with a child and a grandchild moved under a
level-20 frame sits at 21/22/23, and at 0/1/2 once moved to no parent.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 14:08:03 -05:00
SkeewirtandBrues 51d226f67d feat(tooltip): SetHyperlink takes full links and spell:/quest: links (#68)
* feat(tooltip): SetHyperlink takes full links and spell:/quest: links

Vanilla's GameTooltip:SetHyperlink matches only a bare `item:` or
`enchant:` payload and raises "Unknown link type" for anything else,
including the full |cff..|H..|h[..]|h|r links 2.0+ GetItemInfo and chat
hand out. Co-hook it:

- a full link is reduced to the payload between |H and |h;
- spell:<id> builds the SetSpellByID tooltip;
- quest:<id> shows the cached title and objectives text (tokens
  expanded by the engine's quest-text substituter); an uncached quest
  leaves the tooltip empty and queues the quest query once;
- everything else runs the engine function on the payload.

Quest::Cache::Lookup gains `unique`, the engine's own dedup flag (the
last _GetRecord argument, previously passed as an unused 0), so a
tooltip redrawn every frame waits on one queued callback.

Zygor Guides Viewer reads quest titles with
SetHyperlink("|Hquest:<id>:1|h[q]|h") and item names with full links.

Verified in-game (1.12.1 client via VanillaFixes): on a
GameTooltipTemplate tooltip, a full Hearthstone link shows Hearthstone's
tooltip, "spell:133" shows Fireball's, and "quest:957" shows Bashal'Aran's
title and objectives.

* refactor(tooltip): share the C tooltip line helpers; tidy SetHyperlink

- Add tooltip/Lines.h (Clear / Add / Show over the engine's tooltip clear,
  raw add-line and Script_Show) and use it in place of the four local
  copies in Hyperlink, CompareItem, totem/Tracker and equipmentset/Tooltip.
  EquipmentSet keeps its exact colors as packed constants.
- SetHyperlink quest colors come from UI::ColorData (HIGHLIGHT_FONT_COLOR,
  NORMAL_FONT_COLOR) instead of hand-packed doubles.
- spell:<bad id> now clears the tooltip, like quest:<bad id>, instead of
  leaving the previous tooltip's lines.
- Rewrite the SetHyperlink API.md section in plain English without version
  or vanilla-gap notes.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 13:53:22 -05:00
SkeewirtandBrues eb3c32094c feat(pvp): add IsActiveBattlefieldArena (#77)
* feat(pvp): add IsActiveBattlefieldArena

Stock 1.12 has no arenas, so it answers nil there. Turtle-lineage clients
add arena matches, run in instances IsInInstance reports as "pvp": in a
"pvp" instance their scoreboard asks for arena scores over the hidden
TW_ARENA channel, only an arena answers, and the scoreboard keeps the
reply in WorldStateScoreFrame.arenaData until the world states update
outside an instance. isArena follows that state, as the scoreboard's own
arena view does (Octo's FrameXML, patch-4.mpq: WorldStateFrame.lua and
Turtle_ArenaUI.lua). isRegistered is always nil: the reply has no rated
flag, and the client's queue window only joins skirmishes.

Zygor Guides Viewer 2.0 calls it, unguarded, to decide whether to set
its corpse arrow when the player is dead outside a battleground.

Verified in-game (OctoWoW 1.12.1 client via VanillaFixes): outside an
instance it returns nil, nil. The arena branch has not been exercised in
a match yet.

* refactor(pvp): answer IsActiveBattlefieldArena from DBC data in C

The Lua version read Turtle's scoreboard state (WorldStateScoreFrame.arenaData),
which is nil until the server's TW_ARENA reply lands and depends on Turtle's
FrameXML. The client's own data already marks an arena: a pvp-type map
(Map.dbc instanceType 3) whose area carries AreaTable flag 0x80, the flag
Script_GetZonePVPInfo reports as isArena. Check the zone as well as the
subzone, since the arena maps' areas are top-level zones. The type-3 gate
keeps the open-world rings (Gurubashi, The Maul) out.

Returns booleans, as retail does; isRegistered is always false.

Offsets.h: add OFF_AREATABLE_FLAGS, AREA_FLAG_ARENA and
VAR_PLAYER_SUBZONE_AREA_ID, and correct the zone-change handler's second
argument (the subzone ID, not the parent area ID).

* docs(pvp): note the server only ever creates arena map 26

tortoise-wow's GetBattleGrounMapIdByTypeId sends its one arena type,
BATTLEGROUND_BR, to map 26 (Blood Ring); map 28 (Lordaeron Arena) is
client data only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 13:41:25 -05:00
SkeewirtandBrues 26ca1b4e84 feat(quest): print "<quest> completed." for chain quests too (#76)
* feat(quest): print "<quest> completed." for chain quests too

The client prints ERR_QUEST_COMPLETE_S on SMSG_QUESTGIVER_QUEST_COMPLETE
only for a quest with no next quest in its chain. The handler (and the
callback it leaves for a quest not yet cached) skips the message when the
cached record's NextQuestInChain (+0x24) is set, so a turn-in in the
middle of a chain printed only the experience and money lines. Zygor
Guides Viewer 2.0 completes a turn-in step only on that line, so its steps
for chain quests stayed open until the next login.

NOP the two `jne` over the message (0x005DC4DC, 0x005DC756), after
checking the bytes, so every turn-in goes through the engine's own
message path: same text, sound and CHAT_MSG_SYSTEM chat type, ahead of
the experience and money lines.

That later clients print the line for every turn-in is an inference, not
read from a later client's binary: the 3.3.5a build of Zygor Guides
Viewer completes chain turn-ins on this line the same way.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
mid-chain turn-ins (The Harvest Golem Mystery 40470, The Red Crystal
4811, As Water Cascades 4812, Bashal'Aran 956, Buzzbox 323 1002) print
"<quest> completed." and Zygor completes the turn-in step at once; a
quest at the end of its chain prints it as before.

* docs(quest): the completed line's order depends on the quest cache

When the quest record is not cached at turn-in, the callback 0x005DC710
prints the line on the record's arrival: after the experience and money
lines, and after QUEST_TURNED_IN. Stop promising a fixed order.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 13:13:52 -05:00
SkeewirtandBrues 7331922070 fix(build): quote the /DEF path so source trees with spaces link (#59)
* fix(build): quote the /DEF path so source trees with spaces link

CMAKE_SHARED_LINKER_FLAGS passes /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def
unquoted, so a checkout under a path containing a space (e.g.
C:\Projects\Classic API\ClassicAPI) splits the argument and the link fails
with LNK1181: cannot open input file 'API\ClassicAPI\src\ClassicAPI.def'.
Quote the path; the DLL output is unchanged.

Verified: Release/Win32 build now links from a path with a space.

* fix(build): list the .def as a target source instead of a /DEF flag

CMake now emits a quoted /DEF: on every generator, so the hand-escaped
path in CMAKE_SHARED_LINKER_FLAGS goes away, and the .def becomes a link
dependency: editing an export relinks the DLL instead of needing a clean
build.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 02:19:51 -05:00
SkeewirtandBrues 27d8f90f03 fix(unit): UnitFactionGroup("player") answers while addons load (#79)
* fix(unit): UnitFactionGroup("player") answers while addons load

Script_UnitFactionGroup resolves "player" through the in-world unit
descriptor, so it returns nil at file scope, ADDON_LOADED and
VARIABLES_LOADED and only answers from PLAYER_LOGIN. UnitName, UnitRace
and UnitClass already answer then, and later clients answer
UnitFactionGroup too, so ported addons read it at file scope. AceDB-3.0
does exactly that to build its "<faction> - <realm>" profile key, and
fails to load on nil ("attempt to concatenate local 'factionKey'").

Co-hook the engine function: when it answers nothing for the "player"
token, fall back to the login-time race byte (VAR_PLAYER_RACE_BYTE) and
the same race -> faction-group chain its player branch uses. That chain
moves out of C_CreatureInfo.GetFactionInfo into a shared
Creature::Info::FactionGroupForRace; GetFactionInfo's behavior is
unchanged. Once the player object exists the engine answers by itself.

Adds FUN_SCRIPT_UNIT_FACTION_GROUP (slot 13 of table 0x00850438).

Measured in-game before the fix (Octo 1.18.1 client, Human):
UnitFactionGroup("player") = nil at file load / ADDON_LOADED /
VARIABLES_LOADED, "Alliance" from PLAYER_LOGIN; UnitName/UnitRace
answer at every stage.

Verified in-game after the fix (same client and character):
UnitFactionGroup("player") is "Alliance" at file load, ADDON_LOADED and
VARIABLES_LOADED, and AceDB-3.0 sets up Zygor Guides Viewer 2.0's saved
settings.

* fix(unit): keep UnitFactionGroup's in-world nil; describe both engine branches

Script_UnitFactionGroup reads the live faction template when the unit's
object is in the world, and only falls back to the name cache's race when
it isn't. The race fallback now runs only under that same condition (no
player object), so an in-world nil (e.g. charmed by a monster) stays nil
instead of turning into the race's faction. Comments and API.md now say
which engine branch the fallback mirrors.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 02:14:04 -05:00
SkeewirtandBrues 35ef1228eb feat(lua): give each pass of a for loop its own loop variables, as in 5.1 (#66)
* feat(lua): give each pass of a for loop its own loop variables, as in 5.1

Lua 5.0 declares a for loop's variables once for the whole loop (its
parser's forbody activates them before entering the loop block), so every
function made in the body shares one variable, which is nil once a
generic for ends. Lua 5.1 declares them per pass, and 5.1 code relies on
it. Zygor Guides Viewer 2.0's recent-guides menu gives each entry
`checked = function() return ... guide.full end`; on 1.12 it errored
with "attempt to index a nil value" whenever the menu opened.

A new transpile pass rewrites such a loop to the 5.1 manual's own
equivalent, `for __cf_k, __cf_v in e do local k, v = __cf_k, __cf_v;`,
on the same line. It applies only to loops whose body contains a
function, and not when the body assigns to a loop variable (5.0 carries
that into the next pass and 5.1 doesn't). Chunks whose blocks don't
balance are left alone. The trigger scan follows blocks and brackets
without allocating. Toggle: "ForLoopScopes".

Tested offline on the rewriter's own code: 21 targeted cases, and 1,417
addon and Classic Era FrameXML files (67.7 MB). 184 loops were rewritten,
and every rewritten file still compiles under Lua 5.1. The pass adds about
0.03-0.05 s over the whole 67.7 MB.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
the recent-guides menu opens without an error, and functions made in a
generic for, a numeric for and a pair of nested loops each return their
own pass's values.

* refactor(lua): close loop upvalues per pass in the compiler instead of rewriting source

Replace the token-scanning source rewrite with a hook on the parser's
forbody. Lua 5.0's loop variables are the loop's control registers, and
a function made in the body keeps them open until forstat's block ends.
Emitting one OP_CLOSE at the end of each pass, before OP_FORLOOP /
OP_TFORLOOP, gives each function its own pass's values.

The OP_CLOSE is emitted only when a nested function names a loop
variable. markupval already records that on forstat's block, so
nothing is guessed from tokens. A loop that captures nothing compiles
to the engine's exact bytecode.

This fixes the gaps in the source rewrite (indexed and dotted targets,
`function v()`, nested elseif, long target lists). It adds no locals,
so it can't push a function past the 200-local limit, and it adds no
scan to every chunk load. Assignment to a loop variable still steers
the loop, as stock 5.0 code may rely on. The ForLoopScopes toggle is
gone with the rewrite.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 01:58:02 -05:00
SkeewirtandBrues 76cc907bd0 feat(tooltip): add GameTooltip:AddTexture on the engine's own routine (#67)
* feat(tooltip): add GameTooltip:AddTexture on the engine's own routine

Vanilla has the 3.x tooltip-texture feature built in and unexposed:
GameTooltipTemplate declares <name>Texture1..3, the XML-OnLoad scan
binds them (tooltip+0x34C), FUN_005303D0 lays the next one out on the
last line (sized to the line, text shifted right) and the tooltip clear
undoes it. Nothing in the 1.12 binary calls that routine; expose it as
GameTooltip:AddTexture. The engine's limits stay: three textures per
tooltip, none on the header line. Zygor Guides Viewer adds guide
images to its tooltips this way.

Verified in-game (1.12.1 client via VanillaFixes): on a
GameTooltipTemplate tooltip with two lines,
AddTexture("Interface\\Icons\\INV_Misc_Bag_08") shows <name>Texture1
with that file, and ClearLines hides it again.

* fix(tooltip): repair AddTexture layout around the engine routine

The engine's texture routine was never called before, and its layout has
two defects. The tooltip clear re-anchors the line below a textured line
at (0, 0) instead of the template's 2px gap, so that line sat too close on
every later tooltip from the frame. And every re-anchor replaces the
line's offset, so a second texture on one line, or one on the line right
below a texture, broke the text alignment.

Tooltip::ClearItemGuid becomes Tooltip::Clear: the one clear co-hook now
also restores the gap below each textured line. AddTexture refuses a
texture on or right below a textured line, accepts only strings, and
returns whether it placed the texture. Correct the Offsets.h description
of the routine and keep the user doc to the contract.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-17 00:18:26 -05:00
SkeewirtandBrues e9e431e014 feat(frame): add SetClampRectInsets / GetClampRectInsets (#69)
* feat(frame): add SetClampRectInsets / GetClampRectInsets

Later clients let a frame that is clamped to the screen keep a different
rect on screen than its own: SetClampRectInsets(left, right, top, bottom)
grows or shrinks the rect the clamp uses. Zygor Guides Viewer 2.0 sets
them on its frames while building them (a negative top inset lets a
frame's top go off screen), and 3.3.5's own FrameXML uses them too.

The engine clamps a frame to the screen inside its rect calculation
(0x00767A20): it resolves the edges and, for a clamped frame, slides the
rect onto the screen. Hook that calculation: for a frame with insets, take
the unclamped rect, grow it by the insets (converted from the frame's
units to layout units with the inverse of Script GetLeft's scaling), slide
that onto the screen with the engine's own steps and order, and move the
frame's rect by the same amount. Frames without insets go through the
engine's clamp as before. SetClampRectInsets re-lays the frame out, as
SetClampedToScreen does, and (0, 0, 0, 0) clears the insets.

Sign convention from 3.3.5's own calls: WorldMapFrame (0, 0, 0, -60)
"don't overlap the xp/rep bars", chat frames (-35, 35, 26, -50).

Verified in-game (1.12.1 client via VanillaFixes): a clamped 100x100
frame placed 10 above the bottom of the screen stays at 10, moves to 60
with (0, 0, 0, -60) and back to 10 once cleared; a frame placed 20 over
the top is clamped to the edge and, with a top inset of -25, keeps its
20 over. GetClampRectInsets returns the insets set.

* refactor(frame): address review of SetClampRectInsets

Use the existing OFF_REGION_ANCHOR and UI coordinate globals instead of new
duplicate offsets, and move the pixel/anchor-unit conversion into one shared
Game::PixelToInternal / InternalToPixel pair (replacing the copies in
LinePool, Transform and Metrics). Restore only the clamp bit around the
unclamped rect calculation so other layout flag bits survive. Add doc
descriptors for both methods and drop version/implementation notes from
the user docs.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:56:17 -05:00
SkeewirtandBrues fbcc1c9ae1 feat(editbox): add SetCountInvisibleLetters / IsCountInvisibleLetters (#88)
* feat(xml): read parentKey, function= and button font style= from XML

Later clients' XML has three attributes vanilla's parser keeps on the
node but never reads. Ported UIs lean on all three: Zygor Guides Viewer
2.0 uses parentKey 69 times (its arrow, skin buttons and step frames),
binds its main frame's whole OnLoad with function=, and names button
fonts with style=.

All three hook the engine's per-object XML node loaders. Those run on
the object's layout sub-object ((object+0x24)->vtable[+8], called from
the frame builder at 0x006EE4D9 and the region factory at 0x006F2756),
so the object is `this - OFF_XML_LOADER_SUBOBJECT`. The loaders: frames
FUN_00769820 (every derived frame type's loader calls it first),
textures FUN_0076FE20, font strings FUN_00770F40, and Button's
FUN_XML_BUTTON_LOAD (CheckButton's calls it first). Each hook runs the
engine's loader, then:

- parentKey="Name" sets parent[Name] = object on the Lua wrappers, the
  parent being the object's script-region parent (OFF_REGION_PARENT,
  which GetParent reads). An unnamed element has no wrapper yet, so it
  is registered the way the engine's own lazy path does
  (ScriptRegister(object, NULL), as GetParent does); a named one is
  never registered here, since SetName already did that under its name.
  The builder loads content and child frames before it finalizes, so a
  parent's <OnLoad> already sees its keys, and keys inside a virtual
  template bind on every frame built from it.
- A script element with function="Name" becomes
  frame:SetScript(<element>, _G[Name]). The loader runs before the
  builder's finalize, so a bound OnLoad fires at the usual point.
- A NormalFont / HighlightFont / DisabledFont with style and no inherits
  is applied through the engine's own setter for that state with
  _G[style]. Vanilla reads only inherits there and otherwise leaves the
  button's embedded font empty, so its text had no font.

Adds FUN_XML_FRAME_LOAD / FUN_XML_TEXTURE_LOAD / FUN_XML_FONTSTRING_LOAD
/ FUN_XML_BUTTON_LOAD, OFF_XML_LOADER_SUBOBJECT and
OFF_SCRIPT_REGION_NAME, with their derivations.

Verified in-game (1.12.1 client via VanillaFixes, Zygor Guides Viewer 2.0):
probe XML gets its parent keys on a Frame, a Texture, a FontString, a
button's NormalTexture and a frame inside an inherited template; an
<OnShow function="..."/> handler runs; a button with
<NormalFont style="GameFontHighlightSmall"/> reports that font object.
Zygor's frames build with their keys, its main frame's OnLoad runs, and
it loads without a Lua error.

* feat(editbox): add SetCountInvisibleLetters / IsCountInvisibleLetters

3.3.5's EditBox can count color codes and hyperlink markup toward its
SetMaxLetters limit: SetCountInvisibleLetters(true), or
countInvisibleLetters="true" in XML (its macro text, friends broadcast and
BNet report boxes). Off is the default. AceGUI-3.0's MultiLineEditBox
calls the setter while building.

1.12's SetMaxLetters limit (+0x340) counts only visible letters: its
counter (0x0077BC80) skips the markup classes in the edit box's per-byte
table (+0x330), so with a limit of 5 "|cffff0000abcdefg|r" keeps
"|cffff0000abcde" (checked in-game). That is 3.x's default (off). With the
flag on, the limit check's three counts (returns 0x0077C09F, 0x0077C0C1,
0x0077C0E9) count each markup token at its length instead. The counter's
other callers, the cursor movement and GetUTF8CursorPosition, are
unchanged.

For XML, hook the EditBox node loader (0x00779FB0, identified by the
"letters"/"autoFocus"/"multiLine" attributes it reads after calling the
frame loader) and, after it runs, read the attribute and parse it with
the engine's own boolean parser (0x006F1B30, as for autoFocus). The
loader follows the node-loader convention of the XML attributes change
(OFF_XML_LOADER_SUBOBJECT), which this builds on.

Verified in-game (1.12.1 client via VanillaFixes): with SetMaxLetters(12),
"|cffff0000abcdefg|r" is kept whole with the flag off and cut to
"|cffff0000ab" with it on; an XML EditBox with letters="12" and
countInvisibleLetters="true" reports the flag on and cuts the text the
same way.

* docs: correct GetNumLetters note, drop version comparisons

GetNumLetters counts the letters you see (FUN_007992C0 calls the same
counter as the limit check), not bytes. Remove the 3.3.5 / stock 1.12 /
later-client comparisons from the new XML and edit box sections.

* fix(frame): build extra templates' child frames in CreateFrame

The multi-template graft called the main vtable's +8 slot, which is the
Lua method lookup FUN_00778590, not a node applier, passing the XML node
as the Lua state. Its second call (the +0x24 sub-object's node loader)
applied the template's content, but <Frames> children are built only by
FUN_0076A060 from the finalize step, so an extra template's child frames
were never created.

Call the node loader, then FUN_XML_FRAME_LOAD_CHILD_FRAMES directly, so
OnLoad is not re-run. Correct the notes in Offsets.h and CreateFrame.cpp.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:42:22 -05:00
SkeewirtandBrues 142faafb13 feat(frame): add SetHyperlinksEnabled / GetHyperlinksEnabled (#92)
* feat(frame): add SetHyperlinksEnabled / GetHyperlinksEnabled

3.3.5 has SetHyperlinksEnabled/GetHyperlinksEnabled on ScrollingMessageFrame
and SimpleHTML only, so register them on those two types' method tables
(0x00CF5294, 0x00CF5328), and hook the three functions both types fire
OnHyperlinkEnter/Leave/Click through (0x007A3780, 0x007A37B0, 0x007A37E0)
to skip the script while a frame's hyperlinks are off. On stays the
default, which is vanilla's behavior.

Zygor Guides Viewer 2.0 turns hyperlinks off on the step lines it fills
with Gold Guide item lists, and back on when they show guide steps.

Verified in-game (1.12.1 client via VanillaFixes): both types report true
by default, false after SetHyperlinksEnabled(false) and true again after
SetHyperlinksEnabled(true), and a plain Frame has neither method, as in
3.3.5. Not yet checked: the skipped script itself (a click on a link with
hyperlinks off), which needs the mouse; that check runs next session.

* refactor(frame): gate hyperlink scripts via RunnerHook, keep Leave

Replace the three MinHooks on the per-type hyperlink fire functions with
a Frame::RunnerHook subscriber keyed on the script slot (+0x320 Enter,
+0x330 Click). OnHyperlinkLeave is never suppressed, so a tooltip shown
before hyperlinks were disabled still closes. Parse the argument with the
engine's loose boolean (default on), as 3.3.5's setter does.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:27:51 -05:00
GratisNinja86andBrues c243489916 Fixed bleed of druid "Pounce" spell not shown under nameplates (#105)
* Attribute Pounce Bleed to the caster

* fix(aura): attribute triggered auras from the Spell.dbc trigger link

Replace the hardcoded Pounce -> Pounce Bleed table with the DBC link:
any effect of type SPELL_EFFECT_TRIGGER_SPELL whose EffectTriggerSpell
applies an aura gets the trigger's caster.

- Arm the target and attribute on the application hook, so a hit by
  the trigger that the triggered aura never follows (bleed-immune
  target) creates no phantom entry. An existing instance from the same
  caster that the descriptor still lists is refreshed in place, since
  the server's re-apply fires no OnAuraAdded.
- Work for any caster, not only the local player.
- Share the target list (hit list, else the caster) and the duration
  helper with the cast's own aura path.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-10-16 23:12:12 -05:00
Brues 917b15640e feat(quest): add C_QuestLog.GetInfo and C_QuestLog.GetQuestLogTitle
Both run the engine's Script_GetQuestLogTitle (0x004DF930) and repackage
its returns, so title, level, header, collapse and complete/failed state
match the stock GetQuestLogTitle. Also registers Enum.QuestFrequency and
Enum.QuestClassification.

Corrects the Quest::Cache note on +0x14..+0x24: those slots are
RepObjectiveFaction, RepObjectiveValue, the two opposite-rep fields and
NextQuestInChain, not SuggestedPlayers and friends. The 1.12 wire carries
no suggested group size, so suggestedGroup is always 0.
2026-09-30 15:08:38 -05:00
Brues 7ccbbaaf68 fix(compat): stop Turtle's Toggle Movement button erroring
Turtle's Options frame sets GroupFrame_ToggleMovement(state) directly as
the button's OnClick. With modern script args the handler receives the
button as `state`, and `1 - state` errors (Turtle_GroupUI.lua:409). Wrap
the global so only a number reaches the original.
2026-09-30 14:58:58 -05:00
Brues 71805db62f docs(readme): list PickupEquipmentSet and WEAR_EQUIPMENT_SET 2026-09-27 00:07:57 -05:00
Brues 85619155f8 feat(equipmentset): add C_EquipmentSet.PickupEquipmentSet
Equipment sets become action-bar buttons, as in 3.3.5: action type
0x20000000 | setID and cursor type 13. The set can be picked up,
placed, dragged off, and swapped with other actions. The button shows
the set icon, name and tooltip, and grays out while a set item is
locked. A click fires WEAR_EQUIPMENT_SET(setID) and the embedded
EquipmentManager equips the set.

The server drops action type 0x20 without saving it, so placements are
kept in the per-character equipment-set file. The server is sent an
empty slot, and the saved buttons are merged into the
SMSG_ACTION_BUTTONS buffer at login. GetActionInfo and GetCursorInfo
report "equipmentset", name.
2026-09-27 00:05:29 -05:00
Brues 46dc198505 feat(tooltip): add GameTooltip:GetUnit
GetUnit() returns (name, unitToken). The engine drops the token after
SetUnit resolves it to a GUID, so it is kept where it enters:

- SetUnit is re-registered in front of the engine method and stages
  the token; it binds in the unit-builder co-hook that SetEvents
  already owns, before OnTooltipSetUnit fires.
- A build with no staged token is the world mouseover ("mouseover");
  the in-place refresh keeps the bound token.
- Unit-frame hover (Frame::Attributes) stages the frame's unit, since
  the engine resolves "mouseover" only for a live object and an
  offline or far group member has none.

The name follows Script_UnitName's order (live object, group roster
slot, name cache), so offline members resolve for GetUnit and
GetUnitGUID alike.
2026-09-26 22:33:13 -05:00
Brues 7707127d5f fix(macro): add the question-mark icon to the macro icon list
The stock 1.12 macro icon list holds only Ability_* and Spell_* icons,
so on a client without Turtle or pfUI the macro picker cannot select
INV_Misc_QuestionMark, and #showtooltip never gets a macro to take over.

Mirror 3.3.5's loader: seed the icon through pass 3's own append before
the walks, then move it to index 0 after the engine sorts and dedups.
2026-09-26 00:31:19 -05:00
Brues fa7d71435f fix(nameplate): retry CREATED and UNIT_ADDED until they fire
A plate was marked seen before NAME_PLATE_CREATED was sent, and a GUID
entered the tick snapshot before NAME_PLATE_UNIT_ADDED was sent. When a
fire was dropped because its event slot was not claimed yet, the plate
was never announced again until /reload cleared the diff state. pfUI
builds its overlay on CREATED and finds it on ADDED, so such a plate
kept Blizzard's look (brues-code/pfUI#59).

FireWithFrame now reports whether the event went out, and a plate is
marked seen only then. ADDED waits for its slot AND for its frame's
CREATED, so a handler never gets a unit for a plate it was not
introduced to. A deferred GUID gets no token slot and no observers, and
is left out of the snapshot, so the next tick retries it and no REMOVED
fires for an ADDED that never went out.

Not verified in game: the dropped-slot case cannot be forced here, so
only the normal path was built and deployed.
2026-09-25 10:34:40 -05:00
Brues 3467513df9 feat(container): group sorted gear by equip slot
Gear was one category per quality tier, ordered inside by class,
subclass and name, so a helm could sit between two pairs of boots and
an epic helm landed far from a green one. Gear is now one category,
ordered by class, then equip slot, then subclass, then quality (best
first), then name, so like pieces sit together.

The slot key ranks Item.dbc inventoryType through a table rather than
the raw INVTYPE number, which does not follow the character pane (robe
is 20, chest 5; cloak 16). The order is Baganator's: weapons by hand,
ranged and ammo, then armor with shields and off-hands first, head to
feet, then jewelry, shirt and tabard.
2026-09-25 10:19:16 -05:00
Brues 6000df8009 feat(cvar): add C_CVar.SetTempCVar and RemoveTempCVar
SetTempCVar changes a cvar for the session without saving it to
Config.wtf; RemoveTempCVar restores the value from before the first
temp set.

The temp set passes a6 = 0 to the engine setter, the only argument that
marks the config dirty, so it applies the value and runs the change
callback without asking for a save. When something else dirties the
config, a co-hook on the config writer (0x0063D980) points each temp
cvar's value at its saved string for the write and restores it after.
A plain SetCVar over a temp value wins: the entry drops itself, so the
user's set is saved and never rolled back.

Errors mirror Script_SetCVar. Registered in-game and on glue.
2026-09-25 09:59:27 -05:00
Brues fde3beca9d fix(table): keep the stored length on weak-valued tables
The getn heal read a nil at the stored last slot as proof that nobody
maintained the length. For a weak-VALUED table that is false: the
collector clears those slots behind the writer, so `luaL_setn` is still
exactly what the last append left and the holes below it come and go
between two reads.

Compost-2.0 is the casualty. `secondarycache` is `__mode = "v"`, filled
with `table.insert`; `GetTable` walks it with `pairs` and calls
`table.remove(cache, i)` on a live index. Once the GC clears slot 1 and
the stored last slot, the heal bisects to border 0, `luaB_tremove` takes
its `e == 0` empty-table early-out and returns NOTHING, and the next line
indexes with the nil it got back -- "table index is nil" at
Compost-2.0.lua:81, once per recycled table, in every addon that pulls
Compost in through AceLibrary.

The border search is what exposed it (5aa382b's table.insert switch is
not involved -- Compost never appends a nil, so no mark ever applied to
its tables). The walk-down it replaced returned the HIGHEST populated
slot, which is always at or above the live index the pairs loop holds, so
remove still found something; a bisect returns some border and probes its
way down to 0 through the collected slots. That tradeoff was known and
written down -- a weak cache read through table.remove is where it bites.

So gate the heal: `__mode` mentioning "v" returns the stored length
untouched, as rule 3, ahead of the mark. It costs one getmetatable and a
raw `__mode` fetch on the already-rare unhealthy path, so the bisect keeps
its win everywhere else, and the mode is read raw -- the same fetch the
collector's own check uses.

New plumbing: Game::Lua::GetMetatable, from the offset already catalogued
in LuaCAPI.md. LuaEnv::Fenv had rolled its own copy; it now shares this
one.

Verified in game: a three-slot weak table with slots 1 and 3 collected
reports getn 3 and hands back its live slot (0 and nothing before), and
the Compost errors are gone.
2026-09-17 23:39:43 -05:00
Brues 452f3c14fc fix(macro): keep the unlearn sweep from deleting #showtooltip macros
The spell-removal handler FUN_005E9FE0 calls FUN_004E5E20(spellID) right
after the unlearn writer. That sweep walks all 0x78 action slots, resolves
each through FUN_ACTION_SLOT_TO_SPELL -- which is macro-aware, so a macro
slot answers with the primary-spell cache this module writes -- and clears
every slot whose spell matches. The clear notifies with sendToServer = 1,
so CMSG_SET_ACTION_BUTTON goes out and the server persists it: the macro is
gone for good, not until the next reload.

That rule is sound for a cache the engine filled from a /cast line and
wrong for one filled from a display directive, which names an icon rather
than what the button does. `#showtooltip [known:18223] 18223` over a /cast
the player can still perform would lose the whole macro on a respec, and a
conditional directive makes it arbitrary besides -- the cached value is
whichever clause happened to match on the last evaluation.

Co-hook the sweep and park every managed macro's cache at 0 for its
duration, restoring afterwards with a plain field write (nothing we own
changed, so no repaint is owed; the slots the engine really did clear were
announced by its own notify). A BusyScope wraps it because the sweep's
ACTIONBAR_SLOT_CHANGED handlers reach Lookup, whose catch-up would
otherwise re-apply the parked values mid-sweep for slots the loop has yet
to reach. Macros with no directive are untouched.

Consequence: a plain #showtooltip macro whose /cast line names the
unlearned spell now also survives, where stock 1.12 removed it.
2026-09-17 14:04:59 -05:00
Brues 6bb12c3c12 ci: drop the Gitea mirror entirely
octowow.st's Gitea instance would not accept a git push under any
credential scheme, converting the repo off "mirror" status didn't
change that, and its own pull-mirror sync was already unreliable
before any of that -- three commits of narrowing down the actual cause
without ever reaching a fix. The repo is being removed from Gitea, so
there is nothing left to mirror to.

Removes mirror-release.yml, the diagnose-mirror-auth.yml scratch
workflow, and the `mirror` job in build.yml that called it.
2026-09-17 11:49:21 -05:00
Brues 7a08199ba5 ci: replace the status-code probe with a real end-to-end push test
Every scheme in the first diagnostic came back 200 on the GET leg,
which does not actually clear anything -- a bare status code can't tell
a real git advertisement apart from a 200 that's something else, and
`git push` also has a second POST leg the GET-only probe never touched.

Run the real thing instead: `git push --dry-run` end to end against a
disposable ref, with real credentials, under the three most plausible
schemes -- captures git's own diagnostic output instead of inferring
from a client-side parse error that looks the same for a 401, a 403,
and a malformed 200.
2026-09-17 11:41:31 -05:00
Brues a5bb3823fb ci: add a one-shot diagnostic for the mirror git-http auth scheme
Converting the Gitea repo out of mirror status did not fix the push
(same client-side error), which rules out `repo.IsMirror` gating and
means the two prior fixes were both guesses at a credential scheme with
no way to verify which one, if any, the deployed Gitea actually accepts
-- a 401 and a 403 look identical from the git client's side.

Tests six candidate schemes against the real git-receive-pack endpoint
with the real MIRROR_TOKEN, printing only the resulting HTTP status per
scheme -- never the token or a response body. Delete this file once the
working scheme is found and applied to mirror-release.yml.
2026-09-17 11:37:22 -05:00
Brues 8c941c757d fix(ci): authenticate the mirror push with a token header, not oauth2 basic auth
The direct push added in 2fff956 failed every run: git reported
"could not determine hash algorithm; is this a git repository?" against
the Gitea push URL. Reproduced the exact message locally by hitting the
same endpoint with a deliberately bad token -- it is git's confusing
rendering of a plain 401, not a routing or protocol problem (the same
endpoint serves a clean smart-HTTP advertisement for an unauthenticated
fetch).

`oauth2:<token>` as the HTTP Basic auth username is GitLab's convention,
not Gitea's, and Gitea rejected it. Switch to the `Authorization: token
X` header instead -- the exact scheme this same workflow's curl calls
already use successfully against this server -- rather than guess at a
different Basic-auth username convention.
2026-09-17 11:21:27 -05:00
Brues 2fff9564b2 fix(ci): push release tags to the Gitea mirror directly
Gitea's pull-mirror sync is a background queue job: POSTing mirror-sync
only enqueues it, and a failure on Gitea's end (stale pull credentials,
a stuck queue) is invisible from here -- the workflow would just poll
for the tag for 5 minutes and time out, so releases never made it
across.

Replace the ask-and-poll with a direct push: fetch the release tag from
GitHub (shallow, depth 1) and push it straight to the mirror with the
existing MIRROR_TOKEN, which already has write access. A git push either
lands or fails loudly on the spot -- nothing to wait on. Gitea's own
pull-mirror can keep doing whatever it does for everyday commits and
branches; this workflow no longer depends on it working.
2026-09-17 11:09:11 -05:00
Brues bbefdb847d fix(aura): include the player's pet in the PLAYER filter
Blizzard defines the token as "Include only auras that were cast by the
player, or by the player's pet or vehicle"
(AuraUtil.AuraFilters.Player). There are no vehicles here, so the caster
set is player + pet -- but all three comparisons tested
`casterGuid == PlayerGuid()` and nothing else.

A pet's cast carries the PET's guid in the Aura::Source cache, since
that is who SMSG_SPELL_GO names, so "HARMFUL|PLAYER" silently dropped
every aura the pet applied. `!PLAYER` had the mirror bug: it returned
the pet's auras as though a third party cast them.

Centralize on one IsPlayerOrPetCaster predicate rather than fixing each
site -- IsPlayerCast, GroupIsPlayerCast and CachedMatches had already
grown three separate copies of the comparison, which is how they would
drift again. The pet guid comes from VAR_PET_GUID, a live engine global,
so the predicate stays two compares with no object resolve; it runs per
aura slot. A caster of 0 (cache miss) still matches nothing.

Unrelated and untouched: the `isFromPlayerOrPlayerPet` aura field, which
means "applied by ANY player or a player's pet" and is answered from the
caster's HIGHGUID prefix.

Verified in-game: a Hunter pet's Scorpid Poison now returns from
C_UnitAuras.GetAuraDataByIndex("target", 1, "HARMFUL|PLAYER").
2026-09-16 23:32:16 -05:00
Brues 56d2dbbc27 fix(player): file WEAPON_SLOT_CHANGED under PaperDollInfo
Blizzard's generated API docs put all three of this file's events in the
PaperDollInfo system (PaperDollInfoDocumentation.lua), so the descriptor
said the wrong system and would have invented a stray "Item" grouping in
the /classicapi browser. Filing it correctly also lines up
PLAYER_EQUIPMENT_CHANGED and UPDATE_INVENTORY_DURABILITY for whenever
they get descriptors of their own.

Same source confirms the payload: the entry carries no Payload field, so
no arguments is right. It is also flagged UniqueEvent where the two
per-slot events are SynchronousEvent -- recorded in the header comment,
since that makes the per-frame coalescing the event's own shape rather
than a local preference.
2026-09-16 22:25:44 -05:00
Brues 0296e0e5a4 feat(player): add WEAPON_SLOT_CHANGED
Fires with no payload when the item in inventory slot 16, 17 or 18
changes -- equip, unequip and swap alike. The narrow sibling of
PLAYER_EQUIPMENT_CHANGED that swing-timer consumers want, so they no
longer filter three slots out of a 19-slot event in Lua on every gear
change. Blizzard's own Blizzard_SwingTimer registers this event.

Rides the descriptor-field observers Player::Equipment already installs
for PLAYER_EQUIPMENT_CHANGED -- the weapon slots are three of the 19 --
so there is no new hook, no second registration and no polling. The
dispatcher invokes the callback only when a slot's item GUID actually
changed, and seeds each node's mirror from live at registration, so
login and /reload produce no fire.

Coalesced to at most one fire per frame via a dirty flag drained on
WorldTick: one action can change two weapon slots (a two-hander
replacing a one-hander plus an off-hand) and the per-slot observer would
otherwise fire twice for it. The drain checks the reservation slot
before clearing the flag, so a change marked while the reservation is
still unclaimed is held rather than swallowed.

Slot 18 is exempted for the classes whose slot 18 is a relic slot -- a
Libram/Idol/Totem is a passive stat item, not something you swing.
Gated on the same data Script_UnitHasRelicSlot (0x00519E50) reads rather
than a hardcoded class list: ChrClasses.dbc[class] + 0x40, verified in
the disassembly at 0x00519EAE-0x00519EC0 and against the client's own
ChrClasses.dbc, where that column is 1 for PALADIN/SHAMAN/DRUID and 0
for the other six. Read live, since the class-byte global is
repopulated per character-enter.

Not verified in-game yet: built and deployed only.
2026-09-16 20:30:17 -05:00
Brues 6812771a89 fix(event): raise the reservation cap past the 64-slot overflow
MAX_RESERVED was 64 while the codebase carries 65 AutoReserve
instantiations: 60 at file scope plus the 5 lazy function-local ones in
voicechat/Tts.cpp, which construct on first registration and only when
VanillaTTS is absent.

The constructor silently returns once g_reservedCount hits the cap, and
the file-scope 60 all run at static init, so the TTS five land last and
the 65th -- VOICE_CHAT_TTS_VOICES_UPDATE -- was dropped: Slot() stayed
-1 and Fire() no-opped, so the event never fired on any client without
VanillaTTS.

Bump to 96 and record the incident; the comment's "~40 reservations"
figure was stale from the previous overflow at 32.
2026-09-16 20:28:25 -05:00
Brues 4d835aa674 feat(combat): add PLAYER_SWING / PLAYER_SWING_RANGE_UPDATE / C_SwingTimer
Backports the Forever beta swing-timer API. PLAYER_SWING and
PLAYER_SWING_RANGE_UPDATE fire off the packets the engine already sends
for melee/ranged auto-attacks, mirrored as state (parry haste, extra
attacks, on-next-swing abilities, cast-time resets) rather than relayed
1:1, so N packets for one logical reset collapse into one event.

Two bugs surfaced only by in-game testing, not by reading the
disassembly:

- FUN_UNIT_CAN_ATTACK (0x00606980) is __thiscall with the second unit
  pushed on the stack, not __fastcall(ecx,edx) -- Ghidra's decompile of
  the one caller renders it as a plain two-arg call either way, so the
  wrong convention crashed on a bogus ecx read off [ebp+8] garbage.
  Same trap as FUN_SET_CVAR_VALUE.

- The generic engine range core (FUN_006e3480, what
  C_Spell.IsSpellInRange already uses) computes full 3D distance, but
  the server's actual melee-attack gate
  (Unit::CanReachWithMeleeAutoAttackAtPosition,
  WorldObject::CanReachWithMeleeSpellAttack) is explicitly 2D --
  verified by landing hits at 5.08yd 3D distance that the 3D check
  rejected. Melee now gets its own 2D check
  (Combat::Swing::InMeleeRange), reading the same live engine reach
  constants; ranged keeps the 3D core, which matches the server's own
  (also 3D) generic spell-range gate.

Also: attack-start no longer resets the off-hand timer unless the
target is actually in range. Unit::Attack's off-hand reset fires the
instant an attack is declared, even out of range, and the server just
silently re-arms it every 100ms until you're close enough -- firing
PLAYER_SWING there promised a swing time that was not real.

Slam is special-cased for Turtle: every rank in Turtle's own client
Spell.dbc carries AttributesEx2's swing-reset exemption, but packet
timestamps show Turtle's server does not honor it for Slam -- both
melee timers delay after casting it, contradicting both the client
data and the public tortoise-wow reference source.
Combat::Swing::AutoSwingResetOverride is a small extension point
(mirroring Turtle::ComboDuration's Rip override) for this kind of
server/client divergence; src/turtle/SwingReset.cpp registers the
known Slam ranks.

Verified in-game: swing detection (dual-wield, extra attacks
coalescing to one event, cast-time resets), the melee range fix, and
the attack-start range gate. The Slam override has not been
rebuilt/retested yet.
2026-09-16 17:50:13 -05:00
Brues dfbba225d2 fix(macro): correct the loose/mpq split in the icon enumeration
The three macro-icon scan callbacks were named and tagged for the wrong
walker. FUN_LOAD_MACRO_ICONS hands 0x004F0220 to FUN_MPQ_ENUM_FILES (the
archive listfile walk) and 0x004F0350 / 0x004F04F0 to the disk walker,
but Icons.cpp had it exactly inverted: the archive pass was tagged
SRC_LOOSE and both disk passes SRC_MPQ. So GetMacroIcons /
GetMacroItemIcons returned the loose on-disk icons -- nothing at all on a
stock install -- while GetLooseMacroIcons / GetLooseMacroItemIcons
returned the archive set. It went unnoticed because the only consumer,
IconDataProviderMixin, calls all four and concatenates, so only the
ordering within each list changed and never the content.

Tags corrected, and the offsets renamed to what the loader actually hands
them to, with each one's filter recorded since that is the load-bearing
difference between the two disk passes:

  FUN_MACRO_ICON_CB_DISK        -> FUN_MACRO_ICON_CB_MPQ
  FUN_MACRO_ICON_CB_USER_MPQ    -> FUN_MACRO_ICON_CB_DISK_PREFIXED
  FUN_MACRO_ICON_CB_INSTALL_MPQ -> FUN_MACRO_ICON_CB_DISK_ANY

Also replaces a wrong guess in the Offsets.h block. It claimed a filter
inside the SStrDup/array-append helpers downstream of all three callbacks
was what kept INV_* out of the engine's own list. Decompiling the loader
and all three callbacks shows there is no shared downstream helper at
all: the grow-and-append code is inlined identically into each callback,
so the only filtering is what each one does itself. The list is
Ability_*/Spell_* only because passes 1-2 prefix-filter and pass 3 -- the
one that does not -- is a disk walk over a folder that ships empty. A
loose INV_*.blp dropped into Interface\Icons\ does enter the list,
through pass 3 alone. The block now also records the qsort + adjacent
SStrCmpI dedup, that GetMacroIconInfo pushes the empty string rather than
nil for a bad index, and that only GetNumMacroIcons triggers the lazy
build.

Verified in-game.
2026-09-15 10:54:59 -05:00
Brues 930b0c6492 Add C_LossOfControl.GetSchoolLockout
Serving the school-interrupt lockout through the generic list costs a caller
two full BuildList passes -- GetActiveLossOfControlDataCount runs one and
GetActiveLossOfControlData runs another -- plus a Lua table per entry, and
BuildList classifies up to 16 debuff slots with RecordForID on each. All of
that aura work is waste for a caller that only wants the lockout, and a macro
conditional reading it every frame pays it every frame.

The state behind it is g_schoolLock, seven pairs of ticks. Read it directly:
no table, no debuff scan, one pass.

Return a mask rather than a school. Each SMSG_SPELL_COOLDOWN batch locks one
school, so being kicked on fire and then frost leaves two entries active, and
a caller walking the list and stopping at the first SCHOOL_INTERRUPT drops the
rest. filterMask narrows the scan, which is how a caller gets one school's own
remaining time instead of the aggregate.

Also document that the ADDED/UPDATE events diff which effects are active, so
re-locking an already-locked school extends endMs without firing either one --
that expiry cannot be cached on the events.
2026-09-15 10:18:36 -05:00
Brues 428c3308c6 docs(todo): add #101 Frame:SetOnUpdateMode
Records the engine trail found while evaluating it: the per-frame
dispatcher FUN_0076B2C0 (vtable +0x38, __thiscall(frame, float elapsed),
fires the OnUpdate slot at +0x128) and the walk FUN_00765650, which
iterates nine strata buckets of the frame+0x310 intrusive list with NO
visibility check in either place. Visibility is list membership, and that
list is the render list -- which is what splits the enum: Disabled /
RunWhenVisible / RunWhenVisibleOnce need only a co-hook on the
dispatcher, while RunOnce / RunAlways have to be driven from the
already-owned WorldTick because a hidden frame is not in the list at all
and forcing it in would try to draw it.

Also notes the open question (which elapsed the self-driven modes pass,
and whether WorldTick runs before or after the UI pass), that no loaded
DLL references either function, and that RunAlways is the only genuinely
new capability -- the rest is parity with existing idioms.
2026-09-15 02:16:45 -05:00
Brues a33146241e feat(macro): make [button:N] read the click it is running inside
The condition existed but was a stub: it matched only "1" / "leftbutton"
and always passed, because nothing on this client could say which mouse
button had driven the current click. GetMouseButtonClicked can now answer
that, so the condition asks it.

Numbers use the modern API order -- 1 left, 2 RIGHT, 3 middle, 4, 5 --
which is deliberately NOT the order of the engine's internal click
bitmask (where 2 is the middle button); that mask never reaches Lua. A
name is matched as written, so [button:rightbutton] and [button:2] are
the same test, case-insensitively.

Outside a click the answer stays "left button". That is not a cosmetic
default: Macro::ShowTooltip re-evaluates conditional directives off the
world tick with no click in progress, so a nil-means-no-match rule would
change which icon a `/cast [button:2] X; Y` macro displays at rest. The
engine's own no-argument Button:Click() means the left button, so it is
also the honest resting answer for a state-driver poll.

Verified in-game with all five buttons, both directly from OnClick and
through a real macro body -- click -> attribute macro ->
EXECUTE_CHAT_LINE -> SlashCmdList -> SecureCmdOptionParse, which
confirms the click context survives the whole chain -- plus the name
form, the btn alias, and the no-click resting answer.
2026-09-15 01:38:38 -05:00
Brues c7af8d396e feat(frame): accept AnyUp / AnyDown in Button:RegisterForClicks
The engine's RegisterForClicks (0x00782490) SStrCmpI's each argument
against exactly ten literals -- LeftButtonDown 0x1 / LeftButtonUp 0x100,
Middle 0x2 / 0x200, Right 0x4 / 0x400, Button4 0x8 / 0x800, Button5
0x10 / 0x1000 -- treats anything else as contributing nothing, and
ASSIGNS the OR to the click mask. So the collective names later clients
take, "AnyUp" and "AnyDown", did not fall back to the default here: they
zeroed the mask and the button silently stopped answering every real
click, while Button:Click (which ignores the mask) kept working and hid
it. That is what broke the first click-bracket harness yesterday.

Frame::ClickMask re-registers RegisterForClicks on the Button method
registry (0x00CF4E14, new VAR_BUTTON_METHOD_REGISTRY; most recent
registration wins, CheckButton inherits through the type walk). It does
not reimplement the parser: for each AnyUp / AnyDown argument it appends
the five names that collective stands for onto the Lua stack and
tail-calls the engine's own function, which then does the self
typecheck, the parse, the setter and any error text itself. The
collective string is left in place -- the engine's unknown-name -> 0
rule already makes it inert -- so the reshape is purely additive and
the engine's loop terminates exactly as before.

Verified in-game with left, middle and right: AnyUp fires OnClick on
release; AnyDown on press; "anyup" + "LeftButtonDown" (case check) fires
left on both press and release and the others on release only; a
LeftButtonUp control is unchanged; "Fnord" alone still yields no clicks.
2026-09-15 01:04:10 -05:00
Brues a2a2ccb804 feat(frame): bracket the click at the engine's click vmethod
PreClick/PostClick now fire on every click, with or without an OnClick
handler, and GetMouseButtonClicked is live inside OnMouseDown and
OnMouseUp on any frame -- the two things 3.3.5 does that the previous
runner-only bracket could not.

3.3.5 (CSimpleButton::Click FUN_0096fd70 -> FUN_0096f090) fires PreClick,
OnClick and PostClick as three independent `if (slot)` blocks, so
Pre/Post never depended on OnClick. And the frame-manager field behind
GetMouseButtonClicked is written by exactly four dispatches -- that
click, the double-click, and CSimpleFrame::OnMouseDown FUN_0048fc30 /
OnMouseUp FUN_0048fce0 on every frame type -- not by OnDragStart.
Confirmed live on 3.3.5: `down LeftButton LeftButton`, `up LeftButton
LeftButton`, `drag LeftButton nil`. 7a4ab29 had misread +0x16c/+0x174 as
OnClick/OnDoubleClick and made OnMouseDown/OnMouseUp nil; corrected.

1.12 has the same two decision points. The runner interceptor now gates
on all four mouse-button script slots (OnClick +0x4CC, OnDoubleClick
+0x4D4, OnMouseDown +0x150, OnMouseUp +0x158 -- the CSimpleFrame
resolver FUN_0076A0D0's table is recorded in Offsets.h) and scopes the
name from the fire's own "%s" argument. PreClick/PostClick move to a
co-hook on the Button click vmethod FUN_00779540, which every
Lua-creatable button reaches (Button directly, CheckButton's override
toggles then chains to it). The detour fires them through the engine's
own variadic forwarder FUN_007026F0 with the engine's own "%s" literal
and its own name literal for the button mask, so the path and the arg1
pointer are identical to the engine's OnClick fire; the whole click is
scoped, as in 3.3.5. `down` is not supplied -- 1.12's vmethod never
learns press-vs-release. 3.3.5's same-button re-entry guard drops the
whole nested click; 1.12's engine recurses, so this only skips our own
Pre/Post on that re-entry. Net: one new hook.

The previous limitation was justified by a note that SuperWoW
inline-hooks FUN_00779540 and a second MinHook faults (ERROR #132). A
literal scan of every DLL the client loads finds no reference to that
address in any of them, while SuperWoWhook's real targets all appear as
plain literals. The #132 sighting is kept on record; its trigger was not
this function. Both Offsets.h notes rewritten; FUN_BUTTON_CLICK, which
existed only as prose, is now defined.

Verified in-game across real and programmatic clicks: Pre/Post with no
OnClick, full bracket with nested different-button click and restore
(LeftButton / RightButton / UNKNOWN outers), self-click yielding one
extra OnClick and no extra PreClick, CheckButton toggled before
PreClick, plain-Frame OnMouseDown/OnMouseUp reading the button with
OnDragStart nil, double-click without Pre/Post, an OnUpdate watcher
never seeing a value, and /reload -- all with the full DLL stack loaded.
2026-09-15 00:53:17 -05:00
Brues 7a4ab29cb0 fix(input): scope GetMouseButtonClicked to the click dispatch
It read a button captured from the WH_GETMESSAGE mouse-message hook and
evicted two world ticks later, so it reported a button for as long as one
was held, and for a couple of frames after every click -- useless as the
"am I inside a click handler" test it exists to be.

3.3.5 does none of that. The getter (FUN_0050f950) pushes a frame-manager
field that only the four click dispatches ever write -- button OnClick
FUN_0048fc30, OnDoubleClick FUN_0048fce0, and the two secure click paths
FUN_0096fd70/FUN_0096fdd0 that wrap PreClick -> OnClick -> PostClick --
each saving the old value on the C stack, setting its own button argument,
running the handler, then restoring. That C-stack save is what makes a
nested click shadow the outer one and restore on unwind.

1.12 has no such field but the same bracket point: FUN_00779540 (OnClick)
and FUN_00779650 (OnDoubleClick) fire their handler through the runner
Frame::ClickEvents already intercepts, passing the button name as the
fire's only "%s" argument. Mirror the engine's bracket there and read the
name out of the fire, which also inherits the engine's own naming:
Button:Click (FUN_007826C0) folds its optional string down to a numeric
mask, so a bare Click() arrives as "LeftButton" and an unrecognized name
as "UNKNOWN".

GetMouseButtonClicked moves to Frame::ClickEvents with the dispatch it
describes. IsMouseButtonDown and GLOBAL_MOUSE_* keep the message hook,
which is the right source for a device query. The recursion guard now
covers only the PreClick/PostClick pair, so a nested click still gets its
own button scope.

It now returns nil in OnMouseDown / OnMouseUp / OnDragStart, matching
3.3.5, where those paths never touch the field.

Verified in-game across all four dispatches: real and programmatic clicks,
OnDoubleClick, PreClick/PostClick, the nested inner/outer restore, Click()
argument folding, and an OnUpdate watcher that never saw a value outside a
handler.
2026-09-14 22:37:45 -05:00
Brues ef8ea710d4 feat(frame): guard GetPoint against dangling anchor targets
An anchor node (region+0x28 + point*4, vtable 0x0081C44C) stores its
relativeTo as a raw pointer at +0x0C with no weak reference. When the
target frame is freed the pointer is never cleared, so Script_GetPoint
still reads the target's refcount at [relativeTo-0x24+4] and faults --
ERROR #132, ACCESS_VIOLATION at 0x007A2452.

Re-register GetPoint on the Region method registry (front-of-bucket
wins, no MinHook -- the Texture::SetDesaturated mechanism). The
replacement SEH-probes each anchor's target header and hands dead ones
to the engine's own FUN_00767E70, which frees the node and NULLs the
slot by pointer comparison without ever dereferencing the dead target,
then delegates to the stock GetPoint.

Delete rather than NULL the relativeTo. 3.3.5 restructured CFramePoint
as a 16-byte POD with a flags word and invalidates in place (bit 0x800,
gated on by every reader), but the 1.12 node has no flags word, so its
only representation for a dead anchor is the empty slot -- exactly what
FUN_00767E70 produces. A live 0x44C node with relativeTo == 0 is a
state the engine never creates, and the layout readers would fault on
[0 + 0x3C].

Add _classicapi_ArmGetPointCrash to arm the condition deterministically
(decommitted page / low address / null relativeTo). No pure-Lua path
can produce it -- CreateFrame frames are never freed on this client.

Scope: covers the GetPoint reader only. The layout readers GetWidth and
GetHeight (0x007A2FB8) deref the same field unguarded, and nothing here
addresses whatever leaves the pointer dangling in the first place.
2026-09-14 21:37:43 -05:00
Brues a2d7d4e9e6 feat(classes): add C_ClassColor.GetClassColor
Returns a class's color as a ColorMixin, nil for anything that is not a
class name.

There is no engine source for the colors on this client: ChrClasses.dbc
has 17 columns and none of them is a color, and the binary carries no
class-color table (searched it for the class float triples and for the
hex strings, no hits). RAID_CLASS_COLORS is what the client actually
has -- FrameXML builds it in Fonts.xml, Util/Color*.lua gives it the
mixin, and every UI colors classes from it -- so the function reads
that table instead of carrying a second copy of the nine colors. Same
call C_UnitAuras.GetAuraDispelTypeColor makes with the
DEBUFF_TYPE_*_COLOR globals: one source of truth, one shared object per
class, and a class another addon recolors stays recolored here.

The lookup runs metamethods, so the pfUI shim in Util/AddOnCompat.lua
keeps answering grey for unknown keys.

Verified in-game: "WARRIOR" returns 0.78/0.61/0.43 with hex, colorStr
and the mixin methods; an unknown name returns nil; the result is
identical to GetClassColorObj for the same class.
2026-09-14 13:12:25 -05:00
Brues 449841b4eb fix(debugtools): draw the title bars above the backdrop, fit the scroll bar
The Backdrop of each frame draws its background and border into the
BACKGROUND layer, and it is applied after the textures declared in the
XML, so a title bar in that layer ends up underneath it. On
ScriptErrorsFrame, whose backdrop is the 32px dialog border, the header
came out behind the window edge; on EventTraceFrame and
TableInspectorFrame the bar is a translucent one inside the frame, so it
was buried rather than misplaced. Every Blizzard frame that uses
UI-DialogBox-Header puts it in ARTWORK. The title text of all three is in
OVERLAY and still reads over the top.

ScriptErrorsFrame is also the only frame here that takes its scroll bar
from UIPanelScrollFrameTemplate, which hangs the bar off the right edge
of the scroll frame rather than inside it, so the bar ran past the
border. Narrow the scroll frame, and its child edit box with it, to end
where the Close button does. Blizzard's own measurements do not carry
over: its ScriptErrorsFrame inherits UIPanelDialogTemplate, a thin
GearManager border, not this backdrop. The other two frames place their
sliders by hand and already sat inside.
2026-09-14 01:56:48 -05:00
Brues 89c705b922 docs: describe the four macro commands added today
Covers /castsequence, /castrandom, /userandom and /stopmacro in the Lua
reference, plus StopMacro beside GetMacroSpell, and updates the README
groups and counts.

Two caveats are worth having written down rather than found: a list names
no single action, so the random commands give the action button nothing
to show, and being static lines they end the #showtooltip collection --
a /castrandom above a /cast takes the icon from it.
2026-09-14 01:38:13 -05:00
Brues bf79ee1d93 feat(macro): add /stopmacro
Stops the rest of the macro body, split the way 3.3.5 splits it: the
command is addon Lua, and the flag belongs to the body runner, which is
what makes "stop the rest of THIS macro" mean anything.

The runner clears the flag for each body it starts and restores the
previous value when that body ends, so a line that runs another macro
re-enters the runner and the inner /stopmacro ends the inner body only.
A stray StopMacro() outside a macro leaves nothing armed for the next
one. Each line is dispatched synchronously through ChatEdit_SendText
into its handler, so the flag is already set when the fire returns.

The command binds ClassicAPI.StopMacro rather than the bare global.
StopMacro is a plain name in a crowded space, and the global is only
ours until an addon declares one of its own -- after which the command
would set THEIR flag and the macro would keep running with nothing to
show for it. The mirror captured our function at registration.
2026-09-14 01:33:36 -05:00
Brues 4f99a1ed95 feat(macro): add /castsequence, /castrandom and /userandom
Ports the three remaining cast-family commands from FrameXML, including
the macro icon, which is half the feature: a sequence whose button does
not show the current step is barely usable.

The sequence state lives in Lua and the icon asks for it by name. That
is Blizzard's own split -- 3.3.5's macro code calls the Lua global
QueryCastSequence through lua_pcall and takes the item over the spell --
so execution and display read one index and cannot disagree about which
step is current. Macro::ShowTooltip mirrors that call for any option
line that came from a castsequence command.

The random pair is faithful to 3.3.5 rather than nicer than it: the
binary has no query beside QueryCastSequence, so a comma list reaches
the resolver whole, names nothing, and greys the button. Being a static
line it also ends the collection, so a /castrandom above a /cast takes
the icon. #showtooltip gives one of these macros an icon.

QueryCastSequence stops answering if the contested-command pass hands
/castsequence to another macro addon: our table only advances while our
handler runs, so a frozen step one on a sequence somebody else drives
would be worse than no icon at all.

Three adaptations the 1.12 client forces: item detection uses the
carried-item test, since there is no name to item lookup here; the cast
events carry the spell in different argument positions per event; and
CastSpellByName takes no unit, so a targeted step routes through
C_Spell.CastAtUnit like /cast already does. /cast, /use and both random
commands now share one action dispatch instead of four copies of it.
2026-09-14 01:21:12 -05:00
Brues 298eff528c fix(quest): fire QUEST_ACCEPTED from the engine's own accept gate
The client announces "Quest accepted" from a per-quest-slot descriptor
observer, while the quest log is rebuilt from a separate one. Slot 0's
announcer shares a descriptor anchor with the rebuild node and is
registered first, so accepting into an empty quest log announced a
quest no quest-log API could see yet.

Rebuild the log at the top of the observer, as 3.3.5 does, and source
QUEST_ACCEPTED / QUEST_REMOVED from the engine's own per-slot gates
instead of a log diff. That drops the set-diff login heuristic, catches
the re-announce when a quest goes complete to incomplete, and makes the
message precede the event on every path. A cold quest cache rides the
engine's own callback queue so the deferred announce still comes first.
2026-09-13 23:35:05 -05:00
Brues b8b9da2adb ci(release): mirror releases to the Gitea instance
A Gitea pull mirror replicates commits and tags only. A release is a
database record with its own attachments, so it never crosses: the
mirror's Releases page shows bare tags with nothing to download.

Push them over through Gitea's API instead. The new workflow ensures the
tag is on the mirror (forcing a mirror-sync and waiting if it is not),
then creates the release -- or promotes the bare tag record Gitea's own
sync left behind -- and uploads each asset, replacing one whose size no
longer matches. The files come from the GitHub release rather than a
rebuild, so both hosts serve identical bytes.

`Build` calls it after every release build. Running it from the Actions
UI with a tag redoes one release; with the tag blank it backfills every
release on GitHub. A `release: published` trigger cannot work here --
releases created with the default GITHUB_TOKEN do not start another
workflow -- hence the `needs: build` job.

Endpoints, payload fields and status codes verified against the
instance's own swagger (Gitea 1.26.4).

Needs a MIRROR_TOKEN secret holding a mirror token with `repository`
write. Without it the mirror job fails with that instruction, leaving
the already-published GitHub release untouched.
2026-09-13 21:34:16 -05:00
Brues 40abcabe9d fix(unit): read the creature id from the unit's live entry, not the GUID
UnitCreatureID and C_CreatureInfo.GetCreatureID extracted the template from
GUID bits 24-47. On the (v)mangos family that is the spawn row's FIRST
creature id: Creature::CreateFromProto builds the GUID from creature_id[0]
and applies the rolled template through UpdateEntry -> SetEntry only, and a
respawn re-rolls. Turtle's creature table has up to four ids per row (6,935
of 79,773 rows use more than one), so mobs with different names reported the
same id — every Gordunni ogre from a {5236, 5238, 5239} row read 5236.

New Unit::CreatureID reads the entry the engine keys the creature NAME query
on: the instance block at [[unit+0x08]+0x0C] (FUN_00604600's own read), with
the GUID bits only as the out-of-view fallback for creature GUIDs. The
descriptor's OBJECT_FIELD_ENTRY slot reads 0 for creatures on this client,
as it does for items, so it is documented as unusable. Pet GUIDs pack the pet
number, not a template, so Guid::CreatureEntry now returns 0 for them; pets
in view report their real template from the block.

Verified in-game on Turtle: Gordunni Shaman / Battlemaster / Mage-Lord read
5236 / 5238 / 5239 while all three carry 5236 in the GUID.
2026-09-12 23:47:08 -05:00
Brues 108a91a843 feat(api): describe the API in the code, browse it with /classicapi
docs/API.md is 18.7k lines and its anchors are signature slugs, so editing a
signature breaks the 281 internal and 88 README links into it. Blizzard
solved the same problem by shipping the API as data plus a browser addon,
and warcraft.wiki.gg renders those same tables. One set of descriptors can
feed all three.

Each registration now carries one next to it: typed arguments and returns,
nilability, defaults, and a sentence. Events, enumerations and returned table
shapes are described the same way. No Script_* body changed.

Game.h gains namespace Game::Doc (Field with Req/Opt/Vararg, FieldList,
Function, Method, Structure, Event) and a trailing descriptor argument on all
six Game::Lua::Register* plus Event::Custom::AutoReserve.

api/Documentation.cpp records every registration on the first pass of each
Lua state and seals it, so /reload re-registers but records nothing and the
environment comes from which pass saw the name. Export is lazy: nothing is
materialized until C_APIDocumentation.GetSystem asks for one system, because
eager export would build thousands of tables per reload for a slash command.
_classicapi_UndocumentedAPI lists what still has no descriptor.

AddOns/!!!ClassicAPI/APIDocumentation/ ports Blizzard_APIDocumentation's eight
files. Named /classicapi, not /api: the browser holds only what ClassicAPI
adds, so /api would have implied the whole 1.12 surface. Two deliberate
differences from the retail source, both noted in the file headers: a system
search also matches its namespace, since that is the name people type; and an
undocumented function says so, because an empty signature otherwise reads as
a real one.

LinkUtil gains the SetItemRef to LinkUtil.ProcessLink dispatch. That handler
registry was dead code; a new link type now registers a handler instead of
stacking another SetItemRef override. Also defines assertsafe there, which
was undefined and only mattered once the function went live.

Documented: the C_Spell and C_SpellBook surface, the spell globals, both
spellbook enumerations, the 13 UNIT_SPELLCAST_* events, GameTooltip as the
widget-method example and C_Glue as the glue and mixed-environment example.
66 of 692 function registrations. tools/New-ApiDocSkeletons.ps1 emits
paste-ready skeletons to make the rest of the sweep tractable.

Verified in game: the browser lists, searches, and prints detail; links
render and click through; right-click copies and shift-click dumps.
2026-09-12 23:20:46 -05:00
Brues b4e5f4f2bb feat(spell): fill notInterruptible in UnitCastingInfo / UnitChannelInfo
Mirror the 3.3.5 client's own predicate on 1.12 data instead of pushing a
placeholder false. The value is relative to the local player: the spellbook
is folded into school masks of INTERRUPT_CAST spells and MOD_SILENCE spells
(cached on Player::StatSignal), a cast is not interruptible when its
PreventionType is not SILENCE or when the caster's immunity auras (effect /
state / school / mechanic immunity) strip every mask, and the flag is never
reported while the player knows no interrupt or silence at all.

1.12 differences, each documented in Spell::Interruptible: the vanilla
server's interrupt effect also needs InterruptFlags 0x02 (channels:
ChannelInterruptFlags 0x04), which silence ignores; SMSG_SPELL_START carries
no immunity block, so creature_template interrupt immunity stays invisible;
Feral Charge's interrupt lives on its triggered spell, so the capability walk
follows one TRIGGER_SPELL hop.

Verified in-game: a paladin's Holy Light reads false, flips to true while
Divine Shield is up, and back to false when it drops.

Also centralize SPELL_EFFECT_APPLY_AURA in Offsets.h (was local to Level.cpp).
2026-09-12 22:21:16 -05:00
Brues 390eec4749 docs(nameplate): correct stale frame-identity and token-shift prose
The API.md nameplate prose contradicted the code in two places, and
both were resolved in the code's favor after verifying it.

Frame identity: the prose claimed default engine plates get a fresh
wrapper table per call with unstable identity. UI::FrameObject::Push
has one path for both plate kinds — a registry ref at +0x08 of <= 0
delegates to the engine's ScriptRegister, which builds the wrapper and
writes the ref back, so every later call rawgeti's that same cached
slot. Identity is stable for both kinds and addon-set fields survive.
Dropped the registry/metatable mechanics that only existed to justify
the wrong claim; kept the pooled-frame caveat, restated with its real
reason.

Token stability: the NAME_PLATE_UNIT_ADDED note said "nameplate3" can
resolve elsewhere after a slot "vacates and shifts". AssignSlot never
shifts an entry and removal frees the slot in place. Kept the advice
to store the GUID, fixed the reason: slots are reused, never shifted.

Also fixed the iteration example one code block above the slot rules,
which broke at the first vacant slot and so missed every plate past a
gap in the sparse slot array.
2026-09-12 21:58:03 -05:00
Brues f86893c8ef fix(spell): make IsUsableSpell the engine's own usability verdict
IsUsableSpell hand-rolled five checks (known, alive, off cooldown,
power, reagents) and diverged from IsUsableAction exactly where the
engine does more or less: it never tested stance, so Whirlwind in
Battle Stance reported usable, and it tested cooldown first, so a
0-rage Whirlwind on cooldown reported (nil, nil) instead of (nil, 1).

IsUsableAction reads a per-slot cache that FUN_004E5050 fills by
calling FUN_SPELL_IS_USABLE (0x006E3D60) on a player spell's record,
or by comparing the pet's power against the spell cost for a pet
spell. Run those same two paths live, plus the knowledge gate the
helper lacks. Cooldown is not part of usability: the helper has no
cooldown branch and FrameXML greys and swipes as separate states.
Item::Usable had been calling the helper all along.

A pet-book spell given by ID now takes the pet path instead of
always reporting not usable.

Offsets.h: add VAR_PET_GUID and FUN_PET_ACTIONS_USABLE (verified via
Script_HasPetUI, Script_GetPetActionsUsable and the recompute's pet
branch); replace the FUN_SPELL_IS_USABLE comment with the decompiled
check list.

Fixes #51
2026-09-12 09:12:48 -05:00
Brues d4ac5dc210 fix(slash): only give back a command that is still ours
The contested-command heal cleared any key we added whose command string
another addon also claimed. Both halves of that test read the live table a
frame later, so an addon that takes one of our keys OVER broke it.

SuperCleveRoidMacros registers into our own `CANCELAURA` key: it replaces
the handler with its conditional one and writes `SLASH_CANCELAURA2 =
"/unbuff"` over our alias. The heal then tested `/unbuff` -- a command we
never registered -- and on a hit cleared the key, deleting that addon's
`/cancelaura` rather than handing ours back. `/unbuff` kept working through
its own owner, so the symptom was `/cancelaura` alone falling through to
`ChatEdit_ParseText`'s "Type '/help'" message.

Snapshot the handler and the command strings at file scope, while the table
holds exactly what this file registered, and skip a key whose handler is no
longer ours. Taking a key over is already how an addon wins a command, so
there is nothing left to resolve. The `/petattack` case that motivated the
heal is unchanged: that addon registers a rival key beside ours, our handler
stays in our key, and the contested string is one we registered.

Reported downstream as brues-code/SuperCleveRoidMacros#6.
2026-09-12 08:35:38 -05:00
Brues b0585f5628 feat(sound): add C_Sound.PlayVocalErrorSound and Enum.Vocalerrorsounds
Plays the player character's spoken complaint for an error ("my bags are
full") in their race's and sex's voice.

This one maps exactly rather than approximately: VocalUISounds.dbc's
second column IS the modern Enum.Vocalerrorsounds value, and this build
carries every one of the 68 values (0..67) across 11 races. Verified by
resolving a row's sound ids to their SoundEntries names — row
{9, 0, 1, 1875, 1999, ...} is error 0 for race 1, whose normal sounds are
HumanMale_InventoryFull and HumanFemale_InventoryFull, which also pins
the column layout {id, enum, race, normal[male,female],
pissed[male,female]}.

The row lookup is ours because the engine has none: it loads the table
and never reads it, the only xrefs to its records/count globals being the
loader's own writes. Only the indexing is reimplemented — the same
records[id] walk every other DBC reader here performs — while the play
goes through the engine's by-id player like every other sound.

Race and sex come from the login-time bytes rather than the unit
descriptor, so this resolves from addon-load onward, before the in-world
player object exists.

The "pissed" column is deliberately unused: many of the ids it names
match no SoundEntries row. Combinations with no recording play nothing
and raise nothing, which is data rather than an error.

Verified in game on a Night Elf female: Inventoryfull, Outofammo and
Nomana each played, and GetRecentSoundFiles showed
Sound\Character\NightElf\NightElfFemaleErrorMessages\... — right race and
sex — with varying file suffixes, so the engine's weighted variant pick
is running too.
2026-09-12 08:04:16 -05:00
Brues 6013d8b937 feat(sound): add C_Sound.PlaySoundWithOptions and GetSoundScaledVolume
The table form of PlaySound, and the volume reader that falls out of it.
Four of the params' six fields map onto something real here.

volumeOverride is the one that needed finding. FUN_00458DA0(row, stream,
scale) computes the row's own volume times `scale` and hands it to
FUN_007A5DC0, which stores it on the stream and pushes it to FMOD. The
by-id player already calls that with a scale of 1.0 immediately after
starting a sound, and the setter writes rather than accumulates, so
calling it again with another scale simply replaces the volume — no
touching FMOD directly, and no reimplementing the row's volume rules.
Resolving the row needs FUN_0045CDA0, since the applicator is a method on
it.

GetSoundScaledVolume then comes free: that same setter writes the result
to stream + 0x74, so the field IS the scaled volume. Read only off a
stream still in the engine's live list, because a finished stream's
memory returns to a pool.

soundKitID and runFinishCallback were already supported. uiSoundSubType,
forceNoDuplicates and overridePriority are accepted and ignored, and the
docs say so rather than implying they work.

Params are read with GetTable rather than a raw read, so a table built
from a mixin still resolves.

Verified in game: volumeOverride 0.1 is audibly quiet against 1.0,
runFinishCallback still fires SOUNDKIT_FINISHED through this path, and
GetSoundScaledVolume returned 0.276 for a volumeOverride of 0.4 — the
row's own 0.69 scaled, which is what confirms the multiplier semantics
the docs describe.
2026-09-12 07:46:05 -05:00
Brues 27277b11aa feat(sound): fire SOUNDKIT_FINISHED when an opted-in sound ends
Reports the handle of a finished sound, and only for sounds played with
`runFinishCallback` — the documented contract, and what makes it
affordable: without the opt-in every footstep and UI click would dispatch
a Lua event.

Detected on the world tick, NOT from FMOD's end callback. That callback
(FUN_007A53C0) runs on the STREAMER thread — it opens with
EnterCriticalSection and only moves the stream onto a deferred list — so
firing an event there would call into the Lua VM off the main thread. The
engine reclaims those streams in FUN_007A4B60, from the per-frame sound
update that ends in FSOUND_Update, so noticing completion on the main
thread is what the engine itself does.

A tracked entry keeps the stream's path hash (+0x80, stamped at creation
by FUN_007A51D0) beside the handle. Stream objects come from a pool and
their addresses are reused, so "still in the live list" is not enough on
its own — a later sound can land on the same address and the event would
be deferred to the wrong one. Comparing the hash narrows that to the same
file replaying at the same address, for one extra dword read of a node
already found alive.

C_Sound.PlaySound takes the remaining modern arguments: channel,
forceNoDuplicates and runFinishCallback. forceNoDuplicates is accepted
and ignored — the engine's by-path dedup is bit 0 of a flag word the row
itself supplies, and the by-id player ORs in only bits 1, 2 and 4 from
its arguments, so asking for it would mean bypassing that player and
re-deriving its weighted file pick.

The global PlaySound deliberately does NOT take those: it gains the
SoundKitID form and nothing else, staying as close to the global it
replaces as the extension allows.
2026-09-12 07:40:51 -05:00
Brues ccfdb8309c feat(sound): add C_Sound.PlayItemSound and Enum.ItemSoundType
Plays the noise an item makes when handled, without performing the
action. Almost all of it is the engine's: FUN_00457FF0 takes a sound type
and an ItemDisplayInfo id, walks that row's ItemGroupSounds id at +0x2C,
and plays the SoundEntries id at row + 0x04 + soundType * 4. Those four
columns are in the modern Enum.ItemSoundType order (Pickup, Drop, Use,
Close), which makes this a backport rather than a reconstruction — the
module only turns an item argument into a display id and calls it.

The item argument takes an ItemLocation table and the other forms this
project's item APIs accept (GUID string, itemID, link, carried item
name), through the shared ResolveItemArgOrLocation. Sound type is read
off the stack before resolving, since that resolver drives PackBagSlot
and stomps the stack.

Only Pickup and Drop play on a stock client, and that is the data rather
than a limit of the code: of ItemGroupSounds' 24 rows, the 18 any display
row points at (~29,000 displays) populate columns 1 and 2 only. The six
rows naming a Use sound are unreachable or broken — groups 1 and 2 have
no displays, and the ids they name (273/274/275) match no SoundEntries
row — and the Close column is zero in all 24. Use and Close fall through
the engine's own bounds checks silently, which is why no item can test
them; documented so nobody hunts for one.

The type stays a pass-through that indexes the row rather than selecting
a branch here, so a server filling those columns in gets Use and Close
working with no change on this side.

Sound keys off the item's display, not its itemID, so items that look
alike sound alike — the engine's grouping, and why every item has a sound
without one being recorded per item.

Verified in game: Pickup and Drop play, and differ by material.
2026-09-12 07:21:49 -05:00
Brues be7a8a0dd7 feat(sound): mute sound files, play by SoundKitID, and report what played
Five additions, one hook between them.

MuteSoundFile / UnmuteSoundFile stop a given audio file from playing.
They co-hook FUN_007A5450, the single funnel every sound passes through:
this client links only FMOD's FSOUND_Stream_* API, so even a one-shot UI
click is a stream opened by path, and the six callers of that function
are the whole surface (PlaySoundFile, the SoundEntries player behind
PlaySound and every DBC-driven spell/creature/UI sound, and four
music/ambience paths). A muted path returns NULL, which is the engine's
own "did not play" answer — its pre-play gate FUN_007A66A0 already
returns that at a category's concurrency cap, so every caller handles it
and PlaySoundFile already reports it to Lua as nil. Suppressing the open
rather than muting an opened stream means nothing is decoded for a sound
nobody hears; the only visible difference is PlaySoundFile returning nil
instead of 1 for a muted file.

C_Sound.GetRecentSoundFiles reports the last 64 DISTINCT files played,
newest first, with a timestamp and a muted flag — the other half of
muting, since hearing a sound tells you nothing about which of ~9,800
files it was. Always on: the hook exists for muting anyway, recording is
one bounded copy into a preallocated slot on a call that is about to open
and decompress an archive file, and a switch would have to be armed
before hearing the sound you wanted to identify.

C_Sound.PlaySound plays a SoundKitID and returns willPlay, soundHandle. A
SoundKitID is a SoundEntries row id — the same ids the modern client's
SOUNDKIT constants carry, verified against this build's table (850
igMainMenuOpen, 8959 RaidWarning, 8960 ReadyCheck). The engine has always
played by id internally; the only Lua door was the row's NAME string, so
addons written against SOUNDKIT ids could not play a sound here. The
global PlaySound is therefore widened to a strict superset: a number
plays by id, anything else tail-calls the engine with the stack untouched
so the name form is unchanged. It discriminates on lua_type rather than
lua_isnumber, which would also accept a numeric string and reroute
PlaySound("123") away from the name lookup.

C_Sound.IsPlaying validates a handle by walking the engine's live stream
list rather than dereferencing it, since a stream object is freed when
the sound ends, with a step guard so a corrupt list cannot spin inside a
Lua call. Handles are pooled and reused, which the docs call out.

FUN_0045CE60's ABI came from the disassembly, not the decompile — Ghidra
maps its arguments inconsistently against the call site. Re-registering
the PlaySound global was contention-checked first: no sibling DLL
references Script_PlaySound (UnitXP_SP3's "PlaySound" string is the winmm
PlaySoundW import), and the one addon that reassigns the global does a
temporary swap and restore around its own calls.

Verified in game: play by id and by name, mute and unmute across both
entry points, IsPlaying true while a sound runs, and the recent list
filling from sounds the player never triggered (zone music, quest log,
enter world) — which is what shows the funnel sees everything.
2026-09-12 06:59:37 -05:00
Brues f9167bac06 feat(export): add ExportSoundFiles, and sweep every archive when enumerating
ExportSoundFiles is a sibling of ExportDBCFiles that dumps the MPQ
`Sound\` tree to `BlizzardSound\`, with an optional subpath prefix (the
full tree is ~3.5 GB and the command runs synchronously). All 8803
SoundEntries rows have a `Sound\...` DirectoryBase, so that one root is
the whole corpus.

Writing it exposed a bug in the existing enumeration. FUN_00401470's
`archiveSelector` is not an archive SET, as the old comment assumed — it
is an INDEX into a fixed 10-entry table built by the mount function
FUN_00403740 (model, texture, terrain, wmo, sound, misc, interface,
fonts, speech, dbc). Each call walks the primary patch archives plus
exactly ONE of those ten. We passed 6, copied from the macro-icon
loader, so ExportInterfaceFiles was complete only by coincidence —
interface.MPQ IS selector 6 — while the sound export opened neither
sound.MPQ nor speech.MPQ and silently lost 6,561 of 9,535 indexed files.
EnumAllArchives now sweeps all ten, deduped; ExportInterfaceFiles and
ExportDBCFiles go through it too.

The sound export also unions the archive index with SoundEntries.dbc,
because each source holds files the other lacks: an MPQ resolves a read
by hashing the path, so the index is optional and 299 shipped files are
named by no index at all, while ~300 more (character combat grunts,
doodad audio referenced from model data) appear in no SoundEntries row.
Same shape ExportDBCFiles already uses to union the index with its .text
path-getter scan.

Failed reads and writes are now counted and logged rather than silently
skipped. A path only reaches that point because an index or the sound
table named it, so a failure means the two disagree — which is worth
seeing, and was invisible before: the console line gains "(N skipped)"
and each path goes to Logs\classicapi_debug.log.

Also: no extension filter (the tree is all audio, and the table carries
typo'd names like Kill3.wac a whitelist would drop), and the destination
is BlizzardSound\ rather than Sound\ — the loose-file VFS index resolves
loose files ahead of archives, so exporting into Sound\ would shadow the
MPQs with gigabytes of duplicates re-indexed at every launch.

Verified on the 1.18.1 build across both fixes: 3,184 files with the
original selector, 9,526 once SoundEntries was unioned in, 9,818 with
the full archive sweep, and no zero-byte writes. ExportInterfaceFiles
holds at 340/340 and reports nothing skipped, so neither it nor
ExportDBCFiles was ever affected.

Two residuals on this Turtle build, both logged rather than hidden. The
12 `Sound\Creature\Elekk\*` + 4 `Sound\Creature\ElekkPet\*` reads fail
because SoundEntries names a folder the archive does not use (the files
sit flat in `Sound\Creature\`) — a data inconsistency, not a code fault.
Separately, patch-7's 14 flat `Sound\Creature\Elekk_*.wav` index entries
do not reach disk, and that one is NOT explained: patch-7's listfile is
demonstrably walked (its 4 exclusive interface-code files exported, and
interface has no DBC fallback), yet those entries produced neither a file
nor a logged failure.
2026-09-12 06:31:47 -05:00
Brues 7d82ae8e92 docs: record where hooksecurefunc's unhookable list comes from
`kUnhookableNames` is patch 11.0.0's own refusal list, verbatim — the 23
taint-relevant names, not "the Lua base library". Nothing said so, and
with the base-library luaL_reg table (0x00811E28, 36 entries) freshly
catalogued the two looked like they should be one thing. They must not
be: 11.0 leaves tostring / error / loadstring / assert / collectgarbage
hookable and includes secure-family names that are no base function, so
deriving the list from the engine table would move it away from the
client. No earlier client on this machine (3.3.5, 4.3.4, 5.4.8) has the
restriction at all.

Say so in the source comment and the TODO section, and list the names in
the API reference, which documented every other hooksecurefunc error but
this one. Comment-only in the source; no behavior change.
2026-09-12 04:22:14 -05:00
Brues ed7e7628c7 docs: catalogue the Lua base library the batch scan never saw
BlizzardScriptAPI.md and raw_globals.txt were built by walking the 54
FrameScript_RegisterFunction batches, so the Lua standard libraries —
bound by luaL_openlib from luaL_reg tables at state creation — were
missing entirely. That gap read as "1.12 has no debugprofilestart", which
is wrong: the pair is stock since 1.7.0, in the base-library table at
0x00811E28 (36 entries: setglobal … debugstack).

Add the table to both files, with the mechanism (FUN_00703B80 runs the
lib open and the alias snippet at .data 0x008722E8 right before
FUN_LOAD_SCRIPT_FUNCTIONS), the debugprofile pair's clock (rdtsc delta
over a boot-calibrated ticks-per-second, milliseconds as a double), and
the rule this taught: check these tables or /dump the name before
declaring a global absent.
2026-09-12 04:15:44 -05:00
Brues a562573559 perf(table): bisect to a border in the getn heal instead of walking down
The heal's fallback walked down from the stale stored length one rawgeti
per slot — O(stale gap) — and since a length read never writes back, it
did so on EVERY luaL_getn read until the next append re-synced the
length. A table cleared by `pairs` and then polled through getn / concat
/ sort / unpack paid the full walk each time.

The search is now the doubling-then-bisect that luaH_getn uses for 5.1's
`#`, in one shared header (table/Border.h) that both `__len` and the heal
call; the copy that lived in Transpile.cpp is gone. The mark gate is
unchanged in meaning: `n - b == 1` said "slot n-1 is populated", so it is
now that one probe, tested before the registry lookup. For a fully
cleared table and a table stale by one the answer is identical; a stale
table with holes gets some border rather than the highest one, which is
what 5.1 itself returns for that table.

Verified in game: the reserved slot with a hole present (5, "c"), the
stale-by-one recycle (7, 2), the full clear (9, 1), 1,000 getn reads on a
cleared 20,000-slot table with no hitch, and `#` through the shared
search.
2026-09-12 04:05:49 -05:00
Brues 5aa382bbb1 refactor(table): register the marking table.insert instead of hooking it
Table::Length recorded deliberate `table.insert(t, nil)` appends (#36) with
a MinHook on the engine's `luaB_tinsert`. The hook is unnecessary: that
function has exactly one xref — the table-lib luaL_reg entry — so no engine
C code calls it, and every call arrives through one of two Lua values:
`table.insert` (lib open) and the global `tinsert`, which the engine's
embedded compat snippet binds by value (`tinsert = tab.insert`, .data
0x008722E8) from FUN_00703b80 right before FUN_LOAD_SCRIPT_FUNCTIONS
(calls at 0x0048fe97 / 0x0048fe9c; on glue 0x0046a87b / 0x0046a880).

So the marking function is now registered over `table.insert` and calls
`luaB_tinsert` directly, and `tinsert` is re-bound to the same closure by
value through a new `Game::Lua::RegisterGlobalAlias` — the snippet's own
idiom, so `tinsert == table.insert` stays true. Both Lua states get the
pair; the `luaL_getn` heal is unchanged. One MinHook fewer, on a hot Lua
function.

Verified in game: the identity triple (`tinsert == table.insert` and both
`_G.ClassicAPI` mirrors), the #36 reserved slot through both names, the
#39 heal, the positional form, and the error path through the new frame.
2026-09-12 03:49:43 -05:00
Brues d98d3a9fd5 perf(luasyntax): decide with one allocation-free lex before tokenizing a chunk
The load hook gated on raw bytes — memchr for `#`/`%` and three scalar
scans for `...`, `0x` and `[=` — then tokenized the whole chunk, and
RewriteAll threw the token vector away when no operator token existed.
A `%` in a format string or a `...` in prose was enough, so nearly every
addon file and every SavedVariables file paid for a token stream it did
not need: on the dev install, 723 of 1391 addon files (101.6 of 160 MB
on disk) and 39 of 301 SV files (16.7 of 16.9 MB).

The lexer is now `Lex<Sink>`, one body with two sinks. RunPasses runs it
first with `FlagSink`, which records only whether `#`, `%`, `...`, a hex
number or a leveled long bracket occurred outside a string or comment,
and tokenizes only when one did. The byte gates are gone (the flag lex
replaces all five) and RewriteAll takes the flags instead of rescanning
the tokens. A chunk with no real 5.1 syntax costs one pass and no
allocation, and the string/comment skipping stays single-sourced.

Measured in game after the change: 40,921 chunks / 19.1 MB per login,
112 chunks / 2.8 MB tokenized, 39 ms inside the rewrite in total.
`_classicapi_TranspileStats()` exposes those counters.
2026-09-11 21:05:38 -05:00
Brues 3d91ae0f91 feat(macro): show a spell named by ID even when it is not learned
`#showtooltip 1539` resolved its value through the engine's name resolver,
which maps a name -- or an ID through its name -- to a spellbook SLOT. An
unlearned spell has no slot, so the directive matched nothing and the button
greyed with no icon and no tooltip. That constraint is right for `/cast`,
which needs the slot, but an icon and a tooltip need only the spell record.

A value the directive names itself is the author saying what to display, so
it now falls back to `Spell.dbc` when the spellbook has no slot for it. The
button still greys, because the spell still cannot be cast, which is the
honest result: the macro shows what it is about and says you cannot use it.

`spell:N` is accepted as the display counterpart of `item:N`, and skips the
spellbook unconditionally -- nobody writes that form except to name a spell
for display. A bare number skips it only for an explicitly named value; one
lifted off a `/cast` line resolves exactly as far as that cast would, which
keeps the bare form honest about what the macro will actually do. A spell
NAME cannot be rescued either way, since 1.12 has no name-keyed spell index.

The name path still runs first, so a learned spell keeps its real spellbook
resolution including the pet-book flag; the record fallback only sees what
the spellbook rejected, and forces `isPet = 0` there, a pet slot being
exactly what just failed to resolve. The 1..19 inventory-slot rule stays
ahead of it, so `#show 13` is still slot 13 rather than spell 13.

A published value counts as explicit for the same reason a directive's does.
2026-09-11 17:10:51 -05:00
Brues fedccf5b95 fix(macro): give a contested slash command back to whoever owned it
Vanilla has no `/petattack`, so the conditional slash-command registry ADDS
it. SuperCleveRoidMacros ships the same command under its own SlashCmdList
key -- `PETATTACK` where we register `PET_ATTACK` -- and both set a
`SLASH_*` string of "/petattack". Neither ever overwrites the other, so
loading first decides nothing: `ChatEdit_ParseText` walks SlashCmdList with
`pairs` and takes the first match, making the winner hash order.

When ours won, `/petattack [hastarget,alive,harm]` fed that addon's
conditions to `SecureCmdOptionParse`, which does not know `hastarget` or
`alive`, so no clause matched and the line did nothing.

Rejecting a condition we cannot evaluate is right by itself -- retail does
the same -- so the fault is owning the command at all. A command we add and
someone else already binds is handed back. Splitting one command across two
condition parsers would be worse than one owner: the dialects disagree, and
ours deliberately differs from 3.3.5 for an `[@unit]`-only group.

Only added commands are given up. One we replace (`/cast`, `/target`) has no
second entry to lose to, and an addon that wraps ours still chains through
it.

The check runs a frame after load, not at file scope -- this addon loads
first, so nothing is registered to detect yet -- and not on PLAYER_LOGIN,
which has already fired during a `/reload` and would run the check
immediately to the same empty result. The addon load pass is synchronous, so
one frame is enough on both paths. A command registered by a load-on-demand
addon later keeps its own entry, and its coin flip.
2026-09-11 16:50:10 -05:00
Brues 0ba73b8457 fix(macro): leave GetMacroInfo alone and expose the shown icon instead
`Macro::Info` re-registered `GetMacroInfo` to answer with the icon a `?`
macro's `#showtooltip` resolved to, so the Macro UI's list would match the
action button. But the Macro UI seeds its icon selector from that same
return -- `MacroFrame_Update` sets `MacroPopupFrame.selectedIconTexture`
from it, and `MacroPopupFrame_Update` checks the grid button whose name
matches -- so opening the selector on such a macro pre-selected an icon the
player never picked, in the one place that edits the stored icon. Saving was
never at risk (an unclicked selector leaves `selectedIcon` nil and
`EditMacro` then leaves the icon alone), but the display was wrong.

One return cannot serve both readers, and the one that edits state has the
stronger claim, so `GetMacroInfo` goes back to the engine's: it is the read
side of the icon `C_Macro.EditMacro` writes, and every addon that captures
it now gets the stored icon.

The icon a macro SHOWS becomes something a caller asks for, through
`C_Macro.GetMacroIcon`. It reads the macro icon getter this DLL already
hooks, so the `?` rule and the active-icon choice stay in one place and the
answer matches what the action button and the drag cursor get.
Util/MacroIconSelection.lua paints the Macro UI's list buttons from it after
the stock update, which is additive -- an empty slot or a macro with a
chosen icon answers with what stock already drew.

Dropping the re-registration also takes a commonly captured global out of
the override chain.
2026-09-11 16:34:11 -05:00
Brues d9565fbe1f fix(macro): correct macro display on a client that owns the macro tooltip
SuperWoW detours the engine's macro tooltip builder and reimplements
3.3.5's version of it: build the cached spell's tooltip when the macro's
primary-spell field is non-zero, else add the macro's name. We write that
field on purpose -- it is what makes a macro button's icon, cooldown,
range, usable and auto-repeat state correct -- so every display fault here
traced back to what we put in it.

Four things were wrong:

- `MaintainExternal` re-derived "the publisher had a value" from
  `kind != None`, but `Publish` marks a claimed macro `ShowTooltip` either
  way. One tick after `SetMacroDisplay(i, false)` it turned the
  publisher's "nothing matched" (cache 0, button usable) into "named an
  unknown spell" (the grey-out sentinel), and then stuck, because the next
  pass saw the sentinel already in place. The button greyed and the
  tooltip came up empty -- a spell tooltip for -1 builds nothing and skips
  the name. `matched` is stored on the entry now.

- A publisher's claim outlived the publisher. `PrepareForReload` never
  cleared the entries and `Rescan` deliberately never re-reads a claimed
  body, so an addon disabled between reloads left every macro it had ever
  claimed frozen on its last answer. Claims are dropped on reload, each
  one handed back to the engine's own parse first.

- `LookupEntries` scanned all 36 entries for a matching macroID. That is
  not sound: `FUN_MACRO_CREATE` bumps one of two per-scope counters, so a
  general and a per-character macro can share an id, and an entry whose
  slot-map index shifted keeps its old id until the next rescan. Either
  way another macro's resolution could answer. It resolves through the
  slot map now and requires the entry to still agree.

- `#show` is icon-only, but the tooltip is not ours to delegate on this
  client: the field we fill for the icon is the same one the detoured
  builder reads, and it cannot carry the distinction. `#show` builds the
  macro's name through `Script_SetText`, which runs the identical
  sequence. `Publish` takes the kind from the body for the same reason, so
  a claim can no longer promote `#show` to `#showtooltip`.

Also: a resolved spellID with no Spell.dbc row goes back to the engine
rather than rendering an empty frame, matching the fallback 3.3.5's own
macro tooltip ends with; and a conditional directive keeps its tooltip
refreshing while hovered, which is 3.3.5's `dynamic | built` return.
2026-09-11 16:03:27 -05:00
Brues 61db68dd7f fix(lua): gate frame-method self with the engine's own type check
Every engine frame method validates its `self` in three steps before
touching it: lua_type == table, resolve to the CFrameScriptObject, then
the IsA(typeId) vmethod at vtable+0x10. We did the first two and skipped
the third, so ResolveObject handed back a pointer for ANY frame and the
method cast it to whatever type it expected.

That was reachable from plain Lua, not just through a mirror or a hook.
The per-type method dispatcher is an __index handler that rawgeti's the
method closure out of the Lua registry, so frame methods are ordinary
first-class values, and

    GameTooltip.SetSpellByID(ShapeshiftButton1, 133)

reached the tooltip builder with a Button, writing tooltip fields through
Button memory. The engine refuses that same call with "Wrong object type
for member function".

Game::Lua::ResolveTypedObject runs the engine's prologue and raises the
engine's own three error strings, read from .data rather than reinvented.
FrameScriptTypeId mirrors the lazy id assignment, so a check is valid
before any stock method of the type has run. Seven per-type wrappers stop
a call site pairing a method with the wrong class's id, and
ResolveTypedObjectAny covers a method that more than one type reaches.

45 call sites across 19 files now gate self. model/DisplayInfo.cpp had
already worked this out for Model alone and loses its private copy.

Type ids are per CLASS, one shared by all of that class's methods. Each
was read from the prologue of a method that can only belong to its class:
a base-class method registered into several tables carries the BASE id, so
sampling GetObjectType or GetDrawLayer would have produced a gate that
accepts unrelated types. Frame comes from Script_RegisterEvent and Texture
from Script_SetDesaturated, both cross-checked against the first entry of
their table.

Deliberately left ungated: the binding `owner` in bindings/Api.cpp and the
three co-hook sites in texture/Atlas.cpp. Each only uses the pointer as an
identity key and never casts it, so a gate would tighten the contract for
no safety gain -- and raising inside a co-hook would fire before the
original ran.

Verified in-game: the Button-as-tooltip call now raises the engine's
error; a Button still reaches a Frame-registry method
(button:SetAttribute round-trips), so IsA does accept derived types; and
fontstring:GetRotation() still answers -- that reader is registered on
both the Texture and FontString registries and needs the id pair rather
than the Texture id alone.
2026-09-11 14:12:40 -05:00
Brues f895d38f76 refactor(time): drop C_DateAndTime.GetServerTime for the ClassicAPI mirror
The alias existed because FrameXML takes the GetServerTime global on
some clients, and an addon needs some reachable name for the epoch.
ClassicAPI.GetServerTime now is that name, so the hand-made one is
redundant surface.

Docs now point the GetServerTime caveat at the mirror.
2026-09-11 12:37:38 -05:00
Brues 804ed61c76 feat(lua): mirror every registration under a ClassicAPI namespace
Registering a Lua name is not the same as owning it. The engine's
registrars write _G, FrameXML and every addon load after that, and
whoever writes last wins with no error and no way for the loser to
notice. GetServerTime is the case on record: a client whose GameTime.lua
declares its own replaced ours on every login, and no addon could reach
the C function again.

Every name RegisterGlobalFunction, RegisterGlueFunction and
RegisterTableFunction bind is now also bound under _G.ClassicAPI, which
nothing else writes -- ClassicAPI.GetServerTime,
ClassicAPI.C_Item.IsBound, namespaces kept as subtables. All three
registrars are single chokepoints, so this is one helper covering 637
in-game registrations plus 26 glue ones, and anything added later needs
no extra call.

Two properties, both of which depend on doing this in the registrar
rather than from addon Lua:

  - It runs at registration, so the mirror captures OUR function. A
    Lua-side `ClassicAPI.X = X` in the embedded addon would run after
    FrameXML and faithfully copy the clobbered version.
  - It mirrors by VALUE, reading the name back rather than making a
    second closure over the same C function. So `ClassicAPI.X == X`
    holds until something replaces the global, and the pair doubles as a
    clobber check:

        if GetServerTime ~= ClassicAPI.GetServerTime then ... end

EnsureSubTable uses raw get/set so a namespace carrying a metatable
cannot divert either side, and MirrorRegistration restores the stack via
an absolute gettop rather than counting pops.

Not covered, and documented as such: frame methods, which live in the
per-frame-type registry rather than _G, and RegisterIntegerEnum values,
where a mirror would be a stale copy rather than a reference.

Verified in-game: ClassicAPI.GetServerTime == GetServerTime is false on
a client whose FrameXML takes that global, while
ClassicAPI.GetTimeCached == GetTimeCached is true.
2026-09-11 12:35:36 -05:00
Brues aacace1645 fix(time): take the server clock from the engine's own time sync
`GetServerTime` had two separate problems.

The global is not ours to keep. Registration happens inside
FUN_LOAD_SCRIPT_FUNCTIONS and FrameXML loads after that, so a client
whose GameTime.lua declares its own `function GetServerTime()` replaces
it on every login and every /reload, and no addon can reach the C
function again. Turtle's version returns (serverHour, serverMinute) --
which is itself just the player's own machine clock, since it only
diverges from date() if the realm answers a TW_TIME broadcast, and
nothing in the public core does. Add C_DateAndTime.GetServerTime: the
same C function under a name FrameXML never touches.

The value was also the wrong clock. It was rebuilt from the broadcast
gametime struct, which carries minute granularity, no timezone, and
whatever per-map time offset the realm folded in before sending. The
engine already has a better source: FUN_004DE430 (quest-log enter-world
setup) sends CMSG_QUERY_TIME unconditionally, and the handler caches
localEpoch - serverEpoch at VAR_SERVER_TIME_DELTA, refreshed hourly. The
reply is a raw time(nullptr), so the skew yields a true UTC epoch with
real seconds, free of both the timezone and the map offset. The sign is
confirmed by the engine's own use of it -- the timed-quest expiry check
in FUN_004DE510 converts a server timestamp into local-clock space by
ADDING the delta.

The two clocks answer different questions, so Time::Server exposes both
and each function takes the one its contract names:

  CurrentEpoch()     the instant  GetServerTime, GetSecondsUntilDailyReset
  RealmClockEpoch()  the display  GetCurrentCalendarTime, GetServerTimeLocal

GetServerTimeLocal is "the epoch offset by the server's timezone", which
RealmClockEpoch already is by construction, so its round-trip through
the *player's* timezone is gone. GetSecondsUntilDailyReset moves the
other way: a realm buckets its day over a raw time(nullptr), so the
boundary is UTC midnight rather than the displayed midnight.

Verified in-game across an Azeroth -> Kalimdor port on two realms, one
on UTC and one on UTC-5. The instant stays continuous (+51 s across the
port, matching real elapsed time) while GetGameTime jumps 12 hours;
GetServerTimeLocal tracks the realm's displayed clock and rolls the date
with it; GetCurrentCalendarTime's hour matches GetGameTime in both zones.

Fixes #50
2026-09-11 12:17:37 -05:00
Brues 4bf2c17e0f docs: repair the broken links in the API reference
Fourteen anchors in docs/API.md pointed at headings that do not exist, so
those links went nowhere. Most are typos in the slug (`doesitemexit`,
`gettaxiroutep`, `afters`) or a slug that was never updated after its
heading gained an argument (`C_Spell.CastAtUnit` picked up
`placeGroundSpell`, `GetItemSubClassInfo` picked up its `C_Item` twin).
`C_QuestLog.GetQuestIDForLogIndex` was written in camel case, which a
heading anchor never is.

`GetMacroInfo` has no entry of its own in this document, so that reference
is now plain text instead of a link to nothing.

Also correct the table-of-contents text for `C_Spell.CastAtUnit`, which
still named the two-argument signature.

Found while checking the anchors this branch touched. A pass over both
documents now reports no broken link, in either direction.
2026-09-11 08:16:45 -05:00
Brues 3191c4dcfc fix(macro): leave a macro alone when its conditions are not ours
A macro written for another macro addon names conditions this parser does
not have. SuperCleveRoidMacros' `[alive]` is one. The `#showtooltip` reader
scans every macro body, so it met that condition and printed
"SecureCmdOptionParse: unknown condition 'alive'" at login, about a macro
that works.

Worse than the noise: a condition we cannot evaluate cannot pass, so its
group fails. A macro with no unconditional fallback clause then matched
nothing, and the reader wrote 0 over the spellID the engine's own parse had
put in the per-macro cache - costing that button its cooldown, range and
usable state.

`SecureCmdOptionParse` now records the keywords it does not own on its
cached parse and returns the first one as a third value. It still names
them, once each, for a caller the player drove. The reader passes the new
`quiet` flag and reads that value instead: on a foreign dialect it resolves
nothing, writes nothing, and stops re-evaluating until the body changes. The
engine's parse stays in the cache, and the addon that owns those conditions
can still claim the macro through `C_Macro.SetMacroDisplay`.

Such an entry also stays out of `dirty`, because a dirty entry nothing
evaluates would keep `HasPendingWork` true and re-run the catch-up on every
reader.

Moving the warn-once bookkeeping from parse time to call time also stops a
quiet scan from consuming the one warning a keyword gets, so a condition the
player later types is still named.

This is not a load-order fault. SuperCleveRoidMacros creates `_G.CleveRoids`
at the top of Init.lua, and that same file sets `ClassicAPIMacroDisplay`,
which lifts our wholesale yield on purpose so it can publish per macro. The
window is between our first tick and its first publish, which it cannot make
until the player object exists.
2026-09-11 08:14:35 -05:00
Brues 373dce8202 ShaguTweaks nil check
Fixes #49
2026-09-11 08:00:51 -05:00
Brues 4c7f1c36d6 docs: cover the macro conditions and #showtooltip in the README
The README said nothing about either, and its Macros section still claimed
the macro work added no Lua functions.

Two rows in Modern client behaviors: the `[conditions]` / `@unit` syntax
across 38 slash commands, and what `#showtooltip` drives on a macro button.
The Macros section now lists the body forms a macro accepts - the
directive, the conditions, the `!Name` prefix, and `#` comment lines, which
no longer reach chat. A new Slash commands section names all 38 commands by
group, and the bundled-addon section mentions that it holds them and
`SecureCmdOptionParse`.

`C_Macro.SetMacroDisplay` and `GetMacroItem` join the Macros row of the
call table, and `IsUnitToken` / `UnitTokenFromName` join the Unit row.

Also add the `## MerchantFrame` heading the README already linked to. The
six `C_MerchantFrame.*` entries sat inside `## Map` with no heading of
their own, so that link went nowhere.
2026-09-10 18:59:10 -05:00
brues-code 36c367a6d6 feat(macro): native #showtooltip, conditional slash commands, and a display API for addons (#48)
* feat(macro): native #showtooltip/#show, conditional /cast and /use

Backport the macro display system to the engine:

- Macro::ShowTooltip resolves `#showtooltip [cond] value` / `#show` (and
  the bare form over the macro's /cast and /use lines, first match wins)
  through the Lua SecureCmdOptionParse on the world tick and writes the
  result into the engine's per-macro spell cache (+0x564/+0x568), which
  every action reader already consumes. Repaints go through the engine's
  own slot-changed notifier. Item targets are cached alongside.
  Re-evaluates on modifier / mouseover / target / combat changes and every
  200 ms otherwise. Yields entirely when SuperCleveRoidMacros is loaded.
- The `?` icon rule is a co-hook on the macro icon getter, so action
  buttons and the drag cursor both show the resolved icon; GetMacroInfo,
  GameTooltip:SetAction, GetActionCooldown, GetActionCount and
  IsConsumableAction are re-registered in front of the engine for macro
  slots with a resolved directive and tail-call it otherwise.
- Macro::RunBody re-implements the engine's body runner so `#` lines
  never reach chat; Macro::Execute skips them too.
- /cast and /use in the embedded addon (3.3.5 handler shape): items
  first, [@unit] via C_Spell.CastAtUnit, 1..19 = inventory slot, other
  numbers stay spellIDs. SecureCmdOptionParse: a group whose only piece
  is @unit passes only while the unit exists (3.3.5 passes it
  unconditionally). C_Item.GetItemCount accepts names. GetMacroItem
  added; GetMacroSpell now reflects the directive.
- Offsets.h: 0x004F0F40 was the macroID -> MacroEntry resolver,
  mislabeled as a spell-state cache; renamed FUN_MACRO_ID_TO_ENTRY, with
  the action-bar / macro-runner constants this needs added and verified.

* feat(unit): add IsUnitToken for token-vs-name dispatch

Vanilla ships each "act on a unit" verb as two Lua entries, one taking a
unit token and one taking a character name, and each errors on the other
kind of input. Conditional slash commands need to accept either, because
SecureCmdOptionParse hands back a token for `/target [@focus]` and a name
for `/target Bob` through the same variable.

IsUnitToken(value) answers which one a string is by calling the engine's
own token resolver inside lua_pcall and reporting whether it returned or
threw. Consulting the resolver rather than matching a token list here
keeps the answer correct for every family it knows, including this
client's native GUID literals and our own focus / nameplateN / markN,
which unit/TokenExtensions.cpp hooks onto the same function. It reports
whether the text names a token, not whether a unit is there, so "target"
with nothing targeted is still true.

    if IsUnitToken(x) then TargetUnit(x) else TargetByName(x) end

Additive on purpose. Merging the pairs into a modern TargetUnit /
AssistUnit / FollowUnit was written and reverted: those names are common
enough for other DLLs and addons to replace that re-registration is
silently last-one-wins, with no error and no trampoline to chain. That
mechanism is only safe for names nobody else contends.

Offsets.h records two findings from decompiling the resolver. The throw
is raised by its callee, not by FUN_RESOLVE_UNIT_TOKEN, which is a thin
wrapper returning 0 on a miss. And this client reaches the error through
a patched fallthrough that first parses a 0x<hex> GUID literal, so GUID
unit tokens resolve natively with no SuperWoW loaded.

* feat(addon): backport the conditional slash commands

Adds 27 commands that take [conditions] through SecureCmdOptionParse,
following the 3.3.5 SecureCmdList handler shapes: targeting
(/targetexact, /cleartarget, /targetlasttarget, /targetlastenemy,
/targetenemy, /targetfriend, /targetenemyplayer, /targetfriendplayer,
/targetparty, /targetraid), casting and state (/stopcasting, /cancelaura,
/cancelform, /dismount), equipment (/equip, /equipslot), action bars
(/changeactionbar, /swapactionbar) and the pet family.

/target, /assist and /follow gain conditions by replacing the FrameXML
handlers, which is the only way to add them. Plain input keeps working:
each routes through IsUnitToken to the engine's token entry or its
by-name entry, where the old handler always went by name.

Three natives have a different shape here than the 3.3.5 handlers assume,
so those bodies are not straight ports. PetAttack takes no target and
always sends the pet at the current target. ChangeActionBarPage takes no
page and only fires ACTIONBAR_PAGE_CHANGED, so CURRENT_ACTIONBAR_PAGE is
set first. Pet autocast is a spellbook-slot toggle with no enable and
disable entries, so a name is resolved against the pet book and on/off
read the state and toggle only on a mismatch. /cancelaura needs no buff
walk, since CancelSpellByName already matches a player buff by name.

Not ported for want of a native: /targetlastfriend, and the /mt and /ma
family, which needs party assignments the client does not have.

Command names come from the locale files, with the localized name taken
from the matching Blizzard GlobalStrings and the English one alongside.
zhCN ships English for all 27. The short aliases retail also carries
(/eq, /tar) are left out, since those collide with addon shorthands.

* fix(macro): resolve item link values in #showtooltip

`#showtooltip` over a `/use item:6948` line showed no icon. The directive
evaluator built its item argument as `{0, 0, value}`, hardcoding the whole
value as a NAME, so the `item:N` and pasted-link forms could never match
and the value fell through to the spell lookup. `/use` itself was fine,
which is why the macro cast the item but the button stayed blank.

The link parsing already existed for the Lua entries. Split it out as
`Item::Arg::ResolveString` and call that instead of hand-building the
argument, so both paths read the forms the same way.

A bare number keeps skipping the item lookup: `ResolveString` reads "6948"
as an itemID, and a bare number that is not an equipment slot has to stay
a spellID per the documented `/cast 5019` rule.

An explicit itemID now also resolves when none is carried, falling back to
the ID after the bag and equipment walk misses. The icon and tooltip come
from the item record, and the tooltip path already handled an unheld item
through a hyperlink, so a macro naming an item in the bank or one written
before looting it now displays. Names are still limited to what you hold,
since 1.12 has no name-keyed item cache.

* fix(macro): make [@cursor] work in /cast, /use and #showtooltip

`/use [@cursor] item:4390` did nothing and its `#showtooltip` showed the
question mark. Two causes, both in the addon.

The parser rejected the clause. A group whose only piece is `@unit` passes
only while that unit exists, and the existence test ran UnitExists on
`cursor`, which is not a unit token. The engine raises there, the pcall
caught it, and the group failed. `none` was already exempt; `cursor` joins
it, since both name something other than a unit and neither can be tested
that way.

Nothing routed the target once it did pass. `SecureCmdUseItem` sent every
target through UseItemByName, which takes a unit GUID, and the spell branch
had only the on-unit and default cases. A `cursor` target now goes to
C_Item.UseAtCursor and C_Spell.CastAtCursor, which were written as the
`[@cursor]` analogs but had no caller. SecureCmdItemParse has already
turned a bag or inventory slot into a link, so one item call covers every
value form.

`#showtooltip` needed no change: the value resolves once the clause passes.

* feat(macro): accept a character name as a macro target

`/cast [target=Feral] Rejuvenation` did nothing. A `@unit` or `target=unit`
piece takes a character name as well as a unit token, but nothing here
turned a name into something the unit functions accept, and the existence
gate ran UnitExists on it, which raises for a string that names no token.

`UnitTokenFromName(name [, exactMatch])` runs the engine's own by-name
search — `FUN_UNIT_FIND_BY_NAME`, the one behind TargetByName, AssistByName
and FollowByName — and reports the winner as a token: a standard one when
it fits, else the GUID literal, which this client's resolver takes too. It
walks party, then raid, then every loaded unit, so it reaches units no
token names. Prefix match by default, nearest first, like TargetByName.

`GroupPasses` now resolves a named target once and uses it for both jobs:
the bare-group existence test (`[@Feral][] Spell` falls through when they
are not around) and the conditions, which previously raised on a name.
The pcall that already guarded the existence test is what tells a token
from a name, so a token target costs no more than before.

The slash handlers resolve the target the same way before handing it to
CastAtUnit, UseItemByName, FocusUnit or StartAttack, and drop the command
when a named unit is not around.

* feat(macro): drop a ground-target item at your feet with [@player]

`/use [@player] item:4390` sent the grenade through UseItemByName, which
passes a target GUID but never commits the placement, so the reticle came
up instead of the grenade landing. Route a `player` target to
`C_Item.UseAtUnit` instead, which commits at the unit's position.

Only your own position is offered. Aiming a ground-target item at another
unit is not something the game lets you do by hand, so the slash handler
does not either; every other unit target keeps going to UseItemByName.

`C_Item.UseAtUnit` now dispatches with the unit's GUID rather than a zero
target, matching `C_Spell.CastAtUnit`. Without that, routing `@player`
through it would have cost a non-ground item its target: a bandage or a
scroll would fire at the current selection instead of at you. It now
covers both kinds of on-use item, the way CastAtUnit covers both kinds of
spell.

* fix(macro): stop /cast [@unit] aiming ground spells at that unit

The same aim the item side was just denied: `/cast [@target] Flamestrike`
dropped the reticle on the target with no click, for any unit. Placing a
ground spell somewhere you could not put it by hand is aim the player has
not earned, and it should not differ between a spell and a grenade.

`/cast [@player]` and `/cast [@cursor]` still place, since both name a
position you can already aim at. Every other unit still has the spell cast
on it when it takes a unit target, which is what `@unit` is for; only the
ground-placement leg is dropped, leaving the reticle up exactly as casting
by hand does.

`C_Spell.CastAtUnit` keeps the whole primitive and gains a third argument
for the choice, defaulting to placing so the API and its callers are
unchanged. The restriction lives in the slash handler, the same split the
item side uses, where `C_Item.UseAtUnit` stays unrestricted and only
`/use` limits itself to `@player`.

`Spell::AtCursor::GroundPlacementPending` reports whether the in-flight
placement is a ground one, so the handler can leave that reticle up while
still cancelling a placement for a unit the engine did not accept. Leaving
that armed would strand the cursor.

* fix(item): stop a bad unit raising out of C_Item.UseItemByName

The optional `unit` went straight to the engine's token resolver, which
raises for a string that names no token. Both the code comment and the
API docs promised the opposite, that an unrecognized string is no target,
matching the no-op contract the item argument already has.

It is reachable without an addon calling the function directly: secure
button attributes and key bindings both feed it a configured string, so a
`unit="foo"` attribute raised inside a click handler.

Probe with Unit::TokenResolve::IsUnitToken first and treat anything else
as no target, which makes the documented contract true.

C_Item.UseAtUnit keeps raising, which is deliberate and now stated in its
docs: it is the companion of C_Spell.CastAtUnit, which documents the same
behavior as matching UnitHealth("garbage").

* feat(macro): let an addon publish its own macro display

`C_Macro.SetMacroDisplay(macroSlot, value)` hands a macro's resolution to
the display path, for an addon that parses macros itself. It is aimed at
SuperCleveRoidMacros, which today replaces ten action globals plus
GameTooltip.SetAction to put its answers on the bar, and borrows an
unrelated action slot to get a range check for a macro.

None of that is needed. Writing the spell into the engine's own per-macro
cache is what makes cooldown, range, usable, current and auto-repeat
correct for a macro button, with nothing overridden, and the icon then
reaches two places a Lua override cannot: the cursor while a macro is
dragged, and the macro window grid, both of which come from an engine
getter we hook.

The value takes the forms a `#showtooltip` value takes, so a publisher
hands over what it would have written in the macro. Three states: a value
to show, false for claimed-but-nothing-matched, nil to release.

Ownership is per macro. A published one is not parsed or re-evaluated by
us and is honored even while yielding; the rest stay ours. Nothing is
re-evaluated for the publisher, but a published resolution is re-applied
whenever the engine's re-parse overwrites the cache, so a macro edit
cannot silently replace it.

The wholesale stand-down when SuperCleveRoidMacros is loaded is lifted by
`CleveRoids.ClassicAPIMacroDisplay`, which a build sets to say it drives
us. Forks without it keep the all-or-nothing yield, so an older one cannot
end up fighting for the same buttons.

* fix(macro): accept the !Name prefix in /cast and #showtooltip

`#showtooltip` over `/cast !Shoot` showed nothing and the line cast
nothing: the `!` prefix, which asks for a toggle ability to be started
but never turned off, was never part of vanilla and no name resolver
knew it.

Split it in two.

The NAME is normalized in the one co-hook on the engine's single
name -> spellbook-slot resolver, which already owned the numeric
spellID form (so `spell/CastByID.cpp` becomes `spell/NameResolve.cpp`).
`FUN_RESOLVE_SPELL_NAME_TO_BOOK_ID` calls that resolver, so a single
strip covers the runtime cast, `Spell::Arg`, the `#showtooltip`
resolution and the engine's own macro parser -- which is what lets
`IsAutoRepeatAction` light a `/cast !Shoot` button even while macro
display is yielded to SuperCleveRoidMacros.

The BEHAVIOR stays in Lua: `SecureCmdCast` strips the prefix and casts
through the existing `CastSpellNoToggle`, which asks the engine whether
the ability is already up first. `CastSpellByName` cannot carry it --
re-registering that global is off limits and nampower already co-hooks
it.

`CastSpellNoToggle` gains the `placeGroundSpell` argument that
`C_Spell.CastAtUnit` has, so `/cast [@target] !X` keeps the rule that
only your own feet and the cursor place a ground-target spell. It also
tolerates a `!` on its own input now, since its auto-repeat name
compare is ours rather than the engine's.

* feat(item): take an item location wherever an item action takes an item

`C_Item.UseAtCursor`, `UseAtUnit`, `UseItemByName` and `EquipItemByName`
took an item reference only - an itemID, `item:N`, a link, or a name - and
resolved it by searching the bags for the first match. Holding two stacks of
the same item, or two copies enchanted differently, there was no way to say
which one you meant.

They now also take an item location: `{bagID=B, slotIndex=S}`,
`{equipmentSlotIndex=N}`, or an item GUID string - the shapes
`C_Item.GetItemID` already took. A location names one exact item.

`Item::Location::FindItemArgOrLocation` owns the overload; the old `Resolve`
(locations, pointer) and the new `ResolveItemArgOrLocation` (either, pointer)
are wrappers over it, so the classification and the walk live in one place.
None of the four call sites resolves anything itself any more.

A table is only ever a location, and so is a string that parses as a strict
`0x` + 16 hex digit GUID, which no item link or item name can look like. The
form is decided from the argument alone and committed to: a location resolve
STOMPS the Lua stack (`PackBagSlot` overwrites the first slots with its own
arguments), so falling back to the reference forms after one had run would
re-read the argument as whatever it left behind - a bag index read as an
itemID, which names an unrelated item.

`EquipItemByName` needs the source coordinates too, since the swap packet
carries them, which is what the detail form is for. An
`{equipmentSlotIndex=N}` location names a worn item, so with a `dstSlot` it
routes to `Item::Swap::FromPaperdoll` - that is how a ring 11<->12, trinket
13<->14 or weapon 16<->17 move is expressed. Without a `dstSlot` there is no
slot for the engine to pick, so that combination does nothing.

Its `lua_settop(L, 0)` ahead of `ClearCursor` is gone: `Script_ClearCursor`
(`0x004895B0`) is `FUN_00495190(1, 1)` and a return - no arguments, no stack
reads. Dropping it lets the item resolve run after the cursor clear, which a
location needs: while an item is held its slot reads empty, so the location
it came from would otherwise resolve to nothing.

* refactor: centralize the item constants and the icon-path build

Four copies of the ItemStats displayInfoID -> `Interface\Icons\<name>`
chain had accumulated - `item/GetData`, `item/Info`, `container/ItemInfo`
and the new `item/Icon` - and they had already drifted: two checked for an
empty icon name and two did not. `Item::Icon::PathForDisplayInfoID` is now
the one builder and the other three call it, so a change to that chain has
a single site. `PathForItemID` is it plus the cache lookup.

`action/ItemState` had local constants shadowing values already in
`Offsets.h`: `kItemSpellSlots` for `ITEMSTATS_SPELL_SLOT_COUNT`, and a bare
`trigger == 0` for `ITEM_SPELLTRIGGER_ON_USE`. `INVTYPE_AMMO` / `THROWN`
existed in two files, and the engine runner's `0x400` macro line buffer in
two more, so those move to `Offsets.h` as well - `INVTYPE_*` next to the
string table they index, `MACRO_LINE_BUFFER_SIZE` next to the delimiters.

CLAUDE.md's rule, and duplicated offsets drifting is exactly what it was
written for.

* fix(macro): indented directives, release, and icon-getter re-entry

Three defects found reviewing the `#showtooltip` work, plus dead code.

A directive typed with a leading space was lost AND leaked to chat.
`ParseDirective` and the two `#`-line filters all tested byte 0, but the
tokenizer splits on `\r\n` only, so ` #showtooltip` arrives with its space:
the parse missed it, and then `Macro::RunBody` / `Macro::Execute` failed to
recognize the comment and dispatched the line through `EXECUTE_CHAT_LINE` -
the /say this feature exists to stop. All three skip blanks now, as does
`CastCommandArgs` so an indented `/cast` line is still collected, and the
runner dispatches the trimmed line so an indented command works at all.

`C_Macro.SetMacroDisplay(slot, nil)` left the publisher's spell in place.
`Release` cleared our entry and asked for a rescan, but `Rescan` only marks
an entry dirty when it has a directive with options - so for a macro with
no directive of its own, nothing ever rewrote `+0x564`, and the button, its
tooltip, cooldown and auto-repeat state stayed on the published spell until
the engine happened to re-parse. It now hands the field back to the engine
by running the engine's own parser, then repaints.

The macro icon getter could run Lua from under a click. `FUN_MACRO_ICON_PATH`
has two callers and one is `FUN_MACRO_PICKUP`, inside the UseAction core, so
`Lookup`'s catch-up could evaluate conditions through `SecureCmdOptionParse`
and fire `ACTIONBAR_SLOT_CHANGED` into addon handlers while the drag was on
the stack - the re-entrancy hazard CLAUDE.md records for `Tooltip::SetEvents`.
`LookupPassive` reads only what the entries already hold; the world tick
does the work and repaints, so a resolution can be one tick behind an edit.

Also drop `Active()` (no callers) and `RunBody`'s unused trampoline, and
correct the header's reference to a module that does not exist: the icon
comes from `Macro::IconPath`, not `Action::Texture`.

* fix(slash): five conditional-command defects, and cheaper option passes

`/cast 5019` could consume an item. A bare number that is not an
equipment slot is a spellID, but it reached the item lookup, which reads a
number as an itemID - so carrying item 5019 used it instead of casting the
spell. `Macro::ShowTooltip::ResolveValue` already guarded this exact case,
so the button showed the spell while the click used an item.

Every bare `/targetenemy` stepped backwards. The cycling selectors pass
the parsed value as their reverse flag, and a clause that matches with no
value yields the empty string - which is a true boolean in Lua. Six
commands were affected in their most common form.

`/cast [@none] Spell` did nothing. `@none` names no unit, but only
`@cursor` was exempted from the token resolve, so `none` went to the
by-name search and the cast was dropped. It now means no unit, which is
the untargeted form of the verb; `/target [@none]` clears the target, as
documented. The other unit verbs no longer feed `none` to that search
either - it matches on the START of a name, so it would find a Nonek.

`/assist [@target] Bob` ignored Bob. `/target` and `/follow` both let a
trailing name win over an explicit `target=target`; assist did not.

`/use [@player] 0 1` used the wrong item. The position branches ran before
the bag branch, so a named slot was thrown away and the item re-found by
link - the first match anywhere, which with two stacks is the wrong one.
Every form now resolves to one item and goes through one call, which also
means a slot is aimed by `@unit` like a name is, and that `/use` always
uses: clicking a bag slot sells the item at a merchant and repairs it
under the repair cursor (`Script_UseContainerItem` branches on the
merchant GUID and on cursor mode 0x11), which a typed command must not do.

`GroupPasses` also stopped paying for an answer it discards. A group with
conditions only needs a token, so it asks `IsUnitToken` rather than
`pcall(UnitExists)` - the conditions test existence themselves. The
existence path keeps its single pcall, and a name, which costs an
object-manager search, is remembered for the rest of the pass.
2026-09-10 18:43:06 -05:00
Brues fe9e7dddcd fix(color): keep ColorMixin on the standard color globals
An addon that carries a copy of Blizzard's own definition -- a plain
NORMAL_FONT_COLOR = {r=1.0, g=0.82, b=0} and its friends -- assigns it
over ours at file scope, so every addon that loads after it finds a color
with no methods. pfUI's who list then fails on NORMAL_FONT_COLOR:GetRGB()
(brues-code/pfUI#56).

Re-apply the color globals after each addon loads, not only at our own
load, and mix ColorMixin INTO the table that is there instead of replacing
it: the values that addon chose stay, and each reference it already handed
out gets the methods too. ITEM_QUALITY_COLORS entries are repaired on the
same pass. This replaces the SpecialTalentUI special case.

C_UIColor.GetColors() makes a color object for each of its 187 rows, so
the values are kept from the first call and each later pass costs one
lookup per tag. Only a color that is absent or has lost its methods is
touched; a tag holding something else, and a <TAG>_CODE string that is
already set, are left alone.
2026-09-10 16:15:16 -05:00
Brues 9d8f1b1b5d build: pin the project to Win32 with a preset and a configure guard
WoW.exe is x86, so there is no valid 64-bit configure. Nothing in the
repo said so, which let VS Code decide instead: CMake Tools configures on
folder open by default, and with no presets file it takes the
architecture from a scanned kit. Here it chose the x86_amd64 cross kit
(-T host=x86 -A x64) and flipped the shared build directory to x64.

That failure is badly disguised. A 64-bit build first shows up as a
static_assert deep in a struct-layout check, which reads as an unrelated
code fault rather than a build-configuration one.

Add CMakePresets.json with a single win32 configure preset. A presets file
makes CMake Tools use presets and stop using kits, so the architecture
becomes a property of the project instead of whichever kit a machine
happens to select. The preset names no generator, matching the documented
command line: CMake then resolves the newest installed Visual Studio,
which a pinned version would get wrong. Schema version 3 is the earliest
that allows omitting the generator.

Add a CMAKE_SIZEOF_VOID_P guard after project() for whatever gets past
the preset -- a hand-typed -A x64, or a stale cache -- so the configure
fails with the recovery steps instead of building something unloadable.

The existing `cmake -B build -A Win32` flow keeps working; presets are
opt-in on the command line.
2026-09-10 15:23:00 -05:00
Brues 9d1e14410c refactor: share one bounded string copy as Common::BoundedCopy
addons/LoadState.cpp used strncpy, which MSVC deprecates (C4996) and
which does not terminate on truncation, so the call site had to add the
terminator itself. frame/Attributes.cpp had already met that warning and
answered it with a local BoundedCopy helper.

Promote that helper to Common rather than copy it a second time, and
point both files at it. The helper treats a null source as empty, so
LoadState loses its null-name branch as well. Behavior is unchanged:
strncpy(dst, src, kMaxName - 1) plus a manual terminator wrote at most
kMaxName - 1 characters, and so does BoundedCopy(dst, src, kMaxName).

strncpy_s was the other option and is worse here. It invokes the
invalid-parameter handler on truncation unless the caller passes
_TRUNCATE, and LoadState runs inside an engine hook.
2026-09-10 13:36:00 -05:00
Brues 8b06caf708 docs(api): rewrite the EquipmentSet intro for the caller
The paragraph explained the storage choice rather than the API: which
opcode carries set data, that "this server" does not speak it, and that
the file format matches a sibling project. None of that changes what a
caller writes, and "this server" names nothing.

Say what the namespace does, in the section-opener style the rest of the
file uses, and keep the one storage fact a caller acts on -- sets belong
to one character.
2026-09-10 13:30:33 -05:00
Brues 4684088675 docs(api): give GetWeaponEnchantInfo real numbers for both enchant kinds
The example claimed charges = 5 for Brilliant Mana Oil and hid the
enchant ID behind a placeholder. Live dumps of both kinds correct it: the
oil returns charges = 0 with enchant 2629, and Instant Poison returns
charges = 40 with enchant 323. Show each case, so the zero reads as a
property of time-based enchants rather than as the only answer the field
ever gives.
2026-09-10 13:21:43 -05:00
Brues 9a26207df2 feat(item): read a bagged weapon's temporary enchant
C_Item.GetWeaponEnchantInfo covers only the three equipped weapon slots,
so a poisoned or oiled weapon sitting in a bag reported nothing. Add
C_Item.GetItemTempEnchantInfo(itemLocation), returning the same
(hasEnchant, expirationMs, charges, enchantID) tuple for any location the
rest of the C_Item location family accepts.

The temporary-enchant reader already needed nothing but a CGItem, so it
now takes one directly, with a thin wrapper for the paperdoll path. Its
local copy of the slot offset (0x4C) gives way to the block layout
Offsets.h already carried for spell/BonusDamage.cpp, extended with the
within-slot duration and charges deltas and a named temporary-slot index.

Verified in game: a staff carrying Brilliant Mana Oil reports enchant
2629 both equipped and in a bag, and the remaining duration keeps
counting down while the weapon sits there.

Fixes #47
2026-09-10 13:17:56 -05:00
Brues 2adfaff020 docs(api): move the enchant functions into the Item section
C_Item.GetWeaponEnchantInfo and C_Item.GetEnchantInfo were documented
inside the Spell section, sandwiched between C_Spell entries, and
neither appeared in the Contents at all.

Both now sit in Item after C_Item.GetStackCount, kept adjacent since the
second resolves the IDs the first returns, and listed in that section's
Contents block so body and Contents order agree. The headings are
unchanged, so existing links to them still resolve.
2026-09-10 13:00:14 -05:00
Brues 8a7ab490f4 docs(api): move CURSOR_CHANGED in with the other events
It was documented under Cursor while every other standalone event lives
in the Events section, placed there to follow that section's alphabetical
run. The heading text is unchanged, so existing links to
#cursor_changed-event still resolve, and Cursor keeps a pointer to it so
a reader of GetCursorInfo can still find it.
2026-09-10 12:51:58 -05:00
Brues e26f8958e9 fix(compat): let a ClassicAPI-aware addon opt out of its whole shim
pfUI's `handlesHookScript` and ShaguTweaks' `API.classicapi_version` now
return at the top of the compat callback instead of gating one branch
inside it. An addon that advertises ClassicAPI awareness handles its own
color globals, HookScript path and event registrations, so no part of the
shim should run against it. Previously the pfUI flag suppressed only the
CreateFrame shim while the RAID_CLASS_COLORS override and the
UpdateColors no-op still applied, and a ClassicAPI-aware ShaguTweaks
still had its libpredict events unregistered underneath it.

The "brues" X-Website tag now gates the pfUI HookScript shim on its own,
so an unflagged fork carrying that tag behaves as before.
2026-09-10 12:51:58 -05:00
Brues f0a83f1f31 fix(compat): restore Compost:Erase when a 5.1-stubbed copy wins
Compost-2.0 rev 17406 replaces most of its own body with stubs once it
believes it is on Lua 5.1, and our 5.1 syntax backport makes its
loadstring probe pass. Losing GetTable and Reclaim is harmless -- both
are pure recycling hints -- but Erase is not: callers hand it a live
table and expect its keys gone, and the stub takes no parameter at all.
So every call site that clears in place, rather than assigning the
returned table, silently stops clearing.

One addon shipping that revision hands it to every other Ace2 addon,
because AceLibrary arbitrates on the highest $Revision and ignores load
order. Puppeteer is the reported casualty: PTUnit:ClearAuras erases its
debuff-type and aura-ID sets in place, so a dispel type never leaves the
set once applied and a cursed raid member keeps its debuff colour after
the curse is cleaned.

Restore an in-place Erase, picked by behaviour rather than by revision or
addon name so a Compost that already clears is left alone. Checked on
every ADDON_LOADED because AceLibrary can upgrade a library at any point
during load and an upgrade installs the new copy's functions over ours;
the verdict is cached against the function that earned it, so a repeat
check costs two identity comparisons and reuses one probe table.
2026-09-10 11:37:49 -05:00
Brues 5070c892e2 feat(texture): add Texture:SetDesaturation and GetDesaturation
Backports the parametric desaturation methods. The engine desaturates a
region by storing one pixel-shader object in the region's shader slot and
binding it per draw, so an amount is a matter of having more shader
objects: one CGxShader per level (256 levels, framebuffer precision),
created lazily through the engine's own loader under a path that exists
nowhere and filled with hand-encoded ps_2_0 / ps_1_1 bytecode, then
compiled the way the loader does. No draw hooks, no per-region state.

SetDesaturated is re-registered on the Texture registry (the registrar
front-inserts and the dispatcher walks from the front) so all three
methods share one state and `false` clears a partial amount. Every
desaturation path, including SetDesaturated(true), now multiplies the
vertex-colour tint in like retail; the stock shader dropped it.

The first ps_2_0 program was rejected by the D3D9 runtime validator: an
lrp may not write a register it reads as src0 or src2. The shipped
program is the instruction shape fxc emits, and both programs were
validated on a NULLREF device. A build attempted while the device is
lost is not latched, and a level that cannot be built falls back to the
engine's stock shader.
2026-09-09 16:39:14 -05:00
Brues 8295ec9442 docs(api): clarify the multi-flavor TOC section
The intro claimed this client is the Vanilla game type, which read as a
contradiction against the paragraph below it saying `_Vanilla` files are
not used. Both are true: `_Vanilla` as a file suffix names the Classic
Era client, while `vanilla` as a directive value names this client's game
type. Drop the intro sentence, since the [Family] / [Game] table already
states it where a reader can use it, and add a note about the shared word
where the collision is first hit.

Explain why `_Vanilla` and `_Classic` are skipped instead of saying only
that they target an engine this build does not match, and state the
consequence: an addon shipping just `_Vanilla` and `_Mainline` TOCs does
not load at all, so it needs a `_ClassicAPI.toc`.

List both flavor sets in probe order, and give the Bindings half the same
shape as the TOC half.

The `## Interface:` subsection showed a multi-version line and then said
nothing about it. Say that the addon loads when 11200 appears anywhere in
the list.
2026-09-09 13:08:37 -05:00
Brues c5b1431073 feat(cvar): default scriptMemory to 0
Sets the cvar most people already set by hand, so a fresh install does
not meet the interface memory popup. Only the default is overridden: the
write happens when the live value still equals the cvar's registered
value (read from +0x30, so no hardcoded 49152), which leaves a
deliberate Config.wtf choice alone. The archive flag makes the 0 persist,
so the comparison fails on every later boot and the write happens once
per install.

What the cvar gates, since none of it is obvious: FUN_00495590 polls
Lua's live allocation (KB) against it, forces a full GC when over,
and if still over fires MEMORY_EXHAUSTED and starts a 15-second timer,
after which FUN_00401560(0) exits the process. FrameXML shows the event
as the "disable some interface add-ons and restart" popup. The poll is
gated on `limit > 0`, so 0 disables it, and nothing else caps Lua memory
(lmemPool's allocator is six size classes falling through to the Storm
heap, with no ceiling of its own).

Timing is load-bearing: scriptMemory is writable on the glue state and
read-only in the world, where the innermost setter's `if (flags & 4)
return;` makes any write a silent no-op. The glue registration is what
makes this land; the in-game one stays as a second chance. Verified by
deleting the Config.wtf line, launching, and finding 0 in the AddOns
dialog and back in Config.wtf on exit.

Adds CVar::Factory::GetDefaultString for the comparison.
2026-09-09 12:17:33 -05:00
Brues b39c16578b feat(addon): add the /startattack and /stopattack slash commands
Blizzard ships both in FrameXML's ChatFrame.lua; the handlers here are
3.3.5a's verbatim, routed to ClassicAPI's StartAttack / StopAttack.
Every locale file gets the client's own command names, taken from the
per-locale GlobalStrings dumps: /angriffstart, /empezarataque,
/lanceattaque, /начатьатаку, /開始攻擊 and their stop counterparts, next
to the English names. Gives users a clean /startattack without the
macro-addon trick of placing the Attack spell on an action slot to read
its state.

Refs #46
2026-09-09 00:49:26 -05:00
Brues 7d7e392924 fix(spellbook): flag talent ranks by name when the next-rank link is missing
SkillLineAbility's next-rank column is empty for whole chains — every
Mind Flay rank (15407, 17311..17314, 18807) holds 0 — and the engine's
own rank-chain walk (FUN_SPELL_RANK_CHAIN_KNOWN) reads that same field,
so IsClassTalentSpellBookItem flagged only Mind Flay rank 1. Fall back
to the engine's other notion of a rank set: a spellbook spell that
shares a talent rank spell's localized name, the same rule a name-cast
uses to pick the highest rank.
2026-09-09 00:14:23 -05:00
Brues 14fa610bc8 feat(spellbook): add the skill-line, cast-count, and loss-of-control surface
Nine C_SpellBook functions, two C_Spell functions, and one event, all
backed by engine data the stock Lua surface never exposed:

- LEARNED_SPELL_IN_SKILL_LINE(spellID, skillLineIndex, isGuildPerkSpell),
  fired from the learn-spell hook under the engine's own
  LEARNED_SPELL_IN_TAB gate (notified AND appended to the book). The
  learn/unlearn co-hooks move from spell/Info.cpp to spell/Learn.cpp.
- GetNumSpellBookSkillLines / GetSpellBookSkillLineInfo /
  GetSpellBookItemSkillLineIndex / GetSkillLineIndexByID, reading the
  engine's tab array ({skillLineID, numSpells} entries at 0x00B71160).
  The info table calls the engine's own Script_GetSpellTabInfo for the
  name and icon.
- C_Spell.GetSpellCastCount / GetSpellBookItemCastCount: the minimum
  over reagent slots of floor(carried / required), where carried is
  equipped + bags 0..4. GetActionCount / IsConsumableAction are
  item-only in this client, so the count is computed, not mirrored.
- C_Spell.GetSpellLossOfControlCooldown /
  GetSpellBookItemLossOfControlCooldownInfo / ...Duration: the
  C_LossOfControl effect that blocks a given spell, shaped as a
  cooldown. Silence and pacify gate on Spell.dbc PreventionType
  (+0x294, verified against six known spells).
- IsClassTalentSpellBookItem: Talent.dbc rank spells plus trained
  higher ranks via SkillLineAbility's next-rank link (+0x20, verified
  on Mortal Strike). Talent::SpellSet now owns the talent spell set
  that GetCurrentLevelSpells already used.
- ContainsAnyDisenchantSpell: any known spell with effect 99.

Shared helpers added along the way: Spell::Lookup::SpellBankArgToBookType
and SpellbookItemArgsToID, Spell::Cooldown::Query, and
LossOfControl::LockoutForSpell.
2026-09-09 00:10:52 -05:00
Brues 8ca415e9a0 feat(spellbook): add C_SpellBook.GetPlayerSpellsByAura
Every spell the player currently knows with an effect applying the given
aura, as a 1-based ascending array of spell IDs. This is IsPlayerSpell
turned around: it walks the same known-spell bitmap and reports the set
bits whose Spell.dbc record carries the aura, so only a set bit ever
touches a record -- a few hundred lookups, not the ~28,000-row table.

It replaces a C_Spell.GetSpellsByAura that scanned the whole table and
was never committed. That answered "which spells anywhere apply this
aura", when the caller summing aura amounts is asking "which of MY
spells do" -- and the bitmap is that set. It also holds only a talent's
current rank, so a sum over the result never double-counts ranks.

Known is not active. A passive is in effect while known and never shows
in the buff list; a castable buff in the result is merely learned; a
buff another player put on us is not in the bitmap at all. Callers split
on C_Spell.IsSpellPassive and check the buff list for the rest. auraName
0 (no aura) and negatives return the empty array, as does pre-login.

Lives beside PlayerKnowsSpell in spell/Info.cpp rather than in a new
file, so the bitmap read is not copied an eighth time.

Verified in-game. Rogue: aura 217 -> {52508}, rank 2 only. Priest: aura
134 -> {14777, 45553} -- Meditation rank 3 only, plus Improved
Shadowform, a source no one had listed and the data surfaced on its
own. Each class returns empty for the other's aura.

Motivated by pfUI's energytick module: its energy-tick interval needed
the SPELL_AURA_MOD_ENERGY_REGEN_TIME amount, which it had as constants
derived from two hardcoded spell IDs. With this and GetSpellEffectInfo it
names no spell at all.
2026-09-08 23:04:26 -05:00
Brues becf2f4866 feat(spell): add C_Spell.GetSpellEffectInfo
Returns one table per spell effect holding that effect's raw Spell.dbc
fields: effect, auraName, basePoints, baseDice, dieSides, pointsPerLevel,
dicePerLevel, miscValue. Three entries always, an unused slot all zeros.

Deliberately raw rather than one computed magnitude per effect. The
engine's figure also folds in a die roll the client cannot reproduce,
combo points against the combo target, the caster's spell mods, and a
level rescale for flagged spells -- a number carrying none of those while
looking authoritative is worse than the inputs. The docs carry the
formula and say that dieSides > 1 means the magnitude is rolled.

Three new offsets. DieSides was already established by the
EFFECT_BASE_DICE note, which records the verified column run as
Effect@61, DieSides@64, BaseDice@67, BasePoints@76 -- it just had no
named constant. The two per-level fields extend the same run at columns
70 and 73, and both are FLOAT, not int: field 73 on spells
10/17/43/91/122 decodes as 0.1/0.8/1.0/-1.0/0.5 as float and as garbage
as int32.

Verified in-game against answers known independently of the code:
Power Word: Fortitude rank 1 reports auraName 29, basePoints 2,
baseDice 1 and miscValue 2 (STAT_STAMINA), i.e. the +3 Stamina on its
tooltip; Blizzard rank 1 reports effect 27 / auraName 3 with
pointsPerLevel 0.1 (float32 widened), confirming the float read from
Lua; Blade Rush rank 2 reports auraName 217 with basePoints 11 and
baseDice 1, the 12 the server sums for SPELL_AURA_MOD_ENERGY_REGEN_TIME.

Motivated by that last one: computing a rogue's energy tick interval
needs the aura's amount, and the only alternative from Lua was
hardcoding it or parsing a localized spell description.
2026-09-08 22:02:22 -05:00
Brues 28658c2995 docs(api): move Enum.UICursorType in with the other enums
API.md already groups every Enum.* table in one run under Globals, with
its own block of TOC entries. UICursorType was written into the Cursor
section instead, which split that convention and gave one enum a
top-level spot none of the other seven has.

Moved next to Enum.SpellBookItemType, reformatted to the value-first
table shape the neighbouring sections use, and left the CURSOR_CHANGED
payload pointing at it by link. Also corrects "macro index" to "macro
slot" in that payload list, matching what the argument now carries.
2026-09-08 15:48:44 -05:00
Brues f5cce25d23 feat(cursor): add the CURSOR_CHANGED event
Fires CURSOR_CHANGED(isDefault, newCursorType, oldCursorType,
oldCursorVirtualID) and registers the Enum.UICursorType table its two
type arguments index. Classic Era's Vanilla PaperDollFrame uses this
event for the equip-slot highlight, so ported UI code looks for it.

The trigger is a once-per-frame diff of the cursor globals on
Tick::WorldTick, NOT the engine's CURSOR_UPDATE. That event only has
three fire sites and none announces a non-item pickup: the cursor clear
FUN_00495190 fires for every type but zeroes VAR_CURSOR_TYPE first, so
the read there always says "empty", while FUN_00494b60 and FUN_004950f0
fire after a set only when a GUID or itemID is present. An interceptor
on CURSOR_UPDATE therefore reported item cursors correctly and nothing
else -- money, spells, macros and the two pet cursors were silent. There
is no common choke point on the set side (each type writes
VAR_CURSOR_TYPE in its own path), so a state diff is what covers them
all. CURSOR_UPDATE is left completely untouched.

Info::ReadRaw is the cheap half of the diff -- seven plain global reads,
no object-manager lookup -- so the bag-item itemID resolve only happens
on a frame where something actually changed. The cost of the diff is
that the event lands one frame after the change.

Also fixes a bug this uncovered in the shipped GetCursorInfo:
Script_PickupMacro converts the Lua 1-based index into a macroID via
VAR_MACRO_SLOT_MAP before storing it, so the cursor global holds e.g.
0x01000003, and PushMacroCursor pushed that straight through as the
"macro index". docs/API.md already documented the return as macroIndex,
so the contract was right and the code was wrong. Both paths now convert
through the engine's own reverse scan FUN_MACRO_ID_TO_SLOT, the same
conversion the legacy CreateMacro wrapper does. VAR_CURSOR_MACRO_INDEX
is renamed VAR_CURSOR_MACRO_ID, since the old name invited the mistake.

Verified in-game: item, spell (vanilla and Turtle-range IDs) and macro
cursors all fire with correct old/new types, the repeated CURSOR_UPDATEs
from moving a stack produce no CURSOR_CHANGED, and GetCursorInfo returns
slot 4 for a General macro and 21 for a Character one.

oldCursorVirtualID has no faithful equivalent here -- the modern
client's "virtual ID" is not a concept this client has -- so it carries
the previous content's identifying number and says so in the docs. No
consumer is known to read it; Blizzard's own users treat the event as a
bare notification.
2026-09-08 15:45:37 -05:00
Brues 1306eaf1b8 feat(container): add four C_Container getters
GetContainerFreeSlots(bagID) returns which slots are empty as an array
of slot numbers, beside the existing count. A full bag gives an empty
table and a bag that is not there gives nothing, matching the
MayReturnNothing that Blizzard's own EquipmentManager branches on.

GetContainerItemQuestInfo(bag, slot) returns the {isQuestItem, questID,
isActive} table that draws the two quest markers on a bag button.
questID is the item's m_startQuest, isActive asks Quest::Log, and
isQuestItem is item class 12; questID stays absent rather than 0 because
every caller tests it for truth. An uncached item reports the empty
answer and warms the cache, so the next bag update draws what the first
hover could not. Verified in-game: item 18423 reports questID 7495
"Victory for the Alliance", and a quest-class item that starts nothing
reports isQuestItem with no questID.

GetContainerItemEquipmentSetInfo(bag, slot) returns whether the item
belongs to an equipment set, plus the set names joined with ", ".
Blizzard's own version is documented as non-functional, so there is no
working output to match; the lookup goes through the new
EquipmentSet::Data::SetsContainingItem, which matches on the saved
per-instance GUID. That identifies the one physical item saved into the
set rather than any copy of its itemID, and survives the item being
moved between bags. The reserved GUID_EMPTY / GUID_IGNORED slot markers
are rejected there so they cannot match a caller.

Get/SetBackpackAutosortDisabled and Get/SetBankAutosortDisabled keep the
backpack out of SortBags and the main bank container out of
SortBankBags. Both persist as cvars, like sortBagsRightToLeft. This
mirrors the per-bag BagSlotFlags.DisableAutoSort in Blizzard's generated
docs; the backpack and main bank have no slot to carry that flag, which
is why each gets its own pair.

Dropped as unimplementable rather than shipped inert:
Get/SetInsertItemsLeftToRight (the server picks the destination slot,
stack merges included, so there is no client-side insertion point to
steer) and Get/SetBackpackSellJunkDisabled (nothing sells junk here).
SortBank would be a pure alias, since kBankBags already covers the main
bank container.
2026-09-08 14:08:06 -05:00
Brues 807afc083f refactor(quest): share the quest-log lookup by questID
Four functions in this file walked VAR_QUEST_LOG_ENTRIES looking for a
questID, each re-deriving the stride and the header-row gate. Name that
walk once as Quest::Log::IndexForQuestID and let IsOnQuest,
GetLogIndexForQuestID and GetHeaderIndexForQuest read it, so a fifth
consumer does not become a fifth copy.

GetQuestIDForLogIndex indexes rather than searches, so it keeps its own
read, but its raw 16 / +8 / +0 literals become the named
OFF_QUEST_LOG_ENTRY_* offsets.

No behaviour change.
2026-09-08 14:07:41 -05:00
Brues c26afcc050 feat(baselib): forward trailing arguments through xpcall
Ports Lua 5.2's `xpcall(f, msgh, ...)` shape. This client's xpcall takes only
(f, msgh), and its implementation is explicit about the rest: `lua_settop(L, 2)`
discards any further arguments, then `lua_pcall(L, 0, ...)` calls f with none.
So the argument-forwarding form did not fail loudly -- it called f with nothing,
and a function that read its parameters saw nil. (Not a 5.0-vs-5.1 difference:
both take (f, msgh). Forwarding arrived in 5.2.)

Replacing the base-library function rather than wrapping it in Lua, for the
same two reasons `select` and `unpack` are C in this directory. A Lua wrapper
would allocate a closure AND an `arg` table per call, putting an allocation on
every error-guarded path including per-frame ones; and HookSecureFunc's
kUnhookableNames lists xpcall precisely because shadowing a core primitive with
a Lua closure diverges from what C-side callers see.

lua_pcall wants the callee and its arguments contiguous on top with the handler
at a fixed index, but the incoming order puts the handler between them, so:

  PushValue(2) -> f, msgh, a1..aN, msgh
  Insert(1)    -> msgh, f, msgh, a1..aN
  Remove(3)    -> msgh, f, a1..aN

then PCall(top - 2, MULTRET, errfunc = 1), push the status boolean, Insert(2),
and return GetTop - 1 so the handler drops out of the result set. 5.2 does the
same reshuffle with one lua_rotate, which this Lua has no equivalent of.

Purely additive: a two-argument call forwards zero arguments and is the old
behavior exactly, so the capturing-closure idiom in ExecuteFrameScript and the
plain two-argument call in ItemUtil both keep working untouched.

Verified with 25 assertions across both halves. Backward: exact return counts,
embedded and trailing nil returns keeping their slots, reference identity, the
handler running once and receiving the message, a non-function handler tolerated
on the success path, nil f caught, an erroring handler still yielding false, and
the load-bearing one -- a two-argument call still passing f zero arguments.
Forward: false and embedded/trailing nil arguments surviving, twelve arguments
intact, forwarding composing with multiple returns, arguments still arriving on
the error path, and the two cases that target the shuffle -- the handler not
leaking in as argument 1, and the handler also being passable as an argument.

Two incidental confirmations: C-side lua_error does longjmp (the missing-handler
case is caught by an enclosing pcall), which the notes had flagged as uncertain;
and Lua-level error() still does not unwind, so f runs past it and xpcall
reports success with the handler never consulted.

Closes #45
2026-09-08 13:23:04 -05:00
Brues a124629669 fix(item): warm the cache when an icon lookup misses
The three icon accessors (GetItemIcon, C_Item.GetItemIcon,
C_Item.GetItemIconByID) all funnel through PushIconForItemID, which peeked the
cache and returned nil on a miss without queueing the item. Every sibling
getter -- name, quality, level, sell price, max stack size, inventory type,
family, spell, openable, uniqueness, set, average level -- calls WarmCache
there, so the icon was the only field that could not self-heal: a caller
polling after a miss re-resolved every other field while the icon stayed nil
for the rest of the session, because nothing else ever requested that itemID.

Most visible right after login, where the carried-item prefetch can walk a
bag-slot GUID array that SMSG_UPDATE_OBJECT is still filling and legitimately
not see an item yet -- reported downstream as icons that resolve when the
client loads fast and never when it doesn't. But that window only decided
WHICH items were affected; the permanence was this missing warm, which equally
broke every uncached icon the prefetch never touches -- quest rewards, vendor
and auction listings, links out of chat.

Verified in-game: IsItemDataCachedByID(19019) reports false, and a re-poll two
seconds later returns Interface\Icons\INV_Sword_39. Both calls returned nil
indefinitely before.
2026-09-08 09:28:20 -05:00
Brues be03d45f0c feat(frame): add frame:RegisterUnitEvent, unit-aware IsEventRegistered
Modern ports call frame:RegisterUnitEvent("UNIT_HEALTH", "player") to receive
an event for one unit instead of for every unit. Vanilla has no such method, so
the call errored, and FrameUtil.RegisterFrameForUnitEvents was left out of the
FrameXML backport for the same reason.

The engine already owns registration and dispatch, so this is only a filter.
Chain membership delegates to the engine's own Script_RegisterEvent; the filter
is stored per frame here; the check is one gate on the per-frame OnEvent fire,
where the dispatcher has already set _G.event and calls the arg'd script runner
with the OnEvent slot. Suppressing there also covers HookScript chains and
Frame::ScriptArgs' positional path, both downstream, as retail does.

Semantics follow Blizzard's 5.4.8 implementation, where the filter lives on the
subscriber node and the node's kind is fixed at creation: RegisterEvent over a
filtered registration keeps the filter, RegisterUnitEvent over a plain one stays
plain, RegisterUnitEvent over a filtered one replaces the units, and no usable
unit is a plain RegisterEvent. The filter dies with the node, through co-hooks
on Frame::UnregisterEvent and Frame::UnregisterAllEvents -- the latter also
being the CScriptObject destructor's path, so frame destruction is covered.

Two divergences from 5.4.8, both additive. Unit tokens are kept as strings and
compared case-insensitively, since focus/nameplateN/markN are not in the
engine's canonical token table. And the unit count is not capped: 5.4.8 reads
exactly two units and folds each token index into a byte of one dword, and
retail later filled that dword and documents four, but we have no dword to fill,
so every string argument past the event name is a unit.

The filter applies only when the event's first argument is a string. A
number-first event, or a no-arg event (dispatched through a runner this never
sees), fires as with RegisterEvent -- one rule that holds on every dispatch
path, and one that fails toward an extra event rather than a lost one.

FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT was already hooked by Frame::ClickEvents for
PreClick/PostClick, and MinHook allows only one hook per address -- a second
aborts the entire install. Frame::RunnerHook now owns that hook and fans out to
subscribers claiming disjoint slot addresses; ClickEvents moves onto it with no
behavior change.

Verified in-game: 28 assertions over the branch table above, the
PreClick/OnClick/PostClick bracket still firing in order through the shared
runner, and a capture window in which a filtered frame received only its own
token (20 of 20) while a plain control frame on the same events took 115 fires
across six token spellings.
2026-09-08 00:10:22 -05:00
Brues 66c878d03e fix(nameplate): resolve the plate during NAME_PLATE_UNIT_REMOVED
C_NamePlate.GetNamePlateForUnit and GetNamePlateForGUID both returned nil
inside a NAME_PLATE_UNIT_REMOVED handler. They share PushNamePlateForGUID,
which derived the frame purely from live engine state -- Object::ByGuid(guid)
then unit + 0xE60 -- and the event fires precisely because that binding is
gone, with the unit often already out of the object table.

Retail resolves it there (NamePlateDriverMixin:OnNamePlateRemoved calls
GetNamePlateForUnit), so publish the outgoing (guid, frame) pair for the span
of the dispatch and consult it as the getters' last fallback. Live state still
wins whenever it can answer, so nothing else changes.

Frames are pooled, so the engine can pull one off its freelist and rebind it to
another unit between the tick that last saw it and the tick that notices it is
gone. PlateReassigned checks the current tick's walk for the pointer and
suppresses the fallback in that case, rather than trusting the plate's cached
GUID at +0x4E8, which goes stale across recycling.

Fixes #44
2026-09-07 21:54:21 -05:00
Brues 3ad87eb2f3 feat(container): add C_Container.Get/SetSortBagsRightToLeft
Sets which end of the bags a sort fills from, matching the modern pair.
Backed by a `sortBagsRightToLeft` cvar through CVar::Factory, so it
persists to Config.wtf and GetCVar reads it under the same name retail
uses, rather than resetting every session.

The whole implementation is reversed cell lists. Both placement passes
still fill from the "front" and the "far end"; those ends have simply
swapped which physical slot they mean, so the sorted run and the junk
block flip together and junk stays opposite everything else.

Not adding libbagsort's other option, which flips the category ranking
so junk sorts first. It has no Blizzard counterpart, and it is mostly
redundant with this one: what it is reaching for is which end of the
bags the important items live at, and the fill direction already answers
that by moving the sorted run and the junk block at once.
2026-09-07 20:42:00 -05:00
Brues a9d2ad73f3 feat(container): add C_Container.SortBags and SortBankBags
Two phases, because every item move is a server round trip and the second
phase reads slot contents the first changed. Phase one packs partial
stacks; phase two runs on the next Bag::UpdateDelayed, plans the whole
layout, and fires the swaps as one batch, tracking each item's live
position rather than re-reading a layout it is mutating.

The ordering is ours, since retail computes it in code we cannot read and
its categories have no counterpart here: hearthstone, gear by descending
quality, consumables, reagents, trade goods, quest items, everything else
by quality, junk last and filled from the far end. Then class, subclass,
name, fuller stacks first - a real tuple comparator, so there is no
zero-padding to overflow and no placeholder name to wedge an unnamed item
among real ones. An item whose data has not arrived is pinned in place
with its slot withheld, rather than ranked by guesswork.

Neither phase may touch the Lua stack, since phase two runs from a bag
update callback. Hence Lua-free ContainersFrom / AutoStoreFrom /
MoveCountFrom, with the Lua-taking forms reduced to resolving the source
pointer and delegating, plus a direct invMgr walk instead of ResolveBag.

Five things had to be found the hard way, every one of them the client's
local state disagreeing with the server's:

- Never plan a swap between two stacks of the same item. Player::SwapItem
  tries a MERGE before it considers swapping and returns if that
  succeeds, collapsing two slots into one or redistributing counts and
  leaving both in place. Neither is a swap, so a single collision
  invalidated the bookkeeping for every move after it in the batch. One
  collision in a 55-item bag turned one pass into three.

- Entry marked "unplaced" with destBag = -1, safe while bag IDs were 0..4
  and wrong the moment the main bank joined as bagID -1: every item bound
  for the main bank read as unplaced and had its move dropped, so nothing
  could move into the main bank at all. The sentinel is destSlot now,
  which cannot collide because slots are 1-based.

- FUN_INVENTORY_SWAP's last argument gates a pre-send confirmation check.
  With 0, a Bind-on-Equip item the character could wear is stashed behind
  the bind dialog and NO PACKET IS SENT - silently, since the builder
  returns void. Now passes 1, which is what the engine itself does when
  re-issuing a swap after the dialog is accepted, and is correct here
  because both endpoints are always bag content slots where nothing binds.

- CMSG_AUTOSTORE_BAG_ITEM cannot merge across bags. Its destination byte
  can only be INVENTORY_SLOT_BAG_0 (255) for the player, and server-side
  that is the specific-container branch: keyring and backpack only. The
  search-every-bag path is NULL_BAG (0), which the builder cannot emit.
  Both sets now use one explicit two-pointer merge that addresses both
  endpoints and works everywhere. It costs a packet per transfer rather
  than per emptied source, which is the price of being correct.

- The merge loop must choose split-versus-swap from its own model, not
  the item's live stack count. The batch goes out in one frame with no
  replies, so descriptors still hold pre-batch counts; a move the model
  knew was the last of a stack went out as a split, and the server
  rejects a split that would empty its source.

Consolidation packs fully rather than stopping once a slot is freed: 9
and 2 with a cap of 10 become 10 and 1. It settles in one pass and sends
nothing on the next call, because a packed group has every stack full
except the last, so the first step walks straight to the end.

Bank specifics: ViewForBag gains the main bank (bagID -1, inside the
player's own invMgr) and bank bags (5..10, each with its own), which can
themselves be specialty bags. Bank bags resolve only once the bank window
has been opened, which is the right gate rather than an accident - the
server refuses bank moves without it, so a bank sort with the bank closed
finds no cells and does nothing instead of dispatching a batch that would
be rejected wholesale.

_classicapi_SortBagsStats() and _classicapi_SortBagsBags() report the last
pass and the per-bag view. Both earned their keep: the stats ruled out our
side of the swap divergence by showing a complete plan fully dispatched
with zero local failures, and the per-bag dump ended the sentinel hunt by
eliminating the entire area three successive guesses had been aimed at.
2026-09-07 17:47:05 -05:00
Brues db88fca050 feat(container): add C_Container.AutoStoreItem
Moves an item and lets the server pick the destination slot, which is
worth having because the server does not merely find an empty one: it
first merges the stack into existing stacks of the same item, splitting
it across several when that is what fits, and places a remainder only if
one is left. So one call consolidates a partial stack.

Two engine builders back it, because the wire has two autostore opcodes.
FUN_005E12E0 sends CMSG_AUTOSTORE_BAG_ITEM (0x10B) and takes a
destination container, so it can confine an item to one equipped bag or
let the server search the whole inventory. FUN_005E18F0 sends
CMSG_AUTOSTORE_BANK_ITEM (0x282) and takes no destination at all - the
server derives the direction from the source, so a bank source travels
to the inventory and an inventory source to the bank. AutoStore picks
between them and accepts only the pairs the wire can honestly serve.
Bank-to-bank is not among them, so consolidating stacks within the bank
still needs per-pair moves.

Both builders' stack-arg counts come from their RET, not from a
decompiler parameter list. 0x10B's last slot is never read by the body
but is still popped, and declaring seven args instead of eight made the
callee pop four bytes we never pushed - which shifted the caller's frame
and surfaced as a garbage Lua state pointer in the next function to run,
several frames from the call. Noted at both offsets, since this family
is not uniform in arity.

Three fixes to the shared bagID encoder found while doing it:

- EncodeBagSlot had no case for the keyring (bagID -2), so SwapItems,
  MoveItem and AutoStoreItem all rejected keyring slots. The engine's
  own map in FUN_PACK_BAG_SLOT has had it all along, and the linear base
  was already recorded in the invMgr slot map.
- Its bank constants restated named Offsets::INVMGR_* values, so they
  now derive from them, and the slot map is fully named rather than half
  constants and half comment. Each player-container range is bounded by
  both its own last linear slot and the live invMgr count - stricter
  than before, where the backpack had no upper bound at all.
- FUN_PACK_BAG_SLOT's fourth out-param was labelled outUnused. It is the
  bank flag, and it is what makes right-clicking a bank item send 0x282
  instead of using the item.
2026-09-07 01:38:34 -05:00
Brues 26af9bd30f docs(nameplate): correct two stale claims in the Info.cpp header
The header still described the removed fresh-wrapper-per-call path: that
we deliberately skip FUN_00701BD0 to avoid its refcount bump, and that a
default nameplate's wrapper is rebuilt per call so identity is unstable.
PushNamePlateFrame delegates to UI::FrameObject::Push, which registers
lazily when the refkey is unset, so addon-created and engine-created
plates alike resolve through rawgeti(REGISTRY, frame + 0x08) and identity
is stable across calls.

It also claimed there is no central list of live nameplates. There is:
FUN_00608870 walks the header at {0x00C4D928, 0x00C4D92C}, unlinking the
plate and reinserting it in screen-depth order, and FUN_006086E0 calls it
on every position update. FUN_00608A10 unlinks from that list and pushes
the node onto a free list at {0x00C4D91C, 0x00C4D920}, which FUN_006087F0
pops without reinitializing.

Since that reverses the comment's premise, record why the object hash
table is still the enumeration source: the list is unlinked and
reinserted throughout the render while no nameplate code touches the hash
table, a recycled node keeps a stale GUID at +0x4E8 until FUN_007CB6D0
rebinds it so unit + 0xE60 is the only authoritative binding, and the
hash walk yields the unit, nameplate and instance block together.
2026-09-06 23:52:27 -05:00
Brues e7156caa47 feat(console): add the rest of the console API
Eight functions alongside ConsoleGetAllCommands, completing the surface:
ConsoleExec, ConsoleEcho, ConsolePrintAllMatchingCommands, ConsoleIsActive,
ConsoleGetColorFromType, ConsoleGetFontHeight, SetConsoleKey and
CalculateStringEditDistance.

ConsoleExec is the one with real reach. Nothing in this client's Lua could
run a console command before, so every entry ConsoleGetAllCommands returns
was visible and unreachable at once. It is the engine's own executor --
FUN_0063CE00, which resolves the name and calls the node's +0x18 handler,
the same field the enumeration reports -- and it is what replays each
`SET` line of Config.wtf at boot.

That includes `set`, and `set` is not SetCVar: the console handler uses
the unfiltered lookup and performs no read-only test, while Script_SetCVar
checks flag bit 0x4 first. So ConsoleExec can write a cvar SetCVar refuses
and reach cvars no Lua getter can see. Inherent to exposing the console
rather than introduced here, and documented as such.

The remaining state came out of the console module: FUN_0063C860 toggles
VAR_CONSOLE_VISIBLE when the key matches VAR_CONSOLE_TOGGLE_KEY and the
`-console` switch flag VAR_CONSOLE_ENABLED is set, which gives an honest
ConsoleIsActive; FUN_00638A60 seeds nine ARGB colours whose index is the
second argument to the writer, stored per line at +0x1C; FUN_0063CB00 is a
one-line setter for the toggle key.

There is deliberately no ConsoleSetFontHeight, and the crash that settled
it is recorded at FUN_CONSOLE_FONT_INIT in Offsets.h. Writing the height
global alone does nothing, and the only thing that rebuilds the font has
no code callers at all -- one table entry at 0x0080E170 -- so it runs once
at startup before any console line exists. Its null-check-and-free of the
previous handle reads like re-entrancy and is not: every line holds a
cached font-dependent draw object at +0x20, so re-running it faults on the
NEXT frame inside the texture manager, not in the call. The console's own
shutdown frees the font and those objects together, which is what the
pairing is for. A safe setter would mean replaying that teardown across
every line to change a text size nothing asks to change.

SetConsoleKey takes the client's numeric key code rather than a key name,
because the console stores and compares exactly that and nothing in it
translates names.

Verified in-game with -console: edit distance 3 for kitten/sitting and 2
for help/hepl, red and yellow at types 3 and 4 with nil out of range,
ConsoleIsActive false while closed, and exec output, a yellow echo and a
prefix match for both Export commands all present in the console.
2026-09-06 17:19:49 -05:00
Brues 02ad0ea6ab feat(cvar): add C_CVar.GetCVarBitfield / SetCVarBitfield
Treat a cvar's numeric value as a bitfield and read or write one bit, so a
single cvar carries many independent booleans. Upstream this backs the
record of which tutorial popups a player has dismissed: one cvar, one bit
per popup, persisted by the config machinery for free.

Namespaced only. Blizzard's FrameXML calls the bare globals, but those are
its own wrappers rather than the API, and nothing here needs them.

Both wiki pages are stubs, so the details come from Blizzard's generated
documentation: `index` is typed `luaIndex`, which is what settles that it
is 1-based; the getter's return is nilable; and the setter names its
requirements as a valid and public cvar, a non-read-only one, a
non-secure one, and an index in range. The first two are questions this
client can genuinely answer -- the lookup is the same filtering one behind
C_CVar.DoesCVarExist, so a cvar preserved out of Config.wtf without being
implemented is refused here exactly as by every other Lua cvar function --
and nothing here is ever secure.

The value is parsed and rewritten as a uint64 in C++, never handed through
a Lua number, so all 64 bits are exact and the 1..64 index range is the
arithmetic type's own width rather than a limit chosen here. Writing goes
through CVar::Factory::SetString rather than the engine setter directly:
that wrapper already declares FUN_SET_CVAR_VALUE as the __thiscall it is,
and getting that wrong corrupts the stack silently. It also means a
bitfield write fires the change callback and marks the config dirty,
exactly as SetCVar does.

Verified in-game against a registered cvar: bits 1 and 5 give 17,
clearing bit 1 gives 16 with bit 5 untouched, and bit 64 gives
9223372036854775824 and reads back true -- a value past Lua 5.0's exact
integer range, which is what proves the arithmetic is not round-tripping
through a double. Out-of-range indices read nil, and setting refuses for
an out-of-range index, an unknown cvar, and a read-only one.
2026-09-06 16:55:58 -05:00
Brues 1dee8e5683 feat(cvar): add C_CVar.AreCVarsLoaded
Reports the cvar registry's own initialised state rather than a constant,
so the function states a fact instead of an assumption. VAR_CVAR_HASH_MASK
holds 0xFFFFFFFF until the first registration builds the table, and every
lookup already gates on that sentinel, which makes it the closest thing
this client has to "the cvar system is up".

It can only return true from Lua, and that is worth being plain about.
The config system runs off the boot path -- FUN_0063D380 is reached
straight from the entry point, registers the console commands and replays
Config.wtf -- long before a Lua state exists, so there is no window in
which anything could observe false. The question is real upstream, where
an account's cvars can be stored server-side and arrive after login;
nothing here is fetched over the network, so once boot has run there is
nothing left to wait for.

Kept anyway because ported code that gates on it would otherwise call a
nil, which is the whole reason the function is worth having.

Verified in-game: returns true, as a boolean.
2026-09-06 16:36:58 -05:00
Brues 583f4bb011 feat(cvar): add C_CVar.DoesCVarExist
A ClassicAPI addition -- no such function exists upstream -- named for the
DoesAddOnExist / DoesSpellExist pair already here.

It uses FUN_FIND_CVAR, the same lookup GetCVar and SetCVar go through,
because the question a caller actually has is whether those will work.
That makes the invariant exact:

    C_CVar.DoesCVarExist(name) == (C_CVar.GetCVarInfo(name) ~= nil)

The alternative lookup FUN_0063DE30 skips the bit-31 test and would also
find a cvar preserved out of Config.wtf without the client implementing a
setting by that name. Reporting one of those as existing would mislead
every caller who then tried to read it, since no Lua getter can, so the
filtering lookup is the honest one here.

Returns false rather than raising for a non-string, matching the two
Does*Exist functions it is named after: a predicate should answer.

Verified in-game: true for a real cvar, false for an unimplemented
Config.wtf name, for a console command, for an unknown name, and for nil
and a number. A sweep over every entry ConsoleGetAllCommands returns finds
zero disagreements with GetCVarInfo.
2026-09-06 16:32:38 -05:00
Brues 23ebfbe7ab docs(cvar): record why some cvars are invisible to Lua
Chasing four cvars that ConsoleGetAllCommands reported but GetCVar raised
on turned up the mechanism behind both.

FUN_FIND_CVAR is not a plain by-name lookup. After matching it tests
`if (-1 < (int)flags) return 0` -- the flags read as SIGNED, so it returns
only cvars with bit 31 set. GetCVar, SetCVar and C_CVar.* all go through
it, so a cvar without that bit cannot be reached from Lua at all.
FUN_0063DE30 is the same hash walk without the test.

Bit 31 is set when the registrar's registerConsole argument is 0, and the
config loader FUN_0063D820 runs each `SET` line in Config.wtf as a console
command, whose handler FUN_0063D500 registers an unknown name as a fresh
cvar with registerConsole = 1 and flags 0 -- into which the registrar ORs
the archive bit, so the config writer saves it straight back out.

So any line left in Config.wtf becomes a cvar on the next boot whether or
not the client implements a setting by that name: preserved across
sessions, writable from the console, invisible to Lua. realmlist.wtf runs
through the same path, so a line there materialises in Config.wtf too.

Nothing to fix. C_CVar.GetCVarInfo already uses the filtering lookup, so
it returns nil exactly where GetCVar and SetCVar do and the Lua cvar
surface agrees with itself, while ConsoleGetAllCommands lists these
because the console genuinely can reach them. Both functions were already
right; only the reason was unrecorded.
2026-09-06 16:29:03 -05:00
Brues 18927551d5 feat(cvar): add C_CVar.GetCVarInfo
Returns a cvar's value, its default, and what may be done to it. Two of
the seven returns are real here and five are structurally false, which is
worth stating precisely rather than leaving as unimplemented:

  isReadOnly              REAL, flag bit 0x4. Script_SetCVar tests it
                          before anything else and raises `"%s" is read
                          only`, so this answers in advance whether
                          SetCVar will work. Three cvars carry it on this
                          client: realmList, realmName, scriptMemory.
  isStoredServerAccount   Always false. These describe a cvar the server
  isStoredServerCharacter stores per account or per character, kept in
                          step by the synchronizeConfig cvar. No such
                          mechanism and no flag bit for one exists here,
                          so false is accurate rather than a placeholder.
  isLockedFromUser        Always false. No flag governs it.
  isSecure                Always false. Marks cvars that cannot be set in
                          combat, a protection with no analogue here.

Derived by reading 3.3.5's own GetCVarInfo, which returns the first four:
value and default from the struct, then flag bits 4 and 5 for the two
server-stored booleans. The flag OFFSET transfers -- +0x1C in both -- but
the string offsets do not (3.3.5 keeps them at +0x28 and +0x40), so the
ones used here come from this client's own cvarlist handler, which
compares +0x20 against +0x30 to print "(default: %s)".

Unknown names return nil rather than raising, so a caller can tell "no
such cvar" from "set to empty". FUN_FIND_CVAR searches the cvar registry
alone, so a console COMMAND's name finds nothing and returns nil -- the
contract's "console variables only" for free, with no test needed.

Namespaced only. A bare GetCVarInfo global is nil on a live client, and
C_CVar.GetCVarBool already established the namespace here.

Offsets.h gains OFF_CVAR_DEFAULT_STR and names three flag bits, each from
the code that acts on it: archive (the config writer skips cvars without
it), staged (the setter stores to +0x38 and marks the config dirty
instead of applying), and read only.

Verified in-game: realmList reports its value, its stock default and
isReadOnly true; gxResolution reports 2560x1080 against a 640x480 default
with every boolean false; an unknown name and the console command `help`
both return nil; and a sweep over every cvar finds exactly the three
read-only ones.
2026-09-06 16:15:29 -05:00
Brues 2c2f280c51 feat(console): add ConsoleGetAllCommands
Returns every console command and console variable as ConsoleCommandInfo
tables, backed by the registry FUN_CONSOLE_COMMAND_REGISTER fills. The
walk mirrors the `help` handler (FUN_0063FDC0), the only other thing that
enumerates it: step with next = *(uint32_t *)(node + LINK_OFFSET + 4),
stop on null or a low-bit-tagged sentinel.

Commands and CVars are ONE list, not two. The CVar registrar's last act
is FUN_CONSOLE_COMMAND_REGISTER(cvar->name, 0x0063DDE0, categoryId,
help) -- which is what makes `/console <cvar> <value>` work -- so the
command list already holds every CVar, and walking the CVar list that
`cvarlist` and the config saver use would report each one twice. That
shared handler address is also an exact commandType test: a node pointing
at it is a Cvar, anything else a Command. A pointer comparison against a
value the registrar itself writes, not a name or category heuristic.

Named as a global, not a C_Console member. The wiki files it under
C_Console.GetAllCommands, but on a live 12.1.0 client C_Console is nil
and ConsoleGetAllCommands is the real name -- the page is stale about the
namespace. The field names come from Blizzard's own generated API docs
and were then confirmed against a live entry: command, help, category,
commandType, scriptContents, scriptParameters, with the two script fields
empty strings rather than nil there too.

Enum.ConsoleCategory and Enum.ConsoleCommandType are stated in full
rather than derived from the engine's nine categories. Deriving works --
reading VAR_CONSOLE_CATEGORY_TABLE and capitalizing "debug" to "Debug"
was verified in-game -- but it makes the enum track the wrong thing. It
is a Blizzard contract, not a description of the local console, and it
carries values this client cannot produce: Reveal 9 and None 10 have no
category here, Macro 2 and Script 3 no registration path. A client that
renamed its `gm` category would silently register
Enum.ConsoleCategory.Master and turn Enum.ConsoleCategory.Gm into nil,
breaking a constant meant to be fixed everywhere. Offsets.h keeps the
verification: ids 0..8 are debug, graphics, console, combat, game,
default, net, sound, gm in that order, so a category read off a node
lands inside the enum without translation.

scriptContents and scriptParameters describe console macros and scripts,
which have no registration path here, so both are always "".

Verified in-game: 538 entries, 487 of them CVars. `help` -> Command /
Console with its description, `realmList` -> Cvar / Default, and our own
`ExportDBCFiles` -> Command / Debug, which also proves the walk sees
runtime registrations rather than only boot-time ones. C_Console is
absent, both enums resolve including the values this client cannot
return, and every field has its declared type.
2026-09-06 15:46:30 -05:00
Brues 991baa2610 feat(lua): cap upvalues at Lua 5.1's 60 rather than the byte
The limit shipped at 255, the structural ceiling -- f->nups and
LClosure.nupvalues are lu_byte, so that is the most a closure can hold.
But the point of the change is that a function valid on a 5.1 client
should load here, and 5.1's own MAXUPVALUES is 60. Anything past that is
not a compatibility gap, it is an invitation to write something no other
client would accept, which is not a thing to be first at.

One constant. Nothing in either hook depends on which number it is: the
spill path, the stale-clear and the emission loop are unchanged, and the
refusal still comes from the engine's own luaY_checklimit, so its message
carries whichever limit is in force.

Verified in-game: 60 upvalues load and sum correctly, 61 fails with
"too many upvalues (limit=60)".
2026-09-06 14:16:39 -05:00
Brues e68b690cf1 feat(lua): raise the parser's upvalue limit from 32 to 255
Lua 5.0 refuses the 33rd upvalue of a function with "too many upvalues
(limit=32)", as a syntax error on the whole file. Lua 5.1 allows 60, so a
large handler that reads many file-level locals can be valid on a modern
client and unloadable here -- and it is the one class of 5.1 difference
the source transpiler cannot reach, because the limit sits in the parser.

The limit is not the `PUSH 0x20` immediate in indexupvalue. It is the size
of a fixed array: FuncState carries `expdesc upvalues[32]` at +0x38, and
the next field, actvar[200], begins at +0x2B8 -- exactly 0x38 + 32 * 0x14.
Raising the immediate alone writes the 33rd upvalue over actvar[0], the
stack of locals in scope, and the function compiles wrong without raising.

In 5.0 that array has exactly two readers, both located in the binary:
indexupvalue (FUN_006FDF80), which scans it and appends, and pushclosure
(FUN_006FD980), which walks it after the body to emit one OP_MOVE or
OP_GETUPVAL per entry behind OP_CLOSURE. Everything past the parser is
dynamic -- f->nups and LClosure.nupvalues are bytes, OP_GETUPVAL's B field
is 9 bits, closures are sized per instance, the debug-name array grows on
demand -- so the byte is the ceiling and the array the only obstacle.

Co-hook both readers and keep entries 33+ in side storage keyed by the
FuncState's address. A function within 32 never leaves engine code: both
hooks call the original for it. Only a function the engine would have
rejected takes the reimplemented path, and that path calls the same
helpers the original does -- luaY_checklimit with 255 in place of 32, so
an overflow still raises the engine's own message; luaM_growaux for the
name array; luaK_codeABx / luaK_codeABC for emission. Bytecode and VM are
untouched: a 40-upvalue closure is one 5.0's VM already builds and runs.

Staleness is self-correcting without an open_func hook. A parse error
longjmps out, so side storage keyed by a stack address can outlive its
function and the next chunk may reuse the address. But side entries exist
only while nups > 32 and indexupvalue is the only writer, so a FuncState
arriving there with nups <= 32 cannot own any -- whatever is filed under
its address is cleared on the new function's first upvalue, before
anything could read it.

Compile-time only, so no per-frame cost, and nothing else hooks lparser
internals -- LuaSyntax's transpiler hooks luaL_loadbuffer, a different
function, and the other Octo DLLs do not touch the compiler.

The Proto offsets Frame::ScriptArgs kept locally move to Offsets.h now
that a second file reads the struct; its numparams @+0x45 / is_vararg
@+0x46 independently pin nups to the byte before them.

Verified in-game: 32 and 33 both load (engine path, first spill); 40
upvalues through the OP_MOVE path and again through a middle function's
OP_GETUPVAL path each return the right sum; a syntax error inside a
40-upvalue body followed by a clean parse still returns the right sum
(the stale-clear); 255 split across two scopes loads, and 256 fails with
the engine's own "too many upvalues (limit=255)".
2026-09-06 13:38:03 -05:00
Brues e5690b1c6a docs(readme): list texture:ResetTexCoord in the Frame row 2026-09-06 11:24:45 -05:00
Brues e91b1457fd ci: ship the pdb and map in the dev build artifact
The per-push artifact held only the DLL, so a dev build could not be
triaged from the outside. With the PDB sitting next to the DLL the
client's own crash handler resolves our frames to file and line -- a
tester's log reads `Texture::Mask::PrimIndexed_h+12 (Mask.cpp,246)`
instead of a bare address -- and the map resolves `0001:offset`
addresses by hand when there is no dump. That is the difference between
a report that names the function and one that needs a Ghidra session to
place a return address.

Both files are already produced for every build: the /Zi, /MAP and /DEBUG
flags are set unconditionally, not gated on release. They were simply not
being uploaded, while the release path has bundled them all along.

Their value is that they match one exact build, so they travel with it
rather than being rebuilt later from the same commit. All three share
build/Release as their parent and land at the zip root.

if-no-files-found: error, because an artifact missing its symbols looks
identical to a good one until someone needs a stack out of it.
2026-09-06 11:15:15 -05:00
Brues 09c5832ae8 fix(texture): make texture coordinates address the atlas art, not the file
With an atlas applied, SetTexCoord was addressing the whole sheet, so a
crop showed a slice of the surrounding art rather than a part of the
sprite, and GetTexCoord reported the atlas rect where a caller expected
what it had asked for. Both came from one cause: SetAtlas was writing the
atlas rect into the region's only set of coordinates, leaving the
caller's own nowhere to live.

A live client shows the two are separate state there: GetTexCoord reads
the full 0..1 rect immediately after SetAtlas, with GetAtlas still
reporting the atlas, so 0..1 means all of the sprite rather than all of
the sheet. Keep the caller's coordinates alongside the atlas name and
compose: SetTexCoord records them in sprite space and writes the mapped
result for the engine to draw, GetTexCoord hands them back. Both handlers
are co-hooked and pass straight through for a texture with no atlas,
which is every texture in the game until an addon calls SetAtlas -- the
empty-map test is the whole cost in that case. Both the 4-argument and
8-argument corner forms compose; anything else is left to the engine to
reject so its own error text is what the author sees.

ResetTexCoord becomes a plain restore of identity, which is what it
always was conceptually -- the atlas rect now falls out of composing
identity rather than being a special case.

Also clear the atlas on SetTexture rather than inferring it from the
texture handle. The handle comparison cannot detect a re-set to the very
file the atlas names, because the engine returns the same HTEXTURE, so
GetAtlas kept reporting an atlas that was no longer showing. That guard
predates this change. It stays for recycled regions, which is the case it
is actually good for and the reason this module needs no region
constructor hook.

Verified in-game against a registered quarter-rect atlas: identity is
reported after SetAtlas, a crop reads back in sprite space, the engine
receives the composed file-space rect from both argument forms, reset
returns to the full sprite, SetTexture clears the atlas even to the same
file, and a texture with no atlas behaves exactly as before.
2026-09-06 11:12:55 -05:00
Brues 4015fee5e2 feat(texture): add texture:ResetTexCoord
Drops a crop made with SetTexCoord or SetSpriteSheetCell and goes back to
showing the whole of whatever the texture points at.

What "the whole of it" means was the only open question, and the
generated API docs answer none of it -- ResetTexCoord is listed with no
arguments, no returns and no description, and Blizzard's own FrameXML
never calls it. Settled by testing a live client instead: with an atlas
applied, GetTexCoord reads the full 0..1 rect, and a reset after a
SetTexCoord returns to that with GetAtlas still reporting the atlas.

So the source and the user's texcoords are separate state there, and
0..1 means "all of the sprite" rather than "all of the sheet". Here an
atlas IS its texcoords -- SetAtlas is SetTexture plus SetTexCoord, with
no second layer to hold the sub-rect -- so identity for an atlas'd
texture is the atlas's own rect. Resetting to a literal 0,0,1,1 would
show the whole sheet where retail shows the sprite, so the atlas rect is
restored instead, which reproduces the visible result. Reuses GetAtlas's
staleness guard, so a texture pointed elsewhere since resets to the full
rect.

Verified in-game against a registered quarter-rect atlas: SetAtlas gives
the rect, SetTexCoord replaces it, ResetTexCoord restores it with the
atlas still set, and a plain textured region with no atlas resets to
0,0,1,1 with GetAtlas nil.
2026-09-06 10:59:47 -05:00
Brues 744bfb31d1 docs(tick): name the UI render root's arguments for what they are
The hook declared `(void *bounds, int flag)`. The second argument is the
frame's elapsed time as a float: the HLAYER drain calls every layer as
`cb(layer + 0x18, DAT_00885608)`, and DAT_00885608 is set at 0x00442330
and zeroed at 0x004426C3 once the drain finishes. FUN_00765650 passes it
straight to every frame's vtable +0x38 per-frame update, which is where
OnUpdate's `%f` comes from -- the same value visible threading through
both hooks in a recent backtrace.

Rename both parameters and correct the type rather than leave an `int`
sitting under a comment calling it a float. ABI-identical: both are
4-byte stack slots under __stdcall and both are forwarded untouched.

The Offsets.h note gains the rest of what was verified while checking
this: the caller discards the return value, `bounds` is the layer rect
and is never read by this callback, and ECX is not read on entry, so it
is not a __thiscall in disguise.
2026-09-06 10:43:00 -05:00
Brues 8706d1d99b docs: combine the launch switches into one API.md section
The two README rows covered one subject -- things you put on the command
line that starts the game -- and each carried its full detail inline,
which is the one place in that table where a row was doing a reference
page's job. Fold them into a single linked row and give the detail a
Launch section in API.md, next to the console commands, which are the
other entries that are not Lua functions.

The section also states what the two switches share, which neither row
had room for: the name matching is case-insensitive, `-name value`,
`-name=value` and `/name value` are equivalent, and a value with spaces
needs quoting. For -config it now says what does and does not follow a
profile, since the addon enable list living under WTF\Account\ is the
part that surprises people.
2026-09-06 09:59:17 -05:00
Brues 8637bf05e5 feat(config): add -gluescript / -gamescript launch switches
Four switches that run Lua as the client starts:

  -gluescript      <code>   run at the glue screens
  -gluescriptFile  <path>   the file's contents, run at the glue screens
  -gamescript      <code>   run once in-game
  -gamescriptFile  <path>   the file's contents, run once in-game

None of these exist in 1.12. The strings appear nowhere in the binary, so
there is nothing to parse them and nothing to un-gate -- the feature is
ours end to end.

Registration time is too early for either state. The in-game module
callback runs from FUN_LOAD_SCRIPT_FUNCTIONS, before FrameXML loads, and
the glue callback runs from FUN_LOAD_GLUE_SCRIPT_FUNCTIONS, before
GlueXML does. A script running there could not touch a frame, call a
FrameXML function or see an addon, which is most of what one would be
written for. So each is armed at registration and run from a tick, where
the UI is demonstrably up. Glue uses FrameTick, whose hook is the UI
render root and so fires at the login and character-select screens; it is
re-armed by the glue module callback on every glue boot, so a script that
logs in automatically still works after a logout. Game uses WorldTick,
which is world-only, so its first tick is past FrameXML, past the addon
load pass and past enter-world init; it latches for the life of the
process, since "run once in-game" says once.

FUN_LUA_RUN_STRING resolves the Lua state itself rather than taking one,
so a single call site reaches whichever state is current and the two
paths differ only in when they fire. It installs the FrameScript error
handler as the pcall's errfunc, so a faulty script reports itself the way
any script error does instead of failing silently, and because every
chunk goes through the shared compile chokepoint these scripts get the
same 5.1 syntax support the rest of the client's Lua has.

A *File is read with Win32 rather than the engine's file layer. The
engine resolves relative paths against a loose-file index built once at
boot, so it cannot see a file created afterwards and cannot open an
absolute path at all -- and an absolute path is what goes in a shortcut.
Reading it directly also lets both switches share one execution path, so
inline code and file contents cannot diverge.

The switch parser moves to Config::CommandLine, shared with -config
rather than copied: the quoting and token-boundary rules are exactly
where a second copy would drift unnoticed. Lifting it also fixed a latent
overread, where a switch name at the very end of the command line was
compared past its terminator.

Verified in-game on Turtle: inline and file forms in both states; the
in-game script silent across /reload and a second login while the glue
script re-ran on returning to character select; a syntax error and a
missing file each reported without a crash; and -config still applying
through the shared parser.
2026-09-06 09:53:58 -05:00
Brues 802039fd08 fix(texture): stop a zero-dimension texture hanging the mip accounting
The engine's mip-chain byte total FUN_GX_TEXTURE_MIP_BYTES (0x00594CE0)
walks a texture's levels by halving both dimensions until each reaches
exactly 1, and gates every halving on JBE. A dimension of 0 therefore
never shifts and never reaches 1, so the loop at 0x00594D47 spins
forever. It does no allocation and no I/O, which is why it presents as a
hard freeze at 100% of one core rather than a crash, and why the client
never recovers.

Caught live at 0x00594D78 by a SIGSEGV-forced backtrace taken during
login under Wine. The frame's four printed stack slots are this
function's own reused parameter slots, and read back as 4 bytes per block
(uncompressed 32-bit) with both dimensions 0. The call chain checks out
edge for edge from the region-layer draw down through the state flush and
the texture-stage apply, so it is a real stack rather than a heuristic
scan. Note that the state flush applies a dirty list, so the UI draw at
the bottom is where the flush happened, not necessarily what bound the
texture.

One branch above the loop the engine already handles the analogous case
correctly: a texture with no mip chain returns its base-level bytes and
stops. The guard gives a degenerate dimension the same answer. It
resolves the caller's arguments the way the original does and, when
either dimension would shift to zero, raises that one to the smallest
value the accounting can express before calling through. The loop then
terminates on its first pass. No mip math, format table or reduction
level is reimplemented. Clamping at the entry rather than at a call site
also covers the second caller, FUN_00594C80, which accounts for a lock by
passing a dirty rect's width and height and hangs identically on an empty
but still flagged rect.

A dimension can also reach zero without the texture being 0x0, via the
reduction level the accounting shifts by -- a caps field (caps + 0xE0,
the block being device + 0x23C per FUN_005923D0) filled from the graphics
backend. Measured 0 on a native D3D9 driver, unmeasured under wined3d.
So the guard tests the shifted value and clamps to 1 << reduce rather
than assuming either case.

Who feeds it a zero is a separate question, and the guard does not bury
it. ClassicAPI constructs no textures, and DimensionGate cannot admit a
zero it would otherwise reject, since the validator's power-of-two test
already yields "is a power of two" for 0. So the first occurrence is
logged with everything readable off the texture, plus
Texture::Mask::AnyMaskActive() to rule the mask path in or out -- passive
evidence that costs whoever hits the freeze nothing.
2026-09-06 09:07:11 -05:00
Brues d14f0b44c1 fix(compat): restore vanilla script args for LunaUnitFrames headers
Fixes #41.

LunaUnitFrames sets SetupGroupHeader(groupType) as its group headers'
OnEvent while also calling it directly with a group name. As a handler it
relies on groupType being nil so it falls back to this.unitGroup. Our
modern script arguments hand it `self` instead, so it looks the header up
by frame rather than by name, gets nothing, and indexes nil at
units.lua:358.

The addon is correct vanilla code. A function that is both called directly
and used as a script handler is an ordinary vanilla idiom, and passing
positional arguments to handlers breaks it wherever the first parameter
means something other than self.

Adds CAPI_VanillaScriptArgsCompat(frame, script), which rewraps one
handler in a no-parameter closure so the extra arguments stop there and
the original runs with the empty argument list it was written for. The
globals survive the nesting, so it still reads `this` and `event`. Same
shape as the existing CAPI_MouseoverClearedCompat.

Only the group headers need it. SetupRaidHeader takes the header frame
itself, so `self` is already the value it wants.

This covers one addon, not the class. Any addon using the same idiom hits
the same failure, and each will need its own entry here until handlers
opt in to modern arguments rather than receiving them unconditionally.
2026-09-05 16:02:37 -05:00
Brues 008282d65a fix(aura): revoke nibble polarity when the flags contradict it
Detection says a Turtle server writes polarity into the aura flag nibble.
If that stops being true — Turtle changes SetAuraFlag, or something else
identifies as Turtle without the patch — nothing noticed, and the failure
is the loud one: reading 0x08 as "harmful" under the stock encoding means
"effect index 0 has an aura", so nearly every buff reports as a debuff.

IsSlotHarmful now checks each nibble against the three values Turtle's
SetAuraFlag can produce (HELPFUL, HELPFUL|CANCELABLE, HARMFUL). Anything
else latches the slot range back on for the rest of the process.

Revocation only, never promotion. Agreement is not provable: a run of
0x08 readings is equally consistent with single-effect auras under the
stock encoding, so no observation can justify turning nibble reading ON.
Identity stays the only thing that does. Defaulting the other way would
misclassify every buff on a stock server for as long as it took a
contradiction to show up, which is the failure this guards against.

Does not help a non-Turtle server that copied the spill patch — that one
still falls back to the range and reports spilled debuffs as buffs, the
same answer native UnitBuff/UnitDebuff give there. Covering it needs
server-independent polarity (a port of the server's IsPositiveSpell
heuristic), which is a much larger change for a rarer case.
2026-09-05 15:40:50 -05:00
Brues 9a8c314d69 fix(aura): scope flag-nibble polarity to Turtle
The aura flag nibble does not mean the same thing on every server, and
reading it as polarity everywhere was badly wrong off Turtle.

Stock (vmangos SpellAuraDefines.h + SetAuraFlag): the bits are
CANCELABLE 0x01, EFF_INDEX_2 0x02, EFF_INDEX_1 0x04, EFF_INDEX_0 0x08 —
one per spell effect index that carries an aura, plus cancelable for a
positive aura. No polarity is encoded at all. So testing 0x08 for
"harmful" was really testing "effect index 0 has an aura", which is true
of nearly every aura, and reported almost every BUFF as a debuff.

Turtle (tortoise-wow SetAuraFlag) replaces those effect-index bits with
polarity: 0x04 positive, 0x08 negative. It needs to, because it also
drops the else in the slot search so a debuff spills into 0..31 once the
16 harmful slots are full — the range stops being reliable there and the
nibble is what preserves the answer. Stock keeps a strict either/or, so
nothing ever crosses over and the range is exact.

IsSlotHarmful now picks per server and is the one place that decides.
Every other site defers to it rather than testing either signal.

Identity detection rather than behavioral: on Turtle every nibble is one
of {0x04,0x05,0x08}, while stock nibbles routinely fall outside that set,
so an out-of-set nibble proves stock — but the converse never arrives,
since single-effect stock auras land on 0x08 by coincidence. Stock is
provable, Turtle is not, and the unprovable direction is the catastrophic
one. A Turtle fork still detects (clones keep the TURTLE_* global names).
A non-Turtle server that copied the spill patch falls back to the range
and reports spilled debuffs as buffs, matching what native
UnitBuff/UnitDebuff already do there.
2026-09-05 15:34:03 -05:00
Brues 4b1bf16ba5 refactor(settings): one place builds ClassicAPI's WTF\Account paths
Three modules resolved these independently. equipmentset/Storage.cpp and
friendlist/Notes.cpp had byte-identical copies of the same three engine
reads (account, realm, character), the same null and empty gates, and the
same separator joining — differing only in the leaf filename.
settings/Account.cpp had a third copy of the account half, with its own
inline pointer cast instead of Game::Read.

Settings::Paths now owns that, exposing AccountFile and CharacterFile.
Both return an empty string until the globals they need are populated, so
callers keep treating empty as "not available yet" and no longer null-check
the individual names. Behavior is unchanged: the paths built are the same
strings, and the readers moved across verbatim.

AddOns::SavedVarsFirst deliberately keeps its own copies. It mirrors the
ENGINE's saved-variable layout and has to match it byte for byte, which is
a different contract from naming ClassicAPI's own files — the two only
happen to share a directory, and tying them together would mean an edit
intended for one silently moving the other.

Storage.cpp and Notes.cpp no longer reference Game or Offsets at all, so
those includes go too.
2026-09-05 14:38:17 -05:00
Brues 5528fb5fa4 feat(config): add a -config launch switch for settings profiles
`-config Config2.wtf` makes the client read and write WTF\Config2.wtf in
place of WTF\Config.wtf, so one install can hold several profiles.

The engine already names its config file through a single pointer: boot
passes "Config.wtf" to the config initializer, which keeps it at
0x00C4EDD4 and uses it for both sides — the load at 0x0063D428
(MOV ECX,[0x00C4EDD4]; CALL 0x0063D820) and the save in FUN_0063D980,
which builds "WTF\" + the same pointer. Supplying a different string is
therefore the entire feature, with no second code path and no way for
reading and writing to disagree.

This is patched rather than hooked because of when it happens. The config
is read from FUN_00402350 during boot, several calls before VanillaFixes
invokes our Load export — VF hooks GetCPUFrequency (0x0042C060), which is
reached from the FUN_0063A230() that runs after the config is already in.
DllMain does run in time, since VF injects every dlls.txt DLL while the
process is still CREATE_SUSPENDED and resumes it only afterwards. So this
is the one module DllMain names directly. It is a 4-byte store into an
immediate operand, not a hook, so it carries none of the loader-lock cost
that keeps hook installation out of DllMain.

VanillaFixes forwards any non-.exe argument to the game verbatim
(CmdLineParse/AppendArg), so the switch works from the launcher shortcut.

The name must be a plain filename. The engine builds the save path as
"WTF\" + name, and a folder that does not exist makes the save fail
silently, which would discard a session's settings with no warning.
2026-09-05 13:15:04 -05:00
Brues 7ab32df2aa feat(texture): add texture:SetSpriteSheetCell
Crops a texture to one cell of an evenly divided grid, which is the
arithmetic every sprite-sheet call site would otherwise repeat. It is the
primitive behind raid target markers, whose eight icons share one 4x4
sheet, and this client already ships the FrameXML helper
(SetRaidTargetIconTexture) that wants it.

`cell` is 1-based and counts left to right then top to bottom — Blizzard
types the argument `luaIndex`, and its own callers pass GetRaidTargetIndex
straight through without adjusting it.

cellWidth and cellHeight are accepted and ignored. Both call sites in
Blizzard's UI pass the three-argument form, and the grid alone already
fixes each cell's coordinates, so honoring them would mean guessing at
their meaning.

The coordinates go through the engine's own SetTexCoord handler, so this
module contributes only the grid math.
2026-09-05 00:30:39 -05:00
Brues 8e1429bfac feat(texture): publish raid target markers as atlases
All eight markers live in one 4x4 sheet on this client, and the per-marker
files other clients ship are absent here — the art directory holds only
UI-RaidTargetingIcons.blp. So naming a single marker requires a sheet plus
a sub-rect, which is what an atlas is. A port referencing an individual
marker file otherwise gets nothing.

Blizzard publishes no atlas names for these. Checked: zero hits for
raidtarget / targeting / raidmarker in UiTextureAtlasMember, and current
Classic Era still draws them by computing texcoords in
SetRaidTargetIconTexture and by per-file |T markup in chat. The names here
therefore follow the standalone marker filenames other clients ship, so an
addon written against those finds the art under the name it already knows.

This lives in the addon rather than AtlasData.h on purpose: the DLL's
built-in table means "names Blizzard defined", and adding invented ones
there would quietly corrupt that contract. Cell math matches the chat
marker tokens, and markers 4 and 8 were confirmed on screen.

Also adds CreateAtlasMarkup alongside the existing texture markup helper.
2026-09-05 00:26:40 -05:00
Brues 9322382596 feat(texture): bind chatframe-button-highlightalert
First atlas binding derived by widget match rather than name match, and the
reason the table now documents both methods.

Classic Era's FloatingChatFrame.xml draws the chat menu button's Flash
overlay with atlas "chatframe-button-highlightalert". This client's
FloatingChatFrame.xml draws the same $parentFlash region with
UI-ChatIcon-BlinkHilight and no TexCoords, so the whole file is the sprite.
Both UIs are exported on disk, so the binding is read off the two trees
rather than guessed. Verified in-game as a blue glow ring.

Size comes from the 1.12 file (32x32), not the member table (34x32) —
packing added bleed that the vanilla art does not have.
2026-09-05 00:20:43 -05:00
Brues c3cf9e6f3a feat(texture): add atlas support — C_Texture, SetAtlas, and |A markup
An atlas names a texture plus a sub-rect inside it, so SetAtlas is the
engine's own SetTexture + SetTexCoord (+ the size setters for
useAtlasSize) driven off one lookup. Every step delegates to the engine's
Lua handlers, so the argument guards and the UI-scale conversion stay in
force and this module only adds the name resolution.

Adds C_Texture.GetAtlasInfo / GetAtlasExists / GetAtlasID /
GetAtlasElementID / GetAtlasElements, the RegisterAtlas extension for
addons that ship their own sprite sheet, texture:SetAtlas / GetAtlas, and
the |A:name:h:w|a inline markup alongside the existing |T form. All five
readers key off one bound set, so GetAtlasExists is true exactly when
GetAtlasInfo returns a table.

The built-in table is five rows on purpose. Measured against Classic Era's
UiTextureAtlas tables and the community listfile: 0 of 868 atlas sheets
exist in this client's art, and while Blizzard kept the original vanilla
filename as the atlas name for 16 members, only 5 have dimensions that
agree — vanilla's own scrollbar template draws a centered 16x16 out of a
32x32 file that the atlas calls 18x16. Texcoords are not derivable from
the two tables, so the rest grow from _classicapi_DumpAtlasMisses()
rather than from guesses. AtlasData.h carries the full derivation.

|A shares every scanner, measure, wrap and placement stage with |T —
ParseAtlasIcon fills the same IconDesc, since an atlas is exactly the
path-plus-sub-rect that struct already holds. That makes the chat
anti-spoof mandatory, not optional: IconFilter now defangs |A too, or a
player could render art by typing it into /say and the speech bubble.

GetAtlas stores the HTEXTURE it installed and clears itself when the
region points elsewhere, which gives the correct nil-after-SetTexture
behavior and avoids a second hook on FUN_SIMPLETEXTURE_CTOR — Texture::Mask
owns that target and MinHook refuses a duplicate, which would abort the
whole hook install.
2026-09-05 00:11:08 -05:00
Brues f5d6046523 feat(map): add C_Map.GetMapArtLayers
Describes the single fixed layer a map's background is drawn as: a
1002x668 canvas cut into 256-pixel tiles (the 4x3 grid of twelve the
world map frame draws), scale 1 to 1 with no extra zoom steps, since a
map does not zoom here. A map with no art returns an empty table.

Canvas and tile sizes come from the client's own WorldMapFrame.xml, and
move to Map::Area so the overlay hit-rect math shares them instead of
keeping a second copy.

They stay constants on purpose. A live read of WorldMapDetailFrame looks
more engine-driven but is wrong twice: the frame is addon-mutable while
the DBC coordinates it normalizes are authored against the shipped size,
and these are logical sizes -- an HD map patch ships larger tiles, which
Map::Overlays already detects and scales, and reporting a patched tile's
real size would break the canvas/tile division that yields the grid.
2026-09-04 22:57:22 -05:00
Brues e1ebf6ecce feat(map): add C_Map.MapHasArt and GetMapArtLayerTextures
A map's background is the tile set Interface\WorldMap\<dir>\<dir>1..12,
where <dir> is the WorldMapArea row's own name -- the path the world map
frame builds its tiles from. So "does this map have art" and "give me its
tiles" are both answerable by probing that set, even though the modern
atlas art model has no counterpart here.

MapHasArt is independent of whether a map has coordinates: the
whole-world map has art but a zero rect, and instance rows can have a
rect but ship no art.

GetMapArtLayerTextures returns texture PATHS rather than the numeric file
ids of the modern call. A texture is named by its path on this client and
SetTexture takes that path, so ids would be both unavailable and
unusable; GetMapOverlays already makes the same substitution for its
fileDataIDs. There is one art layer, so layerIndex 1 returns the
background and any other index an empty table.

Path convention and the 12-tile count taken from the client's own
FrameXML rather than assumed.
2026-09-04 22:38:49 -05:00
Brues 698827e9c5 fix(addons): report a real loaded state from IsAddOnLoaded
The engine sets an addon entry's loaded byte at the START of its load,
before dependencies and before the addon's own files run, clearing it
again only if a required dependency fails. That byte therefore means
"load has begun" -- it is loadedOrLoading, not loaded.

IsAddOnLoaded returned it for both values, so an addon checking itself
at file scope got (true, true) where it should get (true, false). The
old comment claimed the in-flight window was unobservable from Lua; it
is plainly observable, since the addon's own files and every dependency
pulled in during them execute inside it.

Add AddOns::LoadState, which co-hooks the per-addon loader and keeps a
stack of the names currently loading -- a stack because dependency loads
nest, and the name is copied because LoadAddOn passes a transient Lua
string. loadedOrLoading stays the engine byte; loaded now also requires
the addon not to be mid-load.

Known gap, documented: an addon asking about itself from its own
ADDON_LOADED handler still reads (true, false), as that event fires just
before the load call returns.

Also mirror the stock LoadAddOn global into C_AddOns. It already returns
the modern (loaded, value) shape -- 1/nil plus a locale-independent
reason token -- so it registers as the engine's own handler.
2026-09-04 21:48:13 -05:00
Brues 60530e32fc feat(faction): add C_Reputation inactive getters and by-id setters
Adds SetFactionInactiveByID / SetFactionActiveByID, which file a faction
into or out of the "Inactive" category by id rather than by
displayed-list position, plus IsFactionActive and IsFactionActiveByID to
read that state back.

IsFactionActive is a real backport and takes a sort index, returning a
boolean; the ByID forms are ClassicAPI extensions.

Both stock setters are just an index resolve around one shared engine
setter that already takes a faction id, so the by-id pair is that call
with 1 or 0. It sends the change to the server, rebuilds the displayed
list (so positions shift), and being the function UnitFactionPolyfill
hooks, fires UNIT_FACTION.

The inactive getter is the odd one out: unlike the at-war getter, its
worker takes the 0-based index and resolves internally. So both read
forms are built from Resolver() plus a factored rep-slot read, which
reproduces the engine's own index -> factionID -> repListID -> flag
chain and lets them share one implementation.

Also mirror the stock SetFactionInactive, SetFactionActive and
IsFactionInactive globals into the namespace, registering the engine's
own handlers rather than reimplementing them.
2026-09-04 21:32:14 -05:00
Brues 3cffb71b20 feat(faction): add C_Reputation.ToggleFactionAtWarByID
Flips a faction's at-war state by ID rather than by displayed-list
position. A ClassicAPI extension -- Blizzard has no such call.

The stock FactionToggleAtWar(index) resolves its index to a faction id
and then reads, negates and writes through two helpers that already take
an id, so the by-id form is that pair without the resolve. Every rule
stays in the engine: the refusal to make peace below -3000 standing, the
peace-forced flag, the loot-session bail, and CMSG_SET_FACTION_ATWAR.
Going through the engine's setter also picks up UnitFactionPolyfill's
hook on it, so UNIT_FACTION fires, and its change detection keeps that
silent when the engine refuses the toggle.

Declare the setter's newState as int, matching the polyfill's detour: a
char argument defines only the low byte of EDX and would leave the
detour's int parameter with undefined high bits.

Also mirror the stock global into the namespace as
C_Reputation.ToggleFactionAtWar, registering the engine's own handler
rather than reimplementing it. Adds the getter and script-wrapper
addresses, which existed only as comment references.
2026-09-04 21:18:00 -05:00
Brues 7887237a6e feat(faction): add C_Reputation.SetSelectedFactionByID
Selects a reputation-pane faction by ID rather than by displayed-list
position, matching what SetWatchedFactionByID gives over the stock
index call. A ClassicAPI extension -- Blizzard has no such call.

The engine already holds the selection AS a faction id: the stock
SetSelectedFaction(index) resolves its argument through the faction
index resolver and stores the resulting id, and GetSelectedFaction maps
that id back to an index. So this writes the id straight through, with
no list walk, and works even while the faction is not listed.

Also mirror the stock SetSelectedFaction global into C_Reputation. Its
handler already has the Lua C ABI, so it registers as-is rather than
being reimplemented.
2026-09-04 21:05:53 -05:00
Brues 98af79137b feat(faction): add C_Reputation.GetFactionDataByID
Keys the modern FactionData table by faction ID instead of by
displayed-list position. Reuses the shared ReadFactionData chain and
table builder, so it needs no new engine work.

Unlike GetFactionInfoByID this is not limited to the player's
reputation list: an unencountered faction fills cleanly with
currentStanding 0 and atWarWith false, so any real faction's name,
description and thresholds are readable.
2026-09-04 20:50:58 -05:00
Brues 622882725f feat(map): C_Map user waypoint (Tier 3)
Add the eight user-waypoint calls and USER_WAYPOINT_UPDATED: set, get,
has, clear, per-map position, the two hyperlink converters, and the
can-set check.

The pin is one piece of session state (a uiMapID, a 0..1 position and an
optional z) that survives a UI reload. GetUserWaypointPositionForMap
re-projects it onto any other map through world coordinates, so a zone
pin resolves on its continent and back. The hyperlink is the standard
worldmap:uiMapID:x:y payload with both coordinates scaled by 10000, and
the parser accepts a whole link or the bare payload.

UiMapPoint's factories go in the addon, matching where retail ships them.
Extract the shared vector2 marshalling to map/Vector2.h so the waypoint
and coordinate modules don't each carry a copy.
2026-09-04 20:46:22 -05:00
Brues 0517bb3753 feat(map): C_Map map hierarchy (Tier 2)
Add GetMapInfo, GetMapChildrenInfo, GetMapInfoAtPosition and
GetFallbackWorldMapID, giving the World -> Continent -> Zone tree.

Continents carry no AreaTable id (their WorldMapArea rows have areaID 0),
so they are named by a negative -(WorldMapArea row) -- mirroring the
engine's own zoneOrSort idiom, where positive is an AreaTable zone and
negative a QuestSort row. Names and mapType for those come from Map.dbc.

Move the row math into Map::Area (RowForUiMapID, ContinentRowForMapID,
RowRect, PercentInRow, WorldFromRow) and put the Tier 1 coordinate
functions on it, so they accept continent ids too and GetMapRectOnMap
honors topUiMapID.
2026-09-04 20:06:15 -05:00
Brues fb17488237 feat(map): C_Map coordinate transforms (Tier 1)
Add GetPlayerMapPosition, GetWorldPosFromMapPos, GetMapPosFromWorldPos,
and GetMapRectOnMap on the existing AreaTable-id=uiMapID scheme, reusing
Map::Area's world<->map projection. Positions returned as Vector2DMixin.
2026-09-04 19:34:45 -05:00
Brues b1ee8cc183 fix(texture): pool-free must skip oversized textures too
Regression from the size gate (be92d72 / 2ce5b00): the cave lets textures
larger than the recycle pool can index be created, but the pool FREE path
FUN_00448670 was left filing every freed texture into a bucket keyed by
each dimension's power-of-two factor, with no upper bound. A power-of-two
texture over 1024 (tz 6 per axis) files at bucket 36-42, past the 36-entry
table, and writes through a garbage list head — an ACCESS_VIOLATION at
0x004487D9 that fired whenever such a texture was freed, shutdown included.
Only power-of-two dimensions reach the bad index; NPOT has a small
power-of-two factor and stayed in range, which is why it went unseen until
a 2048x2048 was loaded and the client closed.

The allocator's cave already routes oversized textures around the pool;
this does the mirror on free — a cold hook on FUN_00448670 sends any
texture the bucket table can't index to the engine's own non-pooled free
FUN_0058AD60, exactly the guard's else-branch for too-small / too-many-mip
textures. Verified: a 2048x2048 texture renders and /reload (full texture
teardown) no longer crashes.
2026-09-04 18:46:22 -05:00
Brues c08ded6db4 feat(texture): BLP loads at any size; guard the async fallback
BLP decodes into the shared scratch BEFORE it calls the allocator, so the
size cave (which fires from the allocator) was always too late for it — a
2048x2048 BLP overran the scratch on this client. A pre-hook on the
decompressor FUN_005A8430, which carries the level-0 dimensions, grows the
scratch first and repoints the caller's already-copied scratch slot at the
new buffer, or fails cleanly for a texture the GPU cannot hold. It acts
only when the slot IS the shared scratch; the IMG path decodes into its own
right-sized buffer and is left alone. GrowScratch now retires the previous
buffer one generation late (frees at the next growth) so any unaccounted
holder reads stale bytes, not unmapped memory. Verified: a 2048x2048 DXT1
BLP renders and grows the scratch to 2048.

The async force-load path shoves a read that never fit the shared buffer
into a 512 KiB static fallback with no size check — the engine bug behind
"big BLPs crash". The hook refuses a request larger than the fallback; the
texture stays blank. Growing the fallback to fit was tried and reverted:
the read runs on the engine's async reader thread, which has no crash
handler, and a read into a right-sized heap buffer froze then killed the
process where the same read into the oversized shared buffer does not — not
yet isolated. Consequence: an uncompressed BLP file larger than the shared
buffer (2 MiB stock, 32 MiB with VanillaHelpers) does not load; DXT stays
far under it and TGA is synchronous, so only a pathological input is lost.
Verified: a 2900x2900 uncompressed BLP draws nothing and does not freeze.
2026-09-04 18:31:17 -05:00
Brues 640f78b2eb docs: texture size and shape
README gains an "Any texture size" row in Modern client behaviors; API.md
gains a "Texture size and shape" section under Frame, after the texture
methods: any width and height, no fixed upper limit beyond the graphics
card's maximum, memory allocated only when a large texture loads, and the
two remaining file-level limits (BLP files over ~2 MB / 32 MB with
VanillaHelpers stay blank; NPOT BLPs with DXT or mips are untested).
2026-09-04 17:32:51 -05:00
Brues a51ec40e52 refactor(texture): drop the NPOT toggle; non-power-of-two is simply on
_classicapi_NonPowerOfTwoTextures existed to A/B the same texture during
bring-up. As a knob it fails the same test the textureMaxDimension cvar
did: it reset every launch, so neither a user nor an addon author could
rely on either state, and disabling NPOT for real would be a code change
anyway. Removing it also removes the snapshot/restore machinery that
served only it. The enable path is byte-identical to before.

_classicapi_TextureLimits() stays as the read-only diagnostic, minus the
always-true NPOT field: scratchSide, poolMax, poolSide, deviceMax,
strideFixed.
2026-09-04 17:12:41 -05:00
Brues 2ce5b007af feat(texture): grow the decode scratch on demand; guard the async fallback
Layers 3 and 4 of Texture::DimensionGate. There is no number of ours
anywhere: the cave hands each rejected texture's width and height to
EnsureScratch, which refuses only what the device's own caps max refuses
(the field the create validator FUN_0058AB10 compares against, so we never
grow for a texture the engine would reject a moment later) and otherwise
replaces the scratch with one that fits, through the engine's decode-buffer
constructor FUN_005A4AF0 — the routine both decoders already use when the
scratch is NULL. The scratch is a high-water mark; nothing changes for
anyone who never loads a texture over the stock limit.

The "big BLPs crash" bug is fixed at its source rather than by enlarging
the shared async buffer: FUN_0044AD50 (the force-load every UI texture
takes on first draw) shoves a read that never fit the shared buffer into a
512 KiB static fallback at 0x00885804 with no size check — bounded exactly
by the pool globals that follow it. A hook refuses to force a request
larger than the fallback; it stays pending and the texture stays
invisible, the normal not-loaded state. BLP files are thereby bounded by
the shared buffer (2 MiB stock, 32 MiB with VanillaHelpers) gracefully.
TGA reads are synchronous and never touch that path.

Verified in-game: scratch reads 1024 at boot, a 2560x1080 TGA renders
with no configuration, and the scratch then reads 2560. Device max on
the test GPU is 16384.

_classicapi_TextureLimits() now returns scratchSide, poolMax, poolSide,
deviceMax, nonPowerOfTwo, strideFixed.
2026-09-04 17:06:35 -05:00
Brues be92d721f0 feat(texture): dimension gate — NPOT textures, bound follows the scratch
1.12 requires power-of-two textures at two software sites (allocator
FUN_00448450 and create validator FUN_0058AB10); 3.3.5 and 4.3.4 keep the
rule unchanged. Verified in-game that the D3D backend samples NPOT
correctly for the TGA/UI path, so Texture::DimensionGate relaxes both
sites, default on.

The real size ceiling is not the allocator's `< 0x400` but a single global
decode scratch buffer sized once at boot (512^2 stock, 1024^2 with
VanillaHelpers) and written with no bounds check. The validator's
device-caps max is raw hardware and protects nothing — a 2560x1080 TGA
faulted in the decoder's pixel writer at 0x004492E5. So the gate reads the
scratch dimension back from the boot immediates and enforces exactly that,
and every dimension failure — POT or size — routes through a 28-byte code
cave that applies the bound before the fresh-allocation tail. Routing POT
failures straight to that tail would jump over the size CMPs.

Also fixes VanillaHelpers' recycle-pool collision: it grows the table to
6x6 but leaves the index stride at 5, so 32x1024 shared a bucket with 64x32
(rendered nothing) and 64x1024 with 128x32 (worked by luck). Both index
sites are rewritten to stride 6 when its 36-bucket table is detected.

Lua: _classicapi_NonPowerOfTwoTextures([enable]), _classicapi_TextureLimits().
2026-09-04 16:16:11 -05:00
Brues b81f672ad7 fix(aura): classify auras by polarity flag, not slot range
The server seats buffs in slots 0..31 and debuffs in 32..47 only while it
can: once the 16 debuff slots are full, _AddSpellAuraHolder parks further
debuffs in free BUFF slots and sets UNIT_FLAG_AURAS_VISIBLE so the client
still renders them (tortoise-wow, verified). Classifying by slot range
therefore reported those as buffs -- absent from HARMFUL, and wrong in
isHelpful / isHarmful. Confirmed live on a 20-aura target: 18 harmful, two
of them sitting in buff slots, where the old code returned 16.

Every visible aura carries its real polarity in the per-slot flag nibble
(SpellAuraHolder::SetAuraFlag writes 0x04 positive / 0x08 negative), so
HELPFUL / HARMFUL now select on that instead. All the walkers -- by index,
by spell ID, by name, GetUnitAuras, GetAuraSlots -- share one order helper,
SlotInFilterOrder, which visits the polarity's home range first and the
other range after, so every index is unchanged whenever nothing has
spilled. Aura::Source's KindForSlot classifies the same way, keeping the
descriptor-drop fallback consistent with the query paths. The out-of-range
group array carries spell IDs with no flags, so that path stays
range-based -- the seating range is the only polarity it has.

GameTooltip:SetUnitAura now bridges the two index spaces. The engine's
SetUnitBuff / SetUnitDebuff index their own slot RANGE, which diverges from
the polarity index space as soon as a debuff spills, so the method resolves
(index, filter) to an absolute slot and hands the engine the index that
slot holds within its own range, picking the method by where the slot
lives. It counts with the engine's TOOLTIP visibility predicate
(FUN_GAMETOOLTIP_AURA_VISIBLE = 0x00534DF0), not the UnitBuff one
(0x00519860): the latter adds a per-player immunity-bitmap check off record
+0x2B0, so the two index spaces are not interchangeable. Both decompiled to
confirm the walks being mirrored.

The aura tooltip builder only ever receives spellId / level / stacks, so a
debuff parked in a buff slot renders identically through SetUnitBuff.

Note this is deliberately MORE than the engine does: stock UnitBuff /
UnitDebuff split by slot range and show a spilled debuff as a buff. Retail's
contract is the aura's actual polarity, and the descriptor already carries
it, so we report that.
2026-09-04 03:58:14 -05:00
Brues fdbe66b057 fix(aura): let a duration rule edit an aura its own trigger creates
Turtle's Bestial Wrath gives the hunter's pet Scent of Blood for 18s, but
C_UnitAuras reported 8s and then dropped the timer entirely while the buff
was still up.

Server-side (tortoise-wow spell_hunter_bestial_wrath) Bestial Wrath neither
applies the aura nor carries its duration on the wire: effect 0 is
TRIGGER_SPELL(Scent of Blood, 52995), and the script then stretches the
holder that trigger just created via SetAuraMaxDuration/SetAuraDuration.
Both are plain field writes, and 1.12's only duration packet is scoped to an
aura-bearer that is a PLAYER, which a pet is not -- so the client never hears
the 18s and keeps the 8s base it computed when the aura landed. Exactly the
class of edit Aura::Source's duration rules exist to mirror; two things had
to change before one could express it.

The affected-aura selector demanded a SpellFamilyFlags overlap, and every
Turtle custom spell carries flags of 0 (308 in the hunter family alone), so
no mask could ever name this aura. It now takes a family-flag overlap and/or
a SpellIconID, at least one of the two -- an icon is shared across a spell's
whole rank ladder, so it stays as rank-proof as a flag, and icon 2245 picks
out exactly the four Scent of Blood records and nothing else. Registrars
reject the mask-0 + icon-0 rule that would match a whole class family, and
RefreshDurationByFamily takes the same invariant so the two entry points
cannot disagree about what they match.

A trigger can also CREATE the aura it edits, which the immediate-apply model
could not see. The trigger's SMSG_SPELL_GO arrives before the triggered
cast's, so editing at trigger time either matches nothing or is overwritten
moments later when the triggered cast stores its own base duration -- the
edit is lost either way. Idempotent ops now ARM the target as well, and the
next matching store re-applies the edit, from the cast path or the
application path, whichever the aura arrives on. Only refresh and set arm:
re-running those lands on the same value, while reduce would shave twice and
remove would kill an aura that was legitimately re-applied, so Conflagrate
behaves exactly as before.

The rule reads Bestial Wrath's own duration from the client's spell data
instead of hardcoding the server's 18s, so a rebalance that patches the DBC
carries over on its own.

Fixes brues-code/pfUI#50
2026-09-02 19:39:59 -05:00
Brues bd1bd9f5d8 feat(aura): slot-batching API and lazy caster match
C_UnitAuras.GetAuraSlots / GetAuraDataBySlot / UnitAuraBySlot: enumerate a
unit's auras once into opaque slot ids and fetch each by id, so a full scan
walks the aura array once instead of once per index (the by-index getters
re-walk from slot 0 on every call). Slot ids cover live descriptor slots,
the stealth / range cache fallback, and the out-of-range group array, so
the slot path keeps every source the by-index path had.

GetAuraSlots also takes a table as a 5th argument (ClassicAPI extension)
and fills it in place, returning `continuationToken, n`. Lua 5.0 allocates
an `arg` table for EVERY vararg call (luaD_precall 0x006F6050 ->
adjust_varargs 0x006F6200 -> luaH_new, verified in Ghidra), so a Lua helper
receiving the retail multi-return costs one table per batch. The fill form
keeps per-frame scans allocation-free; AuraUtil.ForEachAura / FindAura use
it with a per-nesting-depth buffer pool instead of retail's vararg helpers.

The descriptor walkers (FindNthSlot, FindSlotBySpellID/Name, GetUnitAuras)
now evaluate IsPlayerCast only when the filter restricts on caster. It
scans the Aura::Source cache (~1000 entries in a raid) and was paid for
every populated slot on every call, PLAYER token or not.

Cache-fallback eligibility is centralized (EligibleFallbacks /
EligibleGroupFallbacks) so the by-index, bulk, and by-slot paths share one
ordering, which is the ordinal an opaque cache slot id encodes.
2026-09-02 16:19:31 -05:00
Brues b6698ecc7e docs(readme): list UnitCreatedBySpell in the Unit row
Already documented in docs/API.md; only the README summary table was
missing it. Inserted in alphabetical position.
2026-09-02 02:34:37 -05:00
Brues e123a6facc fix(cast): fire CHANNEL_STOP/START on same-spell re-channel
Recasting a channel while already channeling it (e.g. Fishing while
fishing) left the cast bar frozen: PollPlayer detected a new channel by
spellID only, but a re-channel keeps channelSpellID == g_chanSpell and
the continuous channel never lets +0x228 (and thus g_channel.spellID)
return to 0 in between, so neither CHANNEL_STOP nor CHANNEL_START fired.

Also flag a same-spell re-channel when the channel was re-stamped
(channelStartMs advanced) AND a fresh CMSG_CAST_SPELL is pending for the
spell (g_pendingGuid). Both are required so neither false-fire trap
triggers: the two-stamp-per-start sequence (SMSG_SPELL_START then
MSG_CHANNEL_START) is absorbed because the first CHANNEL_START already
consumed g_pendingGuid, and a rejected recast (SPELL_IN_PROGRESS) never
re-stamps so channelStartMs is unchanged.
2026-09-02 02:05:55 -05:00
Brues 695077b6da refactor(callbacks): lighter EventRegistry dispatch
Modernize the frame-event forwarder: OnEvent now takes its (self, event, ...)
positionally (modern script args, on by default) and forwards straight to
TriggerEvent — dropping the DispatchGameEvent hop and the unpack(arg) repack,
and forwarding the exact arg count instead of a padded arg1..arg9 (which
silently dropped args for events that emit more than nine). A fallback reads
the event/arg globals when SetModernScriptArgs is off, so the registry stays
robust to that switch.

Keep the ordinary {[event]=count} ref-count table rather than storing counts
as frame attributes: SetAttribute is heavier per call (it lowercases the name,
runs the secure-attribute parsing, and fires OnAttributeChanged) for the same
steady-state memory, so a plain table is lighter on the register/unregister
path.
2026-08-31 16:51:17 -05:00
Brues 4d744b6638 feat(interface): INTERFACE_VERSION global constant
Publishes INTERFACE_VERSION (11200 on this client) — the client interface/TOC
version, read from the engine's own accessor (FUN_ADDON_CLIENT_INTERFACE_
VERSION, the same value the addon loader compares each `## Interface:`
against) rather than a literal. A global number re-published every /reload
via ModuleAutoRegister, mirroring CLASSIC_API_VERSION, since it is a fixed
client constant.

Verified in-game: /dump INTERFACE_VERSION -> 11200.
2026-08-31 15:06:01 -05:00
Brues c2a4cbdf27 docs: drop WoW-version and vanilla-gap phrasing
The docs are a modern-API backport onto 1.12, so "added in <patch>" and
"vanilla 1.12 lacks/does X differently" are implied by the doc existing —
they were noise. Swept both across docs/API.md and README.md: dropped the
version-introduction and vanilla-limitation clauses, and where a version note
was the only thing explaining a hardcoded/degenerate return value, kept the
reason without the version. Behavior notes and "ClassicAPI extension"
provenance are unchanged. The large line count is mostly paragraph reflow
after clause removal; no API names, signatures, or code examples changed.
2026-08-31 13:23:09 -05:00
Brues d30ae53fbf feat(editbox): ClearHistory
Empties the up/down input history. The engine's SetHistoryLines internal
(FUN_0077cd90) frees the whole history ring when called with 0, so clear =
free (0) then reallocate at the saved max — dropping every entry and resetting
the write position while keeping the line limit, so the box keeps recording
afterward (matching modern semantics, not "history disabled"). No-op when the
box has no history.

New offsets: OFF_EDITBOX_HISTORY_MAX (+0x388, the ring capacity GetHistoryLines
reads), FUN_EDITBOX_SET_HISTORY_MAX.

Verified in-game: AddHistoryLine + ClearHistory leaves GetHistoryLines
unchanged and the up-arrow history empty; new lines still record.
2026-08-31 11:14:34 -05:00
Brues 8f32955823 feat(editbox): SetHighlightColor / GetHighlightColor
The engine paints the text selection with three Texture regions (start-line
+0x350, middle-block +0x354, end-line +0x358; positioned by FUN_0077d950),
and their vertex color IS the highlight color — there's no single color field.
So SetHighlightColor packs {b,g,r,a} and writes it to all three via the
engine's own generic region color setter (FUN_0077f750, the same one behind
Texture:SetVertexColor / FontString:SetTextColor), and GetHighlightColor reads
the first region back through the matching getter FUN_0077f8c0 (the read half
of Texture:GetVertexColor). The blinking caret is a separate region (+0x3d4),
left untouched.

Colors are 0..1, alpha defaults to 1. New offsets: FUN_REGION_GET_COLOR,
OFF_EDITBOX_HIGHLIGHT_REGION; FUN_FONTSTRING_SET_COLOR relabeled as the
generic region color setter it always was.

Verified in-game: SetHighlightColor(1,0,0) reads back 1 0 0 1 and a selection
paints red.
2026-08-31 10:53:04 -05:00
Brues 2778643f4f feat(editbox): GetUTF8CursorPosition, ClearHighlightText, HasFocus, HasText
Round out the EditBox method backports alongside Set/GetCursorPosition, all
reusing state the engine already maintains:

- GetUTF8CursorPosition — caret as a character index (vs GetCursorPosition's
  byte offset), via the engine's own byte->char counter FUN_0077bc80.
- ClearHighlightText — drop the selection but keep the caret (collapse
  selStart/selEnd onto the cursor + highlight-dirty bit). Vanilla can only
  clear a selection with HighlightText(0,0), which yanks it to offset 0.
- HasFocus — pointer identity against VAR_FOCUSED_EDITBOX (the global the
  engine's SetFocus writes / ClearFocus clears).
- HasText — text byte length (+0x338) is non-zero.

Registered on the EditBox method registry, so only EditBox frames dispatch
them. New offsets: OFF_EDITBOX_TEXT_LENGTH, FUN_EDITBOX_COUNT_CHARS.

The module now covers cursor / selection / focus / text-state, so it moves
from editbox/Cursor.cpp to editbox/Methods.cpp (namespace EditBox::Methods).
Documented in docs/API.md and the README under the Frame widget section.

Verified in-game: SetText+SetFocus reports HasFocus/HasText true and both
cursor getters 5; an empty box reports both predicates false.
2026-08-31 10:38:35 -05:00
Brues 3b60ca4094 docs(readme): drop addon-Lua functions from the DLL call table
The "In-game Lua calls" table lists what the DLL registers in C++, but it
included functions provided by the bundled !!!ClassicAPI addon's Lua:
AuraUtil.* and the secure-template Frame functions (SecureCmdOptionParse,
Register/UnregisterStateDriver, Register/UnregisterAttributeDriver,
Register/UnregisterUnitWatch, UnitWatchRegistered, SecureButton_GetAttribute,
SecureButton_GetUnit). Remove those; keep the C++-backed entries
(SetAttribute/GetAttribute/ClearAttribute, PreClick/PostClick, GetClickFrame).
2026-08-31 10:25:45 -05:00
Brues c20be0cef1 feat(editbox): SetCursorPosition / GetCursorPosition
Vanilla's EditBox registry has 48 methods (HighlightText, Insert, SetText,
...) but no cursor accessor, so modern addons that set text then place the
caret error with "attempt to call method 'SetCursorPosition' (a nil value)"
(reported: TwitchEmotes autocomplete).

The caret already exists in the engine — a byte offset at editbox+0x36c —
with reusable primitives: FUN_0077e360 clamps and sets it (the engine's own
"restore caret" step in the insert path), FUN_0077ccf0 collapses the
selection onto it. Modern SetCursorPosition is byte-based too, so it maps
1:1. New EditBox::Cursor module registers both methods on the EditBox method
registry (0x00CF5378); registering under that context means only EditBox
frames dispatch them, so `this` is always a real CSimpleEditBox. Set path:
clamp+set the caret, collapse the selection, flag the highlight dirty.

Verified in-game: SetText + SetCursorPosition + GetCursorPosition round-trips
and the caret lands at the requested offset.
2026-08-31 10:16:37 -05:00
Brues 1ad6b57202 fix(table): writer-side mark for deliberate nil appends
The v1.12.8 gap heuristic (keep the stored length when it overshoots the
border by exactly one) protected `table.insert(t, nil)` slots (#36) but
broke every 5.1-dialect table that is stale by exactly one — a
pairs-cleared one-element table, or a recycled table whose previous life
was one slot longer. Tablet-2.0's del()/copy() pool produces the latter on
every tooltip refresh, so FuBar/Ace2/Tablet menus and tooltips shifted by
one with a nil hole at slot 1. The two states are byte-identical, and both
consumers reach the heal through table.insert — neither the table nor the
caller can split them.

Only the writer knows which dialect the table speaks, and the write is
observable: co-hook the engine's table.insert (FUN_LUA_TABLE_INSERT =
0x7FB6B0, verified against the table-lib luaL_reg pair in .data and a
decompile matching 5.0.2 tinsert line for line) and record
{table -> stored length} in a weak-keyed registry table whenever the
two-arg form appends a literal nil. Rule 3 now heals every stale table to
the border (the 5.1 answer) and keeps the stored length only when the gap
is one AND the mark still matches. Stale marks die on their own: any later
insert/remove moves the stored length off the recorded value, and the weak
key collects with the table.

Verified in-game with a 21-test suite (directed regressions for
#29/#36/#39, byte-identical twin discrimination, metamethod isolation,
coroutine-side marking, and a seeded differential fuzzer against an
executable model of the contract): 15/21 on the broken build, 21/21 with
the fix, #36's nil-append cases among the passes both times.

New plumbing: LUA_SET_METATABLE (0x6F4020, from LuaCAPI.md) and
Game::Lua::SetMetatable for building the weak mark table from C.

Fixes #39
2026-08-31 01:56:03 -05:00
Brues 993c82cca7 refactor: route unit-token resolution through Game::ResolveUnitToken
Sweep the ~35 modules that each hand-rolled a `ResolveUnitToken_t` typedef
plus `reinterpret_cast<…>(Offsets::FUN_RESOLVE_UNIT_TOKEN)` onto the shared
`Game::ResolveUnitToken` helper. Behavior-identical — it is the same engine
function — and net -87 lines. The typedef now lives in exactly one place
(Game.cpp).

unit/Position.h is a header, so it gains an `#include "Game.h"`; every other
touched file already pulled Game.h in. No offsets, logic, or unrelated
typedefs were changed.
2026-08-30 16:57:04 -05:00
Brues 94e77ef5af feat(unit): UnitCreatedBySpell + consolidate the unit-token resolver
Add `UnitCreatedBySpell(unit)` — the spell that summoned a unit (a totem's
totem-drop spell, a pet/guardian's summon spell), read from the unit's
UNIT_CREATED_BY_SPELL descriptor field (byte +0x230). It is the same field
the engine's unit-title builder consults to pick the Pet/Minion/Guardian/
Creation title, and it is broadcast, so it resolves for any unit in range.
This is the summoning spell, distinct from the (server-only) spell a totem
casts.

Also add a single `Game::ResolveUnitToken(token)` helper that wraps the
engine token->CGUnit* resolver, and route unit/Pet.cpp through it plus the
Game::Read<T> field accessors — removing the hand-rolled ResolveUnitToken_t
typedef and raw reinterpret_casts from this module. The name was already
referenced in Offsets.h and CLAUDE.md but had never been implemented.
2026-08-30 16:43:54 -05:00
Brues 1458d60b51 feat(spell): GetSpellInfo accepts a spell name, link, or "Name(Rank N)"
The shared spell-arg resolver errored on any non-number arg1, so
GetSpellInfo("Fireball") raised instead of resolving. Match retail's
full argument set: arg1 may now be a spellID, a spell name, or a spell
hyperlink.

A name resolves against the player's then pet's spellbook (retail's
scope) and returns the highest rank you know; an unknown name returns
nil rather than raising. A trailing "(Rank N)" subtext pins that exact
rank -- the vanilla SpellName(Rank N) form CastSpellByName uses, with a
space before the paren tolerated. A link's embedded spellID is parsed
directly, so it works for any spell, learned or not.

Because GetSpellLink / IsPassiveSpell / IsHarmfulSpell / IsHelpfulSpell
share this resolver, they gain the same name and link forms.

Verified in-game: "Mind Blast" -> rank 9 (max known); "Mind Blast
(Rank 8)" and "Mind Blast(Rank 8)" -> rank 8; "Mind Blast (Rank 99)"
-> nil.
2026-08-30 13:36:07 -05:00
Brues 8b106ae0d6 fix(addons): define global HookScript for ModernSpellBook
ModernSpellBook's MSB_Spellbook.lua re-hooks its "show all ranks"
checkbox by calling HookScript(frame, "OnClick", handler) as a GLOBAL.
That global never existed on any client -- HookScript has always been
the widget method frame:HookScript, which our DLL provides -- so the
call errors with "attempt to call global 'HookScript' (a nil value)".

Define the global in AddOnCompat, forwarding to the method, gated on the
addon being present so the global namespace stays clean otherwise.
2026-08-30 08:52:01 -05:00
Brues 3c4d4eda5f docs(readme): link the Feature column instead of trailing "See […]"
Move each feature table's docs/API.md link onto the Feature name in
column one and drop the "See [x]." tail from the Effect column. Rows
with no docs section (Inline textures, Tooltip line cap) are unchanged.
2026-08-29 19:20:59 -05:00
brues-code 6aea4a1752 Retail-like /reload: new addons, new files, ## metadata edits, deletions (#37)
* feat(addons): retail-like /reload — new addons and new files load

Two engine limitations kept anything created after boot from loading
until a client restart:

1. File visibility: every relative-path read resolves through a
   loose-file hash index built once at boot (FUN_00646EA0, latched);
   the archive layer's live disk probe is dead code (DAT_00865B44
   ships 1, boot clears it) and writes never register. New files -
   including a new addon's TOC and first-ever SavedVariables - were
   invisible until restart.
2. Registry membership: the addon registry is built once at login;
   new folders are never walked.

Addons::Rescan runs per /reload from the ModuleAutoRegister callback
(before the AddOns.txt re-read, progress count, and load pass) and:

- re-indexes Interface\AddOns + WTF\Account with the engine's own
  dedup-safe boot indexer (FUN_VFS_INDEX_SUBTREE) - new files in
  existing addons then load natively, since the per-addon loader
  re-reads the TOC from disk on every load pass; a newly installed
  addon's first SV save now also survives /reload;
- replays login scan walk #2 verbatim (FUN_ADDON_SCAN_DISK_DIRS with
  the engine's own dir-to-parser callback) so new folders register as
  complete normal entries via the dedup-guarded TOC parser;
- mirrors the two structures the login scan would have filled:
  reverse-LoadWith links (tail-loop grow+append, restricted to pairs
  involving a new entry - the engine's lists have no dedup) and the
  flat GetNumAddOns display array (FUN_0051DA70 phase 2: filter byte
  +0x29, engine grow + qsort comparator).

Append-only by design: no teardown, no eviction, no mutation of
existing entries - the prior hot-reload attempt died re-running the
login-only FUN_ADDON_INIT teardown mid-reload. Still login-only
(no safe mechanism): ## metadata edits of already-registered addons
and folder deletions.

Also centralizes Embedded.cpp's addon-entry field constants into
Offsets.h now that a second module reads them.

* refactor(addons): centralize the registry list walk; record dedup-guard probe

The intrusive linked-list walk (head at [VAR_ADDON_LIST_HEAD], next at
entry + [VAR_ADDON_LIST_CTRL] + 4, low-bit-1/NULL sentinel) was
implemented twice -- a template in Rescan.cpp and an inline loop in
Embedded.cpp's FinalizeEmbeddedEntry. Move it to addons/Registry.h as
Addons::ForEachEntry, with a void- or bool-returning visitor (false
stops early). The early stop is load-bearing for Embedded: re-linking
the found entry to the head invalidates the current next-pointer, so
the walk must not continue past it -- the shared helper makes that
contract explicit.

Also record the TOC-parser dedup-guard verification in Rescan.cpp's
scan-call comment: the guard is the parser's FIRST block (hash lookup +
name compare, early return before the TOC path is even built), and both
the Storm hash (uppercases a-z, folds '/'->'\') and the SStrCmpI compare
are case-insensitive -- so the per-/reload scan replay can never re-read
or mutate a registered entry's ## metadata, and a case-renamed folder
matches its existing entry instead of registering a duplicate.

* feat(addons): /reload re-parses edited ## metadata and drops deleted addons

The TOC parser's dedup guard makes a registered entry's ## metadata
read-once: an edited ## SavedVariables: / ## Dependencies: / any ##
line never re-parsed until re-login, and a deleted folder's entry
lingered in the addon list. Both were documented login-only limits of
the retail-like /reload feature.

Rescan's new step 1.5 closes them with evict + re-register, all engine
machinery:

- Change detection: FNV-1a over the TOC's ## lines only (BOM skipped
  like the parser, line terminators excluded), read through the hooked
  FUN_FILE_READ so flavor selection is hashed exactly as the parser
  reads it. File-reference edits never trigger the evict path -- the
  load pass re-reads those natively. Baselines seed at login and for
  newly registered folders in the same Run(), so an edit is always
  diffed against what was actually parsed.
- Evict: scrub the victim from every other entry's reverse-LoadWith
  list (the one reference the destructor cannot clean -- the loader's
  post-ADDON_LOADED loop dereferences those entries), then call the
  engine's own complete per-entry destructor FUN_ADDON_ENTRY_DESTROY
  (0x005200A0, __stdcall, RET 4; vtable slot 0 of the registry node
  factory at [0x00BE1B60], dispatched per entry by the login teardown).
  Verified by decompile: frees every owned allocation (name, dep/SV
  arrays + buffers, reverse-LoadWith buffer, the ## metadata table) and
  self-unlinks from both the intrusive list (FUN_00520A60, no-ops when
  already unlinked) and the hash bucket chain.
- Re-register: feed the name back to FUN_TOC_PARSER -- the dedup guard
  now misses and the entry rebuilds from the edited TOC exactly like a
  fresh registration. Refreshed entries join the existing
  FixupReverseLoadWith/RebuildDisplayArray fix-ups; the refresh runs
  before the before-snapshot so an address-reusing allocator cannot
  make the added-diff double-count or miss one. Any eviction forces the
  display-array rebuild (it holds the dead entry's freed name pointer
  until then). Victims are collected during the walk and processed
  after it -- the dtor zeroes the link node under the iterator.
- Deletion: an unreadable TOC evicts without re-registering; a folder
  the scan still finds simply re-registers the same reload.

Exempt: !!!ClassicAPI (Addons::Embedded owns its entry) and ## Secure:
/ SMSG-managed entries (the parser cannot restore packet-delivered
state; new OFF_ADDON_ENTRY_SECURE = 0x28, parser write site verified).

Verified in-game: ## X-Test metadata edit picked up across /reload
(GetAddOnMetadata returns the new value), SavedVariables list edit
takes effect, folder deletion drops the entry from GetNumAddOns, folder
restore re-registers it, and a no-change /reload is a pure hash-compare
no-op.

* fix(addons): address review — hash commit timing, evict flag, README row

- README: the /reload row said `##` metadata edits still need a
  re-login — stale from before the metadata-refresh step landed. Now
  matches docs/API.md: edits take effect, deleted folders drop from
  the addon list.
- Rescan: commit g_metaHash only after the re-registration actually
  happened, so a failed resolve/parse retries next /reload instead of
  silently absorbing the edit forever; a victim the parse cannot
  restore is erased (treated as deleted) so a reappearing folder
  re-seeds.
- Rescan: RefreshChangedMetadata returns whether anything was actually
  destroyed, not whether victims were collected — no display-array
  rebuild when every victim failed to resolve.
- Rescan: comment the load-order consequence of evict + re-register
  (the fresh entry appends at the list tail; declared deps still
  force-load first).
2026-08-29 19:16:34 -05:00
Brues 6304a253dd feat(aura): Dark Harvest tick compression; channel-aura pushback restamp
Turtle's Dark Harvest (52550-52552) makes the caster's Affliction dots on
the channel target tick 30% faster - and live preserves each dot's tick
COUNT, so the dot's real remaining time drains at 130% rate during the
overlap and it falls off early with no client-visible aura change (report:
gone with ~3 s still showing; Cursive's community calibration models the
same `remaining -= 0.30 x overlap`). tortoise-wow's recreation only
re-times tick timers and leaves holder duration alone, which does NOT
reproduce the early fall-off - so the compression math mirrors live, while
the affected-set predicate mirrors the server script
(IsDarkHarvestAfflictionPeriodicAura): same-caster warlock-family auras
matching the Corruption/Drain Life/CoA/Drain Soul/Siphon Life/CoD mask
with a periodic damage/leech effect (the aura-type gate is load-bearing:
Turtle's Atrocity shares Corruption's 0x2 bit but is a proc aura - DBC
sweep verified; the trigger mask 0x4000000000 selects exactly the three
DH ranks).

Aura::Source::AddTickCompression: while a caster's trigger-mask aura is
live on a target (its cache entry's lifetime: SpellGo -> OnAuraRemoved
eviction), that caster's matching entries drain an extra pct% of real
time. Anchored math (expiration = anchor - elapsed x pct/100) so frame
pacing can't drift it; an external re-stamp (dot recast, duration mod)
re-anchors from the fresh value. Caster-paired like the server, so an
observed party warlock's DH compresses their dots' timing too. ~1/s
UNIT_AURA nudges for event-driven consumers, final nudge at release.
Registered Turtle-gated in turtle/DarkHarvest.cpp with the percent read
from the rank record's EffectBasePoints[1]+1 (-31 -> 30, fallback 30).

Also: MSG_CHANNEL_UPDATE now re-anchors the cached target-side expiration
of the player's channel-applied auras (Aura::Source::RestampPlayerChannel).
The server shortens every hit target's holder on channel pushback
(Spell::DelayedChannel -> DelaySpellAuraHolder) and only tells the caster
via the new remaining time, so the SpellGo-stamped expiration read late by
the accumulated pushback; self-target holders were already corrected
natively via SMSG_UPDATE_AURA_DURATION. This also bounds the compression
window under pushback for free.
2026-08-28 21:56:02 -05:00
Brues fbe40743c0 fix(table): keep length on a deliberate single nil append
The stale-length heal (Length.cpp) could not tell a cleared-and-reused
table from a deliberate table.insert(t, nil): both leave the stored
length pointing at a nil slot with no explicit n field, but they want
opposite answers. Rule 3 healed both, so the next table.insert on a
single-nil-appended array wrote over the nil and shifted every later
element down by one -- silent corruption in Ace2-era addons (Waterfall's
{key1,val1,...} arg builder inserts a nil whenever an option has no
passValue, the common case), with no error anywhere.

The issue's preferred fix -- make table.insert always use the raw stored
length -- reintroduces #29: Dewdrop clears `values` with a pairs loop +
no-op table.setn, then rebuilds it via table.insert, and that append is
exactly what the heal fixed. Both consumers reach the heal through
table.insert, so the caller cannot distinguish them; only the staleness
gap can. A cleared table is stale by many slots; a deliberate nil append
is off by exactly one. So heal only when the stored length overshoots
the border by more than one slot, and keep it otherwise.

Verified in-game via CAPI_DemoTableLengthHeal (AddOnCompat.lua): nil
append preserved (getn=3, t[2]=nil, t[3]=b), Waterfall pairs intact, and
the cleared-table rebuild still heals to slot 1.

Fixes #36
2026-08-27 15:37:54 -05:00
Brues 772cd5ec15 fix(luasyntax): recover call args for loadstring'd vararg chunks
WoW's Lua 5.0 main chunks are not vararg (unlike stock 5.0), so a chunk
called with args had no `arg` to back the transpiled `...` and the old
`local arg={n=0}` fallback silently dropped them --
loadstring("return ...")(1,2,3) yielded nothing.

Compile non-addon vararg chunks (loadstring, /run, XML handler bodies)
as a real 5.0 vararg closure instead: wrap the body in
`return function(...) ... end` and call the main once after a successful
compile, so the caller receives the inner function whose genuine 5.0
`arg` carries the call args. Helper captures stay in the outer main
(runs once at load in the default env, preserving the setfenv-sandbox
rationale); the prefix is newline-free and the closing `end` rides its
own appended line, so error line numbers are unchanged. Addon file
chunks keep the (addonName, addonTable) preamble path.

Verified in-game: loadstring("print(select('#',...),...)")(1,2,3)
prints "3 1 2 3".
2026-08-26 20:24:15 -05:00
Brues a962c76f09 feat(aura): real isFromPlayerOrPlayerPet; fix castByPlayer to Era semantics
Populate AuraData's isFromPlayerOrPlayerPet from the cached caster GUID's
HIGHGUID prefix (player 0x0000 / pet 0xF140, confirmed in tortoise-wow
ObjectGuid.h) instead of hardcoding false. The UnitAura tuple's
castByPlayer (position 13) is the same value under retail's older name --
Era defines it as "applied by a player, their pet or vehicle", not the
local player -- so both now share one HIGHGUID test in ResolveDisplay.
The local-player question stays with the PLAYER filter, unchanged.

False on a cache miss (cast not observed this session); totem buffs read
false (vanilla totems carry the creature prefix, not pet).
2026-08-26 14:34:35 -05:00
Brues 292e206dee fix(aura): seat descriptor slots on cast captures FIFO, not newest-first
When two casters' same-spellID DoTs land in one descriptor-update batch,
the server assigns their slots ascending in cast order
(_AddSpellAuraHolder's first-free-slot scan, verified in tortoise-wow)
and the client diff dispatcher FUN_00604d00 fires OnAuraAdded in
ascending slot order (both loops are plain slot = 0..0x2F walks). The
first application therefore belongs to the earliest cast, and seating
the NEWEST unbound capture paired the two casters' timers and
attribution backwards.

Seat the oldest unbound capture instead, bounded by a 3s window that
covers instant applications and max-range projectile flight (Fireball's
DoT applies at impact) while skipping hit-but-never-applied captures.
The old "older = resisted" rationale for newest-first was moot: resisted
and immune targets sit in SMSG_SPELL_GO's miss list, which ParseSpellGo
never reads, so they never create captures at all.
2026-08-26 14:19:51 -05:00
Brues eef7140722 feat(combat): add StartAttack / StopAttack globals
Backport the non-toggling melee auto-attack verbs. 1.12 ships only the
toggling AttackTarget (FUN_006131A0), so /startattack can't guarantee it
won't cancel an in-progress swing. These call the engine's own start
(FUN_005ECB70, sends CMSG_ATTACKSWING) and stop (FUN_005ECAC0) primitives
directly, split into the two idempotent verbs.

StartAttack takes an optional unit-token target (resolved under pcall so a
name/bad token can't raise, falling back to the current target); with no
arg it validates and attacks the current target via FUN_00612DF0.
2026-08-26 13:49:44 -05:00
Brues 5279c463ed feat(slash): add /click handler and SLASH_CLICK locale strings
Register SlashCmdList["CLICK"], routing through SecureCmdOptionParse and
GetClickFrame to click a named button. Parse name + button only: 1.12's
Button:Click has no down/up path (verified in the base vmethod), so the
retail `down` arg has nothing to act on and a nil button defaults to
LeftButton natively.

Add SLASH_CLICK1/2 to all locale files from Classic Era GlobalStrings.
2026-08-26 12:28:30 -05:00
Brues 855b338736 feat(frame): add GetClickFrame global
Backport the engine-registered C global behind the /click command: resolve
a frame by its global name, returning it only when _G[name] holds a real
frame whose GetName() matches. Mirrors 3.3.5's Script_GetClickFrame; reads
the name from CFrameScriptObject vtable slot +4, verified against 1.12's
own Script_GetName.
2026-08-26 12:01:33 -05:00
Brues 6ca6af1497 fix(slash): route /focus, /clearfocus, /equipset through SecureCmdOptionParse 2026-08-26 11:30:32 -05:00
Brues 1170a4e63a fix(texture): mask via texgen for SetMask so SetTexCoord sprites clip right
SetMask rode the minimap BLIP recipe (explicit second UV stream, uv1 = uv0),
so the mask sampled at the base's texcoords - a SetTexCoord'd sprite made
unit 1 sample that same sub-rect of the MASK image, showing a corner chunk
of the shape instead of the shape.

Retire that path: the path mask is now one extra unit in the texgen draw
(MaskedRegionDrawImpl), its matrix built from the BASE region's own rect
(SetTexCoordModifiesRect crop gate included) plus the base's SetRotation so
a mask keeps following a rotated texture. The angle-0 matrix is exactly the
old recipe's formula re-expressed through positions (see BuildMaskMatrix),
so default 0..1-texcoord textures render identically; sprites become the
fixed case, matching retail (SetMask ignores SetTexCoord).

Side effect: SetMask and AddMaskTexture now compose (alpha intersection)
instead of object masks winning; the path mask occupies one of the 7 units.
2026-08-25 22:28:21 -05:00
Brues 0c8468cf2f refactor(reload): auto-register per-/reload cleanup; fix Time::Timer stale refs
Modules that keep reload-fragile file-static state (Lua registry refs,
frame/object-pointer-keyed maps, cached event-slot indices) each expose a
PrepareForReload() that DllMain called from the FrameScript_Initialize hook.
That list was hand-maintained, so a new module could silently forget to wire
itself in -- the failure mode behind the Frame::Attributes stale-ref bug and
Tooltip::SetEvents issue #33.

Add Game::ReloadAutoRegister + Game::RunReloadCleanups(), mirroring the
existing ModuleAutoRegister / HookAutoRegister / WorldTick::AutoSubscribe
patterns. Each module self-registers with one file-scope line after its
PrepareForReload:

    static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};

DllMain's 11-call manual list collapses to a single RunReloadCleanups(); a
new module can no longer be forgotten. Player::NameCache::Flush stays an
explicit call -- it's a save-before-teardown, not a stale-state clear, and
also runs on the glue-return path.

A sweep of all file-static state found one uncovered module: Time::Timer
pins C_Timer.After/NewTimer/NewTicker callbacks in the Lua registry but never
released them on /reload -- a surviving/stale ref could fire a dead closure.
Add Time::Timer::PrepareForReload (release each pending timer's registry
callback, clear the queue) and self-register it.

- Game.{h,cpp}: ReloadAutoRegister + RunReloadCleanups.
- DllMain.cpp: collapse the manual list to RunReloadCleanups(); drop 6
  now-dead includes.
- 11 existing reload modules + Time::Timer: add the self-register line.

Behavior-preserving (same cleanups, before the Lua teardown); auto-register
order is unspecified across TUs but every callback only clears its own state.
2026-08-25 18:37:44 -05:00
Brues 5196aab4b8 fix(frame): clear attribute maps on /reload to stop stale-ref SetAttribute error
Frame::Attributes kept two file-static maps keyed by the frame's C object
pointer -- g_attrHandlers (OnAttributeChanged handler refs) and
g_unitByFrame (unit tokens for the mouse-focus poll) -- and never cleared
them across a /reload. The Lua reset kills the handler refs, but the map
entries persist, and the allocator recycles frame addresses. So a new
frame reusing an old address, then getting a SetAttribute call, made
FireAttributeChanged fire a DEAD handler ref through the (protected)
invoker; the engine error handler still printed it as an error "in
function SetAttribute" with no Lua frame and an empty message.

SecureStateDriverManager sets OnAttributeChanged every session, so there
was always at least one entry to go stale -- this was the "SetAttribute
occasionally errors after /reload" report (pfUI UpdateVisibility ->
SetAttribute). Same class of bug as Tooltip::SetEvents issue #33.

Add Frame::Attributes::PrepareForReload() clearing both maps, wired from
FrameScript_Initialize_h alongside the other per-reload cleaners.

Also harden EnableFrameMouse (a best-effort side effect of setting the
"unit" attribute): it did SetTop(L, 1) to set up the engine EnableMouse
call, which destroyed the (name, value) at stack[2]/[3] that
FireAttributeChanged reads right after -- handing any OnAttributeChanged
handler a nil arg2. It now invokes the setter through lua_pcall at the top
of the stack and restores the caller's stack, so DoSet's (self, name,
value) survives, and a best-effort mouse-enable can never surface an error
in the addon's SetAttribute.
2026-08-25 18:22:49 -05:00
Brues 536c5e3c51 feat(frame): add PreClick/PostClick button scripts
Backport the modern PreClick/PostClick button scripts as real,
SetScript/GetScript/HookScript-able handlers. Vanilla 1.12 has only
OnClick and OnDoubleClick on buttons.

Two co-hooks (same technique as Tooltip::SetEvents):
- FUN_BUTTON_SCRIPT_RESOLVER: recognize the two names, returning an
  external per-button {handler, context} cell (a button has no free
  slot, and the resolver contract is just "return where the handler
  lives"). This makes them SetScript/GetScript/HookScript-able.
- FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT (the arg'd script runner), gated on
  slotPtr == frame + OFF_BUTTON_ONCLICK_HANDLER (0x4CC): fire PreClick
  before / PostClick after the OnClick, re-invoking with the same
  (fmt, varargs) so both receive arg1 = the button name like OnClick.

Deliberately NOT the button click vmethod FUN_00779540 -- SuperWoW
inline-hooks it for click-casting and a second MinHook there faults
(ERROR #132). The runner is uncontested and fires at the same top-level
dispatch level, so its exec-context stamp is valid.

Cells are keyed by button pointer and cleared on /reload
(PrepareForReload, wired in FrameScript_Initialize_h), matching the
tooltip-cell lifetime.

Limitation (documented): PreClick/PostClick fire only when the button
also has an OnClick handler, since the runner only runs for a non-empty
slot. Bracketing an existing OnClick is the common case; the no-OnClick
path would need the SuperWoW-owned vmethod.

Verified in-game: programmatic and real mouse clicks fire pre,click,post
with matching button-name args, and GetScript round-trips.

- Add offsets FUN_BUTTON_SCRIPT_RESOLVER, OFF_BUTTON_ONCLICK_HANDLER,
  FUN_FRAME_RUN_SCRIPT_WITH_CONTEXT.
- Document under docs/API.md (Frame section) and the README index.
2026-08-25 17:43:41 -05:00
Brues c06bed9158 feat(unit): add GetSheathState
Vanilla 1.12 ships ToggleSheath but no getter for the player's
drawn-weapon type. Add GetSheathState(), which returns the modern
1-based value: 1 = None (sheathed), 2 = Melee, 3 = Ranged.

The engine stores the sheath state as a plain CGUnit int member at
+0xD40 (0-based: 0 sheathed, 1 melee, 2 ranged) -- the same field its
own toggler FUN_005EB480 reads to pick the next state and its setter
FUN_00611CF0 writes. GetSheathState reads the local player's value and
adds 1 for the Lua contract.

- Add OFF_UNIT_SHEATH_STATE (0xD40) with RE-provenance comment.
- Document under docs/API.md (State section) and the README index.
- Also list the Tracking API in the README function index (missed in
  the prior tracking commit).
2026-08-25 15:53:10 -05:00
Brues e793f80f6b feat(spell): add minimap tracking enumeration
Vanilla 1.12 only stores the single active tracking spell
(VAR_ACTIVE_TRACKING_SPELL, 0xBC6378) and never enumerates the tracking
abilities the player knows. Add GetNumTrackingTypes/GetTrackingInfo/
SetTracking, which rebuild that list by scanning the player spellbook for
spells whose EffectApplyAuraName is Track Creatures/Resources/Stealthed
(44/45/151) -- the same discriminator the engine's own active-tracker
updater (FUN_004E4170) uses. This reproduces pfUI's hardcoded tracking
table with no table to maintain and auto-covers server-custom trackers
(e.g. Turtle's Find Trees, 52917).

- GetTrackingInfo returns name, texture, active, category ("spell"),
  spellID; active is read from VAR_ACTIVE_TRACKING_SPELL.
- SetTracking selects a tracker by tail-calling the engine's
  Script_CastSpell(slot, "spell").
- Add offsets VAR_ACTIVE_TRACKING_SPELL and FUN_SCRIPT_CAST_SPELL
  (0x4B42F0), both with RE-provenance comments.
- Document under docs/API.md (Tracking section).

Verified in-game on a Hunter (8 trackers) and a Dwarf racial (1):
active flips on SetTracking, texture matches native GetTrackingTexture,
out-of-range returns nil.
2026-08-24 23:16:34 -05:00
Brues ded767bf19 feat(aura,turtle): support Turtle's Stinging Nettle serpent sting timers
The hunter talent (403; rank passives 51579/51580) makes Mongoose Bite
and triggered fire traps apply the highest known Serpent Sting rank at
20/40% of its duration. Server-side it's a direct AddAura (tortoise-wow
ApplyStingingNettle) — no SMSG_SPELL_GO for the sting — so the cache
seated it caster-less at the full 15s base.

Aura::Source gains a generalized triggered-application rule table (the
judgement-attribution shape, rule-driven): a registered trigger's
SPELL_GO — local player casts only — arms its hit targets for 2s, and a
caster-less application matching the rule's family/mask consumes the arm
in StampApplication (player attribution + the server's own
max(1, base × pct / 100) with player duration mods). Rules can be gated
on a talent passive, read live from the spell-knowledge bitmap at
trigger time so respecs are honored. Because the server's re-apply over
an existing shorter sting reuses the descriptor slot (no OnAuraAdded —
verified in-game with chained Mongoose Bites), the trigger also
refreshes the player's existing cached entry in place, honoring the
server's keep-the-longer-remaining guard. The arm consume runs BEFORE
the WasRecentPlayerCast attribution: the arm names the exact target,
while the recent-cast window is spell-scoped only — a real sting cast at
unit A must not stamp its full duration onto a nettle application on
unit B; a real cast's own application stays protected by the cast-owned
entry guard.

Turtle::StingingNettle registers the rules behind the Turtle::Detected
latch: fire traps by family mask (hunter 0x4 selects exactly the eight
trap-effect spells the server's script binding lists — rank-proof),
Mongoose Bite by exact IDs (its bit 0x2 is shared with Raptor Strike,
which does not trigger nettle). The percents come from the rank spells'
own EffectBasePoints[0] + 1 — the same DBC value the server's
CalculateSimpleValue reads — with the server's 20/40 fallback.
2026-08-24 22:47:52 -05:00
Brues 70a69b64fd fix(spell): fire UNIT_SPELLCAST_DELAYED when the server reconciles cast time
The server scales ranged abilities' cast times by ranged attack speed
(SpellEntry::GetCastTime's IsRangedSpell branch — quiver/Quick Shots),
which the client's prediction helper FUN_006e3340 can't see (its only
caster factor is the spell-haste mult at descriptor +0x22c). So Steady
and Aimed Shot bars started at the fixed 1.5s/2.5s prediction; the
confirming SMSG_SPELL_START already snapped g_cast.endMs to the server's
authoritative castTime, but nothing told addons — the bar kept the
duration it read at UNIT_SPELLCAST_START.

PollPlayer now snapshots the cast's endMs instead of its delayMs and
fires UNIT_SPELLCAST_DELAYED on any end movement — one signal covering
pushback (previously detected via delayMs growth) and the server
reconcile in either direction. Cast bars re-read UnitCastingInfo on
DELAYED and pick up the corrected end. If the packet ever beats the
first poll, START itself carries the corrected end and no DELAYED fires.

Fixes brues-code/pfUI#43
2026-08-24 21:57:11 -05:00
Brues 0f5b50ea14 feat(aura): refresh paladin judgement timers off swings and re-judges
The judgement debuff's duration is maintained server-side with no
client-visible aura change: the paladin's white swings refresh it
(tortoise-wow Unit::DealMeleeDamage - any nonzero melee damage
RefreshHolder's every SPELLFAMILY_PALADIN + SPELL_ATTR_EX3_ALWAYS_HIT
aura the attacker cast on the victim), and a re-judge is an in-place
holder refresh that dirties no descriptor field. Aura::Source's
countdown therefore ran to zero while the debuff stayed up
(brues-code/pfUI#45).

Two client-side triggers mirror the server exactly:

- Swings: Aura::JudgementRefresh subscribes to SMSG_ATTACKERSTATEUPDATE
  (wire format verified against the server's own SendAttackStateUpdate
  writer) and on totalDamage > 0 calls the caster-STRICT
  Aura::Source::RefreshJudgements(victim, attacker) - strict because a
  swing gives no proof of ownership, and adopting there would let any
  tank's white swings pin another paladin's judgement timer.

- The Judgement cast (20271, the one spell every seal judges through):
  the debuff itself is cast server-side with no SPELL_GO the client
  hears (verified live - entries were seated caster-less by
  OnAuraAdded), so the Judgement cast's own SPELL_GO is what names the
  owner. HandleSpellGo refresh-with-adoption's the victim's existing
  judgement entries (the re-judge case) and arms a 2s attribution
  window that StampApplication consumes to give the freshly applied
  debuff its caster - which is what makes the strict swing matching
  work at all.

Refreshes re-fire UNIT_AURA via the existing deferred signal;
StoreFromCast now signals on its in-place recast path too, since an
identity-preserving refresh changes no descriptor field and
event-driven aura bars would never re-read the new timing.

Ability damage (Crusader/Holy Strike) is deliberately NOT a trigger:
melee-class spell damage never routes through the server's refresh
block (DealMeleeDamage's only caller is the white-swing path).

Verified in-game: every landing swing snaps the cached expiration to
now + 10s and pfUI's timer follows; misses/dodges (totalDamage 0) do
not refresh; re-judging resets the timer.

Fixes brues-code/pfUI#45
2026-08-24 18:58:46 -05:00
Brues be6dc200fe fix(addon): restore MouseIsOver's stock 1/nil return contract
The embedded addon replaces FrameXML's MouseIsOver with a wrapper over
the native region:IsMouseOver(), which returns a boolean. Stock 1.12's
MouseIsOver (UIParent.lua:1718) returns 1 on hit and nil otherwise, and
vanilla-era addons compare against that literal - Quiver gates every
widget click on `MouseIsOver(f) == 1`, which `true` silently fails,
deadening its entire config dialog (buttons, checkboxes, icon clicks).

Return 1/nil from the wrapper. 1 is truthy, so modern-style
`if MouseIsOver(f) then` callers are unaffected.
2026-08-24 10:37:39 -05:00
Brues 7f52afeef4 perf(event): fire custom events through O(1) AutoReserve::Slot()
Every custom-event fire resolved its name to a slot via
Event::Custom::Lookup - a linear strcmp scan over the ~46-entry
reservation table, on every single fire. The slot itself was already
cached per table build; only the name->reservation hop was repeated.
On a client that is purely CPU-bound, that is wasted work on paths
that burst exactly when frames are scarce (nameplate churn, cast spam,
loot rolls, mouse/modifier input).

AutoReserve now remembers its reservation index (the ctor's dedup
resolves duplicate names to the same entry) and exposes Slot() - two
loads. Reload safety is unchanged and stays centralized: Slot() reads
through the shared table PrepareForReload/RetryClaims maintain, so the
value can never go stale. Lookup is deleted; LookupByName remains for
engine-defined names.

All fire sites converted to the instance handle:
- CastEvents: the k* name constants become the AutoReserve instances,
  so all 19 fire sites stay textually identical; the Fire/FireReticle/
  FireRemote helpers take the reservation by reference.
- equipmentset: reservations move into Set.h as C++17 inline consts so
  the Data and Api TUs share one handle.
- Cache::QueryLoad::Register takes the AutoReserve pointer instead of
  an event-name string (creature/gameobject callers updated).
- Tts keeps its lazy VanillaTTS-stand-down reservations via
  null-guarded pointers set at registration.
- GlobalMouse/InputEvents/lossofcontrol instances moved above their
  fire sites (static-init list order is irrelevant).
2026-08-24 10:11:10 -05:00
Brues a2d25cef8b fix(object): gate ByGuid on the registry root to survive out-of-world calls
The engine resolver FUN_OBJECT_RESOLVE_BY_GUID funnels unconditionally
into FUN_00464890, whose first action loads the registry root from
[VAR_LOCAL_PLAYER_PTR] and derefs its +0x24 hash mask with no null
check. That root is NULL out of world (login, loading screens, logout
teardown), so any resolve reaching it mid-transition dies with ERROR
#132 (MOV EAX,[EDX+0x24], EDX=0) - hit in the wild by Aura::Source's
SMSG_SPELL_GO path, whose Store/Claim eviction consults
DescriptorListsAura while packets still dispatch during transitions.

Promote the readiness predicate from Unit::TokenObserver's private
RegistryReady into Object::RegistryReady (object/Resolve.h) and gate
ByGuid on it, returning nullptr out of world. That is both the
contract ByGuid's comment already promised ("pre-world ... safe from
any context") and provably behavior-preserving: a zero result is the
engine's own handled "not found" case (verified in Ghidra - 0x468460
null-checks the node; 0x464870 itself returns 0 for guid==0), and out
of world the call had no non-crashing outcome. Every ByGuid call site
already null-checks. TokenObserver now delegates to the shared helper.

Fixes #34
2026-08-24 08:43:03 -05:00
Brues c3c79356be fix(tooltip): clear per-tooltip OnTooltipSet* cells on reload
Tooltip::SetEvents kept its handler cells in a fixed 32-entry table keyed
by tooltip pointer, assuming tooltips are permanent. They are not: every
/reload (and logout) destroys and recreates the tooltip objects, so the
table gained ~2 dead cells per reload with no eviction. After ~18 reloads
it filled, the resolver co-hook returned 0 for new tooltips, and
SetScript/HookScript failed with "doesn't have a 'OnTooltipSetItem'
script". Worse, a stale cell whose address the allocator recycled into a
new tooltip fired its old handler ref into the reset Lua registry - a
dead-epoch ref, garbage call, crash.

Add Tooltip::SetEvents::PrepareForReload() - a full cell-table clear -
and call it from FrameScript_Initialize_h alongside the other per-reload
resets. The clear is semantically right, not just leak plumbing: handler
refs never survive the Lua reset, so post-reload every cell held garbage
regardless; addons re-register in OnLoad and get fresh cells.

Fixes #33
2026-08-24 08:17:12 -05:00
PeapandBrues 6ae3299041 Add Secure Action Button Templates and Comma-Split Support (#32)
* feat: port SecureActionButtonTemplate + CreateFrame comma-split

- Hook Script_CreateFrame (0x007060B0) to split comma-separated template
  strings. Vanilla only accepts a single template name; modern addons pass
  lists like 'UIPanelButtonTemplate, SecureActionButtonTemplate'.

- Add SecureTemplates.xml registering SecureFrameTemplate,
  SecureActionButtonTemplate, SecureUnitButtonTemplate, and
  InsecureActionButtonTemplate as real virtual XML templates.

- Add SecureTemplates.lua with modifier/button attribute resolution helpers
  (SecureButton_GetModifiedAttribute, GetModifierPrefix, GetButtonSuffix,
  GetModifiedUnit) and OnClick dispatchers.

- Remove duplicate SecureButton_GetAttribute/GetUnit from SecureStateDriver.lua
  (now provided by SecureTemplates.lua which loads earlier).

- Include SecureTemplateTest debug addon for verification.

* Include AuraUtil.lua in ClassicAPI.toc

mistake from merge

* fix(frame): apply every template in a CreateFrame comma list

Vanilla 1.12 CreateFrame resolves the 4th arg as a single template name.
The initial comma-split hook only applied one template, and its tokenizer
dropped everything after the first entry for the common "A, B" format
(writing the NUL terminator clobbered the delimiter the advance step
checked). Rework it into real multi-inheritance:

- SplitTemplates: record whether a comma was hit before writing the
  terminator, so "A, B" / "A,,B" / leading-comma inputs all split.
- CreateFrame_h: build the frame with the first resolved template through
  the engine, then graft each remaining template onto the created object
  with the engine's own inherit primitive - content (frameObj->vtable[+8],
  FUN_00769820) and child <Frames> ((frameObj+0x24)->vtable[+8],
  FUN_0076a060), the two __thiscall calls the builder FUN_006ee280 emits
  per inherited node. Regions, backdrop, attributes, script handlers, and
  child frames all apply, in left-to-right override order; OnLoad runs once.
- Offsets: add FUN_FRAMESCRIPT_STATUS_RESET (the XML-build error
  accumulator reset, __thiscall) and document the status object layout.

Also fix the relocated SecureButton_GetUnit/GetModifiedUnit pet-token
pattern: "[^%%d]"/"[%%d]" matched literal % and d instead of the digit
class %d, so raid1pet never normalized to raidpet1. Restore single %d.

Verified in-game: a comma list applies both templates' regions/text in
either order; pet-suffix normalization works.

* refactor(secure): tidy SecureTemplates review follow-ups

- Guard RunMacroText (absent on this client) so a type=macro button with
  only a macrotext attribute no-ops instead of erroring.
- Honor a SecureUnitButton_OnLoad menufunc: the menu/togglemenu action now
  calls self.menu when set, else falls back to ClassicAPI_ToggleUnitMenu
  (self.menu was previously stored but never read).
- InsecureActionButtonTemplate now inherits SecureActionButtonTemplate
  instead of duplicating its OnClick block.
- Drop the committed SecureTemplateTest debug addon; test harnesses live in
  the WoW install, not the repo.

* refactor(secure): make Frame::Attributes the single click dispatcher

The Lua SecureActionButton_OnClick from the secure-templates work
duplicated the native C++ dispatcher and only backfilled the verbs C++
lacked. Fold those into DispatchVerb so C++ is the sole dispatcher for
every secure button (template or plain SetAttribute), and drop the Lua
redundancy.

- DispatchVerb gains `action` (UseAction), `pet` (CastPetAction), `click`
  (forward to the frame in the `clickbutton` attribute via
  delegate:Click), and a custom fallback (a function stored as a raw
  field on the frame named by the verb). `menu`/`togglemenu` now cancels
  spell-targeting when a spell is on the cursor (the old
  SecureUnitButton_OnClick nuance).
- Numeric spell IDs work as the `spell` attribute: SetAttribute("spell",
  id) (a Lua number) or a numeric string cast the EXACT rank by ID via
  the spellbook-slot resolver (Spell::AtUnit::CastByID at a unit,
  Spell::AtCursor::DispatchSpellCast with none) - not name resolution,
  which fired the highest known rank.
- CopyAttr coerces a number-valued attribute to its digits, so numeric
  IDs also read for `item` and any other string attribute.
- Remove SecureActionButton_OnClick / SecureUnitButton_OnClick from
  SecureTemplates.lua, simplify SecureUnitButton_OnLoad, and strip the
  <OnClick> from the templates (plain Button templates now; the C++
  closure installs on SetAttribute("type*")). The attribute-reader
  helpers stay - public API and SecureStateDriver depend on them.

* fix(frame): restore the Lua stack after the multi-template graft

The comma-template graft can run a grafted template's child-frame OnLoad,
and in this hook-saturated environment a nested Lua op can shift the stack
slot the caller reads its return value from. Snapshot lua_gettop after the
engine build and restore it once the graft finishes, so the caller always
reads back the frame the engine left. Mirrors the Tooltip::SetEvents
save/restore discipline.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-23 18:15:27 -05:00
Brues f97379795d Localize UnitAura call for performance 2026-08-23 13:45:02 -05:00
Brues 3c6b449df5 feat(aura): zero-alloc positional accessors + AuraUtil
C_UnitAuras.GetAuraDataByIndex builds a fresh Lua table per call, churning
Lua 5.0's mark-sweep GC when addons scan many units' auras in an OnUpdate
loop. Add Blizzard's own answer: positional (multi-return) accessors plus the
AuraUtil helper library, so hot loops allocate nothing.

DLL: C_UnitAuras.UnitAura / UnitBuff / UnitDebuff return the 15-value
Classic-Era UnitAura tuple with no table allocation. Built on a shared
Emit { Table, Positional } seam through PushEnriched -- selection, cache
fallback, out-of-range group, and timing resolution are the same code as the
table path; only the terminal leaf differs (BuildTable vs the new
PushPositional, both fed by an extracted ResolveDisplay). Namespaced under
C_UnitAuras so the native UnitBuff/UnitDebuff globals keep their vanilla
short return.

Addon: Util/AuraUtil.lua backports ForEachAura / FindAura / FindAuraByName /
UnpackAuraData over those accessors. ForEachAura (non-packed) and FindAura
both source from the positional accessor, so they allocate nothing per aura --
FindAura included, where retail itself allocates a table per aura scanned.

Verified in-game: positional values match GetAuraDataByIndex field-by-field
(#12 isBossDebuff / #13 castByPlayer differ by design, matching retail's
UnitAura-vs-AuraData divergence); ForEachAura counts match GetUnitAuras; and
the positional path allocates ~0 KB/scan where the table path allocates
~1.3 KB per aura.

Fixes #30
2026-08-23 12:16:53 -05:00
Brues f000fd6ed7 build(release): emit matched PDB and ship crash symbols as a zip
Add /Zi (compiler) + /DEBUG (linker) so the Release build produces a
line-level ClassicAPI.pdb alongside the existing linker map. /OPT:REF and
/OPT:ICF stay explicit so /DEBUG does not re-enable dead code and the
shipped DLL does not grow; the PDB is a separate file matched to the DLL
by signature.

CI release runs now bundle the pdb + map into one crash-symbols zip
attached to the release, replacing the loose renamed map. The zip name is
kept unappealing to normal players and carries the tag so a dev can match
symbols to the exact build.
2026-08-23 10:40:03 -05:00
Brues 7960266969 feat(addon): add [known] macro conditional to SecureCmdOptionParse
ClassicAPI extension (not a 3.3.5 conditional). [known:spellID] uses
IsPlayerSpell (broad knowledge: spellbook, talents, recipes, racials);
[known:name] falls back to a spellbook name scan via GetSpellName, since
1.12 has no name-to-id resolver -- so a name matches castable spellbook
entries only.

Verified in-game: [known:133] false on a non-Mage, [known:45651] and
[known:Aspect of the Viper] both true by id and by name.
2026-08-23 02:42:48 -05:00
826 changed files with 121388 additions and 18492 deletions
+38 -8
View File
@@ -53,6 +53,9 @@ jobs:
build:
needs: pick-runner
runs-on: ${{ needs.pick-runner.outputs.label }}
outputs:
tag: ${{ steps.ver.outputs.tag }}
is_release: ${{ steps.ver.outputs.is_release }}
# Windows PowerShell 5.1 is always present; the runner's default `pwsh`
# (PowerShell Core) is not installed by default, and `shell: bash` on a
# self-hosted box resolves to the WSL launcher in System32.
@@ -68,7 +71,7 @@ jobs:
# is_release=true when a vX.Y.Z tag is in play (pushed tag,
# dispatched-from-tag, or a `tag` input); the embedded addon's
# `## Version:` and the compiled CLASSIC_API_VERSION are both
# stamped from it at embed/compile time (see cmake/embed_addon.cmake)
# stamped from it at embed/compile time (see cmake/embed_resources.cmake)
# — no source edit.
- name: Resolve version
id: ver
@@ -97,19 +100,46 @@ jobs:
- name: Build
run: cmake --build build --config Release
# Dev builds carry their own symbols. With the PDB sitting next to the DLL
# the client's crash handler resolves our frames to file and line — a
# tester's log reads `Texture::Mask::PrimIndexed_h+12 (Mask.cpp,246)`
# instead of a bare address — and the map resolves `0001:offset` addresses
# by hand when there is no dump. Both match THIS build and nothing else,
# which is why they travel with it rather than being rebuilt later from the
# same commit. Unlike the release bundle these need no off-putting name:
# the artifact is behind the Actions UI, so whoever downloads it is already
# the audience for symbols.
- name: Upload artifact
if: steps.ver.outputs.is_release != 'true'
uses: actions/upload-artifact@v4
with:
name: ClassicAPI-${{ github.sha }}
path: build/Release/ClassicAPI.dll
# Shared parent is build/Release, so all three land at the zip root.
path: |
build/Release/ClassicAPI.dll
build/Release/ClassicAPI.pdb
build/Release/ClassicAPI.map
# A symbol-less artifact looks identical to a good one until someone
# needs a stack out of it, so fail the build instead of warning.
if-no-files-found: error
# The linker map resolves the logical addresses in a user's crash log
# (the ERROR #132 stack trace) to function names. Ship it under a name
# that tells users what it's for, so crash reports come with it.
- name: Rename linker map for release
# Bundle both debug artifacts into one zip so a crash from a shipped DLL
# can be triaged exactly. The PDB gives WinDbg/x64dbg/VS a line-level
# symbolicated call stack from a crash dump; the map resolves the
# `0001:offset` addresses in WoW's ERROR #132 crash log by hand when no
# dump is available. Both match THIS exact DLL build.
# The filename is deliberately off-putting: it is NOT the addon and has no
# value to a normal player. The loud all-caps name keeps them from
# grabbing it by mistake; the tag suffix lets a dev match symbols to the
# exact build. (pdb/map keep their real names inside so the pdb signature
# still lines up with the DLL.)
- name: Package debug symbols for release
if: steps.ver.outputs.is_release == 'true'
run: Copy-Item build/Release/ClassicAPI.map build/Release/USE_FOR_DEBUGGING_CAPI_CRASHES.map
run: >
Compress-Archive
-Path build/Release/ClassicAPI.pdb, build/Release/ClassicAPI.map
-DestinationPath build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
-Force
- name: Release
if: steps.ver.outputs.is_release == 'true'
@@ -118,5 +148,5 @@ jobs:
tag_name: ${{ steps.ver.outputs.tag }}
files: |
build/Release/ClassicAPI.dll
build/Release/USE_FOR_DEBUGGING_CAPI_CRASHES.map
build/Release/Crash-Symbols-Use-For-Debugging-${{ steps.ver.outputs.tag }}.zip
generate_release_notes: true
+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
-50
View File
@@ -1,50 +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
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\UIParent.lua
Util\AuraDurationModifiers.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
Util\MacroOptions.lua
Util\SecureStateDriver.lua
-99
View File
@@ -1,99 +0,0 @@
if C_AddOns.DoesAddOnExist('pfUI') then
EventUtil.ContinueOnAddOnLoaded('pfUI', function()
-- pfUI\pfUI.lua redeclares the RAID_CLASS_COLORS object at least 14 times (insane!!)
-- This stops it from replacing all our ColorMixin values
pfUI.UpdateColors = function() end
RAID_CLASS_COLORS = setmetatable(RAID_CLASS_COLORS, { __index = function()
local unknownColor = CreateColor(0.6, 0.6, 0.6)
unknownColor.colorStr = unknownColor:GenerateHexColor()
return unknownColor
end})
-- Compatibility with OLDER forks of pfUI. Our DLL adds the modern
-- HookScript widget method; older pfUI forks branch on
-- `if button.HookScript` and take a modern code path they were never
-- written for on their pfActionBar buttons, which breaks them. While
-- the actionbar module builds those buttons, temporarily wrap
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The wrapper is removed as
-- soon as the actionbar module finishes loading so it affects nothing
-- else. A pfUI is trusted to handle HookScript itself if EITHER signal
-- is present: the maintained "brues" X-Website tag OR the
-- `pfUI.handlesHookScript` capability flag. Without the website the
-- flag is required; if neither is present we apply the shim.
local website = GetAddOnMetadata("pfUI", "X-Website")
local hasBruesWebsite = website and strfind(website, 'brues')
if not hasBruesWebsite and not pfUI.handlesHookScript then
local _createFrame = CreateFrame
local HookedCreateFrame = function(frameType, name, parent, template)
local frame = _createFrame(frameType, name, parent, template)
if frameType == "Button" and strfind(name or "", "pfActionBar") then
frame.HookScript = false
end
return frame
end
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
if moduleName == "updatenotify" then
CreateFrame = HookedCreateFrame
elseif moduleName == "actionbar" then
CreateFrame = _createFrame
end
end)
end
end)
end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
local libp = ShaguTweaks.libpredict
if libp then
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_START")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_STOP")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_FAILED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
libp.sender:UnregisterEvent("UNIT_SPELLCAST_SENT")
end
end)
end
-- Guard UPDATE_MOUSEOVER_UNIT handlers (issue #12).
function CAPI_MouseoverClearedCompat(frame)
if not frame then return end
local originalOnEvent = frame:GetScript("OnEvent")
frame:SetScript("OnEvent", function()
if event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") then
return
end
if originalOnEvent then
originalOnEvent()
end
end)
end
CAPI_MouseoverClearedCompat(GameTooltip)
if C_AddOns.DoesAddOnExist("Puppeteer") then
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
end)
end
-- Cartographer_Notes gates a WorldMap release-spirit hook behind an
-- `if lua51` probe, treating a 5.1-capable Lua as a modern client that has
-- the WorldMapDeathRelease frame. Our 5.1 syntax backport makes the probe
-- pass, so Cartographer takes the modern branch and hooks a frame 1.12's
-- WorldMap never had (WorldMapDeathRelease:SetScript in OnEnable) -> nil
-- index. Provide an inert, hidden stub so the hook succeeds and does nothing
-- -- the same outcome vanilla had, where the branch never ran. The embedded
-- !!!ClassicAPI addon loads first, so the frame exists before Cartographer's
-- OnEnable.
if C_AddOns.DoesAddOnExist("Cartographer") and not WorldMapDeathRelease then
CreateFrame("Button", "WorldMapDeathRelease", WorldMapButton or UIParent):Hide()
end
-98
View File
@@ -1,98 +0,0 @@
ColorMixin = ColorMixin or {}
function CreateColor(r, g, b, a)
local color = CreateFromMixins(ColorMixin)
color:OnLoad(r, g, b, a)
return color
end
function ColorMixin:OnLoad(r, g, b, a)
self:SetRGBA(r, g, b, a)
end
function ColorMixin:IsRGBEqualTo(otherColor)
return self.r == otherColor.r
and self.g == otherColor.g
and self.b == otherColor.b
end
function ColorMixin:IsEqualTo(otherColor)
return self:IsRGBEqualTo(otherColor) and self.a == otherColor.a
end
function ColorMixin:GetRGB()
return self.r, self.g, self.b
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local h, s, l = C_ColorUtil.ConvertHSVToHSL(C_ColorUtil.ConvertRGBToHSV(r, g, b))
return h, s, l, a or 1
end
function ColorMixin:GetRGBAsBytes()
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255)
end
function ColorMixin:GetRGBA()
return self.r, self.g, self.b, self.a
end
function ColorMixin:GetRGBAAsBytes()
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255), Round((self.a or 1) * 255)
end
function ColorMixin:SetRGBA(r, g, b, a)
self.r = r
self.g = g
self.b = b
self.a = a or 1
end
function ColorMixin:SetRGB(r, g, b)
self:SetRGBA(r, g, b, nil)
end
function ColorMixin:GenerateHexColor()
return C_ColorUtil.GenerateTextColorCode(self)
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2X%.2X%.2X", self:GetRGBAsBytes())
end
function ColorMixin:GenerateHexColorMarkup()
return "|c"..self:GenerateHexColor()
end
function ColorMixin:WrapTextInColorCode(text)
return C_ColorUtil.WrapTextInColor(text, self)
end
function WrapTextInColorCode(text, colorHexString)
return C_ColorUtil.WrapTextInColorCode(text, colorHexString)
end
function WrapTextInColor(text, color)
return C_ColorUtil.WrapTextInColor(text, color)
end
function ColorMixin:WrapTextInColorTableCode(text)
return WrapTextInColorCode(text, self:GenerateHexColor())
end
for _, v in pairs(ITEM_QUALITY_COLORS) do
v.color = CreateColor(v.r, v.g, v.b, 1)
end
function CAPI_ApplyStandardColorGlobals()
local envTbl = _G
for _, dbColor in ipairs(C_UIColor.GetColors()) do
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
end
end
end
CAPI_ApplyStandardColorGlobals()
-140
View File
@@ -1,140 +0,0 @@
-- Item location bitflags (Using tonumber because lua 5.0 doesn't support hexadecimal literals. 0x)
ITEM_INVENTORY_LOCATION_PLAYER = tonumber("0x00100000", 16);
ITEM_INVENTORY_LOCATION_BAGS = tonumber("0x00200000", 16);
ITEM_INVENTORY_LOCATION_BANK = tonumber("0x00400000", 16);
ITEM_INVENTORY_BAG_BIT_OFFSET = 8; -- Number of bits that the bag index in GetInventoryItemsForSlot gets shifted to the left.
ITEM_INVENTORY_BANK_BAG_OFFSET = 4;
-- Inventory slots
INVSLOT_AMMO = 0;
INVSLOT_HEAD = 1; INVSLOT_FIRST_EQUIPPED = INVSLOT_HEAD;
INVSLOT_NECK = 2;
INVSLOT_SHOULDER = 3;
INVSLOT_BODY = 4;
INVSLOT_CHEST = 5;
INVSLOT_WAIST = 6;
INVSLOT_LEGS = 7;
INVSLOT_FEET = 8;
INVSLOT_WRIST = 9;
INVSLOT_HAND = 10;
INVSLOT_FINGER1 = 11;
INVSLOT_FINGER2 = 12;
INVSLOT_TRINKET1 = 13;
INVSLOT_TRINKET2 = 14;
INVSLOT_BACK = 15;
INVSLOT_MAINHAND = 16;
INVSLOT_OFFHAND = 17;
INVSLOT_RANGED = 18;
INVSLOT_TABARD = 19; INVSLOT_LAST_EQUIPPED = INVSLOT_TABARD;
INVSLOTS_EQUIPABLE_IN_COMBAT = {
[INVSLOT_MAINHAND] = true,
[INVSLOT_OFFHAND] = true,
[INVSLOT_RANGED] = true,
}
-- Item IDs
HEARTHSTONE_ITEM_ID = 6948;
--
-- Equipment Set
--
MAX_EQUIPMENT_SETS_PER_PLAYER = 10;
EQUIPMENT_SET_EMPTY_SLOT = 0;
EQUIPMENT_SET_IGNORED_SLOT = 1;
EQUIPMENT_SET_ITEM_MISSING = -1;
--
-- Totems
--
MAX_TOTEMS = 4;
FIRE_TOTEM_SLOT = 1;
EARTH_TOTEM_SLOT = 2;
WATER_TOTEM_SLOT = 3;
AIR_TOTEM_SLOT = 4;
STANDARD_TOTEM_PRIORITIES = {1, 2, 3, 4};
SHAMAN_TOTEM_PRIORITIES = {
EARTH_TOTEM_SLOT,
FIRE_TOTEM_SLOT,
WATER_TOTEM_SLOT,
AIR_TOTEM_SLOT,
};
-- Druid Forms.
CAT_FORM = 1;
BEAR_FORM = 5;
--
-- Class
--
CLASS_SORT_ORDER = {
"WARRIOR",
"PALADIN",
"PRIEST",
"SHAMAN",
"DRUID",
"ROGUE",
"MAGE",
"WARLOCK",
"HUNTER",
};
MAX_CLASSES = table.getn(CLASS_SORT_ORDER);
LOCALIZED_CLASS_NAMES_MALE = {};
LOCALIZED_CLASS_NAMES_FEMALE = {};
FillLocalizedClassList(LOCALIZED_CLASS_NAMES_MALE, false);
FillLocalizedClassList(LOCALIZED_CLASS_NAMES_FEMALE, true);
--
-- Inventory
--
BAG_ITEM_QUALITY_COLORS =
{
[LE_ITEM_QUALITY_COMMON] = COMMON_GRAY_COLOR,
[LE_ITEM_QUALITY_UNCOMMON] = UNCOMMON_GREEN_COLOR,
[LE_ITEM_QUALITY_RARE] = RARE_BLUE_COLOR,
[LE_ITEM_QUALITY_EPIC] = EPIC_PURPLE_COLOR,
[LE_ITEM_QUALITY_LEGENDARY] = LEGENDARY_ORANGE_COLOR,
[LE_ITEM_QUALITY_ARTIFACT] = ARTIFACT_GOLD_COLOR,
};
-- faction
PLAYER_FACTION_GROUP = { [0] = "Horde", [1] = "Alliance", Horde = 0, Alliance = 1 };
FACTION_LABELS = {
[0] = FACTION_HORDE,
[1] = FACTION_ALLIANCE,
};
FACTION_LABELS_FROM_STRING = {
["Horde"] = FACTION_HORDE,
["Alliance"] = FACTION_ALLIANCE,
}
PLAYER_FACTION_COLORS =
{
[0] = PLAYER_FACTION_COLOR_HORDE,
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
};
--
-- Glyph
--
INSCRIPTION_AVAILABLE = false;
-- PaperDollFrame.lua
EQUIPPED_FIRST = 1;
EQUIPPED_LAST = 19;
BASE_MOVEMENT_SPEED = 7;
UPPER_LEFT_VERTEX = 1;
LOWER_LEFT_VERTEX = 2;
UPPER_RIGHT_VERTEX = 3;
LOWER_RIGHT_VERTEX = 4;
-64
View File
@@ -1,64 +0,0 @@
-- Backport of FrameXML/FrameUtil.lua (core subset) to vanilla 1.12.
--
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
-- native in 1.12.
-- - RegisterFrameForUnitEvents (no RegisterUnitEvent in 1.12), and the
-- scale/layout/secure/EventRegistry helpers (rely on APIs absent here).
FrameUtil = FrameUtil or {};
function FrameUtil.RegisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:RegisterEvent(event);
end
end
function FrameUtil.UnregisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:UnregisterEvent(event);
end
end
function FrameUtil.GetRootParent(frame)
local parent = frame:GetParent();
while parent do
local nextParent = parent:GetParent();
if not nextParent then
break;
end
parent = nextParent;
end
return parent;
end
-- Reparents while preserving the frame's render layering.
function FrameUtil.SetParentMaintainRenderLayering(frame, parent)
local origStrata = frame:GetFrameStrata();
local origFrameLevel = frame:GetFrameLevel();
frame:SetParent(parent);
frame:SetFrameStrata(origStrata);
frame:SetFrameLevel(origFrameLevel);
end
function DoesAncestryInclude(ancestry, frame)
if ancestry then
local currentFrame = frame;
while currentFrame do
if currentFrame == ancestry then
return true;
end
currentFrame = currentFrame:GetParent();
end
end
return false;
end
function DoesAncestryIncludeAny(ancestry, frames)
for _, frame in ipairs(frames) do
if DoesAncestryInclude(ancestry, frame) then
return true;
end
end
return false;
end
@@ -1,101 +0,0 @@
-- Backport of 3.3.5 FrameXML/GlobalCallbackRegistry.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - 3.3.5 ref-counts WoW frame events via a SetAttribute/OnAttributeChanged
-- dance because attribute mutations are taint-safe in the secure-template
-- model. 1.12 has neither secure templates nor SetAttribute on frames,
-- so we keep an ordinary {[event]=count} table and Register/Unregister
-- on 0<->1 transitions.
EventRegistry = CreateFromMixins(CallbackRegistryMixin)
function EventRegistry:OnLoad()
CallbackRegistryMixin.OnLoad(self)
self:SetUndefinedEventsAllowed(true)
self.eventCounts = {}
self.frameEventFrame = CreateFrame("Frame")
self.frameEventFrame.registry = self
self.frameEventFrame:SetScript("OnEvent", function()
-- 1.12 globals: `event` is the event name; `arg1..argN` are payload.
-- We don't repack them here; CallbackRegistryMixin:TriggerEvent uses
-- Lua-5.0 varargs, so we forward through a small wrapper.
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
end)
-- Stop delivering events once logout begins: a callback firing during the
-- engine's teardown can touch state that's already gone. A dedicated frame
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
-- — folding the check into frameEventFrame's OnEvent would depend on the
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
self.logoutFrame = CreateFrame("Frame")
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
self.logoutFrame:SetScript("OnEvent", function()
self.frameEventFrame:UnregisterAllEvents()
self.frameEventFrame:SetScript("OnEvent", nil)
end)
end
-- Helper so we can forward up to nine event args (the most any vanilla
-- event currently emits) into TriggerEvent's vararg machinery.
function EventRegistry:DispatchGameEvent(event, ...)
self:TriggerEvent(event, unpack(arg))
end
function EventRegistry:RegisterFrameEvent(frameEvent)
local n = (self.eventCounts[frameEvent] or 0) + 1
self.eventCounts[frameEvent] = n
if n == 1 then
self.frameEventFrame:RegisterEvent(frameEvent)
end
end
function EventRegistry:UnregisterFrameEvent(frameEvent)
local n = self.eventCounts[frameEvent] or 0
if n > 0 then
n = n - 1
self.eventCounts[frameEvent] = n
if n == 0 then
self.frameEventFrame:UnregisterEvent(frameEvent)
end
end
end
function EventRegistry:RegisterFrameEventAndCallback(frameEvent, ...)
self:RegisterFrameEvent(frameEvent)
return self:RegisterCallback(frameEvent, unpack(arg))
end
local function CreateCallbackHandle(cbr, cbrHandle, frameEvent)
local handle = {
Unregister = function()
cbr:UnregisterFrameEvent(frameEvent)
cbrHandle:Unregister()
end,
}
return handle
end
function EventRegistry:RegisterFrameEventAndCallbackWithHandle(frameEvent, ...)
self:RegisterFrameEvent(frameEvent)
local cbrHandle = self:RegisterCallbackWithHandle(frameEvent, unpack(arg))
return CreateCallbackHandle(self, cbrHandle, frameEvent)
end
function EventRegistry:UnregisterFrameEventAndCallback(frameEvent, ...)
self:UnregisterFrameEvent(frameEvent)
self:UnregisterCallback(frameEvent, unpack(arg))
end
function EventRegistry:GetEventCounts(...)
local counts = {}
for i = 1, arg.n do
local frameEvent = arg[i]
local count = self.eventCounts[frameEvent] or "?"
table.insert(counts, frameEvent .. " : " .. tostring(count))
end
return table.concat(counts, "\n")
end
EventRegistry:OnLoad()
@@ -1,248 +0,0 @@
-- SecureStateDriver: automatic frame state / visibility from macro conditionals.
--
-- Backports FrameXML's SecureStateDriverManager. A registered driver re-runs
-- SecureCmdOptionParse (Util/MacroOptions.lua) on a 0.2s poll (plus an instant
-- rescan on the events below) and applies the result:
-- * RegisterStateDriver(frame, "visibility", "[combat] hide; show") -> the
-- value "show"/"hide" drives frame:Show()/Hide();
-- * RegisterStateDriver / RegisterAttributeDriver for any other state ->
-- frame:SetAttribute("state-"..state, value), so an OnAttributeChanged
-- handler reacts to it;
-- * RegisterUnitWatch(frame [, asState]) -> shows/hides (or sets
-- "state-unitexists") as the frame's `unit` attribute comes and goes.
--
-- Base: 3.3.5 FrameXML/SecureStateDriver.lua, with the anniversary refinements
-- grafted in (the resolveDriver() consolidation, tonumber() coercion of driver
-- values, the literal string 'nil' -> nil, and the "statehidden" attribute).
--
-- Three deliberate deviations from upstream, all because 1.12 has no taint and
-- our attribute system fires OnAttributeChanged SYNCHRONOUSLY:
-- 1. No attribute-bootstrap manager. Upstream drives itself through
-- SecureStateDriverManager:SetAttribute("setframe"/"setstate"/...) -- a
-- taint-boundary trick. Our SetAttribute has a recursion guard (a nested
-- SetAttribute stores the value but does NOT fire the handler, see
-- src/frame/Attributes.cpp), so doing the registration-time resolve from
-- inside that handler would silently swallow the target frame's own
-- OnAttributeChanged. Instead the public Register* functions write plain
-- local tables and resolve immediately at top level. Public signatures are
-- unchanged from retail.
-- 2. GetTime()-deadline throttle instead of the OnUpdate `elapsed` arg (1.12
-- handlers take no parameters).
-- 3. UpdateUnitWatch guards its "statehidden"/"state-unitexists" writes on a
-- real change, so a watched frame's handler is not fired at 5 Hz.
-- ---------------------------------------------------------------------------
-- SecureButton_* (minimal -- no taint, no modifier/button suffix machinery)
-- ---------------------------------------------------------------------------
-- frame:GetAttribute(name), then walk up parents while the chain opts in via
-- "useparent-<name>" or "useparent*".
function SecureButton_GetAttribute(frame, name)
local value = frame:GetAttribute(name);
while value == nil
and (frame:GetAttribute("useparent-" .. name) or frame:GetAttribute("useparent*")) do
frame = frame:GetParent();
if not frame then
break;
end
value = frame:GetAttribute(name);
end
return value;
end
-- The frame's `unit` attribute plus an optional `unitsuffix` (3.3.5 verbatim,
-- including the raid1pet -> raidpet1 normalization).
function SecureButton_GetUnit(frame)
local unit = SecureButton_GetAttribute(frame, "unit");
if unit then
local unitsuffix = SecureButton_GetAttribute(frame, "unitsuffix");
if unitsuffix then
unit = unit .. unitsuffix;
unit = string.gsub(unit, "^([^%d]+)([%d]+)[pP][eE][tT]", "%1pet%2");
end
return unit;
end
end
-- ---------------------------------------------------------------------------
-- Private state
-- ---------------------------------------------------------------------------
local secureAttributeDrivers = {}; -- [frame][attribute] = optionsString
local unitExistsWatchers = {}; -- [frame] = doState (boolean)
local unitExistsCache = setmetatable({}, {
__index = function(t, k)
local v = (UnitExists(k) and true) or false;
t[k] = v;
return v;
end,
});
local STATE_DRIVER_UPDATE_THROTTLE = 0.2;
local nextUpdate = 0;
local pairs = pairs;
function UnitWatchRegistered(frame)
return unitExistsWatchers[frame] ~= nil;
end
-- ---------------------------------------------------------------------------
-- Resolvers
-- ---------------------------------------------------------------------------
local function UpdateUnitWatch(frame, doState)
local unit = SecureButton_GetUnit(frame);
local exists = (unit and unitExistsCache[unit]) or false;
if doState then
if frame:GetAttribute("state-unitexists") ~= exists then
frame:SetAttribute("state-unitexists", exists);
end
else
if exists then
frame:Show();
else
frame:Hide();
end
local hidden = (not exists) or nil;
if frame:GetAttribute("statehidden") ~= hidden then
frame:SetAttribute("statehidden", hidden);
end
end
end
local function resolveDriver(frame, attribute, values)
local newValue = SecureCmdOptionParse(values);
if attribute == "state-visibility" then
if newValue == "show" then
frame:Show();
if frame:GetAttribute("statehidden") ~= nil then
frame:SetAttribute("statehidden", nil);
end
elseif newValue == "hide" then
frame:Hide();
if frame:GetAttribute("statehidden") ~= true then
frame:SetAttribute("statehidden", true);
end
end
elseif newValue then
if newValue == "nil" then
newValue = nil;
else
newValue = tonumber(newValue) or newValue;
end
if frame:GetAttribute(attribute) ~= newValue then
frame:SetAttribute(attribute, newValue);
end
end
end
-- ---------------------------------------------------------------------------
-- Manager frame
-- ---------------------------------------------------------------------------
SecureStateDriverManager = CreateFrame("Frame", "SecureStateDriverManager");
SecureStateDriverManager:Hide(); -- hidden -> no OnUpdate until a driver exists
SecureStateDriverManager:SetScript("OnUpdate", function()
local now = GetTime();
if now < nextUpdate then
return;
end
nextUpdate = now + STATE_DRIVER_UPDATE_THROTTLE;
for frame, drivers in pairs(secureAttributeDrivers) do
for attribute, values in pairs(drivers) do
resolveDriver(frame, attribute, values);
end
end
for k in pairs(unitExistsCache) do
unitExistsCache[k] = nil;
end
for frame, doState in pairs(unitExistsWatchers) do
UpdateUnitWatch(frame, doState);
end
end);
SecureStateDriverManager:SetScript("OnEvent", function()
nextUpdate = 0; -- force a rescan on the next frame
end);
SecureStateDriverManager:SetScript("OnAttributeChanged", function()
if arg1 == "updatetime" and tonumber(arg2) then
STATE_DRIVER_UPDATE_THROTTLE = tonumber(arg2);
end
end);
-- Events that warrant an early rescan. Each is guarded so a name this client
-- doesn't define (e.g. UPDATE_STEALTH) is simply skipped.
local rescanEvents = {
"MODIFIER_STATE_CHANGED", -- ClassicAPI-fired
"PLAYER_FOCUS_CHANGED", -- ClassicAPI-fired
"UPDATE_SHAPESHIFT_FORM", -- ClassicAPI-fired (singular)
"UPDATE_SHAPESHIFT_FORMS", -- native 1.12 (plural)
"ACTIONBAR_PAGE_CHANGED",
"UPDATE_BONUS_ACTIONBAR",
"PLAYER_ENTERING_WORLD",
"UPDATE_STEALTH",
"PLAYER_TARGET_CHANGED",
"PLAYER_REGEN_DISABLED",
"PLAYER_REGEN_ENABLED",
"UNIT_PET",
"RAID_ROSTER_UPDATE",
"PARTY_MEMBERS_CHANGED",
};
for _, event in ipairs(rescanEvents) do
if C_EventUtils.IsEventValid(event) then
SecureStateDriverManager:RegisterEvent(event);
end
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
function RegisterAttributeDriver(frame, attribute, values)
if attribute and values and string.sub(attribute, 1, 1) ~= "_" then
local drivers = secureAttributeDrivers[frame];
if not drivers then
drivers = {};
secureAttributeDrivers[frame] = drivers;
end
drivers[attribute] = values;
SecureStateDriverManager:Show();
resolveDriver(frame, attribute, values);
end
end
function UnregisterAttributeDriver(frame, attribute)
if attribute then
local drivers = secureAttributeDrivers[frame];
if drivers then
drivers[attribute] = nil;
end
else
secureAttributeDrivers[frame] = nil;
end
end
function RegisterStateDriver(frame, state, values)
RegisterAttributeDriver(frame, "state-" .. state, values);
end
function UnregisterStateDriver(frame, state)
UnregisterAttributeDriver(frame, state and ("state-" .. state));
end
function RegisterUnitWatch(frame, asState)
local doState = (asState and true) or false;
unitExistsWatchers[frame] = doState;
SecureStateDriverManager:Show();
UpdateUnitWatch(frame, doState);
end
function UnregisterUnitWatch(frame)
unitExistsWatchers[frame] = nil;
end
@@ -1,25 +0,0 @@
function RegisterNewSlashCommand(callback, command, commandAlias)
local name = string.upper(command);
_G["SLASH_"..name.."1"] = "/"..command;
_G["SLASH_"..name.."2"] = "/"..commandAlias;
SlashCmdList[name] = callback;
end
SlashCmdList["FOCUS"] = function(msg)
if msg == "" then
FocusUnit();
else
FocusUnit(msg);
end
end
SlashCmdList["CLEARFOCUS"] = function(msg)
ClearFocus()
end
SlashCmdList["EQUIP_SET"] = function(msg)
if msg ~= "" then
C_EquipmentSet.UseEquipmentSet(C_EquipmentSet.GetEquipmentSetID(msg))
end
end
-3
View File
@@ -1,3 +0,0 @@
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
end
-87
View File
@@ -1,87 +0,0 @@
if GetLocale() ~= "deDE" then return end
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?";
CONFIRM_SAVE_EQUIPMENT_SET = "Möchtet Ihr das Ausrüstungsset \"%s\" speichern?";
DEBUFF_SYMBOL_CURSE = "Fl";
DEBUFF_SYMBOL_DISEASE = "Kr";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Gi";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Ausrüstungsmanager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Diesen Platz ignorieren";
EQUIPMENT_MANAGER_IS_DISABLED = "Der Ausrüstungsmanager ist abgeschaltet.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d fehlen)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "In Beuteln platzieren";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Diesen Platz einbeziehen";
EQUIPMENT_SETS = "Ausrüstungs-Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ein Ausrüstungsset mit diesem Namen gibt es bereits.";
EQUIPMENT_SETS_TOO_MANY = "Ihr könnt keine weiteren Ausrüstungssets erstellen.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Spezialisierung zuweisen:";
EQUIPMENT_SET_EDIT = "Name/Icon ändern";
EQUIPMENT_SET_SETTINGS = "Einstellungen";
EQUIPSET_EQUIP = "Anlegen";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Ausrüstungstausch fehlgeschlagen. Inventar ist voll.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Einige Gegenstände des Sets '%s' können nicht gewechselt werden, da Ihr Euch im Kampf befindet.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Mindestens ein Teil des Sets '%s' fehlt und kann nicht ausgerüstet werden.";
CLEAR_FOCUS = "Fokus löschen";
SET_FOCUS = "Fokus setzen";
FOCUSTARGET = "Ziel als Fokus festlegen";
GEARSETS_POPUP_TEXT = "Setnamen eingeben (Max. 16 Zeichen):";
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
ITEM_MISSING = "%s fehlt";
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "K";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "G";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Alle Symbole";
ICON_FILTER_ITEM = "Gegenstände";
ICON_FILTER_SPELL = "Zauber";
ICON_SELECTION_TITLE_CURRENT = "Aktuell ausgewählt";
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
ITEM_MOD_AGILITY_SHORT = "Beweglichkeit";
ITEM_MOD_STRENGTH_SHORT = "Stärke";
ITEM_MOD_INTELLECT_SHORT = "Intelligenz";
ITEM_MOD_SPIRIT_SHORT = "Willenskraft";
ITEM_MOD_STAMINA_SHORT = "Ausdauer";
ITEM_MOD_ATTACK_POWER_SHORT = "Angriffskraft";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Distanzangriffskraft";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Kritischer Trefferwert (Nah)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Kritischer Trefferwert (Distanz)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Trefferwert (Nah)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Trefferwert (Distanz)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Trefferwert (Zauber)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Kritischer Trefferwert (Zauber)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonusschaden";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonusheilung";
ITEM_MOD_MANA_REGENERATION_SHORT = "Manaregeneration";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Verteidigung";
ITEM_MOD_DODGE_RATING_SHORT = "Ausweichen";
ITEM_MOD_PARRY_RATING_SHORT = "Parieren";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocken";
ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Ausrüstungsmanager";
PAPERDOLL_NEWEQUIPMENTSET = "Neues Set";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Notizen für %s eintragen:";
SET_NOTE = "Notiz eintragen";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/fokus";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
-89
View File
@@ -1,89 +0,0 @@
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
COPPER_AMOUNT_SYMBOL = "c";
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
DEBUFF_SYMBOL_CURSE = "Cu";
DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Frame Stack";
EQUIPMENT_MANAGER = "Equipment Manager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignore This Slot";
EQUIPMENT_MANAGER_IS_DISABLED = "The Equipment Manager is disabled.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d missing)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Place In Bags";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Include This Slot";
EQUIPMENT_SETS = "Equipment Sets: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "An equipment set with that name already exists.";
EQUIPMENT_SETS_TOO_MANY = "You cannot create any more new equipment sets.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assign to Specialization:";
EQUIPMENT_SET_EDIT = "Change Name/Icon";
EQUIPMENT_SET_SETTINGS = "Settings";
EQUIPSET_EQUIP = "Equip";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Equipment swap failed. Inventory is full.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped because you are in combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
CLEAR_FOCUS = "Clear Focus";
SET_FOCUS = "Set Focus";
FOCUSTARGET = "Focus Target";
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "All Icons";
ICON_FILTER_ITEM = "Items";
ICON_FILTER_SPELL = "Spells";
ICON_SELECTION_TITLE_CURRENT = "Currently Selected";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Health";
ITEM_MOD_AGILITY_SHORT = "Agility";
ITEM_MOD_STRENGTH_SHORT = "Strength";
ITEM_MOD_INTELLECT_SHORT = "Intellect";
ITEM_MOD_SPIRIT_SHORT = "Spirit";
ITEM_MOD_STAMINA_SHORT = "Stamina";
ITEM_MOD_ATTACK_POWER_SHORT = "Attack Power";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Ranged Attack Power";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Critical Strike (Melee)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Critical Strike (Ranged)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Hit (Melee)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Hit (Ranged)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Hit (Spell)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Critical Strike (Spell)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Spell Damage";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Spell Healing";
ITEM_MOD_MANA_REGENERATION_SHORT = "Mana Regeneration";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defense";
ITEM_MOD_DODGE_RATING_SHORT = "Dodge";
ITEM_MOD_PARRY_RATING_SHORT = "Parry";
ITEM_MOD_BLOCK_RATING_SHORT = "Block";
ITEM_MOD_BLOCK_VALUE_SHORT = "Block Value";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Damage Per Second";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
ITEM_MISSING = "%s missing";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
SET_NOTE = "Set Note";
SILVER_AMOUNT_SYMBOL = "s";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
-87
View File
@@ -1,87 +0,0 @@
if GetLocale() ~= "esES" then return end
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "¿Quieres guardar el conjunto de equipamiento \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Mal";
DEBUFF_SYMBOL_DISEASE = "En";
DEBUFF_SYMBOL_MAGIC = "Mag";
DEBUFF_SYMBOL_POISON = "Ve";
DEBUG_FRAMESTACK = "Trama de pila";
EQUIPMENT_MANAGER = "Gestor de equipamiento";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorar esta casilla";
EQUIPMENT_MANAGER_IS_DISABLED = "El Gestor de equipamiento está desactivado.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(falta %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Colocar en bolsas";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Incluir esta casilla";
EQUIPMENT_SETS = "Conjuntos de equipamiento: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Ya existe un conjunto de equipamiento con ese nombre.";
EQUIPMENT_SETS_TOO_MANY = "Ya no puedes crear más equipamientos.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Asignar a especialización:";
EQUIPMENT_SET_EDIT = "Cambiar nombre/icono";
EQUIPMENT_SET_SETTINGS = "Config.";
EQUIPSET_EQUIP = "Equipar";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "No se ha podido cambiar el equipamiento. Inventario lleno.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Algunos objetos del equipo %s no se cambiarán porque estás en combate.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Falta uno o más objetos del equipo %s y no se ha podido equipar.";
CLEAR_FOCUS = "Quitar foco";
SET_FOCUS = "Establecer foco";
FOCUSTARGET = "Establecer objetivo";
GEARSETS_POPUP_TEXT = "Nombra este equipamiento (Máx. 16 carac.):";
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
ITEM_MISSING = "Falta: %s";
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
TARGETFOCUS = "Fijar unidad objetivo";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Todos los iconos";
ICON_FILTER_ITEM = "Objetos";
ICON_FILTER_SPELL = "Hechizos";
ICON_SELECTION_TITLE_CURRENT = "Selección actual";
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
ITEM_MOD_MANA_SHORT = "Maná";
ITEM_MOD_HEALTH_SHORT = "Salud";
ITEM_MOD_AGILITY_SHORT = "agilidad";
ITEM_MOD_STRENGTH_SHORT = "fuerza";
ITEM_MOD_INTELLECT_SHORT = "intelecto";
ITEM_MOD_SPIRIT_SHORT = "espíritu";
ITEM_MOD_STAMINA_SHORT = "aguante";
ITEM_MOD_ATTACK_POWER_SHORT = "Poder de ataque";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Poder de ataque a distancia";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Golpe crítico (cuerpo a cuerpo)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Golpe crítico (a distancia)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Golpe (cuerpo a cuerpo)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Golpe (a distancia)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Golpe (hechizo)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Golpe crítico (hechizo)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bon. daño";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bon. sanac.";
ITEM_MOD_MANA_REGENERATION_SHORT = "Regeneración de maná";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defensa";
ITEM_MOD_DODGE_RATING_SHORT = "Esquiva";
ITEM_MOD_PARRY_RATING_SHORT = "Parada";
ITEM_MOD_BLOCK_RATING_SHORT = "Bloqueo";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestor de equipamiento";
PAPERDOLL_NEWEQUIPMENTSET = "Nuevo conjunto";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Establecer nota para %s:";
SET_NOTE = "Establecer nota";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/conjequip";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/enfoque";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
-87
View File
@@ -1,87 +0,0 @@
if GetLocale() ~= "frFR" then return end
BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?";
CONFIRM_SAVE_EQUIPMENT_SET = "Voulez-vous sauvegarder l'ensemble d'équipement %s ?";
DEBUFF_SYMBOL_CURSE = "Mn";
DEBUFF_SYMBOL_DISEASE = "Md";
DEBUFF_SYMBOL_MAGIC = "Mg";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Empilement des cadres";
EQUIPMENT_MANAGER = "Gestionnaire d'équipement";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorer cet emplacement";
EQUIPMENT_MANAGER_IS_DISABLED = "Le Gestionnaire d'équipement est désactivé.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(objet manquant : %d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Mettre en sac";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Inclure cet emplacement";
EQUIPMENT_SETS = "Ensembles d'équipement : |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Un ensemble d'équipement portant ce nom existe déjà.";
EQUIPMENT_SETS_TOO_MANY = "Vous ne pouvez plus créer de nouveaux ensembles d'équipement.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assigner à la spécialisation :";
EQUIPMENT_SET_EDIT = "Modifier le nom/l'icône";
EQUIPMENT_SET_SETTINGS = "Réglages";
EQUIPSET_EQUIP = "Equiper";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Échec de l'échange d'équipement. L'inventaire est plein.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Certains objets de l'ensemble %s ne peuvent pas être échangés car vous êtes en combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Une ou plusieurs pièces de l'ensemble %s manquaient et n'ont pu être équipées.";
CLEAR_FOCUS = "Effacer la focalisation";
SET_FOCUS = "Définir la focalisation";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
ITEM_MISSING = "Objet manquant : %s";
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GEARSETS_POPUP_TEXT = "Nom de l'ensemble (16 caractères max.) :";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Toutes les icônes";
ICON_FILTER_ITEM = "Objets";
ICON_FILTER_SPELL = "Sorts";
ICON_SELECTION_TITLE_CURRENT = "Sélection actuelle";
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Vie";
ITEM_MOD_AGILITY_SHORT = "Agilité";
ITEM_MOD_STRENGTH_SHORT = "Force";
ITEM_MOD_INTELLECT_SHORT = "Intelligence";
ITEM_MOD_SPIRIT_SHORT = "Esprit";
ITEM_MOD_STAMINA_SHORT = "Endurance";
ITEM_MOD_ATTACK_POWER_SHORT = "Puissance d'attaque";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Puissance d'attaque à distance";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Score de critique (mêlée)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Score de critique (distance)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Toucher (mêlée)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Toucher (distance)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Toucher (sorts)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Score de critique (sorts)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonus aux dégâts";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonus aux soins";
ITEM_MOD_MANA_REGENERATION_SHORT = "Régénération du mana";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Défense";
ITEM_MOD_DODGE_RATING_SHORT = "Esquive";
ITEM_MOD_PARRY_RATING_SHORT = "Parade";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocage";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestionnaire d'équipement";
PAPERDOLL_NEWEQUIPMENTSET = "Nouvel ensemble";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Écrire une note pour %s :";
SET_NOTE = "Écrire une note";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/annulerfocal";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/équipens";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focal";
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
-87
View File
@@ -1,87 +0,0 @@
if GetLocale() ~= "ruRU" then return end
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?";
CONFIRM_SAVE_EQUIPMENT_SET = "Хотите сохранить комплект экипировки \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Пр";
DEBUFF_SYMBOL_DISEASE = "Бз";
DEBUFF_SYMBOL_MAGIC = "Мг";
DEBUFF_SYMBOL_POISON = "Яд";
DEBUG_FRAMESTACK = "Соединение рамок";
EQUIPMENT_MANAGER = "Комплекты экипировки";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Пропустить ячейку";
EQUIPMENT_MANAGER_IS_DISABLED = "Управление экипировкой отключено.";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(предмет для ячейки \"%d\" отсутствует)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Поместить в сумку";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Учитывать ячейку";
EQUIPMENT_SETS = "Комплекты экипировки: |cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "Это название уже используется для другого комплекта.";
EQUIPMENT_SETS_TOO_MANY = "Вы не можете создавать новые комплекты экипировки.";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Выбрать специализацию:";
EQUIPMENT_SET_EDIT = "Изменить название/значок";
EQUIPMENT_SET_SETTINGS = "Параметры";
EQUIPSET_EQUIP = "Надеть";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Не удалось заменить комплект обмундирования. Инвентарь переполнен.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Некоторые предметы из набора \"%s\" не будут экипированы, поскольку вы участвуете в бою.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Недостает одного или более предметов из набора \"%s\".";
CLEAR_FOCUS = "Отменить запоминание цели";
SET_FOCUS = "Запомнить цель";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов; в сумках";
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
GEARSETS_POPUP_TEXT = "Введите название комплекта (не более 16 символов):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "з";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "Все значки";
ICON_FILTER_ITEM = "Предметы";
ICON_FILTER_SPELL = "Заклинания";
ICON_SELECTION_TITLE_CURRENT = "Текущий выбор";
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
ITEM_MOD_MANA_SHORT = "к мане";
ITEM_MOD_HEALTH_SHORT = "к здоровью";
ITEM_MOD_AGILITY_SHORT = "к ловкости";
ITEM_MOD_STRENGTH_SHORT = "к силе";
ITEM_MOD_INTELLECT_SHORT = "к интеллекту";
ITEM_MOD_SPIRIT_SHORT = "к духу";
ITEM_MOD_STAMINA_SHORT = "к выносливости";
ITEM_MOD_ATTACK_POWER_SHORT = "к силе атаки";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "к силе атаки дальнего боя";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "к показателю критического удара в ближнем бою";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "к показателю критического удара в дальнем бою";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "к меткости в ближнем бою";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "к меткости в дальнем бою";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "к меткости заклинаний";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "к критическому эффекту заклинаний";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "к дополнительному урону";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "к дополнительному исцелению";
ITEM_MOD_MANA_REGENERATION_SHORT = "к восполнению маны";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Защита";
ITEM_MOD_DODGE_RATING_SHORT = "Уклонение";
ITEM_MOD_PARRY_RATING_SHORT = "Парирование";
ITEM_MOD_BLOCK_RATING_SHORT = "Блок";
ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Комплекты экипировки";
PAPERDOLL_NEWEQUIPMENTSET = "Новый комплект";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Заметка о |3-5(%s):";
SET_NOTE = "Добавить заметку";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
SLASH_EQUIP_SET1 = "/комплект";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/фокус";
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
-87
View File
@@ -1,87 +0,0 @@
if GetLocale() ~= "zhCN" then return end
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_TARGETFOCUS = "目标焦点";
CONFIRM_DELETE_EQUIPMENT_SET = "你确认要删除装备方案%s吗?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要保存装备方案\"%s\"吗?";
DEBUFF_SYMBOL_CURSE = "诅";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架栈";
EQUIPMENT_MANAGER = "装备管理";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此栏位";
EQUIPMENT_MANAGER_IS_DISABLED = "装备管理已禁用。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(缺少%d)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放入背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包括此栏位";
EQUIPMENT_SETS = "装备配置方案:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "一个重名的装备方案已经存在。";
EQUIPMENT_SETS_TOO_MANY = "你不能再创建新的装备方案了。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定专精:";
EQUIPMENT_SET_EDIT = "修改名称/图标";
EQUIPMENT_SET_SETTINGS = "设置";
EQUIPSET_EQUIP = "装备";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "装备更换失败。背包已满。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "装备方案%s中的某些物品不能在战斗中切换。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "未找到套装方案%s中的一件或多件物品,无法装备。";
CLEAR_FOCUS = "清除焦点";
SET_FOCUS = "设置焦点";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "已装备%d件物品";
ITEMS_IN_INVENTORY = "背包中有%d件物品";
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
ITEM_MISSING = "%s缺失";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
GEARSETS_POPUP_TEXT = "输入方案名称(最多16个字符):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有图标";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法术";
ICON_SELECTION_TITLE_CURRENT = "当前已选择";
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
ITEM_MOD_MANA_SHORT = "法力值";
ITEM_MOD_HEALTH_SHORT = "生命值";
ITEM_MOD_AGILITY_SHORT = "敏捷";
ITEM_MOD_STRENGTH_SHORT = "力量";
ITEM_MOD_INTELLECT_SHORT = "智力";
ITEM_MOD_SPIRIT_SHORT = "精神";
ITEM_MOD_STAMINA_SHORT = "耐力";
ITEM_MOD_ATTACK_POWER_SHORT = "攻击强度";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "远程攻击强度";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "爆击(近战)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "爆击(远程)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近战)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(远程)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法术)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "爆击(法术)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "伤害加成";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治疗加成";
ITEM_MOD_MANA_REGENERATION_SHORT = "法力回复";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防御";
ITEM_MOD_DODGE_RATING_SHORT = "躲闪";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格挡";
ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "装备管理";
PAPERDOLL_NEWEQUIPMENTSET = "新的方案";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "为%s设置备注:";
SET_NOTE = "设置备注";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
-87
View File
@@ -1,87 +0,0 @@
if GetLocale() ~= "zhTW" then return end
BINDING_NAME_FOCUSTARGET = "鎖定目標";
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
CONFIRM_DELETE_EQUIPMENT_SET = "你確定要刪除裝備設定:%s嗎?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要儲存裝備設定「%s」嗎?";
DEBUFF_SYMBOL_CURSE = "咒";
DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架堆疊";
EQUIPMENT_MANAGER = "裝備管理員";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此欄位";
EQUIPMENT_MANAGER_IS_DISABLED = "裝備管理員已關閉。";
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d件%s|cffff0000(缺少%d件)|r";
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放進背包";
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包含此欄位";
EQUIPMENT_SETS = "裝備設定:|cFFFFFFFF%s|r";
EQUIPMENT_SETS_CANT_RENAME = "您所選擇的名稱已經有裝備設定使用了。";
EQUIPMENT_SETS_TOO_MANY = "你無法製造更多的新裝備設定。";
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定專精:";
EQUIPMENT_SET_EDIT = "改變名稱/圖示";
EQUIPMENT_SET_SETTINGS = "設定";
EQUIPSET_EQUIP = "裝備";
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "裝備切換失敗。物品欄已滿。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "%s設定中的某些物品必須等到你脫離戰鬥後才能更換。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "%s設定中的一個或多個物品未尋獲,因此無法裝備。";
CLEAR_FOCUS = "清除專注目標";
SET_FOCUS = "設定專注目標";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
ITEMS_EQUIPPED = "裝備%d件物品";
ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
ITEM_MISSING = "缺少%s物品";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "銅";
GEARSETS_POPUP_TEXT = "輸入設定名稱(最多16個字元):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "所有圖示";
ICON_FILTER_ITEM = "物品";
ICON_FILTER_SPELL = "法術";
ICON_SELECTION_TITLE_CURRENT = "目前選擇";
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
ITEM_MOD_MANA_SHORT = "法力";
ITEM_MOD_HEALTH_SHORT = "生命力";
ITEM_MOD_AGILITY_SHORT = "敏捷";
ITEM_MOD_STRENGTH_SHORT = "力量";
ITEM_MOD_INTELLECT_SHORT = "智力";
ITEM_MOD_SPIRIT_SHORT = "精神";
ITEM_MOD_STAMINA_SHORT = "耐力";
ITEM_MOD_ATTACK_POWER_SHORT = "攻擊強度";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "遠程攻擊強度";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "致命一擊(近戰)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "致命一擊(遠程)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近戰)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(遠程)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法術)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "致命一擊(法術)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "傷害加成";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治療加成";
ITEM_MOD_MANA_REGENERATION_SHORT = "法力恢復";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防禦";
ITEM_MOD_DODGE_RATING_SHORT = "閃躲";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格擋";
ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "裝備管理員";
PAPERDOLL_NEWEQUIPMENTSET = "新設定";
PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "設定%s的註記:";
SET_NOTE = "設定註記";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/清除專注目標";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/裝備套裝";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/專注目標";
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
-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;
+69 -62
View File
@@ -2,6 +2,21 @@ cmake_minimum_required(VERSION 3.10)
project(ClassicAPI LANGUAGES CXX RC)
# WoW.exe is x86, so the injected DLL must be x86 too — there is no valid
# 64-bit configure of this project. Fail here rather than let one through:
# a 64-bit build's first visible symptom is a static_assert deep in a
# struct-layout check, which reads as an unrelated code fault. An IDE that
# configures on its own (VS Code's CMake Tools picks an architecture from a
# scanned kit) can flip an existing build directory this way, which is what
# CMakePresets.json's `win32` preset exists to prevent.
if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4)
message(FATAL_ERROR
"ClassicAPI builds 32-bit only. This configure uses "
"${CMAKE_SIZEOF_VOID_P}-byte pointers. Delete the build directory, "
"because CMake cannot switch platform in place. Then configure with "
"`cmake --preset win32` or `cmake -B build -A Win32`.")
endif()
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded")
@@ -30,7 +45,7 @@ set(TINYCBOR_SOURCES
# always pass locally. CI release builds pass `-DCLASSICAPI_TAG=vX.Y.Z`,
# which drives BOTH the compiled `CLASSIC_API_VERSION` int AND the
# embedded addon's `## Version:` toc line (substituted in
# embed_addon.cmake). The on-disk source toc stays `## Version: DEV`;
# embed_resources.cmake). The on-disk source toc stays `## Version: DEV`;
# only the embedded copy is stamped, so a release build needs nothing
# but the flag — no source edit.
set(CLASSICAPI_VERSION_VALUE 99999999)
@@ -58,55 +73,24 @@ elseif(CLASSICAPI_TAG)
)
endif()
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
# surface the addon to the engine even when the user doesn't have it
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
# returns these buffers when the engine asks for paths under
# `Interface\AddOns\!!!ClassicAPI\`.
set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
"${EMBEDDED_CLASSICAPI_SRC}/*")
# The `.classicapi-dev` marker is a disk-only developer signal — keep it
# out of the embed inputs (embed_addon.cmake filters it too).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "/\\.classicapi-dev$")
add_custom_command(
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
-DADDON_NAME=ClassicAPI
-DADDON_VERSION=${CLASSICAPI_TOC_VERSION}
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
DEPENDS
${EMBEDDED_CLASSICAPI_FILES}
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
COMMENT "Embedding !!!ClassicAPI addon into ${EMBEDDED_CLASSICAPI_HEADER}"
VERBATIM)
# Bundle `src/bindings/Bindings.xml` (our TARGETING-group additions)
# into a generated header. `src/bindings/Inject.cpp` extracts the
# inner `<Binding>` elements from the embedded buffer and splices them
# into the engine's `Interface\FrameXML\Bindings.xml` at load time.
set(EMBEDDED_BINDINGS_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml")
set(EMBEDDED_BINDINGS_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_bindings.h")
add_custom_command(
OUTPUT "${EMBEDDED_BINDINGS_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DINPUT_FILE=${EMBEDDED_BINDINGS_SRC}
-DOUT_HEADER=${EMBEDDED_BINDINGS_HEADER}
-DSYMBOL=kEmbeddedBindingsXml
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
DEPENDS
"${EMBEDDED_BINDINGS_SRC}"
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
COMMENT "Embedding bindings XML into ${EMBEDDED_BINDINGS_HEADER}"
VERBATIM)
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}
${EMBEDDED_CLASSICAPI_HEADER}
${EMBEDDED_BINDINGS_HEADER})
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
# 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\<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)
embed_resources(
TARGET ${PROJECT_NAME}
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")
target_compile_definitions(
${PROJECT_NAME}
@@ -114,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")
@@ -125,6 +108,12 @@ configure_file(
@ONLY)
target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# The .def exports `Load` (VanillaFixes' post-injection entry point)
# undecorated so its GetProcAddress(module, "Load") resolves. See the
# loader-lock rationale in src/DllMain.cpp. Listed as a source so CMake emits
# a quoted /DEF: on every generator and relinks when the file changes.
target_sources(${PROJECT_NAME} PRIVATE src/ClassicAPI.def)
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
@@ -137,20 +126,38 @@ 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")
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG")
# /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
# /MAP emits build/Release/ClassicAPI.map — a linker-only output (no effect
# on code layout) that maps `0001:offset` addresses from WoW's ERROR #132
# crash dumps back to functions. Published as a build/release artifact so
# any crash from a shipped DLL can be triaged exactly. (/OPT:ICF folds
# identical functions, so a few map symbols share an address — harmless.)
# /DEF exports `Load` (VanillaFixes' post-injection entry point) undecorated
# so its GetProcAddress(module, "Load") resolves. See src/ClassicAPI.def and
# the loader-lock rationale in src/DllMain.cpp.
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
# Two debug artifacts for triaging crashes from a SHIPPED DLL. Both are
# published as release artifacts, match this exact build, and have no effect
# on code layout:
# /MAP -> build/Release/ClassicAPI.map: a text symbol->address table
# that resolves the `0001:offset` addresses in WoW's ERROR #132
# crash log back to functions by hand (no debugger/dump needed).
# /DEBUG -> build/Release/ClassicAPI.pdb: full symbols (paired with the
# compiler's /Zi) so WinDbg/x64dbg/VS produce a line-level
# symbolicated call stack from a crash dump. A SEPARATE file —
# it is NOT embedded in the DLL, only a small signature record
# linking the two is.
# /DEBUG implicitly disables /OPT:REF and /OPT:ICF (dead-code strip +
# 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.)
# 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"
)
+25
View File
@@ -0,0 +1,25 @@
{
"version": 3,
"cmakeMinimumRequired": {
"major": 3,
"minor": 21,
"patch": 0
},
"configurePresets": [
{
"name": "win32",
"displayName": "Win32 (x86)",
"description": "The only supported configuration: WoW.exe is 32-bit, so the DLL must be 32-bit too. No generator is named, so CMake picks the newest installed Visual Studio, matching the documented command line.",
"binaryDir": "${sourceDir}/build",
"architecture": "Win32"
}
],
"buildPresets": [
{
"name": "release",
"displayName": "Release",
"configurePreset": "win32",
"configuration": "Release"
}
]
}
+227 -106
View File
@@ -15,25 +15,28 @@ 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 | 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 = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
| String methods | 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. Vanilla's Lua 5.0 errors with `attempt to index a string value`. Works on literals and variables, in-world and on the login screen, and inside coroutines. See [String methods](docs/API.md#string-methods-supper-sformat). |
| Script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, 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. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
| 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 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 | 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 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. |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. 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. |
| 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. |
| Event-driven nameplates | The modern `C_NamePlate` API, driven by real events instead of vanilla frame-scraping. `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. See [NamePlate](docs/API.md#nameplate). |
| Focus target | A sticky focus unit, like later clients. `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. See [Focus](docs/API.md#focus). |
| Multi-flavor & conditional TOC | 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. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
Smaller behavior fixes, such as frame alpha and `SetParent` frame levels, are
in **[docs/API.md](docs/API.md)** with the API that they change.
## Full API reference
@@ -44,65 +47,95 @@ reference in **[docs/API.md](docs/API.md)**.
<details>
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
In the Frame rows, a name that starts with `:` is a method of the widget that the row names, for example `:SetShown` is `frame:SetShown`.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [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`, `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) | `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` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [CombatLog](docs/API.md#combatlog) | `CombatLogGetCurrentEventInfo`, `COMBATLOG_OBJECT_*` (constants) |
| [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) | `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` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [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.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `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](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `SecureCmdOptionParse`, `RegisterStateDriver`, `UnregisterStateDriver`, `RegisterAttributeDriver`, `UnregisterAttributeDriver`, `RegisterUnitWatch`, `UnregisterUnitWatch`, `UnitWatchRegistered`, `SecureButton_GetAttribute`, `SecureButton_GetUnit` |
| [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` |
| [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`, `: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) | `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` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
| [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.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` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [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.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` |
| [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.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [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`, `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.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `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` |
| [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`, `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.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `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` |
| [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.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `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` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [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`, `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`, `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`, `FlashClientIcon`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [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` |
| [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` |
| [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`, `GetTickTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [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`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
| [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` |
@@ -114,9 +147,7 @@ reference in **[docs/API.md](docs/API.md)**.
Registered on the **glue** Lua state (the engine that runs the login,
realm-select, and character-select screens). The persistence entries
in the first row are *glue-only* — they exist to support GlueXML
patches that need a small persistence surface across sessions, since
vanilla 1.12 glue ships no general-purpose persistence API beyond
`GetSavedAccountName`/`SetSavedAccountName` (saturated by autologin).
patches that need a small persistence surface across sessions.
The rest of the table is in-game calls that we also mirror onto
the glue state because GlueXML had no way to reach them otherwise.
@@ -124,7 +155,7 @@ the glue state because GlueXML had no way to reach them otherwise.
|-------|-------|
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool`, `C_CVar.SetTempCVar`, `C_CVar.RemoveTempCVar` (storage is process-global — writes from glue are visible in-world and vice versa) |
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
@@ -132,17 +163,82 @@ the glue state because GlueXML had no way to reach them otherwise.
</details>
<details>
<summary><b>Macros</b> — engine-level parsing extensions</summary>
<summary><b>Macros</b> — forms a macro body accepts</summary>
Engine-level extensions to macro parsing and dispatch — no new Lua
functions, just behavior the stock 1.12 engine didn't have. See the
[Macros section in the Lua reference](docs/API.md#macros) for details.
The DLL parses these forms, so an action-bar addon needs no code for them.
See the [Macros section in the Lua reference](docs/API.md#macros) for
details.
| Form | What it does |
|------|--------------|
| `/cast <spellID>` | `/cast 5019` casts Shoot if known; macro slot tags correctly for action-bar UI |
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
| `#showtooltip [conditions] Value` | First line of a macro. Sets what the button shows. Bare `#showtooltip` follows the first `/cast` or `/use` line instead. `#show` is the same form. |
| `[conditions]` and `@unit` | Accepted by 42 commands. The slash-command list below names them. |
| `!Name` | Starts an ability but never turns it off. Use it for auto-repeat shots and for the self-buffs (stance, aspect, seal, form, tracking). |
| A line that starts with `#` | A comment. The line never reaches chat. |
| `/cast <spellID>` | For a spell you know, `/cast 5019` casts it by ID. The macro slot also tags correctly for an action-bar addon. |
| `CastSpellByName("<spellID>")` | A numeric string resolves through the engine name resolver, the same as `/cast`. |
| `CastSpellNoToggle("<name>")` in a macro | The engine macro parser reads this as a primary-spell line. The macro slot in an addon such as pfUI then highlights while the spell is on auto-repeat or its self-aura is active. |
</details>
<details>
<summary><b>Slash commands</b> — <code>[conditions]</code> on 42 commands</summary>
The bundled addon registers these commands. Each one takes the same
`[conditions]` and `@unit` syntax as `/cast`. Each client language has its
own command names next to the English ones. See
[More commands with `[conditions]`](docs/API.md#more-commands-with-conditions)
for what each command does, and
[`/castsequence`](docs/API.md#castsequence) and
[`/castrandom`](docs/API.md#castrandom-and-userandom) for the list forms.
| Group | Commands |
|-------|----------|
| Casting | `/cast`, `/use`, `/castsequence`, `/castrandom`, `/userandom`, `/stopcasting`, `/stopmacro`, `/cancelaura`, `/cancelform`, `/dismount` |
| Targeting | `/target`, `/targetexact`, `/cleartarget`, `/targetlasttarget`, `/targetlastenemy`, `/targetenemy`, `/targetfriend`, `/targetenemyplayer`, `/targetfriendplayer`, `/targetparty`, `/targetraid`, `/assist`, `/follow`, `/focus`, `/clearfocus`, `/startattack`, `/stopattack` |
| Equipment | `/equip`, `/equipslot`, `/equipset` |
| Action bars | `/changeactionbar`, `/swapactionbar`, `/click` |
| Pet | `/petattack`, `/petfollow`, `/petstay`, `/petpassive`, `/petdefensive`, `/petaggressive`, `/petautocaston`, `/petautocastoff`, `/petautocasttoggle` |
</details>
<details>
<summary><b>CVars</b> — new settings, set with <code>/console name value</code></summary>
Each setting is saved in `WTF\Config.wtf`. Read and write them with `GetCVar`
/ `SetCVar` or `/console`. Click a group name for the full rules. To change
the most common settings and the player cache in the game, open the game
menu and click AddOn Options, then ClassicAPI.
| Group | CVar | Default | What it does |
|-------|------|---------|--------------|
| [Bindings](docs/API.md#actionbuttonusekeydown) | `ActionButtonUseKeyDown` | `0` | `1` uses an action-bar key binding when you press the key. `0` uses it when you release the key. |
| [Combat](docs/API.md#stopautoattackontargetchange) | `stopAutoAttackOnTargetChange` | `0` | `1` stops melee auto-attack, Auto Shot and wand shots when you change target. |
| [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. |
| | `SoftTargetInteractRangeIsHard` | `0` | `1` always applies the range. `0` also picks an object past it that you can use now. |
| | `SoftTargetIconInteract` | `0` | `1` shows an icon over the soft-interact object. |
| | `SoftTargetIconGameObject` | `0` | `1` also shows that icon over game objects. |
| [Soft enemy / friend](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) | `SoftTargetEnemy` / `SoftTargetFriend` | `1` / `0` | Turns `softenemy` / `softfriend` on: `2` keyboard and mouse, `3` always, `0` / `1` off. |
| | `SoftTargetEnemyArc` / `SoftTargetFriendArc` | `0` | The same values as `SoftTargetInteractArc`. |
| | `SoftTargetEnemyRange` / `SoftTargetFriendRange` | `45` | Maximum distance in yards. |
| | `SoftTargetMatchLocked` | `1` | `1` uses your target as the soft unit when it qualifies. `0` always uses the nearest unit. |
| | `SoftTargetWithLocked` | `1` | `0` turns soft enemy and soft friend off while you have a locked target. |
| | `SoftTargetForce` | `1` | A loose target follows `softenemy` (`1`) or `softfriend` (`2`). `0` keeps it. |
| | `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`. |
</details>
@@ -158,6 +254,10 @@ when launching with `-console`), not as Lua functions. See the
| `ExportInterfaceFiles code` | Extracts Blizzard's UI source (`.lua`/`.xml`/`.toc`/`.xsd`) from the MPQs to `BlizzardInterfaceCode\` |
| `ExportInterfaceFiles art` | Extracts Blizzard's UI art (`.blp`/`.tga`) from the MPQs to `BlizzardInterfaceArt\` |
| `ExportDBCFiles` | Extracts the client's `.dbc` tables from the MPQs to `DBFilesClient\` |
| `ExportSoundFiles [subpath]` | Extracts the client's sound files from the MPQs to `BlizzardSound\`; the optional subpath narrows it (the full tree is ~1 GB) |
| `pitchLimit degrees` | Sets how far the camera can tilt up and down (default 89) |
| `AppendLogToFile [filename] [numLines]` | Writes the console output to a text file (default `ConsoleLogs\Log<Timestamp>.txt`) |
| Tab / Ctrl+Tab | Tab completes CVar names after `set`, `cvar_reset` and `cvar_default`; Ctrl+Tab lists every command that contains the typed text |
</details>
@@ -168,6 +268,11 @@ when launching with `-console`), not as Lua functions. See the
|-------|---------|
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `CHAT_MSG_*` | adds `lineID, guid` as `arg11, arg12` after the existing ten ([details](docs/API.md#chat_msg_-arguments-lineid-arg11-and-guid-arg12)) |
| `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` |
@@ -175,29 +280,43 @@ 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` |
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
| `LOOT_SCAN_COMPLETED` | *(none)* |
| `LOSS_OF_CONTROL_ADDED` | `eventIndex` |
| `LOSS_OF_CONTROL_UPDATE` | `unitToken` (always `"player"`) |
| `MAP_EXPLORATION_UPDATED` | *(none)* |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
| `PLAYER_AVG_ITEM_LEVEL_READY` | *(none)* |
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
| `PLAYER_FOCUS_CHANGED` | *(none)* |
| `PLAYER_SOFT_ENEMY_CHANGED` | *(none)* |
| `PLAYER_SOFT_FRIEND_CHANGED` | *(none)* |
| `PLAYER_SOFT_INTERACT_CHANGED` | `oldTarget, newTarget` |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
| `PLAYER_STARTED_MOVING` | *(none)* |
| `PLAYER_STOPPED_MOVING` | *(none)* |
| `PLAYER_STARTED_TURNING` | *(none)* |
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `PLAYER_SWING` | `swingDuration, swingType` |
| `PLAYER_SWING_RANGE_UPDATE` | `swingType, isInRange, checksRange` |
| `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` |
@@ -211,12 +330,17 @@ when launching with `-console`), not as Lua functions. See the
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_INTERRUPTIBLE` | `unit` |
| `UNIT_SPELLCAST_NOT_INTERRUPTIBLE` | `unit` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `USER_WAYPOINT_UPDATED` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
| `WEAPON_SLOT_CHANGED` | *(none)* |
| `WEAR_EQUIPMENT_SET` | `setID` |
</details>
@@ -225,7 +349,7 @@ when launching with `-console`), not as Lua functions. See the
| Group | Constants |
|-------|-----------|
| Version | `CLASSIC_API_VERSION` |
| Version | `CLASSIC_API_VERSION`, `INTERFACE_VERSION` |
| Expansion | `LE_EXPANSION_LEVEL_CURRENT`, `LE_EXPANSION_CLASSIC` … `LE_EXPANSION_MIDNIGHT` |
| Item quality | `LE_ITEM_QUALITY_POOR` … `LE_ITEM_QUALITY_WOWTOKEN` |
| Unit stat | `LE_UNIT_STAT_STRENGTH` … `LE_UNIT_STAT_SPIRIT` |
@@ -234,26 +358,31 @@ when launching with `-console`), not as Lua functions. See the
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
| Cursor type | `Enum.UICursorType.{Default,Item,Money,Spell,PetAction,Merchant,Macro,Pet,…}` (0–20; this client yields 0–5, 7 and 9) |
| 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>
<details>
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code></summary>
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code>, <code>softinteract</code>, <code>softenemy</code>, <code>softfriend</code>, <code>anyinteract</code>, <code>anyenemy</code>, <code>anyfriend</code></summary>
| Token | Resolves to |
|-------|-------------|
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
| `softinteract` / `anyinteract` / `anyleftinteract` | The object that the Interact Key uses. `softinteract` is the nearest object in front of you that you can interact with. `anyinteract` is your target when you can interact with it, else `softinteract`. Both can be a game object or a corpse, which only `UnitGUID` reads. See [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract). |
| `softenemy` / `softfriend` / `anyenemy` / `anyfriend` | The nearest enemy and the nearest friendly unit, or your target when it qualifies. `anyenemy` and `anyfriend` use your target first. See [Soft enemy and soft friend](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend). |
</details>
<details>
<summary><b>Bindings</b> — direct-action and override binding families</summary>
ClassicAPI backports the later-client direct-action and temporary override
ClassicAPI backports the direct-action and temporary override
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
session-only, frame-owned, and support the usual priority flag.
@@ -289,22 +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` etc.).
`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 —
@@ -321,45 +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
(originally shipped with the 3.0 client) 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 3.3.5 helpers that don't exist in
1.12):
| Global | Purpose |
|--------|---------|
| `print(...)` | Backport of the 3.3.5 `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
+438 -157
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.
@@ -951,88 +952,15 @@ See [src/unit/Tooltip.cpp](src/unit/Tooltip.cpp) and the
`FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_*` block in
[src/Offsets.h](src/Offsets.h).
## 46. `GameTooltip:SetFrameStack([showHidden])` — deferred (Lua-side fix in place)
## ~~46. `GameTooltip:SetFrameStack([showHidden])`~~ — DONE
Renders a tooltip listing the chain of frames at the mouse cursor,
sorted by strata + level. The 3.0+ debug-tooling primitive used by
Blizzard's `Blizzard_DebugTools` and every layout-debugging addon.
### Current state in this project
Our bundled `DebugTools/` backport ships its own `FrameStackTooltip`
in [DebugTools/DebugTools.lua](DebugTools/DebugTools.lua), driven
by `_CollectFramesUnderMouse`. As of the EnumerateFrames switch,
the iterator walks the engine's own frame list — fast (typically
~hundreds of entries) and includes anonymous frames the engine
sees but a `_G` walk would miss.
What's still missing relative to the real engine `SetFrameStack`:
- **Anonymous-frame display name.** We currently show
`<anonymous>` for frames where `frame:GetName()` returns nil.
The engine's method shows the frame's pointer + the Lua-side
registry-table address. To get there we'd need a Lua-callable
shim that exposes a numeric handle per frame (vftable + Lua-ref
lookup), which is non-trivial.
- **Single-tooltip semantics.** Modern addons can call
`GameTooltip:SetFrameStack(...)` directly on any tooltip;
ours requires the `FrameStackTooltip` we ship in `DebugTools/`.
Most consumers (`Blizzard_DebugTools` ports, layout addons via
`/framestack`) don't care, but a strict polyfill would.
### C++ port from 3.3.5 — kept as a research note
What `Script_GameTooltip_SetFrameStack`
actually does in 3.3.5 (Frostmourne client, registration entry at
VA 0x00AD2CF0 → function VA 0x00626560 → inner builder
`FUN_006230d0`):
- Pulls cursor (in frame space) via `FUN_0047BFF0` from a render-
state global at `[DAT_00B499A8 + 0x1224 / +0x1228]`.
- Walks an intrusive linked list at `[DAT_00B499A8 + 0xCD4]`. Each
frame node reads:
- `[+0x00]` — vftable; `vftable[+0x10]` = "is the frame visible
enough to hit-test" predicate.
- `[+0x0E0]` (`node[0x38]`) = strata; gates `(strata != 0 ||
showHidden)`.
- `[+0x0D0]..[+0x0DC]` (`node[0x34..0x37]`) = frame rect for
hit-test.
- `[+0x0D8]` (`node[0x36]`) flags, bit 2 → visible.
- `[+0x288]` (`node[0xA2]`) = next pointer.
- Per-frame name via `vftable[+0x04]` (a `GetDebugName()`-style
virtual). If the frame is Lua-side (`vftable[+0x10]` returns
type code 5), an extra `lua_rawgeti(L, REGISTRY, frame->luaRef)`
pulls the Lua-table reference for the `"%s: %p (%s)"` format;
otherwise it's `"%s: %p"`.
- Builds into a scratch sort array at `DAT_00C5D370` (entry stride
`0x410`), `qsort` against comparator `FUN_0061A5B0`.
- Header line via `FUN_00819D40("DEBUG_FRAMESTACK", ...)` (Lua
`GetGlobalString`) then per-entry `FUN_0061FEC0` (tooltip
`AddLine` equivalent, takes color codes from a 4-byte-stride
table at `0xAD2DC0` / `0xAD2DB8` / `0xAD2DB0` for
visible/hidden/special states).
For a 1.12 port we'd need to derive:
- The frame-manager global (3.3.5's `DAT_00B499A8` — different VA
in 1.12, different field offsets likely).
- Frame struct offsets for rect, strata, flags, next, name vftable
slot — the layout shifted between 1.12 and 3.3.5 (same way unit
field offsets did, per the `OFF_UNIT_FIELD_HEALTH` discovery in
CLAUDE.md).
- AddTooltipLine engine address (1.12 has it — every
`GameTooltip:SetX` method calls it internally).
- Lua-side reference lookup helper (whatever 1.12 uses to map
frame → Lua table; the `FrameScript_GetObject` chain at
`0x6F3740` is part of this).
### Recommendation
The Lua-side iterator switch is done. The C++ port stays deferred
unless we need the engine frame list for something else
(`GetMouseFocus` polyfill, anonymous-frame inspector with extra
fields beyond what Lua exposes, single-tooltip strict polyfill,
etc.) that would amortize the RE cost.
A mirror of 4.3.4's CGTooltip::SetFrameStack in
[src/tooltip/FrameStack.cpp](src/tooltip/FrameStack.cpp), plus the
`OnTooltipSetFrameStack` script in Tooltip::SetEvents. Every input exists in
1.12: the CSimpleTop frame list, the NDC cursor + NDCToDDC, CLayoutFrame
GetRect, the frame's visible / enabled-input / strata / level fields,
FrameStrataToString and the two default tooltip colors (all in Offsets.h).
The `/fstack` UI itself is addon Lua and can now call the method.
## ~~47. `IsPlayerSpell(spellID)`~~ — DONE
@@ -1120,51 +1048,50 @@ spell-API functions use. Returns `(1, nil)` / `(nil, 1)` /
`(nil, nil)` per legacy convention. `C_Spell.IsSpellUsable` is the
modern shape — same logic, returns proper booleans.
**Two engine-cache approaches investigated and discarded:**
**Reworked for issue #51 (2026-09-12) — it now IS the engine's
verdict.** `IsUsableAction` reads the per-slot cache
(`0x00BC6B60` usable / `0x00BC67A0` noMana, reader `FUN_004E5230`),
which the recompute `FUN_004E5050` fills per slot. For a player spell
slot that recompute is one call: `FUN_SPELL_IS_USABLE(record,
&noMana)` (`0x006E3D60`). For a pet spell slot it is `FUN_PET_ACTIONS_
USABLE()` then the pet's power vs `FUN_GET_SPELL_COST(spellID, 0)`.
`Spell::Usable` now runs exactly those two paths live (plus a
knowledge gate on `VAR_PLAYER_SPELL_BITMAP`, since the helper is also
fed item on-use spells and never checks knowledge). Verified check
list of the helper is on `FUN_SPELL_IS_USABLE` in `Offsets.h`.
**The first version hand-rolled five checks instead** (known, alive,
off cooldown, power, reagents) and diverged from `IsUsableAction`
exactly where the helper does more or less — the issue #51 repro
(Warrior, Whirlwind):
| State | `IsUsableAction` | old `IsUsableSpell` |
|---|---|---|
| Berserker, 0 rage, on CD | `nil, 1` | `nil, nil` (we tested cooldown first — the engine never does) |
| Battle Stance, 25 rage | `nil, nil` | `1, nil` (we never tested stance) |
Cooldown is deliberately NOT part of usability: FrameXML greys
(`IsUsableAction`) and swipes (`GetActionCooldown`) as two separate
states, and the engine helper has no cooldown branch. Rule #1 case:
`Item::Usable` had been calling the helper all along.
**Two engine-cache approaches investigated and discarded before the
first version** (still true, kept for the record):
1. **Per-spell cache at `0x004F0F40` returning fields at +0x564
/ +0x568** — initially looked promising (the action-bar
dispatcher at `0x004E5BA0` reads these), but the writers
(`0x004EFF17` cluster) showed they're parsed config integers,
not real-time usability bools. False trail.
not real-time usability bools. False trail. (Later identified as
the macro entry's cached primary spell — see the `#showtooltip`
notes.)
2. **Action-bar per-slot caches at `0x00BC67A0` (noMana) and
`0x00BC6B60` (usable)** — these ARE real-time, but only updated
for the 120 action-bar slots. Spells off the bar stay
uninitialized. Action-bar-only is the wrong abstraction since
modern `IsUsableSpell(spellID)` is action-bar-agnostic.
Settled on a **manual five-check approach**:
1. Player knows the spell (`VAR_PLAYER_SPELL_BITMAP` lookup —
covers profession recipes, talents, racials, etc.).
2. Player is alive (`HEALTH > 0`).
3. Spell is not on cooldown (`FUN_SPELL_QUERY_COOLDOWN` —
the engine helper `Script_GetSpellCooldown` calls internally
after slot resolution, queried with `bookType=0` for player).
4. Player has ≥ `ManaCost` of the spell's `PowerType` —
only this failure flips `noMana=true`.
5. Player has all required reagents in bags
(`Spell.dbc` Reagent[8] / ReagentCount[8] at `+0x110` /
`+0x130`, walked via `Item::Location::ResolveBag`).
**Not checked** (deliberate, different concerns): silence, GCD,
stance/form, range, target type, line-of-sight, casting state.
**Verified on Turtle WoW:**
- Mana check: Renew rank 3 (cost 105) reports usable at 144 mana
and unusable at 39 mana, transitioning exactly at the cost
boundary.
- Reagent check: SHIPPED BUT UNVERIFIED — the Reagent[8] /
ReagentCount[8] offsets at `+0x110` / `+0x130` are the
CMaNGOS-documented vanilla layout, and we know spell records
match CMaNGOS docs (PowerType=+0x7C and ManaCost=+0x80 both
match), so high confidence — but no in-game verification yet.
Should test with a reagent spell (e.g., Mage Teleport spell 3565
needs Rune of Teleportation 17031). Failure mode if offsets are
wrong: reagent check becomes a no-op (always passes).
- Cooldown check: SHIPPED BUT UNVERIFIED in this implementation —
the helper signature was traced via `Script_GetSpellCooldown` at
`0x004B40A0` but not exercised in-game. Should test with a
cooldown'd spell.
modern `IsUsableSpell(spellID)` is action-bar-agnostic. The fix
above sidesteps this by calling what FILLS the cache, not the
cache.
**Important offset correction along the way.** Initial implementation
read POWER1 at descriptor `+0x5C` based on the CMaNGOS-documented
@@ -1539,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
@@ -1741,7 +1660,14 @@ is a thin wrapper. If they're inlined, we'd either need to skip
the clear ourselves (call after some no-op state setup) or
manually call the line-emission primitives.
## ~~61. `GameTooltip:GetItem()` / `GetSpell()` / `GetUnit()`~~ — `GetItem` + `GetSpell` DONE; `GetUnit` skipped
## ~~61. `GameTooltip:GetItem()` / `GetSpell()` / `GetUnit()`~~ — DONE
`GetUnit` shipped later in [src/unit/Tooltip.cpp](src/unit/Tooltip.cpp):
`SetUnit` is re-registered in front of the engine's (no MinHook) to
stage the token, and the token binds in the unit-builder co-hook that
`Tooltip::SetEvents` already owned. The builder has three callers:
SetUnit, the world-mouseover setter (→ `"mouseover"`), and an in-place
refresh (keeps the token). The notes below are the original skip.
Modern query methods that return what the tooltip is currently
showing:
@@ -1847,10 +1773,17 @@ are supported.
### Unhookable globals
Modern WoW rejects `hooksecurefunc("<core lua/secure fn>", ...)` outright
to keep callers from clobbering language primitives or breaking taint
propagation. We mirror that with a name blacklist in `Script_HookSecureFunc`
that fires `lua_error` with `"hooksecurefunc: function is unhookable"`
Patch 11.0.0 (The War Within, 2024-07-23) made `hooksecurefunc` refuse a
fixed set of names with a "Cannot hook function" error, to keep callers
from clobbering language primitives or breaking taint propagation. No
earlier client has the restriction (3.3.5, 4.3.4 and 5.4.8 carry no such
string), so 11.0's published list is the reference — and it is NOT the
engine's base-library table (`0x00811E28`, 36 entries, catalogued in
`BlizzardScriptAPI.md` §3): 11.0 leaves `tostring`/`error`/`loadstring`
hookable and includes secure-family names that are not base functions. We
mirror the 11.0 list verbatim with a name blacklist in
`Script_HookSecureFunc` that fires `lua_error` with
`"hooksecurefunc: function is unhookable"`
when the two-arg form targets one of: `getfenv`, `getmetatable`,
`hooksecurefunc`, `ipairs`, `issecurevalue`, `issecurevariable`, `next`,
`pairs`, `pcall`, `pcallwithenv`, `rawget`, `rawset`, `scrub`,
@@ -2012,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
@@ -3827,9 +3760,10 @@ private namespace table **only** when BOTH hold, else `nil`:
boolean flag; approach (a) from the notes below — no parser hook).
The two paths stay separate, as required:
- **Internal** `__addonns` (the `local name, ns = ...` preamble) stays
**ungated** — an addon always gets its own namespace. Still in
`luasyntax/Transpile.cpp`.
- **Internal**: each addon file is called with `(name, ns)` as real
arguments, as 4.3.4 does (`Script_RunAddonFile` in
`luasyntax/Transpile.cpp`) — **ungated**, an addon always gets its own
namespace. (Replaced the `__addonns` global preamble.)
- **Public** `C_AddOns.GetAddOnLocalTable` carries the gate, in
`addons/LocalTable.cpp`.
@@ -3854,3 +3788,350 @@ Not shipped by default: no addon opts in yet. The embedded `!!!ClassicAPI`
addon could add `## AllowAddOnTableAccess: 1` to its `.toc` to expose its own
namespace as a worked example, but that's a product/security decision left to
the maintainer.
## 100. Self-documenting API + `/classicapi` browser — SHIPPED (first slice)
`docs/API.md` is 18.7k lines and its anchors are signature slugs, so a
signature edit breaks the 281 internal + 88 README links into it. Blizzard
solved the same problem by shipping the API as DATA
(`Blizzard_APIDocumentationGenerated`: one table per system with typed
`Functions` / `Events` / `Tables`) plus a browser addon
(`Blizzard_APIDocumentation`, the `/api` command). warcraft.wiki.gg's
Arguments/Returns blocks are those same tables rendered by a bot — so one set
of descriptors feeds the in-game browser, a page generator, and a drift check.
**Shipped in this round**
- `namespace Game::Doc` in [src/Game.h](src/Game.h) — `Field` +
`Req`/`Opt`/`Vararg`, `FieldList`, `Function`, `Method`, `Structure`,
`Event`. Constant-initialized `const` objects; no heap, no dynamic init.
- A trailing descriptor argument on all six `Game::Lua::Register*`, and on
`Event::Custom::AutoReserve`. Zero changes inside any `Script_*`.
- [src/api/Documentation.cpp](src/api/Documentation.cpp) — records every
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()`.
- `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.
- `Util/LinkUtil.lua` — the `SetItemRef` → `LinkUtil.ProcessLink` dispatch,
wiring up a `RegisterLinkHandler` registry that had been dead code. Any
future link type now registers a handler instead of stacking another
`SetItemRef` override. Also fixed `assertsafe` being undefined there.
- Documented: the whole `C_Spell` / `C_SpellBook` surface, the spell globals
(`SpellGlobals`), the two SpellBook enums, the 13 `UNIT_SPELLCAST_*`
events, `GameTooltipAPI` (frame-method example), and `C_Glue` (glue and
mixed-environment example).
**The remaining sweep.** `_classicapi_UndocumentedAPI()` is the worklist —
~700 registrations across ~46 namespaces. Do it a namespace at a time; the
descriptor goes beside the registration and must be written against the
`Script_*` BODY, not against `docs/API.md` (which is already stale in
places — `IsHarmfulSpell` / `IsHelpfulSpell` describe an `AttributesEx` bit
read that the code does not do; it walks the effects' implicit targets).
`tools/New-ApiDocSkeletons.ps1` emits paste-ready skeletons from the 207
single-line `Usage:` strings + the API.md headings, with `// TODO` on every
guess — a starting point to verify, never to paste blind.
When the sweep finishes, delete the `= nullptr` / `= 0` defaults from the six
registrar parameters: a registration without a descriptor then fails to
compile, which is the whole point of putting the descriptor at the call site.
**Then, not before:** an `ExportAPIDocumentation` console command (glue-
registered like `ExportInterfaceFiles`, writing one Blizzard-format `.lua`
per system plus `Undocumented.txt`), a CI diff of the committed copies to
catch descriptor drift, and a Markdown/wiki generator to replace the
hand-maintained `docs/API.md` — the reason the descriptors exist at all.
## 101. `Frame:SetOnUpdateMode(mode)` + `Enum.OnUpdateMode` — medium
```
Frame:SetOnUpdateMode(onUpdateMode) -- Enum.OnUpdateMode
0 Disabled no OnUpdate, visible or not
1 RunWhenVisible the vanilla behaviour (default)
2 RunWhenVisibleOnce run once while visible; resets to Disabled BEFORE running
3 RunOnce run once regardless of visibility; resets to Disabled BEFORE running
4 RunAlways run regardless of visibility
```
"Resets before running" is load-bearing: the handler re-arms by calling
`SetOnUpdateMode` again from inside itself.
**Engine (verified 2026-09-15).** The per-frame dispatcher is `FUN_0076B2C0`,
`__thiscall(frame, float elapsed)` at vtable `+0x38`, present in every frame
vtable with subclass overrides chaining into it (same shape as the click
vmethod `FUN_00779540`):
```c
if (frame[0x128] != 0) // the OnUpdate slot
FUN_007026F0(frame, frame + 0x128, "%f", elapsed); // fmt @0x00835160
```
The walk is `FUN_00765650`: nine strata buckets of an intrusive list
(`frame+0x310` = next, saved into the bucket before each call so a handler may
unlink itself), calling `vtable+0x38` per node — with **no visibility check in
the loop and none in the dispatcher**. Visibility is therefore *list
membership*, not a test, and that list is the per-strata render list, so a
hidden frame is simply absent. Contention: a literal scan of every DLL in
`dll\` + `dll_local\` finds no reference to `FUN_0076B2C0` or `FUN_00765650`.
**Design.** Modes 0/1/2 need only a co-hook on `FUN_0076B2C0` — mode 0 returns
without calling the original, mode 2 sets Disabled then calls it, mode 1 is the
engine unchanged. Guard the detour on an empty mode map so non-users pay a
compare.
Modes 3/4 cannot come from that hook: the frame is not in the walk's list while
hidden, and forcing membership would put it in the RENDER list (it would try to
draw). Drive them from `Tick::WorldTick` instead — an already-owned hook, so no
second MinHook — calling the engine's own `FUN_0076B2C0(frame, elapsed)` so the
handler sees exactly the fire it normally would, with a per-tick mark set by the
hook so a *visible* `RunAlways` frame is not dispatched twice.
**Open question before writing it:** the `elapsed` for the self-driven modes.
The walk receives it from its caller; cleanest is to capture it in the hook (one
value per pass, shared by every frame) and reuse it, falling back to a
`Time::Clock` delta when nothing visible ran that frame. Needs checking whether
`Tick::WorldTick` (`FUN_0066FD50`, the world subsystem) runs before or after the
UI pass, which decides whether the captured value is this frame's or last
frame's.
**Cost:** one new MinHook (uncontended, guarded to ~zero when unused), one
WorldTick subscriber, a frame-keyed mode map, `Enum.OnUpdateMode`, and
`SetOnUpdateMode` / `GetOnUpdateMode`.
**Value, honestly.** `RunAlways` is the only genuinely new capability — a hidden
frame currently cannot run OnUpdate at all, which is why addons keep a
permanently-shown dummy frame for their tickers. `Disabled` duplicates
`SetScript("OnUpdate", nil)` and `RunOnce` overlaps `C_Timer.After(0, …)`, so
the rest is API parity. Worth it for the one capability plus a clean enum, not
as a performance feature.
## 102. Texture tiling: `SetHorizTile` / `SetVertTile` (+ `SetNonBlocking`) — medium
The only texture methods 4.3.4 has that 1.12 + ClassicAPI lack
(`wowdecomp15595/Engine/Source/Frame/CSimpleTexture_Scripts.cpp`): six
setter/getter pairs on `m_textureFlags`. Getters answer 1 or nil.
| Methods | 4.3.4 flag | Effect |
|---|---|---|
| `SetHorizTile` / `GetHorizTile` | `0x4` | repeat on U |
| `SetVertTile` / `GetVertTile` | `0x8` | repeat on V |
| `SetNonBlocking` / `GetNonBlocking` | `0x1` | skip drawing an unloaded image this frame instead of force-loading it |
**How 4.3.4 tiles.** `CRenderBatch::QueueTexture` (`CRenderBatch.cpp:164`):
while the dirty flag `0x2` is set, every non-zero U texcoord becomes
`rectWidthInPixels / gxTex->m_width` (V likewise with height), using
`DDCToNDCWidth(aspect × 1024 × (right − left)) / m_layoutScale` from the drawn
corners. A layout change (`CSimpleRender.cpp:2391`) sets `0x2` again when either
tile flag is on. Repeat only shows if the image was loaded with wrap.
**Wrap on 1.12.** `CSimpleTexture::SetTexture` `0x770200` already takes
`(name, wrap, filter, shader)` (benilla-re `ui/texture#settexture`); the Lua
`SetTexture` always passes wrap 0. 4.3.4's Lua `SetTexture(file, tile)` reads
arg 3 as wrap for both axes — add that. To verify: vanilla has one wrap flag,
4.3.4 has `wrapU`/`wrapV`; check whether `CGxTexFlags` `0x58A980` can wrap one
axis alone.
**Where to hook.** The rescale belongs where the drawn corners are built — the
texture `OnFrameSizeChanged` co-hook `0x770670` the animation system already
owns (`src/animation/Region.cpp`).
**XML.** `<Texture horizTile= vertTile= nonBlocking=>`; 4.3.4's loader also
turns wrap on when `<TexCoords>` leave 0..1 and errors
`Texture %s: Invalid TexCoords value (horizTile is on)` (`CSimpleRender.cpp:3024`).
**NonBlocking.** Only meaningful if 1.12 has a lazy texture-load path (4.3.4:
`TextureForceLoadForDiskOnly` vs `NotifyDelayedLoad`). Not checked — if 1.12
always loads synchronously it is a stored flag with no effect.
## ~~103. FontString `SetWordWrap` / `CanWordWrap` / `GetFieldSize`~~ — DONE
The only FontString methods 4.3.4 has that 1.12 + ClassicAPI lacked
(`CSimpleFontString_Scripts.cpp`). Code: `src/fontstring/WordWrap.cpp`,
`GetFieldSize` in `src/fontstring/Metrics.cpp`.
- **SetWordWrap / CanWordWrap.** 4.3.4 style bit `0x40` (set = no wrap). The
setter is 1.12's own `SetNonSpaceWrap` (`0x79E9F0`, identical body on bit
`0x1000`) with `0x40` inverted. `TextBlockCreate` `0x44D420` already maps
`0x40` to text flag `0x2` exactly as 4.3.4's `ConvertStringFlags`, and 1.12's
layout `0x5CDC20` / line count `0x5C2070` stop after one line on it.
Verified in-game: one line, `...` at the width (the fit `0x771EC0`), lines
after `\n` dropped, no width = the whole line. A change must lay the string
out again (`FontString::Layout::Invalidate`, as `SetText`), not only rebuild
it: a text-sized string kept its two-line height and the fit cut at a whole
word with no `...`.
- **GetFieldSize.** 4.3.4 returns a constant 8191. 1.12's truth: a string with a
fixed text size `+0xE8` (XML `bytes="n"`) keeps `n − 1` bytes (`SetText`'s
`SStrCopy(text, src, n)`, `0x771E0F`); without one the buffer grows to any
length, so it returns infinity.
## ~~104. XML `<KeyValues>`~~ — DONE
Shipped in a2e3eed (`mixin=` and `method=` followed in 2cab0dc). Code:
`src/xml/KeyValues.cpp`; the texture / font string hooks in
`src/xml/ParentKey.cpp` call it directly (no `RegionLoad` fan-out), and
`src/animation/Xml.cpp` reads it for groups and animations. Notes below
are the original plan.
Not in 4.3.4 (no decomp); the contract comes from retail's `UI.xsd`
(`_classic_era_/BlizzardInterfaceCode/.../Blizzard_SharedXML/UI.xsd`) and
`XMLUtilDocumentation.lua`. Blizzard's classic-era UI uses it in 356 files.
- `<KeyValues><KeyValue key= value= type= keyType=/></KeyValues>`; `type` and
`keyType` are `nil` / `boolean` / `number` / `string` (default) / `global`
(`_G[value]`). Sets `object[key] = value` on the Lua wrapper.
- A LayoutField: frames, textures, font strings. Also a child of
`AnimationGroup` and `Animation`.
- `C_XMLUtil.GetTemplateInfo(name).keyValues` is a list of
`{key, keyType, type, value}` (all strings); ours always returns `{}`
(`src/xml/Templates.cpp`).
**Ordering.** `NineSlicePanelMixin:OnLoad` reads `self:GetParent().layoutType`
— a parent's KeyValue must be set before its child frames load. 1.12's frame
load (benilla-re `ui/frames#loading-from-xml`): `0x769820` loads the inherited
node first, then attributes, `Layers`, `Scripts`; `0x76A060` then loads the
child `<Frames>` and the load ends (OnLoad). So the existing
`Xml::FrameLoad::AutoSubscribe` (after `0x769820`, as `<Attributes>` uses) is
the right point: template values first, the node's own win, before children
and OnLoad.
**Plan.**
1. Textures / font strings: `ParentKey.cpp` owns the `0x76FE20` / `0x770F40`
loader hooks and calls `Animation::Xml::LoadAnimations` from them by hand.
Turn them into a shared `Xml::RegionLoad::AutoSubscribe` fan-out (like
`FrameLoad`) and subscribe ParentKey, Animations and KeyValues.
2. `Xml::PushTypedValue(L, type, value)` shared with `<Attributes>`
(`Frame::Attributes::LoadXmlAttributes`, engine `FUN_XML_ATTR_TO_BOOL` /
`FUN_SSTR_TO_FLOAT`), plus `global`.
3. `src/xml/KeyValues.cpp` for frames + regions; the animation loader
(`src/animation/Xml.cpp`) reads it for groups and animations.
4. Fill `GetTemplateInfo`'s `keyValues` from the template node.
**Unverifiable without a retail binary** — follow `<Attributes>` and say so in
the code: a `boolean` value other than true/false, a `number` that fails to
parse, and whether `GetTemplateInfo` includes inherited templates' entries
(plan: the node's own only).
## 105. Button methods — small to medium
Button methods later clients have that 1.12 + ClassicAPI lack. 4.3.4 adds three
over 1.12 (`CSimpleButton_Scripts.cpp`); retail's `SimpleButtonAPIDocumentation`
(`_classic_era_` install) lists the rest. `Get/SetNormalFontObject` are done
(`src/frame/Modern.cpp`).
**From 4.3.4 (decomp available).**
- **DONE** (`src/frame/MotionScriptsWhileDisabled.cpp`, verified in-game
2026-10-19). `SetMotionScriptsWhileDisabled(enabled)` / `GetMotionScriptsWhileDisabled()`
— the one real new capability: tooltips on disabled buttons. 4.3.4
`CSimpleButton::OnLayerCursorEnter/Exit` (`CSimpleButton.cpp:827/844`): an
enabled button runs the frame's enter/leave and the label refresh; a disabled
one does nothing unless the flag is set, then shows `m_hoverFrame` and runs
ONLY the OnEnter/OnLeave script (no highlight, no label refresh). 1.12 is
4.3.4 without the flag: `0x779490` (slot 19) / `0x7794E0` (slot 20) skip the
frame's enter/leave (`0x76B6A0` / `0x76B6F0`, scripts + highlight) and the
label refresh `0x779810` for state 0 (benilla-re `ui/button#entering-and-leaving`).
Needs: a per-button flag (side map, reset on reload), co-hooks on the two
slots, a way to run just the script (find the script runner inside
`0x76B6A0`), what 1.12 has for `m_hoverFrame`, and the XML attribute
`motionScriptsWhileDisabled` (4.3.4 `m_buttonFlags` bits 0x4 set / 0x8 value,
`CSimpleButton.cpp:1058`). Default false.
- **DONE** (`src/frame/FormattedText.cpp`, shared with the font string's;
verified in-game 2026-10-19).
`button:SetFormattedText(fmt, ...)` — 4.3.4 formats into a 4096-byte buffer
and calls the button's `SetText`. We have it on font strings only
(`src/fontstring/Metrics.cpp`, engine `str_format` under pcall); register the
same on the Button registry with the button's own SetText.
**From later clients (contract from retail docs only — no decomp).**
- **DONE** (`src/frame/SetEnabled.cpp`). `SetEnabled(enabled)` — `Enable()` /
`Disable()` by a bool, through the button's Enable vmethod as they do.
- `SetNormalAtlas` / `SetPushedAtlas` / `SetDisabledAtlas` /
`SetHighlightAtlas(atlas[, blendMode])` — the state texture made or reused
through `SetNormalTexture` etc., then `Texture::Atlas`'s `SetAtlas`.
- `ClearNormalTexture` / `ClearPushedTexture` / `ClearDisabledTexture` /
`ClearHighlightTexture` — remove the state texture. Check what 1.12's
`SetNormalTexture(nil)` does first (`CSimpleButton::SetStateTexture`
`0x778FD0` deletes the old one).
- **DONE** (`src/frame/MouseRegistration.cpp`). `RegisterForMouse(buttons...)`
— which mouse buttons fire OnMouseDown/Up. 1.12 filters nothing: the base
frame pair 0x76BF70 / 0x76C040 fire both for any button through the runner,
where `Frame::ClickEvents`' gate now refuses unregistered ones.
## 106. Script-object API sweep — remaining methods, globals and scripts
A name-level diff, 2026-10-19: every 4.3.4 method table
(`wowdecomp15595/Engine/Source/Frame/*_Scripts.cpp`) and retail's
`Blizzard_APIDocumentationGenerated/Simple*APIDocumentation.lua` (`_classic_era_`
install) against 1.12 (`docs/BlizzardScriptAPI.md`) and what `src/` registers.
Texture (#102), FontString (#103 done) and Button (#105) have their own items.
Each entry still needs the usual check of how the engine does it before building.
### From 4.3.4 (decomp available)
- **DONE** (`src/slider/Enable.cpp`). **Slider** `Enable` / `Disable` / `IsEnabled`: frame flag `0x400`, read by the
thumb-drag path (`CSimpleSlider.cpp:317`); `Disable` runs OnDisable unless
loading (we have OnEnable/OnDisable in `src/frame/EnableScripts.cpp`).
- **DONE** (`src/frame/MinMaxChanged.cpp`). **OnMinMaxChanged** (Slider, StatusBar): `SetMinMaxValues` runs it with
`(min, max)` when the range changes (`CSimpleStatusBar.cpp:39/141`). 1.12 has
no such script.
- **DONE** (`src/statusbar/Fill.cpp`, `src/statusbar/BarTexture.cpp`; XML
`reverseFill` / `rotatesTexture` / `fillStyle` attributes not backported).
**StatusBar** `SetReverseFill` / `GetReverseFill`, `SetRotatesTexture` /
`GetRotatesTexture` (`CSimpleStatusBar.cpp:135`, the vertical texcoord path
at `:285`). Retail adds `SetFillStyle` (STANDARD / CENTER / REVERSE),
`SetColorFill`, `SetStatusBarDesaturated` / `Desaturation`.
- **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)` /
`GetMaxAnimFramerate()` — 4.3.4's `CSimpleAnim::s_frameInterval`, the 1/30 s
throttle our animation port already has.
- Not worth it: Frame `EnableJoystick` / `IsJoystickEnabled`; the stereo-depth
set `SetDepth` / `GetDepth` / `GetEffectiveDepth` / `IgnoreDepth` /
`IsIgnoringDepth`; `GetText(token[, gender[, ordinal]])` — gendered
global-string lookup, little use on 1.12.
### From later clients (retail docs only — contract, no decomp)
Likely worth it (small, ported code uses them):
- Object: `GetParentKey` / `SetParentKey` / `ClearParentKey`, `GetDebugName`.
- Region: `SetIgnoreParentAlpha` / `IsIgnoringParentAlpha` (4.3.4 has it
internally: its texture XML load calls `SetIgnoreParentAlpha` from
`m_regionFlags 0x10/0x20`, `CSimpleRender.cpp`),
`SetIgnoreParentScale` / `IsIgnoringParentScale`.
- ScriptRegion: `GetScaledRect`, `IsRectValid`, `Intersects`.
- Frame: `GetResizeBounds` (we have `SetResizeBounds`), `SetFixedFrameStrata` /
`SetFixedFrameLevel` / `HasFixed*`, `GetHighestFrameLevel` /
`GetRaisedFrameLevel`, `SetUsingParentLevel`, `ClearAttributes`,
`IsObjectLoaded`, `RotateTextures` (on `Texture::Transform`), `AbortDrag`.
- Slider: `SetObeyStepOnDrag`, `SetStepsPerPage`, `IsDraggingThumb`,
`SetEnabled` (also EditBox `SetEnabled`).
- FontString / Font: `SetTextScale` /
`GetTextScale`, `ClearText`, `SetTextToFit`,
`GetUnboundedStringWidthForText`, `FindCharacterIndexAtCoordinate`.
- Texture: `GetTextureFilePath`, `ClearVertexOffsets`.
- SimpleHTML `GetContentHeight`; ColorSelect alpha (`SetColorAlpha` + its
textures).
Larger, decide separately: mouse-motion / click split and propagation
(`SetMouseMotionEnabled`, `SetMouseClickEnabled`, `SetPropagateMouseClicks`,
`SetPassThroughButtons`); `SetClipsChildren` (scissoring); `CreateLine` (the
Line region type); texture slicing (`SetTextureSliceMargins` / `Mode`); Model
camera / transform / alpha (~40 methods); `RegisterEventCallback`; EditBox
secure-text and byte-limit options; MessageFrame fade power and message IDs.
-138
View File
@@ -1,138 +0,0 @@
# embed_addon.cmake — bundle an addon directory into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads every
# file under SRC_DIR recursively and emits OUT_HEADER containing:
#
# namespace Addons::Embedded {
# struct File {
# const char *path;
# const unsigned char *data;
# size_t size;
# };
# extern const File kFiles[];
# extern const size_t kFileCount;
# }
#
# Paths are normalized to lowercase backslash-form relative to
# `Interface\AddOns\` so they match what the engine's TOC parser
# constructs when it asks for the addon's files (see
# `s_Interface_AddOns__s__s_toc_00853840` in the binary). The
# manifest is hash-table-friendly for fast lookup but the lookup
# walks the array linearly — fine for 25 files.
#
# Usage:
# cmake -DSRC_DIR=... -DOUT_HEADER=... -DADDON_NAME=... -P embed_addon.cmake
if(NOT SRC_DIR)
message(FATAL_ERROR "embed_addon.cmake: SRC_DIR required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_addon.cmake: OUT_HEADER required")
endif()
if(NOT ADDON_NAME)
message(FATAL_ERROR "embed_addon.cmake: ADDON_NAME required")
endif()
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
# Never embed the developer marker — it must reflect on-disk presence
# only (see src/addons/Embedded.cpp DiskHasDevMarker), and it is
# gitignored so it only exists in a developer working tree anyway.
list(FILTER INPUT_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(SORT INPUT_FILES) # deterministic codegen
set(BODY "")
set(MANIFEST "")
set(INDEX 0)
foreach(REL_PATH ${INPUT_FILES})
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
# Stamp the addon's `## Version:` line from the build flag rather
# than the committed source (which stays `DEV`). This is what makes
# a release build a pure function of `-DCLASSICAPI_TAG` — the source
# toc is never edited. Only the `.toc` file is rewritten, in-memory,
# before hex-encoding; every other file is embedded verbatim.
if(ADDON_VERSION AND REL_PATH MATCHES "\\.toc$")
file(READ "${ABS_PATH}" TOC_TEXT)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${ADDON_VERSION}" TOC_TEXT "${TOC_TEXT}")
# Round-trip through a temp file to hex-encode the modified text
# (portable to CMake 3.10; avoids requiring string(HEX), 3.18+).
set(TOC_TMP "${OUT_HEADER}.toc.tmp")
file(WRITE "${TOC_TMP}" "${TOC_TEXT}")
file(READ "${TOC_TMP}" CONTENT_HEX HEX)
file(REMOVE "${TOC_TMP}")
else()
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
endif()
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
# Convert hex string to comma-separated byte literals. 16 bytes per line
# keeps the generated header readable.
set(BYTES "")
set(COL 0)
set(POS 0)
while(POS LESS HEX_LEN)
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
if(COL EQUAL 0)
string(APPEND BYTES " ")
endif()
string(APPEND BYTES "0x${BYTE},")
math(EXPR POS "${POS} + 2")
math(EXPR COL "${COL} + 1")
if(COL EQUAL 16)
string(APPEND BYTES "\n")
set(COL 0)
else()
string(APPEND BYTES " ")
endif()
endwhile()
if(NOT COL EQUAL 0)
string(APPEND BYTES "\n")
endif()
# Normalize path to engine-style: `<AddonName>\Sub\Path.lua` with
# backslashes. Engine constructs full path as
# `Interface\AddOns\<AddonName>\<file>` — we strip the
# `Interface\AddOns\<AddonName>\` prefix at lookup time so storing
# just the relative form keeps the manifest small.
string(REPLACE "/" "\\\\" REL_PATH_BS "${REL_PATH}")
string(APPEND BODY "static const unsigned char kData_${INDEX}[] = {\n${BYTES}};\n\n")
string(APPEND MANIFEST " { \"${REL_PATH_BS}\", kData_${INDEX}, ${FILE_SIZE} },\n")
math(EXPR INDEX "${INDEX} + 1")
endforeach()
set(HEADER_CONTENT "// Auto-generated by embed_addon.cmake — do not edit.
// Source: ${SRC_DIR}
//
// Addon \"${ADDON_NAME}\" embedded as a fallback for users who don't have
// it installed on disk. See src/addons/Embedded.cpp for the file-read
// hook that surfaces these buffers to the engine.
#pragma once
#include <cstddef>
namespace Addons::Embedded {
namespace ${ADDON_NAME}Files {
${BODY}static const struct File {
const char *path;
const unsigned char *data;
size_t size;
} kFiles[] = {
${MANIFEST}};
static constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace ${ADDON_NAME}Files
} // namespace Addons::Embedded
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_addon: wrote ${OUT_HEADER} (${INDEX} files)")
-62
View File
@@ -1,62 +0,0 @@
# embed_file.cmake — bundle a single file into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads
# INPUT_FILE and emits OUT_HEADER containing a constexpr byte array
# named SYMBOL plus a `SYMBOL ## Size` size constant. Both live at file
# scope; the header is meant to be included exactly once.
#
# Usage:
# cmake -DINPUT_FILE=... -DOUT_HEADER=... -DSYMBOL=... -P embed_file.cmake
if(NOT INPUT_FILE)
message(FATAL_ERROR "embed_file.cmake: INPUT_FILE required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_file.cmake: OUT_HEADER required")
endif()
if(NOT SYMBOL)
message(FATAL_ERROR "embed_file.cmake: SYMBOL required")
endif()
file(READ "${INPUT_FILE}" CONTENT_HEX HEX)
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
# Convert hex string to comma-separated byte literals. 16 bytes per
# line keeps the generated header readable.
set(BYTES "")
set(COL 0)
set(POS 0)
while(POS LESS HEX_LEN)
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
if(COL EQUAL 0)
string(APPEND BYTES " ")
endif()
string(APPEND BYTES "0x${BYTE},")
math(EXPR POS "${POS} + 2")
math(EXPR COL "${COL} + 1")
if(COL EQUAL 16)
string(APPEND BYTES "\n")
set(COL 0)
else()
string(APPEND BYTES " ")
endif()
endwhile()
if(NOT COL EQUAL 0)
string(APPEND BYTES "\n")
endif()
set(HEADER_CONTENT "// Auto-generated by embed_file.cmake — do not edit.
// Source: ${INPUT_FILE}
#pragma once
#include <cstddef>
static constexpr unsigned char ${SYMBOL}[] = {
${BYTES}};
static constexpr size_t ${SYMBOL}Size = sizeof(${SYMBOL});
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_file: wrote ${OUT_HEADER} (${FILE_SIZE} bytes)")
+152
View File
@@ -0,0 +1,152 @@
# 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'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::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 = ...;
# }
#
# 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.
#
# 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 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
# 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> 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)
set(tmp "${path}.tmp")
file(WRITE "${tmp}" "${content}")
configure_file("${tmp}" "${path}" COPYONLY)
file(REMOVE "${tmp}")
endfunction()
function(embed_resources)
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)
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 "")
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_ADDONS_ROOT}/${rel}")
if(rel MATCHES "\\.toc$")
file(READ "${abs}" toc)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${E_ADDON_VERSION}" toc "${toc}")
set(stamped "${E_OUT_DIR}/embed/${rel}")
_embed_write_if_changed("${stamped}" "${toc}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${abs}")
set(abs "${stamped}")
endif()
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")
endforeach()
_embed_write_if_changed("${E_OUT_DIR}/embedded.rc" "${rc}")
_embed_write_if_changed("${E_OUT_DIR}/embedded_resources.h"
"// Generated by cmake/embed_resources.cmake — do not edit.
// Resource IDs for the RCDATA entries in embedded.rc. Load them through
// src/embed/Resource.h.
#pragma once
#include <cstddef>
namespace Embed::Ids {
constexpr int kBindingsXml = ${bindings_id};
} // namespace Embed::Ids
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;
int resourceId;
};
constexpr File kFiles[] = {
${manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // 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}")
endfunction()
+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)
+11886 -2934
View File
File diff suppressed because it is too large Load Diff
+58 -1
View File
@@ -9,7 +9,8 @@ ImageBase `0x00400000`. All VAs in this doc use that base.
## How Blizzard wires Lua
There are two registries.
There are two Blizzard registries, plus the Lua standard libraries, which are
bound a third way (§3) and are NOT in the batch counts below.
### 1. Global functions — `FrameScript_RegisterFunction` at `0x00704120`
@@ -57,6 +58,62 @@ C3 ret
23 tables found, **481 methods total**. The runtime dispatcher at `0x005368D0`
resolves `frame:method(...)` by looking the name up in the matching context.
### 3. Lua standard libraries — `luaL_openlib` tables
The Lua 5.0 standard libraries, and Blizzard's replacements inside them, are
bound by `luaL_openlib` walking one `luaL_reg { char *name; lua_CFunction
func; }[]` table per library when the Lua state is created (`FUN_00703B80`,
called immediately BEFORE `FUN_LOAD_SCRIPT_FUNCTIONS`). They never pass
through `FrameScript_RegisterFunction`, so none of them appear in the 54
batches above or in the loop scan behind `raw_globals.txt` — which is how
`debugprofilestart` / `debugprofilestop` (stock since 1.7.0) went
uncatalogued here until 2026-09. **Before declaring a global absent, check
these tables or `/dump` the name in game.**
**Base library** — table `[0x00811E28]`, 36 entries, `.rdata`. Blizzard's
own base library: the stock 5.0 set minus `dofile`/`loadfile`/`require`/
`print` (`print` is a ClassicAPI backport), plus the `debug*` /
`*errorhandler` / `date` / `time` additions.
```
0x00702770 setglobal 0x00702DF0 type
0x007027B0 getglobal 0x00702E30 tonumber
0x007027E0 debugload 0x00702F40 tostring
0x007027F0 debuginfo 0x007030A0 unpack
0x00702800 debugprint 0x007030F0 rawequal
0x00702810 debugdump 0x00703130 rawget
0x00702820 debugbreak 0x00703160 rawset
0x00702830 debugtimestamp 0x007031A0 assert
0x00702840 debugprofilestart 0x00703200 gcinfo
0x00702860 debugprofilestop 0x00703250 collectgarbage
0x00702900 seterrorhandler 0x00703280 loadstring
0x00702950 geterrorhandler 0x007032F0 pcall
0x00702970 error 0x00703340 xpcall
0x007029F0 getmetatable 0x007033A0 date
0x00702A40 setmetatable 0x007035A0 time
0x00702AC0 getfenv 0x00703760 debugstack
0x00702BE0 setfenv
0x00702C90 next
0x00702CD0 ipairs
0x00702DA0 pairs
```
`debugprofilestart` stores `rdtsc` in `0x00CEEB58` (u64);
`debugprofilestop` pushes `|rdtsc − start| / (ticksPerSecond / 1000)` as a
double, where `ticksPerSecond` is `FUN_0042C020` = `(int64)(1000.0 /
[0x008332C8])` — a TSC scale calibrated at boot (the image holds `1.0`). So:
milliseconds with a sub-microsecond fraction, from the CPU counter, unrelated
to the engine's millisecond tick and to `GetTime()`.
The `table`, `string` and `math` libraries have the same shape (the `table`
library's `luaL_reg` is at `0x00822D70` — see `Offsets.h`
`FUN_LUA_TABLE_INSERT`) and are not enumerated here. The short global
aliases (`tinsert`, `getn`, `strlen`, `format`, `floor`, …) are not registered
at all: an embedded Lua chunk at `.data 0x008722E8`, run by the same state
init, assigns them from the library tables (`tinsert = tab.insert`). A module
that registers over a library function must therefore re-bind its alias —
`Game::Lua::RegisterGlobalAlias`.
## Scope
The 54 global-function batches split into two phases:
+200 -116
View File
@@ -69,30 +69,37 @@ The DLL hooks the engine's own text pipeline — no companion addon:
earlier `0xFF` alpha force at emit was a misdiagnosis of a bubble that built
while faded out; it froze icon-line text fully opaque while everything
around it faded.
- **Outlined fonts** — outline ink extends past the glyph advances; the
engine itself grows line height by exactly 4.0 / 2.0 pen px for thick / thin
outline (`face+0x180` bits 3 / 0, `.rdata` constants). `OutlineInkPen` reads
the same flags and constants, and half the ink leads each icon, half trails
— threaded through the shared advance helper so the emitter advance, the
justify pre-shift, and the wrap-stepper probes all agree. Without it, coins
clipped into outlined digits the moment the exact K removed the old
miscalibration's accidental slack (`968335d`).
- **Size, spacing and alignment are 4.3.4's rules** (decompiled source in
`C:\Git\wowdecomp15595`; 3.3.5 has the same). See "Icon geometry" below.
Every hand-tuned constant this module once carried (a 0.18 × font lead/trail
pad, a 0.6 × font vertical centre, a −1px region nudge, a size multiplier)
is gone.
- **Measure** — three cold FontString-level co-hooks make measure match the
drawn result: width (`FUN_00772890`), height (`FUN_007729B0`), and wrap
(`FUN_005C7260`). `GetStringWidth`, `fontstring:GetStringHeight`, wrap
breaks, and line height all count the icon.
- **Tall icons** — an icon taller than the font grows its line height, so it
does not overlap its neighbors, and it centers in the grown line. A hyperlink
- **Tall icons** — a line holding an icon taller than the font moves itself
and the lines after it down by the overflow, as 4.3.4 does, so it does not
overlap its neighbors; the icon aligns by the line's vertical justification
(below). A hyperlink
that contains a tall icon is hoverable across the whole icon
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
- **Editboxes** — an editbox keeps the raw markup, so the caret stays aligned
(two complementary signals, below).
- **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, 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
TGA.
- **Anti-spoof** — chat strips player-injected `|T` icons.
- **Anti-spoof** — chat neutralizes a player-injected `|T` the way the edit
box neutralizes a typed one (the `|` is doubled).
- **Raid markers** — `{rt1}`/`{skull}`/… become icons in the CHAT_MSG text and,
separately, in the speech bubble after its escape strip (4.3.4's
`CGChatBubbleFrame::ParseRaidIcons`).
Every fs-level measure path now counts icons: width (`FUN_00772890`), height
(`FUN_007729B0`), wrap (`FUN_005C7260`), and the substring measure
@@ -171,7 +178,7 @@ SetTexture `+0x104`). Selector map (shared across GL/D3D backends): 7=blend,
0x10=depthtest, 0x14=cullface, 0x17-0x1e=texture bind (8 units),
0x1f-0x26=colorop/alphaop preset (unit 0-7).
## SOLVED — positioning, measure/wrap, editbox exclusion, texcoords
## SOLVED — positioning, measure/wrap, edit boxes, texcoords
The primitive draws at arbitrary coords; the rest is knowing WHERE and WHEN.
Everything is in [src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp);
@@ -196,8 +203,7 @@ frame → renderNode [frame+0x118] → group [renderNode+0x18]
- The **emitter** `FUN_005ccbe0(line, text, len, colorState, penXYZ, pageMask,
linkState)` builds one wrapped line's glyph quads. `this` (the "line"/node the
icon list keys on) owns batches `[+0xa0+page*4]` and origin `[+0x70]`. **Note
the render node the emitter sees has NO back-pointer up to the frame** — the
editbox discriminator had to be found another way (see below).
the render node the emitter sees has NO back-pointer up to the frame.**
### Positioning — segmented delegation (co-hook the emitter)
@@ -224,14 +230,27 @@ We do NOT reimplement the emitter's intricate glyph vertex math. Instead, in the
does NOT reset it between left-justified lines, so leaving it mutated
cascade-shifts every following line).
### FontString pipe-doubling — the escape arrives as `||T…||t`
### The span rule — `|T…|t` only, `||` is always a literal pipe
The FontString text sanitizer **doubles any pipe that doesn't begin a recognized
escape** so it renders as a literal `|`. 1.12 doesn't know `|T`, so a caller's
`|Tpath:h|t` reaches the emitter as `||Tpath:h||t` (verified: emitter text length
+2, pipes doubled). The detector therefore matches BOTH the doubled form (the
real-world case) and a clean `|T` (should one ever arrive undoubled). Normal
doubled pipes (`|| `) never match because the next char must be `T`.
One scanner, `IconSpanAt`, decides what a span is, with 4.3.4's
`GxuDetermineQuotedCode` CODE_TEXTURESTART rule: after `|T` (or `|A`), the
FIRST `|` must be followed by `t` (`a`), and an empty `|T|t` is literal.
Anything else is not a span and draws as text. Every walker steps over `||`
as a pair, because `||` is the escaped pipe in both engines.
That is what keeps typed markup literal everywhere — chat display, edit
boxes, macros: the edit box stores a typed `|` as `||` (`0x77c254`; Paste
goes through the same `InsertChar`, as in 4.3.4), so a typed `|Tpath|t` is
`||Tpath||t` in every buffer it reaches.
(An earlier version matched a "doubled" `||T…||t` form too, on the belief
that the FontString sanitizer doubled unknown escapes. Stock 1.12 has no
such sanitizer — not SetText `0x771d80`, its grammar pass `0x7716f0`,
FitText `0x771ec0`, nor the edit box's RebuildLabel `0x77d4d0`. The `||T`
seen in chat lines was typed text, and our own `{rt1}` expansion emitted it
on purpose to survive the bubble's escape strip. Matching it turned every
typed `|T` into an icon, which is why the edit box needed the suppression
gates the span rule replaced.)
### bit-3 batch-clear mode
@@ -258,15 +277,76 @@ per build — on the first wrapped line, detected by `text == [node+0x48]` (the
node's text start) — and icons ACCUMULATE across lines. Multiple icons per line
fall out of the segmentation loop naturally.
### Vertical centring — font-relative
### Icon geometry — 4.3.4's rules, ported
`penXYZ[1]` sits near the text TOP, so the icon centre = `penY + fontHeight *
g_centerFrac` (0.6, calibrated in-game across chat + pfUI's bubble). Font height
comes from the engine's own `FUN_005c6fa0(flag, [node+0x1c])` (the emitter's
glyph-sizing call), so centring holds across font sizes without a fixed pixel
nudge. A TALL icon (height > font) centres in its GROWN line (see the tall-icon
sections in Current design) — the string-height co-hook adds the overflow, so
the extra room sits half above and half below the text.
**Size** (`ParseEmbeddedTexture`, 0x00617BA0 in 4.3.4). A WRITTEN size is not
pixels: it multiplies by `fontPx / (14 × ddcWidthScale)`, the reduction of the
engine's `windowPx / (aspectComp × 1024) × fontHeight / NDCToDDCWidth(14 /
(aspectComp × 1024))` (the call list of the real apwow.exe function confirms
`CoordinateGetAspectCompensation` + `NDCToDDCWidth`). So sizes are relative to
a 14-unit font and follow the text's size, UI scale and resolution. 1.12 holds
ddcWidthScale in the same Coordinate static (`VAR_COORD_DDC_WIDTH_SCALE`,
0x00832A44 — the four statics sit at 0x00832A40 with 4.3.4's exact initial
values). On top of that, every size multiplies by the fontstring's layout
scale (`fs+0x7C`) — 3.3.5's `CGxString::m_scale`, applied again although the
font height already includes it. 4.3.4 dropped this factor; 3.3.5's was kept
after a side-by-side look in game. Every wrap gets the same scale, as 4.3.4
passes `m_layoutScale` to every `CalcWrapPoint`: the draw builder through the
node's owner, and the three fs-level callers (the height getter, the
display-text truncation inside the rebuild, the break array) through a
fontstring scope their co-hooks set (`WrapLayoutScale`). Size cases: height ≤ 0 → font height; width absent → the height;
width > 0 → width × scale, or a RATIO of the height when the height came from
the font (`:0:2` is twice as wide); width ≤ 0 → the font height. Offsets are
screen pixels and move only the drawn texture (+x right, +y UP), never the
layout. `ResolveIconSize` is the one implementation.
**Spacing** (`InitializeTextLine`, case CODE_TEXTURESTART). An icon starts at
the previous glyph's INK edge — bearing + cell width, clamped to ≥ 0 and
capped at the glyph's own advance (`FUN_TEXT_GLYPH_BASE_ADVANCE`) — plus
the face's outline ink (4 × scale thick / 2 × scale thin, `face+0x180` bits 3 /
0; the same constants the 1.12 emitter grows its line by); with no glyph
since the line start or the last icon it starts at the pen, no gap. The next
glyph starts at `floor(iconRight + 0.5)` with no kerning against the icon.
1.12's emitter doesn't report its last glyph's position (its pen is lazy), so
`drawRun` replays the run's placements with the emitter's own helpers — the
pair advance, the snap rounding (`FADD 0.5; __ftol`), `FUN_TEXT_GLYPH_DESC`
(`CGxFont::NewCodeDesc`) and `FUN_TEXT_GLYPH_BEARING` — and floors the cell
width in snap mode exactly as the emitter draws the quad. Measures count an
icon as its width plus the outline gap when a glyph precedes it
(`SumIconAdvances`); the engine measures width alone, but counting the gap
keeps measure and render in step for tooltips and right-justified text. The
advance cap is ClassicAPI's: every measure counts the preceding glyph by its
advance, so an ink edge past the advance (a glyph cell wider than its step —
seen on "[" in chat) pushed each following icon and the rest of the line past
the measured width, and wrapped chat lines overran the frame edge.
**Vertical** (same case + `CreateGeometry`). By the node's vertical justify
(`OFF_TEXT_NODE_JUSTIFY_V`, node+0x50: 0 top / 1 middle / 2 bottom — the
branch of the origin finalize `FUN_005cdf70`): top → the icon's top on the line
top; bottom → its bottom on the baseline; middle → centred on the font height
below the line top. The baseline is `penXYZ[1]` and the line top is the draw
builder's `linkState[1]` (baseline + font height + spacing), 4.3.4's
m_baseLineHeight / m_lineHeight.
**Tall lines** (`CGxString::InitializeText` + `InitializeViewTranslation` +
`GxuFontGetWrappedTextHeight`), mirrored step for step:
- A line whose tallest icon reaches past the font height moves down by that
overflow, and every later line with it: all of it before the line for
bottom justify, all after for top, `floor(overflow / 2)` before and after
for middle. The emitter co-hook moves the builder's pen y and link band
(`penXYZ[1]` = its `local_48`, `linkState[1]/[3]` = its `local_74[0]/[2]`),
which the builder lowers per line, so the move carries forward.
- The node's summed overflow (`m_extraLineHeight`) joins the text height the
origin finalize justifies by: the `FUN_TEXT_NODE_FINALIZE_ORIGIN` co-hook
raises the node position y (`+0x24`) by half / all of it for middle /
bottom justify for the call, so the engine's own math and rounding produce
4.3.4's origin. It also runs on every reposition (`FUN_005ccb10`).
- `GetStringHeight` adds the same per-line overflows, summed. The wrap
stepper reports each line's overflow during the engine's own height pass,
so the lines are the engine's breaks; the sum is kept per fontstring while
the engine serves its cached height.
A link's hit band grows toward where its icon reaches (below for top, above
for bottom, half each way for middle); 4.3.4 keeps the band at the text.
### Measure / wrap — co-hook the tokenizer
@@ -299,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)
@@ -308,13 +388,9 @@ the measure core `FUN_0044D670`, then divides the pen-space result by
`fs+0x7C`.
The co-hook lives in `src/text/InlineTexture.cpp`. It calls the original,
then adds `SumIconAdvances(text) / K` when two gates pass:
- `InlineInterceptActive(text)` is true — the **same predicate** the tokenizer
hook stands down on, factored into one function so measure and render can
never disagree.
- The fs is not an editbox: `fs+0x120 & 0x1000` is clear (the fs-level bit
`FUN_0044D670` translates into the gxu editbox flag 0x40).
then adds the icons' advances (`SumIconAdvances`) whenever the feature is
on — the same condition the tokenizer hook intercepts on, so measure and
render never disagree.
The icon sum is computed in TRUE PEN PX — the exact numbers the emitter
reserves — and bridged into the getter's return space by the font height
@@ -355,8 +431,7 @@ measure `FUN_005C2070`, the chars-that-fit counter `FUN_005C21C0` (ellipsis),
and the break-array computer `FUN_005C2430` (behind `FUN_00772B60`).
The co-hook shrinks the `wrapWidth` argument by a **minimal feasible
shrink**, gated on the same `InlineInterceptActive` predicate plus the flags
editbox bit 0x40 the dispatcher already carries. The stepper reports each
shrink** whenever the feature is on. The stepper reports each
probe's measured (icons-at-zero) line width in `outWidth`, which gives a
direct feasibility check: the line renders inside the frame iff its icons
fit in the shrink plus the spare the break left (`iconSum ≤ s + (probeW −
@@ -410,87 +485,81 @@ line through our co-hooked tokenizer (`FUN_005c2810`) and wrap-stepper
(`FUN_005c7260`). So the recorded rect rides the same icon-aware position
accounting the wrap fix installs; the icon lands inside the hover region for free,
and hovering it fires the frame's `OnHyperlinkEnter`. This is a consequence of the
wrap fix, not a separate feature. (Ghidra caveat: the decompile of the
`FUN_005cd310` call args is mangled — a pointer-as-float + an uninitialized temp —
so this is confirmed by the call path plus in-game test, not the byte-level
formula.)
wrap fix, not a separate feature. (`FUN_005cd310` itself, from benilla
font/string "Hyperlink regions", difftested: it multiplies the rect's y fields
`+0x08`/`+0x10` by 1/H and its x fields `+0x0c`/`+0x14` by 1/W and appends the
32-byte record to the array at `+0x7c`. Ghidra's own decompile of the call
args is mangled.)
**Still icon-blind (accepted residuals):**
- Sub-pixel only for a string-final icon: the width hooks report its INK edge
(advance minus the trail half-pad/ink — `TrailingIconTrimPen`), matching the
engine's own last-glyph convention (the gxu width loop ends on ink, not
advance — the source of the old −0.8px measurement, whose previous-glyph
advance treatment is semantically CORRECT once an icon renders after it).
- Sub-pixel only for an icon after a glyph: layout starts it at that glyph's
INK edge while measures count the glyph's advance, so the two differ by the
glyph's right bearing — the same discrepancy 4.3.4 has between its own
layout and measure loops. A string-final icon measures exactly (the gxu
width loop ends on the last glyph's ink, which is where the icon starts).
What remains is the icon region's ±0.5px placement rounding and the base
loop's ~0 contribution for the payload-0 icon token.
- An fs whose `fs+0xFC` cache was filled while the feature was toggled
differently (`_classicapi_InlineTexEnable`) keeps its old base width until
the next `SetText`/font change re-dirties it. Debug-toggle-only.
**Editbox measure stays raw (by design).** The caret/measure path reads the
focused editbox's INPUT buffer in place, and the tokenizer stands down on it
per-editbox (`TextInFocusedEditbox` pointer-range test — not a global focus
check), so caret math measures the raw `|T…|t` literal the editbox renders.
The width hook mirrors that with the `fs+0x120 & 0x1000` editable gate. One
marginal wobble: the focused single-line chat editbox's display fontstring
carries editable=0 (only multi-line editors set the bit), so the caret
positioner's line-boundary branch (multi-line-only in practice) can see an
icon-adjusted `FUN_00772890` on it. Closing that costs a per-token content
compare, which is not worth it.
### Edit boxes — 4.3.4's model, no special case
### Editbox exclusion — two COMPLEMENTARY signals (bit 6 + focus global)
4.3.4's edit box renders `|T…|t` in its text: no frame code ever sets the
FontString style bit 0x10000 that `ConvertStringFlags` maps to the gxu
texture-off flag 0x1000. Its cursor treats the span as one markup token
(`UpdateTextInfo` / `GetNumToLen`), and its `CopyToClipboard` strips with
0x1500 — colours, links AND textures. Typed markup never renders because a
typed `|` is stored as `||` (`CSimpleEditBox.cpp`, `ch == '|'`). 1.12 does
the same input escape, so ClassicAPI's edit boxes need no special case:
Editable text must show raw, editable `|T…|t` markup, not rendered icons — you
edit macros, tweak icon fields, copy/paste texture strings. **This is
retail-correct, verified in-game on a modern client:** typing
`|TInterface\Icons\INV_Misc_Coin_01:16|t` into the chat entry box OR a macro body
shows it **raw** (no icon) — Blizzard neutralizes player-*typed* escape sequences
as anti-abuse — while addon/system text (`AddMessage`, `GetCoinTextureString`)
renders. Retail's discriminator is the text's SOURCE (trusted vs player-typed),
not editbox-vs-display; a shift-clicked item link renders in the chat box because
it's inserted through the trusted path, but a typed `|T`/`|c` does not. Our
editbox-raw + display-renders split reproduces the same observable outcome. (One
mechanism-level divergence we accept: our editbox-raw uses the GLOBAL focus signal
below, so while an editbox is focused OTHER display text that rebuilds also
briefly shows raw; retail suppresses per-source. Cosmetic, and a per-editbox fix
would need hot-path text-buffer matching — not worth it.) 1.12's tokenizer,
unlike 4.3.4's, has no per-render texture-disable bit (4.3.4 uses tokenizer flag
**0x1000** to kill only `|T`, **0x800** to kill all escapes), and editbox text
nodes are NOT distinguishable by class from the render node — the emitter can't
reach the owning frame. It takes **two signals together**, not one (an earlier
version of this note wrongly called the focus global a redundant "belt-and-
suspenders fallback" — removing it renders the chat/copy boxes' `|T` as icons):
- **Render** — the label FontString (`RebuildLabel` `0x77d4d0`,
`SetText(buffer + scroll, 0)`, no pipe doubling) renders like any other,
and its regions parent to the edit box, so a ScrollFrame clips them.
- **Cursor** — the class array (`0x77ba90`) tokenizes through our tokenizer
hook, so a span is one class-6 letter: Left/Right cross it in one step,
Backspace deletes it whole, and an insert can never land inside it.
Class 6 is also the safe class — a skip class (0/4) as the last token
hangs the step loop (benilla ui/editboxmarkup "Faults"). 4.3.4 joins an
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
kernel counts an icon as one character of no width. Its co-hook mirrors
4.3.4's `GxuFontGetMaxCharsWithinWidth`: the run before each icon is fitted
by the original with the budget the earlier icons left, and an icon fits
when the icon-aware width through its end does.
- **Ctrl+C** — `CopySelection` (`0x77e1d0`) pushes 0x500; patched to 4.3.4's
0x1500, and the `GxuStripControlCodes` (`0x5c2590`) co-hook honours 0x1000
by dropping spans and stray `|t`. Paste re-escapes every `|`
(`InsertChar` per character), as in 4.3.4.
- **Node flag `[node+0x5c]` bit 6 (0x40) = EDITABLE** — catches the **macro
editor** (`MacroFrameText`, flags `0x4D`), which builds its layout once,
**un-focused**, so the focus global never sees it. But single-line inputs
(chat entry box, `pfChatCopyBox`) have bit 6 **CLEAR** — their nodes are
`0x0D`/`0x20D`, identical to display FontStrings — so bit 6 can't catch them.
- **Focused-editbox global `DAT_00cf4dc8`** (`InputFocused()`; holds the editbox
with keyboard focus, cleared by `ClearFocus` = `FUN_0077e410`) — catches those
single-line inputs. It's GLOBAL (true UI-wide, not per-node), which is why,
while an editbox is focused, other display text that *rebuilds* also briefly
renders raw. That's an accepted tradeoff: there is no per-node signal for
single-line inputs, and when you're in an editbox seeing raw markup is what you
want anyway.
**Why the tokenizer's bit-6 gate is load-bearing for RENDER, not just measure:**
when the emitter suppresses an editbox it delegates to the ORIGINAL emitter,
which **re-enters the tokenizer** to lay out glyphs (`FUN_005ccbe0` →
`FUN_005c2810`). On that render path `flags` is the node's own flags, so bit
0x40 makes the tokenizer stand down and the `|T` draws as literal text. Drop the
bit-6 gate and the (un-focused) macro editor draws **BLANK** — the tokenizer eats
the span as a zero-width token and the original emitter renders nothing (verified
in-game). Focused single-line inputs get the same "don't intercept" via
`Suppressed()` → `InputFocused()`. So both the emitter and the tokenizer consult
`Suppressed() || bit-6`; the two must agree or you get blank-vs-raw mismatches.
(Dead end: **monochrome render flag `DAT_00c2b9dc`** (`FUN_005c8b70` sets it per
group) — un-set at MacroFrameText's build time and flickered as a flush gate.
Dropped. The editbox class vtable is `0x0081c8c0`, its script-slot resolver
`FUN_0077a310` with focus slots at `+0x438`/`+0x440` — useful if a frame-level
hook is ever needed.)
(History: before the span rule, every typed `|T` reached the emitter as a
matching `||T`, so edit boxes were special-cased with three gates — node
flag bit 6, a focused-buffer pointer test, and a content compare of the
render line against the focused input — plus a re-entry bracket so the
delegated raw layout did not draw blank. All removed. Node flag bit 6 is
NON-SPACE-WRAP, not "editable"; the gate only worked because the macro
editor sets it.)
### Descriptor fields + the vertical flip
@@ -544,9 +613,7 @@ backend note.)
- **Still icon-blind** (accepted): the hyperlink hit-test past a tall icon
OUTSIDE the link on the same line. The substring measure `FUN_00772AE0` was
hooked once its real consumer surfaced (the tooltip auto-size measures
wrapped SEGMENTS with it), and the trailing-icon width residual is trimmed
to sub-pixel (`TrailingIconTrimPen` — a string-final icon reports its ink
edge, not its advance, per the engine's own last-glyph convention).
wrapped SEGMENTS with it).
## Goal & spec
@@ -556,9 +623,26 @@ Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequenc
|Tpath:height[:width[:offsetX:offsetY[:texWidth:texHeight:left:right:top:bottom[:rV:gV:bV]]]]|t
```
Atlas markup (`|A:atlas:…|a`) is Legion+ (needs an atlas DB 1.12 lacks) — out
of scope. FileDataID paths are Legion+ — 1.12 resolves texture PATH strings
only.
Atlas markup is also supported:
```
|A:atlasName:height:width[:offsetX:offsetY]|a
```
It shares every scanner, measure, wrap and placement stage with `|T` — only the
payload parser differs. `ParseAtlasIcon` resolves the name through
`Texture::Atlas` and fills the SAME `IconDesc`, because an atlas is exactly the
"path + normalized sub-rect" that struct already carries for a `|T` span with
texcoords. A `height`/`width` of `0` resolves to the atlas's own pixel size at
PARSE time (not the line font height `|T` falls back to), which is what keeps the
rest of the pipeline marker-agnostic. Tiling atlases draw one quad, so
`tilesHorizontally` / `tilesVertically` are not honored here.
Adding any further marker means touching `IconStartLen`, `FindIconClose`,
`InlineSpanLen`, the ghost guard in `FlushLayout` — and, mandatory,
`chat/IconFilter.cpp`, or the marker becomes a chat-spoof vector.
FileDataID paths are Legion+ — 1.12 resolves texture PATH strings only.
## Confirmed: 1.12 has ZERO inline-texture support
+2
View File
@@ -213,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
|--------------|--------------------------|---------------------------------------------------|
| `0x006F3140` | `setobj` / TValue copy | 16-byte memcpy |
| `0x006F58B0` | `equalobj` | called by `lua_equal` |
| `0x006F6050` | `luaD_precall` | frame setup for a call; Lua closure: reads Proto (`+0xc` of the closure), `is_vararg` at Proto `+0x46` → `adjust_varargs`, `numparams` `+0x45`, `maxstacksize` `+0x47`; C closure: reserves 0x14 slots and calls the C function |
| `0x006F6200` | `adjust_varargs` | stock 5.0: pads missing fixed params with nil, then `luaH_new(L, nvar, 1)` + `luaH_setnum` per extra arg + sets field `"n"` and pushes the table. So EVERY call to a vararg Lua function allocates one table, whether the body uses `arg` or `...` — keep vararg helpers off per-frame paths |
| `0x006F65A0` | `luaD_call` | the actual call dispatcher |
| `0x006F6960` | `luaD_pcall` | protected call dispatcher |
| `0x006F7340` | `luaD_growstack` (or checkstack) | invoked when stack needs space |
+38
View File
@@ -1259,4 +1259,42 @@
2: 0x00706040 CreateFont
3: 0x007060B0 CreateFrame
=== luaL_openlib base library Table 0x00811E28 Count=36 (not a FrameScript_RegisterFunction batch; see BlizzardScriptAPI.md §3) ===
0: 0x00702770 setglobal
1: 0x007027B0 getglobal
2: 0x007027E0 debugload
3: 0x007027F0 debuginfo
4: 0x00702800 debugprint
5: 0x00702810 debugdump
6: 0x00702820 debugbreak
7: 0x00702830 debugtimestamp
8: 0x00702840 debugprofilestart
9: 0x00702860 debugprofilestop
10: 0x00702900 seterrorhandler
11: 0x00702950 geterrorhandler
12: 0x00702970 error
13: 0x007029F0 getmetatable
14: 0x00702A40 setmetatable
15: 0x00702AC0 getfenv
16: 0x00702BE0 setfenv
17: 0x00702C90 next
18: 0x00702CD0 ipairs
19: 0x00702DA0 pairs
20: 0x00702DF0 type
21: 0x00702E30 tonumber
22: 0x00702F40 tostring
23: 0x007030A0 unpack
24: 0x007030F0 rawequal
25: 0x00703130 rawget
26: 0x00703160 rawset
27: 0x007031A0 assert
28: 0x00703200 gcinfo
29: 0x00703250 collectgarbage
30: 0x00703280 loadstring
31: 0x007032F0 pcall
32: 0x00703340 xpcall
33: 0x007033A0 date
34: 0x007035A0 time
35: 0x00703760 debugstack
Vendored Submodule
+1
+71
View File
@@ -14,6 +14,7 @@
#include "Common.h"
#include <cstring>
#include <string>
#include <windows.h>
namespace Common {
@@ -31,4 +32,74 @@ bool PatchBytes(void *dst, const void *src, size_t len) {
return true;
}
bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target) {
const auto *code = reinterpret_cast<const uint8_t *>(site);
if (code[0] != 0xE8)
return false;
const auto callee = [site](int32_t rel) { return site + 5 + static_cast<uintptr_t>(rel); };
int32_t rel = 0;
std::memcpy(&rel, code + 1, sizeof rel);
const uintptr_t to = reinterpret_cast<uintptr_t>(target);
if (callee(rel) == to)
return true;
if (callee(rel) != expected)
return false;
rel = static_cast<int32_t>(to - (site + 5));
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[64];
if (len < 5 || len > sizeof code)
return false;
code[0] = 0xE8;
const int32_t rel = static_cast<int32_t>(reinterpret_cast<uintptr_t>(target) - (site + 5));
std::memcpy(code + 1, &rel, sizeof rel);
std::memset(code + 5, 0x90, len - 5);
const void *at = reinterpret_cast<const void *>(site);
if (std::memcmp(at, code, len) == 0)
return true;
if (std::memcmp(at, original, len) != 0)
return false;
return PatchBytes(reinterpret_cast<void *>(site), code, len);
}
void BoundedCopy(char *dst, const char *src, size_t n) {
if (dst == nullptr || n == 0)
return;
size_t i = 0;
for (; src != nullptr && src[i] != '\0' && i + 1 < n; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
void EnsureParentDirs(const char *relPath) {
std::string accum;
for (const char *p = relPath; *p != '\0'; ++p) {
if (*p == '\\' || *p == '/') {
if (!accum.empty())
CreateDirectoryA(accum.c_str(), nullptr);
accum.push_back('\\');
} else {
accum.push_back(*p);
}
}
}
} // namespace Common
+30
View File
@@ -11,8 +11,38 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include <cstddef>
#include <cstdint>
namespace Common {
bool PatchBytes(void *dst, const void *src, size_t len);
// Points the `E8 rel32` call at `site` to `target` when it still calls
// `expected`; true when the call now reaches `target`. A call that reaches
// anything else (another DLL's patch, a different build) is left alone.
// Idempotent, so it can run on every registration pass.
bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target);
// 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);
// Bounded, always-terminated string copy: writes at most `n - 1`
// characters plus the terminator, and treats a null `src` as empty. `n`
// is the size of `dst`. Stands in for `strncpy`, which MSVC deprecates
// (C4996) and which does not terminate on truncation.
void BoundedCopy(char *dst, const char *src, size_t n);
// Creates every parent directory of `relPath` (backslash- or slash-separated,
// e.g. "BlizzardInterfaceCode\FrameXML\Foo.lua"), relative to the working
// directory. Directories that already exist are a no-op.
void EnsureParentDirs(const char *relPath);
} // namespace Common
+23 -47
View File
@@ -15,10 +15,9 @@
#include "Game.h"
#include "MinHook.h"
#include "Offsets.h"
#include "config/CommandLine.h"
#include "config/FileSwitch.h"
#include "event/Custom.h"
#include "luasyntax/StringMethods.h"
#include "model/DisplayInfo.h"
#include "nameplate/Walk.h"
#include "player/NameCache.h"
#include "text/InlineTexture.h"
#include "text/InlineTexturePool.h"
@@ -39,47 +38,17 @@ static FrameRegisterEvent_t FrameRegisterEvent_o = nullptr;
static void __fastcall InvalidFunctionPtrCheck_h() {}
static bool __fastcall FrameScript_Initialize_h() {
// BEFORE the engine tears down the old event table (at the start of
// FrameScript_Initialize), invalidate our cached slot indices. The
// table is rebuilt at a fresh allocation; the old slots are stale.
Event::Custom::PrepareForReload();
// Fire every module's PrepareForReload BEFORE the engine tears down the old
// event table / Lua state. Each module that keeps reload-fragile state (Lua
// refs, frame-pointer-keyed maps, cached event-slot indices) self-registers
// via `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`,
// so this one call clears them all — a new module can't forget to wire in
// (see the ReloadAutoRegister doc in Game.h). This hook fires on both
// `/reload` and `/logout` (the engine re-inits the Lua state in both).
Game::RunReloadCleanups();
// Clear the nameplate diff state so currently-visible plates
// refire CREATED and UNIT_ADDED on the next tick — re-presents
// them to the freshly reloaded UI, matching modern WoW. The
// wrappers themselves now live in the engine's own registry
// (via `FrameScript_Object::ScriptRegister`), which the engine
// tears down and rebuilds across the Lua reset — no per-module
// cache to clear here.
NamePlate::Events::PrepareForReload();
// The reload teardown destroys every icon pool texture, fontstring,
// and text node — forget all inline-icon pointers now so nothing
// touches them (see InlineTexture.h / InlineTexturePool.h).
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
// Drop per-region corner transforms (SetRotation / SetVertexOffset) — the
// reload destroys every addon texture and the region pool reuses their pointers.
Texture::Transform::PrepareForReload();
// Drop per-region texture masks for the same reason (SetMask).
Texture::Mask::PrepareForReload();
// Drop pending character-dress jobs and their engine compositors — the
// reload destroys every Model frame the jobs point at.
Model::DisplayInfo::PrepareForReload();
// Forget the captured `string` table before the world Lua state resets
// (this hook also fires on /logout, where the state's tables die) —
// string-method resolution fails closed (stock index error) until
// LoadScriptFunctions re-captures.
LuaSyntax::StringMethods::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
// deterministic save point for the common lifecycle events.
// Persist the name cache before the engine tears down — a SAVE, not a clear,
// so it stays an explicit call rather than a ReloadAutoRegister.
Player::NameCache::Flush();
FrameScript_Initialize_o();
@@ -113,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);
}
@@ -240,6 +207,15 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hModule);
// The ONE thing that has to happen this early, and the only modules this
// file names directly. The engine parses its launch switches and reads
// its config file during boot, several calls before VanillaFixes invokes
// `Load`, so our switches cannot be wired from the normal install path.
// Each is a single rel32 store, not a hook, so it carries none of the
// loader-lock cost described above.
Config::CommandLine::Install();
Config::FileSwitch::Install();
// Install nothing here (loader lock — see the block comment above).
//
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
+305 -54
View File
@@ -14,9 +14,42 @@
#include "Game.h"
#include "MinHook.h"
#include "Offsets.h"
#include "api/Documentation.h"
namespace Game {
void *ResolveUnitToken(const char *token) {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn(token);
}
float PixelToInternal(float px) {
const float mul = Read<float>(Offsets::VAR_COORD_DDC_WIDTH_SCALE);
const float div = Read<float>(Offsets::VAR_COORD_ASPECT_COMPENSATION);
if (div == 0.0f)
return 0.0f;
return px * mul / (div * static_cast<float>(Offsets::UI_COORD_SCALE_UNIT));
}
double InternalToPixel(float internal) {
const float mul = Read<float>(Offsets::VAR_COORD_DDC_WIDTH_SCALE);
const float div = Read<float>(Offsets::VAR_COORD_ASPECT_COMPENSATION);
if (mul == 0.0f)
return 0.0;
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
@@ -38,8 +71,11 @@ namespace Lua {
F(NewTable, lua_newtable, LUA_NEW_TABLE) \
F(GetTable, lua_gettable, LUA_GET_TABLE) \
F(RawGet, lua_rawget, LUA_RAW_GET) \
F(RawGetI, lua_rawgeti, LUA_RAWGETI) \
F(SetTable, lua_settable, LUA_SET_TABLE) \
F(RawSet, lua_rawset, LUA_RAW_SET) \
F(GetMetatable,lua_getmetatable,LUA_GET_METATABLE)\
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
F(Insert, lua_insert, LUA_INSERT) \
F(Remove, lua_remove, LUA_REMOVE) \
F(GetTop, lua_gettop, LUA_GET_TOP) \
@@ -48,6 +84,7 @@ namespace Lua {
F(PCall, lua_pcall, LUA_PCALL) \
F(Next, lua_next, LUA_NEXT) \
F(Type, lua_type, LUA_TYPE) \
F(TypeName, lua_typename, LUA_TYPE_NAME) \
F(Error, lua_error, LUA_ERROR) \
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
F(ToThread, lua_tothread, LUA_TO_THREAD) \
@@ -58,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);
@@ -92,26 +132,114 @@ void *ResolveObject(void *L, int idx) {
return result;
}
void RegisterGlobalFunction(const char *name, CFunction func) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
int ObjectLuaRef(void *object) {
using ScriptRegister_t = void(__thiscall *)(void *object, void *nameOrNull);
if (Game::Read<int>(object, Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
reinterpret_cast<ScriptRegister_t>(
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(object, nullptr);
return Game::Read<int>(object, Offsets::OFF_COBJECT_LUA_REF);
}
// Aliased to the same engine entry; the engine reads VAR_LUA_STATE
// internally to decide which state to write to, and the glue hook
// runs while that pointer is set to the glue state. Wrapped as a
// named function (rather than `using RegisterGlueFunction = ...`) so
// callers express intent at the call site.
void RegisterGlueFunction(const char *name, CFunction func) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
void PushObject(void *L, void *object) {
RawGetI(L, REGISTRY_INDEX, ObjectLuaRef(object));
}
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count) {
auto fn = reinterpret_cast<RegisterFrameMethods_t>(Offsets::FUN_REGISTER_FRAME_METHODS);
fn(table, count, context);
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);
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);
}
namespace {
using FrameScriptIsA_t = char(__thiscall *)(void *self, int typeId);
const char *EngineString(uintptr_t address) {
return reinterpret_cast<const char *>(address);
}
// Shared body: the engine's prologue with N accepted type ids.
void *ResolveChecked(void *L, int idx, const uintptr_t *typeIdVars, int count,
bool raiseError) {
if (Type(L, idx) != TYPE_TABLE) {
if (raiseError)
Error(L, EngineString(Offsets::STR_FS_THIS_NON_TABLE));
return nullptr;
}
void *obj = ResolveObject(L, idx);
if (obj == nullptr) {
if (raiseError)
Error(L, EngineString(Offsets::STR_FS_THIS_NON_OBJECT));
return nullptr;
}
for (int i = 0; i < count; ++i) {
if (IsA(obj, typeIdVars[i]))
return obj;
}
if (raiseError)
Error(L, EngineString(Offsets::STR_FS_WRONG_OBJECT_TYPE));
return nullptr;
}
} // namespace
int FrameScriptTypeId(uintptr_t typeIdVar) {
auto &typeId = Game::Ref<int>(typeIdVar);
if (typeId == 0) {
auto &counter = Game::Ref<int>(Offsets::VAR_FRAMESCRIPT_TYPE_ID_COUNTER);
counter += 1;
typeId = counter;
}
return typeId;
}
bool IsA(void *object, uintptr_t typeIdVar) {
auto **vtable = *reinterpret_cast<void ***>(object);
auto isA = reinterpret_cast<FrameScriptIsA_t>(
vtable[Offsets::OFF_VMT_FRAMESCRIPT_ISA / sizeof(void *)]);
return isA(object, FrameScriptTypeId(typeIdVar)) != 0;
}
void *ResolveTypedObject(void *L, int idx, uintptr_t typeIdVar, bool raiseError) {
return ResolveChecked(L, idx, &typeIdVar, 1, raiseError);
}
void *ResolveTypedObjectAny(void *L, int idx, const uintptr_t *typeIdVars,
int count, bool raiseError) {
return ResolveChecked(L, idx, typeIdVars, count, raiseError);
}
void *ResolveTooltip(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_GAMETOOLTIP_LUA_TYPE_ID, raiseError);
}
void *ResolveFrame(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_FRAME_LUA_TYPE_ID, raiseError);
}
void *ResolveRegion(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_REGION_LUA_TYPE_ID, raiseError);
}
void *ResolveTexture(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_TEXTURE_LUA_TYPE_ID, raiseError);
}
void *ResolveFontString(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_FONTSTRING_LUA_TYPE_ID, raiseError);
}
void *ResolveEditBox(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_EDITBOX_LUA_TYPE_ID, raiseError);
}
void *ResolveModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_MODEL_LUA_TYPE_ID, raiseError);
}
// Looks up `_G[name]`. If absent, creates a fresh table and binds it.
@@ -129,11 +257,110 @@ void EnsureGlobalTable(void *L, const char *name) {
Insert(L, -2); // [tbl, name, tbl]
SetTable(L, GLOBALS_INDEX); // _G[name] = tbl; pops k+v. [tbl]
}
// Same, one level down: ensures `parent[name]` is a table, with `parent`
// on top of the stack. Leaves `[parent, sub]`. Raw access, so a namespace
// carrying a metatable can't divert the read or the write.
void EnsureSubTable(void *L, const char *name) {
PushString(L, name); // [parent, name]
RawGet(L, -2); // [parent, parent[name]]
if (Type(L, -1) == TYPE_TABLE)
return;
SetTop(L, -2); // pop the non-table. [parent]
NewTable(L); // [parent, sub]
PushString(L, name); // [parent, sub, name]
PushValue(L, -2); // [parent, sub, name, sub]
RawSet(L, -4); // parent[name] = sub. [parent, sub]
}
// The escape-hatch namespace. Registering a name is not the same as owning
// it: the engine's registrars write `_G`, FrameXML and every addon load
// after that, and whoever writes last wins with no error and no way for the
// loser to notice. `GetServerTime` is the case on record — a client whose
// GameTime.lua declares its own replaced ours on every login.
//
// So every name the registrars below bind is also bound under
// `_G.ClassicAPI`, which nothing else writes. Two properties earn their
// keep, and both depend on doing this HERE rather than from Lua:
//
// - It runs at registration, so the mirror captures OUR function. A
// Lua-side `ClassicAPI.X = X` in the embedded addon would run after
// FrameXML and faithfully copy the clobbered version.
// - It mirrors by VALUE, reading the name back instead of making a
// second closure over the same C function. So `ClassicAPI.X == X`
// holds until something replaces the global, and the pair doubles as
// a clobber check for addons and for us.
//
// Not covered: frame methods (`obj:Method()` lives in the per-frame-type
// registry, not `_G`) and `RegisterIntegerEnum` values (a mirrored number
// is a copy, not a reference).
constexpr const char *kMirrorTable = "ClassicAPI";
void MirrorRegistration(const char *tableName, const char *name) {
void *L = State();
if (L == nullptr)
return;
const int top = GetTop(L);
// Read back whatever the registration actually bound.
if (tableName != nullptr) {
EnsureGlobalTable(L, tableName); // [src]
PushString(L, name); // [src, name]
RawGet(L, -2); // [src, value]
} else {
PushString(L, name); // [name]
GetTable(L, GLOBALS_INDEX); // [value]
}
if (Type(L, -1) != TYPE_FUNCTION) {
SetTop(L, top); // registration didn't land; nothing to mirror
return;
}
const int valueIdx = GetTop(L); // absolute, so the pushes below can't skew it
EnsureGlobalTable(L, kMirrorTable); // [..., mirror]
if (tableName != nullptr)
EnsureSubTable(L, tableName); // [..., mirror, sub]
PushString(L, name);
PushValue(L, valueIdx);
RawSet(L, -3); // target[name] = value
SetTop(L, top);
}
} // namespace
void RegisterGlobalFunction(const char *name, CFunction func, const Doc::Function *doc) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
MirrorRegistration(nullptr, name);
Api::Documentation::RecordGlobal(name, doc);
}
// Aliased to the same engine entry; the engine reads VAR_LUA_STATE
// internally to decide which state to write to, and the glue hook
// runs while that pointer is set to the glue state. Wrapped as a
// named function (rather than `using RegisterGlueFunction = ...`) so
// callers express intent at the call site.
void RegisterGlueFunction(const char *name, CFunction func, const Doc::Function *doc) {
auto fn = reinterpret_cast<FrameScript_RegisterFunction_t>(
Offsets::FUN_FRAMESCRIPT_REGISTER_FUNCTION);
fn(name, func);
// `State()` is the glue state here for the same reason the engine
// registrar writes there, so the mirror lands on the glue `_G`.
MirrorRegistration(nullptr, name);
Api::Documentation::RecordGlobal(name, doc);
}
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs, int docCount) {
auto fn = reinterpret_cast<RegisterFrameMethods_t>(Offsets::FUN_REGISTER_FRAME_METHODS);
fn(table, count, context);
Api::Documentation::RecordFrameMethods(context, table, count, docs, docCount);
}
// Registers `func` at `_G[tableName][methodName]`. If the namespace
// doesn't already exist, creates an empty table for it.
void RegisterTableFunction(const char *tableName, const char *methodName, CFunction func) {
void RegisterTableFunction(const char *tableName, const char *methodName, CFunction func,
const Doc::Function *doc) {
void *L = State();
if (L == nullptr)
return;
@@ -142,10 +369,41 @@ void RegisterTableFunction(const char *tableName, const char *methodName, CFunct
PushCClosure(L, func, 0); // [tbl, methodName, closure]
SetTable(L, -3); // tbl[m]=c; pops k+v. [tbl]
SetTop(L, -2); // pop tbl. []
MirrorRegistration(tableName, methodName);
Api::Documentation::RecordTable(tableName, methodName, doc);
}
// `_G[alias] = _G[tableName][methodName]`, by VALUE. The engine binds its
// short aliases this way (`tinsert = tab.insert` in the compat snippet it
// runs at Lua init), so a module that registers over a library function has
// to re-bind the alias to the same closure — that keeps `tinsert ==
// table.insert` true, as the engine left it, instead of minting a second
// closure. A plain raw write, exactly what the snippet's assignment did. No-op
// if the source is not a function (nothing was registered to alias).
void RegisterGlobalAlias(const char *alias, const char *tableName, const char *methodName,
const Doc::Function *doc) {
void *L = State();
if (L == nullptr)
return;
const int top = GetTop(L);
EnsureGlobalTable(L, tableName); // [tbl]
PushString(L, methodName); // [tbl, m]
RawGet(L, -2); // [tbl, value]
if (Type(L, -1) != TYPE_FUNCTION) {
SetTop(L, top);
return;
}
PushString(L, alias); // [tbl, value, alias]
PushValue(L, -2); // [tbl, value, alias, value]
RawSet(L, GLOBALS_INDEX); // _G[alias] = value. [tbl, value]
SetTop(L, top);
MirrorRegistration(nullptr, alias);
Api::Documentation::RecordGlobal(alias, doc);
}
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count) {
const EnumIntegerEntry *entries, int count,
const char *docSystem) {
void *L = State();
if (L == nullptr)
return;
@@ -159,6 +417,7 @@ void RegisterIntegerEnum(const char *parent, const char *sub,
}
SetTable(L, -3); // parentTbl[sub] = subTbl
SetTop(L, -2); // pop parentTbl
Api::Documentation::RecordEnum(parent, sub, entries, count, docSystem);
}
void SetFieldNumber(void *L, const char *key, double value) {
@@ -179,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);
@@ -223,45 +488,14 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback)
}
}
// In-place `string.gsub(stack[-1], pattern, replacement)` — replaces
// the string at the top of the stack with the substitution result.
// Discards gsub's second return (the substitution count) by
// requesting only one return from `Call`.
static void GsubInPlace(void *L, const char *pattern, const char *replacement) {
PushString(L, "string");
GetTable(L, GLOBALS_INDEX);
PushString(L, "gsub");
GetTable(L, -2); // [..., str, string, gsub]
Remove(L, -2); // [..., str, gsub]
Insert(L, -2); // [..., gsub, str]
PushString(L, pattern);
PushString(L, replacement);
Call(L, 3, 1); // [..., result]
}
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n) {
// Resolve `string.format` from _G.
PushString(L, "format");
GetTable(L, GLOBALS_INDEX);
PushLocalizedString(L, globalName, fallback);
// Expand WoW's `|4singular:plural;` (and 3-form
// `|4nom:gen_sing:plural;`) pluralization template by piggybacking
// on Lua's `string.gsub`. The 2- vs 3-form distinction is locale
// grammar (Slavic counting has a separate genitive-singular form
// for 2..4); we collapse to nominative/plural since vanilla has
// no proper locale-aware counting rules. 3-form pattern runs
// first because it's more specific. Vanilla's `string.format`
// doesn't process `|4` natively, so without this the template
// would render literally.
const char *picker3 = (n == 1) ? "%1" : "%3";
const char *picker2 = (n == 1) ? "%1" : "%2";
GsubInPlace(L, "|4([^:;]+):([^:;]+):([^;]+);", picker3);
GsubInPlace(L, "|4([^:;]+):([^;]+);", picker2);
PushNumber(L, static_cast<double>(n));
Call(L, 2, 1); // format(expandedFmt, n) → string
Call(L, 2, 1); // format(fmt, n) → string
}
} // namespace Lua
@@ -269,7 +503,7 @@ namespace Console {
namespace {
using RegisterCommand_t = int(__fastcall *)(const char *name, void *handler,
int category, const char *description);
using Write_t = void(__fastcall *)(const char *line, int colorFlag);
using Write_t = void(__fastcall *)(const char *line, int color);
} // namespace
void RegisterCommand(const char *name, CommandHandler handler, int category,
@@ -278,16 +512,20 @@ void RegisterCommand(const char *name, CommandHandler handler, int category,
fn(name, reinterpret_cast<void *>(handler), category, description);
}
void Write(const char *line) {
void Write(const char *line, Color color) {
auto fn = reinterpret_cast<Write_t>(Offsets::FUN_CONSOLE_WRITE);
fn(line, 0);
fn(line, color);
}
void(__cdecl *const Printf)(const char *format, ...) =
reinterpret_cast<void(__cdecl *)(const char *, ...)>(Offsets::FUN_CONSOLE_PRINTF);
} // namespace Console
namespace {
ModuleAutoRegister *g_moduleHead = nullptr;
GlueModuleAutoRegister *g_glueModuleHead = nullptr;
HookAutoRegister *g_hookHead = nullptr;
ReloadAutoRegister *g_reloadHead = nullptr;
} // namespace
ModuleAutoRegister::ModuleAutoRegister(Fn f) : fn(f), next(g_moduleHead) {
@@ -295,8 +533,10 @@ ModuleAutoRegister::ModuleAutoRegister(Fn f) : fn(f), next(g_moduleHead) {
}
void RunModuleRegistrations() {
Api::Documentation::BeginPass(Api::Documentation::ENV_GAME);
for (auto *node = g_moduleHead; node != nullptr; node = node->next)
node->fn();
Api::Documentation::EndPass();
}
GlueModuleAutoRegister::GlueModuleAutoRegister(Fn f)
@@ -305,8 +545,19 @@ GlueModuleAutoRegister::GlueModuleAutoRegister(Fn f)
}
void RunGlueModuleRegistrations() {
Api::Documentation::BeginPass(Api::Documentation::ENV_GLUE);
for (auto *node = g_glueModuleHead; node != nullptr; node = node->next)
node->fn();
Api::Documentation::EndPass();
}
ReloadAutoRegister::ReloadAutoRegister(Fn f) : fn(f), next(g_reloadHead) {
g_reloadHead = this;
}
void RunReloadCleanups() {
for (auto *node = g_reloadHead; node != nullptr; node = node->next)
node->fn();
}
HookAutoRegister::HookAutoRegister(uintptr_t target, void *hook, void **original)
+361 -16
View File
@@ -13,6 +13,7 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace Game {
@@ -57,6 +58,44 @@ inline T &Ref(uintptr_t address) {
return *reinterpret_cast<T *>(address);
}
// Calls `fn(object)` for each object of one of a frame's intrusive lists,
// OFF_FRAME_REGION_LIST or OFF_FRAME_CHILD_LIST: node+4 next, node+8 object,
// a NULL or low-bit-1 link ends it - the walk the engine's own frame code
// does (the alpha setter, the bounds walk 0x00786F80).
template <typename Fn>
void ForEachFrameListObject(void *frame, uintptr_t listOffset, Fn fn) {
uintptr_t link = Read<uintptr_t>(frame, listOffset);
while (link != 0 && (link & 1) == 0) {
void *obj = *reinterpret_cast<void *const *>(link + 8);
const uintptr_t next = *reinterpret_cast<const uintptr_t *>(link + 4);
if (obj != nullptr)
fn(obj);
link = next;
}
}
// --- Engine primitives ------------------------------------------------------
// Resolve a unit token (`"player"`, `"target"`, `"party3"`, `"nameplate1"`,
// …) to its live `CGUnit_C *`, through the engine's own resolver
// (`FUN_RESOLVE_UNIT_TOKEN`). Returns null for a valid token that currently
// has no unit (e.g. `"target"` with nothing targeted). NOTE: the engine
// RAISES a Lua error for a string that isn't a valid token form (an arbitrary
// name like `"Bob"`), so only pass real unit tokens — the same contract every
// stock `Unit*` global carries. Centralizes the
// `reinterpret_cast<…>(FUN_RESOLVE_UNIT_TOKEN)` idiom that was copy-pasted
// across a dozen modules.
void *ResolveUnitToken(const char *token);
// UI pixels <-> internal layout (anchor) units. Script_SetPoint converts its
// pixel offsets with `px * [VAR_COORD_DDC_WIDTH_SCALE] / ([VAR_COORD_ASPECT_COMPENSATION]
// * UI_COORD_SCALE_UNIT)`; Script_GetLeft and Script_GetStringWidth push the
// inverse. A region's LOCAL pixels must be multiplied by its effective scale
// (OFF_REGION_EFFECTIVE_SCALE) first. Both return 0 while the scale globals
// are unset, so an early caller gets a zero offset rather than a wild one.
float PixelToInternal(float px);
double InternalToPixel(float internal);
using FrameScript_Initialize_t = bool(__fastcall *)();
using LoadScriptFunctions_t = void(__fastcall *)();
@@ -65,6 +104,126 @@ 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
// carry a descriptor: the signature the function presents to Lua — typed
// arguments and returns, nilability, defaults — plus a one-sentence summary.
// The descriptors are the single source of truth for the API's SHAPE.
// `src/api/Documentation.cpp` records each registration on the first pass of
// each Lua state and exports the result on demand as Blizzard
// `APIDocumentation` tables (`C_APIDocumentation.GetSystems` / `GetSystem`),
// which the embedded addon's `/api` browser renders. Prose beyond one
// sentence stays in the docs; a descriptor is shape, not manual.
//
// Rules:
// - One descriptor per REGISTERED NAME, not per C function. The same
// `Script_*` bound twice with different return conventions
// (`IsUsableSpell` → 1/nil pairs, `C_Spell.IsSpellUsable` → booleans)
// gets two descriptors; a pair with the same shape may share one.
// - `type` uses Blizzard's spellings — "number", "string", "bool",
// "table", "luaIndex", "UnitToken", "SpellIdentifier", … — or the Name
// of a `Structure` / enumeration declared elsewhere, which the browser
// renders as a link.
// - `Function::system` names the System a GLOBAL belongs to; convention
// `<Area>Globals` ("SpellGlobals", "ItemGlobals"). Table functions
// derive theirs from the table ("C_Spell" → System "Spell", Namespace
// "C_Spell"); frame methods from the registry. A global's system must
// not reuse a namespaced System's Name — the browser would prefix it.
// - Descriptors are `const` objects with constant initializers (string
// literals + file-scope arrays): no heap, no dynamic init.
// - Summaries are user-facing text: one sentence, plain words, present
// tense, no patch versions.
namespace Doc {
struct Field {
const char *name;
const char *type;
bool nilable;
const char *defaultValue; // Lua literal ("false", "0", "\"player\""), or nullptr
const char *doc; // one sentence, or nullptr
};
// Required argument / always-present return.
constexpr Field Req(const char *name, const char *type, const char *doc = nullptr) {
return {name, type, false, nullptr, doc};
}
// Optional: nilable when no default is given, otherwise non-nil with that
// default (Blizzard marks defaulted arguments `Nilable = false` + `Default`).
constexpr Field Opt(const char *name, const char *type, const char *dflt = nullptr,
const char *doc = nullptr) {
return {name, type, dflt == nullptr, dflt, doc};
}
// Variable arguments / returns.
constexpr Field Vararg(const char *type, const char *doc = nullptr) {
return {"...", type, true, nullptr, doc};
}
// A view over a file-scope `const Field[]`; `{}` means none.
struct FieldList {
const Field *data = nullptr;
int count = 0;
constexpr FieldList() = default;
template <size_t N>
constexpr FieldList(const Field (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
// A registered function. Positional init: `{summary, args, rets}` for a
// table function, `{summary, args, rets, "XGlobals"}` for a global, and
// `{summary, args, rets, nullptr, true}` for a namespaced ClassicAPI
// extension.
struct Function {
const char *summary;
FieldList args;
FieldList rets;
const char *system; // globals / glue / aliases only; see the rules above
bool extension; // a ClassicAPI-original API, not a Blizzard one
};
// Pairs a frame method with its descriptor BY NAME (the engine's method
// tables are name-keyed; `SetShown` exists on four registries with four
// function pointers). Order is free; unmatched names are reported.
struct Method {
const char *name;
const Function *doc;
};
// A table shape a function returns or accepts → `Tables{Type="Structure"}`
// in `system`. Self-registering: nothing binds a structure to Lua, so it
// chains onto a static list at construction. Declare it next to the
// function that returns it.
struct Structure {
Structure(const char *name, const char *system, FieldList fields,
const char *summary = nullptr);
const char *name;
const char *system;
FieldList fields;
const char *summary;
const Structure *next;
};
// A custom event's payload, attached to its `Event::Custom::AutoReserve`.
struct Event {
const char *system;
const char *summary;
FieldList payload;
};
} // namespace Doc
namespace Lua {
using CFunction = int(__fastcall *)(void *L);
@@ -108,8 +267,17 @@ using lua_pushcclosure_t = void(__fastcall *)(void *L, CFunction fn, int upvals)
using lua_newtable_t = void(__fastcall *)(void *L);
using lua_gettable_t = void(__fastcall *)(void *L, int idx);
using lua_rawget_t = void(__fastcall *)(void *L, int idx);
// Pushes t[n] for the table at idx, raw (no metamethods). With idx =
// REGISTRY_INDEX it pushes a value stored under an integer ref.
using lua_rawgeti_t = void(__fastcall *)(void *L, int idx, int n);
using lua_settable_t = void(__fastcall *)(void *L, int idx);
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
// Pushes the metatable of the value at idx and returns 1, or pushes
// nothing and returns 0 when it has none.
using lua_getmetatable_t = int(__fastcall *)(void *L, int idx);
// Pops the table at the top and installs it as the metatable of the value
// at idx; returns 1.
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_insert_t = void(__fastcall *)(void *L, int idx);
using lua_remove_t = void(__fastcall *)(void *L, int idx);
using lua_gettop_t = int(__fastcall *)(void *L);
@@ -118,6 +286,7 @@ using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
using lua_pcall_t = int(__fastcall *)(void *L, int nargs, int nresults, int errfunc);
using lua_next_t = int(__fastcall *)(void *L, int idx);
using lua_type_t = int(__fastcall *)(void *L, int index);
using lua_typename_t = const char *(__fastcall *)(void *L, int type);
// The 1.12 `lua_error` wrapper at `0x6F4940` is actually variadic —
// it does `lua_pushvfstring(L, fmt, args)` then prepends `luaL_where`
// and throws. Existing callers pass a single literal (no `%`), which
@@ -140,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;
@@ -156,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;
@@ -163,8 +348,11 @@ extern const lua_pushcclosure_t PushCClosure;
extern const lua_newtable_t NewTable;
extern const lua_gettable_t GetTable;
extern const lua_rawget_t RawGet;
extern const lua_rawgeti_t RawGetI;
extern const lua_settable_t SetTable;
extern const lua_rawset_t RawSet;
extern const lua_getmetatable_t GetMetatable;
extern const lua_setmetatable_t SetMetatable;
extern const lua_insert_t Insert;
extern const lua_remove_t Remove;
extern const lua_gettop_t GetTop;
@@ -173,6 +361,7 @@ extern const lua_call_t Call;
extern const lua_pcall_t PCall;
extern const lua_next_t Next;
extern const lua_type_t Type;
extern const lua_typename_t TypeName;
extern const lua_error_t Error;
extern const lua_topointer_t ToPointer;
extern const lua_tothread_t ToThread;
@@ -183,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.
@@ -199,9 +391,88 @@ void *State();
// the `self` slot of any `frame:method(...)` invocation.
void *ResolveObject(void *L, int idx);
// The inverse: the object's Lua-registry ref, created on first use exactly as
// the engine does it (GetMouseFocus and the script runners register an
// object with FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER when its refcount is 0).
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>"`.
const char *ObjectDisplayName(void *object);
// Same resolve, then the type gate every engine frame method applies to its
// `self` before touching it: `lua_type == table`, resolve to the object, then
// the `IsA(typeId)` vmethod at vtable+0x10. `ResolveObject` alone is not
// enough for a method that casts the result — it hands back a
// `CFrameScriptObject *` for ANY frame, so `GameTooltip.SetSpellByID(button)`
// would reach the tooltip builder with a Button. Frame methods are ordinary
// first-class Lua values (the per-type dispatcher is an `__index` handler
// that returns the method closure), so that call is reachable from plain Lua
// and the gate is not optional.
//
// `typeIdVar` is the class's `VAR_*_LUA_TYPE_ID`, which must match the
// registry the method was registered on. The id is assigned lazily by
// whichever method of the class runs first, so these mirror that assignment
// and work even before any stock method of the type has been called.
//
// Raises the engine's own three errors and returns null. Pass
// `raiseError = false` for an optional object ARGUMENT, where the caller
// wants a silent null rather than a "this" error naming the wrong slot.
// Returns null either way, so callers always check.
void *ResolveTypedObject(void *L, int idx, uintptr_t typeIdVar,
bool raiseError = true);
// For a method registered on SEVERAL registries, where any of those types is
// a legitimate `self`. Accepts the object if `IsA` passes for any of the
// listed ids — which is exactly what being in all of those registries means,
// and avoids having to pin down the shared base class.
void *ResolveTypedObjectAny(void *L, int idx, const uintptr_t *typeIdVars,
int count, bool raiseError = true);
// The class's live type id, assigning it if the engine has not yet. Needed
// directly only when calling a type-specific vtable slot.
int FrameScriptTypeId(uintptr_t typeIdVar);
// The object's `IsA(typeId)` vmethod for a class's `VAR_*_LUA_TYPE_ID`.
bool IsA(void *object, uintptr_t typeIdVar);
// Per-type sugar over `ResolveTypedObject`, so a call site cannot pair a
// method with the wrong class's id. `idx` defaults to the `self` slot.
void *ResolveTooltip(void *L, int idx = 1, bool raiseError = true);
void *ResolveFrame(void *L, int idx = 1, bool raiseError = true);
void *ResolveRegion(void *L, int idx = 1, bool raiseError = true);
void *ResolveTexture(void *L, int idx = 1, bool raiseError = true);
void *ResolveFontString(void *L, int idx = 1, bool raiseError = true);
void *ResolveEditBox(void *L, int idx = 1, bool raiseError = true);
void *ResolveModel(void *L, int idx = 1, bool raiseError = true);
// Every name these registrars bind is ALSO bound under `_G.ClassicAPI` —
// `ClassicAPI.GetSpellInfo`, `ClassicAPI.C_Item.IsBound` — as an escape
// hatch for names something else replaces later, and by value so
// `ClassicAPI.X == X` until that happens. Automatic; a new module needs no
// extra call. See `MirrorRegistration` in Game.cpp for why it lives here
// and what it does not cover.
//
// Every registrar also RECORDS the registration for the API documentation
// (`src/api/Documentation.cpp`): kind, table, name, registry, and which Lua
// state's pass it ran in (in-game / glue / both). Only the FIRST pass of
// each state records — later passes (every `/reload`) are no-ops — and a
// registration made outside a `Run*Registrations` pass is not recorded at
// all. The trailing `doc` argument is the descriptor (see `Game::Doc`
// above); pass `nullptr` for a not-yet-documented registration and
// `_classicapi_UndocumentedAPI()` lists it. The defaults exist only until
// the sweep finishes; then they go and a missing descriptor stops compiling.
// Registers a single global Lua function (e.g. `GetSpellInfo`). The function
// must use the WoW Lua C function ABI: `int __fastcall(void *L)`.
void RegisterGlobalFunction(const char *name, CFunction func);
void RegisterGlobalFunction(const char *name, CFunction func,
const Doc::Function *doc = nullptr);
// Glue-state equivalent of `RegisterGlobalFunction`. Identical wire
// (calls `FrameScript_RegisterFunction`); the engine routes the
@@ -211,7 +482,8 @@ void RegisterGlobalFunction(const char *name, CFunction func);
// its own 109 glue functions, and `VAR_LUA_STATE` still points at the
// glue state. Calling outside that window would silently target the
// wrong state.
void RegisterGlueFunction(const char *name, CFunction func);
void RegisterGlueFunction(const char *name, CFunction func,
const Doc::Function *doc = nullptr);
// Frame-method registration entry: { name, func } pairs walked by the engine's
// per-frame-type method-table iterator. Layout matches what the engine expects
@@ -223,15 +495,26 @@ struct FrameMethodEntry {
// Registers a batch of methods on a per-frame-type registry (e.g.
// GameTooltipMethodRegistry for `tooltip:Foo()` calls). `context` is the
// registry address — see Offsets::VAR_*_METHOD_REGISTRY.
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count);
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `docs` pairs
// descriptors to the entries by NAME (any order, any subset).
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs = nullptr, int docCount = 0);
// Registers `func` at `_G[tableName][methodName]`, creating the namespace
// table if it doesn't already exist. This is how modern WoW C_*-style APIs
// are bound — the engine has no built-in support for table-bound Lua
// functions, so we manipulate the globals table directly via the Lua C API.
void RegisterTableFunction(const char *tableName, const char *methodName,
CFunction func);
CFunction func, const Doc::Function *doc = nullptr);
// Binds `_G[alias]` to the SAME closure as `_G[tableName][methodName]`, by
// value — the way the engine's own Lua-init snippet binds its short aliases
// (`tinsert = table.insert`, `strlen = string.len`, …). Use it after
// registering over a library function whose alias the snippet has already
// captured, so the alias follows the replacement and `alias == table.method`
// stays true. Mirrored under `_G.ClassicAPI` like every other registration.
void RegisterGlobalAlias(const char *alias, const char *tableName, const char *methodName,
const Doc::Function *doc = nullptr);
// Key/value pair for `RegisterIntegerEnum`. `key` becomes a field name
// (PascalCase, matching Blizzard's `Enum.*` naming) and `value` is the
@@ -244,8 +527,11 @@ struct EnumIntegerEntry {
// Registers `_G[parent][sub] = { entries }` as an integer-valued enum
// table, creating `_G[parent]` if needed. Used for Blizzard-style
// `Enum.AddOnSecurityStatus = { Secure=0, Insecure=1, ... }` shapes.
// `docSystem` is the documentation System the enumeration belongs to
// ("SpellBook", "Item", …); the entries themselves are the documentation.
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count);
const EnumIntegerEntry *entries, int count,
const char *docSystem = nullptr);
// Set `t[key] = value` on the table currently at stack[-1] (the most
// common shape used when populating a struct-style table mid-build).
@@ -255,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
@@ -282,6 +572,8 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
// `ITEM_SLOTS_IGNORED = "%d slot(s) ignored"`, etc.). Callers that
// need string or float args, or multiple args, can build their own
// using `PushLocalizedString` + manual `string.format` invocation.
// A `|4singular:plural;` in the string stays; the engine's grammar pass
// (Text::Grammar) expands it when the text is set.
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n);
@@ -309,16 +601,17 @@ bool CallGlobalString(void *L, const char *name, const char *arg);
// section in CLAUDE.md for the underlying offsets and ABI.
namespace Console {
// Console-command handler ABI: `args` is the command-line text after
// the command name (empty string when invoked bare). The engine
// ignores the return value; return 1 by convention.
using CommandHandler = int(__fastcall *)(void *unused, const char *args);
// Console-command handler ABI — 4.3.4's `COMMANDHANDLER(command,
// arguments)`. `command` is the name the command was invoked by, which
// is how one handler serves several names: every CVar is registered with
// the shared FUN_CONSOLE_CVAR_COMMAND_HANDLER, which looks the CVar up by
// `command`. `args` is the text after the name (empty string when
// invoked bare). The engine ignores the return value; return 1.
using CommandHandler = int(__fastcall *)(const char *command, const char *args);
// Help-grouping categories — the engine's `help` enumerates these nine
// (in this order). Cosmetic: the value only affects which group `help`
// lists the command under; the engine doesn't validate it. Values are
// sequential table indices (graphics=1 confirmed via FUN_0066F6C0, the
// rest follow the displayed order).
// Help-grouping categories — 4.3.4's `CATEGORY` enum, which matches the
// nine-entry table the 1.12 `help` command lists (VAR_CONSOLE_CATEGORY_TABLE).
// Cosmetic: only `help` grouping reads it; the registrar doesn't validate it.
enum Category {
CATEGORY_DEBUG = 0,
CATEGORY_GRAPHICS = 1,
@@ -340,9 +633,30 @@ enum Category {
void RegisterCommand(const char *name, CommandHandler handler, int category,
const char *description);
// Line colour types — 4.3.4's `COLOR_T`, indexing the nine-entry
// VAR_CONSOLE_COLORS table. The engine's `help` writes WARNING (yellow),
// its failures ERROR (red), and its plain output DEFAULT.
enum Color {
COLOR_DEFAULT = 0,
COLOR_INPUT = 1,
COLOR_ECHO = 2,
COLOR_ERROR = 3,
COLOR_WARNING = 4,
COLOR_GLOBAL = 5,
COLOR_ADMIN = 6,
COLOR_HIGHLIGHT = 7,
COLOR_BACKGROUND = 8,
};
// Writes a line to the console output buffer. No-ops cleanly when the
// console isn't active, so it's always safe to call.
void Write(const char *line);
void Write(const char *line, Color color = COLOR_DEFAULT);
// The engine's own `ConsolePrintf` (FUN_CONSOLE_PRINTF): formats with
// Storm's printf into a 1024-byte buffer, then writes it in
// COLOR_DEFAULT. Exposed as the engine's entry itself rather than a
// wrapper, because a cdecl varargs call cannot be forwarded.
extern void(__cdecl *const Printf)(const char *format, ...);
} // namespace Console
@@ -412,4 +726,35 @@ struct HookAutoRegister {
// and stops on first failure.
bool RunHookRegistrations();
// Self-registration for per-/reload state cleanup. A module that keeps
// file-static state which goes STALE across a game /reload declares a
// file-scope `static const Game::ReloadAutoRegister _reload{&PrepareForReload};`
// right after its `void PrepareForReload()`. `RunReloadCleanups()` — called
// from the FrameScript_Initialize hook, BEFORE the engine tears down the Lua
// state — fires them all, so DllMain no longer hand-maintains the list and a
// new module can't forget to wire itself in.
//
// State is "reload-fragile" when it survives in C++ but its meaning does not:
// - Lua registry refs / handler cells (the Lua reset invalidates them);
// - maps/sets keyed by a frame/object POINTER (the allocator recycles
// addresses, so an old entry aliases an unrelated new object);
// - cached event-slot indices (the engine rebuilds the event table).
// Firing a stale entry is the recurring bug this prevents (Tooltip::SetEvents
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
// NOT fragile and must not register.
//
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
// undefined cross-TU order — fine, because each callback only clears its OWN
// state. This is the "clear stale state" category only; a save-before-teardown
// like Player::NameCache::Flush stays an explicit DllMain call (it persists
// rather than clears, and also runs on the glue-return path).
struct ReloadAutoRegister {
using Fn = void (*)();
explicit ReloadAutoRegister(Fn fn);
Fn fn;
ReloadAutoRegister *next;
};
void RunReloadCleanups();
} // namespace Game
+99 -110
View File
@@ -11,7 +11,10 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "HookSecureFunc.h"
#include "Game.h"
#include "Offsets.h"
#include <cstring>
@@ -19,14 +22,19 @@ namespace HookSecureFunc {
namespace {
// Names that must not be hooked when targeting `_G`. Hooking any of these
// would either break taint propagation (the "secure" functions) or replace
// core Lua language primitives whose behavior the engine depends on
// internally — e.g. `pairs` is called from C in the iterator path; if Lua
// `pairs` is replaced with a wrapper closure, the engine still gets the
// original via its own table-method dispatch, but Lua-side iteration goes
// through our wrapper, and the two diverging is a footgun. Modern WoW
// rejects the hook outright; we mirror that. Sorted alphabetically.
// Names that must not be hooked when targeting `_G`. This is the client's
// own list, VERBATIM: patch 11.0.0 (The War Within, 2024-07-23) made
// `hooksecurefunc` refuse exactly these 23 names with a "Cannot hook
// function" error — the taint-relevant primitives (the "secure" family plus
// the Lua primitives secure code paths run through). It is deliberately NOT
// "every base-library function": 11.0 leaves `tostring`, `error`,
// `loadstring`, `assert`, `collectgarbage`, … hookable, and includes names
// that are not base-library functions at all. So do not derive this from
// the engine's `luaopen_base` table (`0x00811E28`, 36 entries — see
// BlizzardScriptAPI.md §3); that is a different set with a different
// purpose. Names with no 1.12 counterpart (`wipe` is `table.wipe` here; the
// secure family does not exist) stay for parity of the error an addon
// sees. Sorted alphabetically.
const char *const kUnhookableNames[] = {
"getfenv", "getmetatable", "hooksecurefunc", "ipairs", "issecurevalue",
"issecurevariable", "next", "pairs", "pcall", "pcallwithenv", "rawget",
@@ -45,138 +53,113 @@ bool IsUnhookable(const char *name) {
return false;
}
} // namespace
// Wrapper closure body — called when the hooked function is invoked.
// Stack contains the wrapper's args (`[arg1..argN]`); the closure's
// upvalues hold `(orig, callback)`.
// upvalues hold `(orig, callback)`. 4.3.4's FrameScript_HookWrapper, less
// its taint bookkeeping (1.12 has no taint):
//
// Modern `hooksecurefunc` semantics: original runs first, callback
// runs after with the same args (returns ignored), original's
// results propagate to the caller. No cap on return count. A buggy
// callback must NOT prevent the original's results from reaching
// the caller — that's why step (3) uses pcall.
//
// Stack-shuffling mirrors 3.3.5's `FUN_00817050` so the args sit at
// the top for both calls without copying them twice; results sink
// to the bottom and end up as the wrapper's natural return frame.
int __fastcall HookWrapper(void *L) {
// 1. orig(...) with lua_call — an error in the original propagates to
// the caller, as it would unhooked.
// 2. Results sink below the args, so they end up as the wrapper's
// return frame without being copied again.
// 3. callback(...) through lua_pcall with the engine's frame-script
// message handler as errfunc: a broken hook is REPORTED the way any
// script error is (the error frame / geterrorhandler), and the
// original's results still reach the caller.
int __fastcall Wrapper(void *L) {
const int nargs = Game::Lua::GetTop(L);
// (1) orig(...) — pops orig + nargs args (copies), pushes results.
// Stack after: `[arg1..argN, res1..resM]`.
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
// (1) Stack after: `[arg1..argN, res1..resM]`.
for (int i = 1; i <= nargs; ++i)
Game::Lua::PushValue(L, i);
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
Game::Lua::Insert(L, nargs + 1);
Game::Lua::Call(L, nargs, Game::Lua::MULTRET);
const int nresults = Game::Lua::GetTop(L) - nargs;
// (2) Shuffle results below the saved args. Each `Insert(L, 1)`
// moves the current top down to position 1; running it
// nresults times rotates the results block beneath the args.
// (2) Each `Insert(L, 1)` moves the top value to the bottom; nresults
// of them rotate the results block beneath the args.
// Stack after: `[res1..resM, arg1..argN]`.
for (int i = 0; i < nresults; ++i)
Game::Lua::Insert(L, 1);
// (3) callback(...) via pcall — errors are caught and dropped so
// the original's return values always reach the caller. This
// is the load-bearing safety property of hooksecurefunc on
// 3.x; on 1.12 it just means a buggy hook can't break the
// hooked path. errfunc=0 → no traceback (3.3.5 uses
// `registry["_TRACEBACK"]`, not a 1.12 convention; we'd be
// discarding the formatted message anyway).
// (3) Stack: `[res1..resM, errfunc, callback, arg1..argN]`. The
// handler ref is the one every engine script pcall passes; 0 (not
// yet set) falls back to no handler rather than a bad index.
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2));
Game::Lua::Insert(L, nresults + 1);
if (Game::Lua::PCall(L, nargs, 0, 0) != 0) {
// pcall failure leaves the error message on top; drop it.
Game::Lua::Remove(L, -1);
int errIdx = 0;
const int errRef = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
if (errRef > 0) {
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, errRef);
Game::Lua::Insert(L, nresults + 1);
errIdx = nresults + 1;
}
Game::Lua::PCall(L, nargs, 0, errIdx);
// Stack: `[res1..resM]` — return the original's results verbatim.
// Drop the handler and any error value — `[res1..resM]` remains.
Game::Lua::SetTop(L, nresults);
return nresults;
}
// `hooksecurefunc(name, callback)` — hooks a global by name, target
// table is `_G`.
// `hooksecurefunc(table, name, callback)` — hooks `table[name]`.
//
// In both cases the original function runs first and its results
// propagate; `callback` runs after with the same args (returns
// discarded). The "secure" label refers to taint propagation in
// 3.x+; on 1.12 it's just a post-hook with return preservation.
void PushWrapper(void *L) {
Game::Lua::PushCClosure(L, &Wrapper, 2);
}
namespace {
// `hooksecurefunc([table,] "function", hookfunc)` — replaces
// `table[name]` (default `_G`) with a wrapper that runs the original, then
// `hookfunc` with the same args, and returns the original's results.
// 4.3.4's Script_hooksecurefunc, less taint:
// * a non-table first argument means `_G` is inserted at index 1, so
// both forms share one index layout;
// * the name only has to pass lua_isstring, so a number works (and is
// looked up as its string, the same as lua_tostring makes it);
// * the original is read with lua_gettable (inherited methods resolve
// through __index) but the wrapper is written with lua_rawset, so a
// target's __newindex never runs;
// * the error texts are 4.3.4's.
// The one addition is the 11.0 unhookable-name check for the `_G` form.
int __fastcall Script_HookSecureFunc(void *L) {
int targetIdx, nameIdx, callbackIdx;
const int t1 = Game::Lua::Type(L, 1);
const bool implicitGlobals = Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE;
if (implicitGlobals) {
Game::Lua::PushValue(L, Game::Lua::GLOBALS_INDEX);
Game::Lua::Insert(L, 1);
}
if (t1 == Game::Lua::TYPE_TABLE) {
// Three-arg form: (table, name, callback)
targetIdx = 1;
nameIdx = 2;
callbackIdx = 3;
} else if (t1 == Game::Lua::TYPE_STRING) {
// Two-arg form: (name, callback) — target = _G via pseudo-
// index. Don't push _G with `lua_pushvalue(GLOBALS_INDEX)`:
// it crashes (the pushvalue → setobj2s chain dereferences a
// bad TValue pointer for that pseudo-index). `lua_gettable` /
// `lua_settable` accept GLOBALS_INDEX as the table arg
// directly, so we use it that way without pushing.
targetIdx = Game::Lua::GLOBALS_INDEX;
nameIdx = 1;
callbackIdx = 2;
} else {
Game::Lua::Error(L,
"hooksecurefunc(table, name, callback) or hooksecurefunc(name, callback)");
if (!Game::Lua::IsString(L, 2) ||
Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "Usage: hooksecurefunc([table,] \"function\", hookfunc)");
return 0;
}
Game::Lua::SetTop(L, 3); // [t, name, hookfunc]
// Converts a number name to its string in place; the string stays on
// the stack, so the pointer outlives every use below.
const char *name = Game::Lua::ToString(L, 2);
// Block the hook when targeting `_G` implicitly and the name is in the
// unhookable set. Not applied to the three-arg form (table targets):
// hooking `MyLib.pairs` is fine even if Lua's global `pairs` is not,
// and a caller who passes `_G` explicitly is opting in deliberately.
if (implicitGlobals && IsUnhookable(name)) {
Game::Lua::Error(L, "hooksecurefunc: function is unhookable");
return 0;
}
if (Game::Lua::Type(L, nameIdx) != Game::Lua::TYPE_STRING) {
Game::Lua::Error(L, "hooksecurefunc: name must be a string");
return 0;
}
if (Game::Lua::Type(L, callbackIdx) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "hooksecurefunc: callback must be a function");
return 0;
}
// Block the hook when targeting `_G` and the name is in the unhookable
// set. We don't apply this to the three-arg form (table targets):
// hooking `MyLib.pairs` is fine even if Lua's global `pairs` is not —
// the blacklist is specifically about `_G[name]`. A user who passes
// `_G` explicitly as the table target is opting in deliberately and we
// don't second-guess it.
if (targetIdx == Game::Lua::GLOBALS_INDEX) {
const char *name = Game::Lua::ToString(L, nameIdx);
if (IsUnhookable(name)) {
Game::Lua::Error(L,
"hooksecurefunc: function is unhookable");
return 0;
}
}
// Fetch target[name] — the original function. `gettable` uses
// metamethods, so frame methods (resolved via the frame
// metatable's __index) come back as callable closures, exactly
// what we want to wrap.
Game::Lua::PushValue(L, nameIdx);
Game::Lua::GetTable(L, targetIdx);
Game::Lua::PushValue(L, 2);
Game::Lua::GetTable(L, 1); // [t, name, hookfunc, orig]
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "hooksecurefunc: target field is not a function");
Game::Lua::Error(L, "hooksecurefunc(): %s is not a function", name);
return 0;
}
// Push callback as the second upvalue; orig is already on top
// from the GetTable above.
Game::Lua::PushValue(L, callbackIdx);
// Build the wrapper as a C closure with (orig, callback) as the
// two upvalues. PushCClosure pops the upvalues from the stack
// and pushes the closure.
Game::Lua::PushCClosure(L, &HookWrapper, 2);
// Install: target[name] = wrapper. SetTable expects the value
// on top and the key just below; push them in that order.
Game::Lua::PushValue(L, nameIdx);
Game::Lua::PushValue(L, -2); // duplicate the closure
Game::Lua::SetTable(L, targetIdx);
Game::Lua::Insert(L, 3); // [t, name, orig, hookfunc]
PushWrapper(L); // [t, name, wrapper]
Game::Lua::RawSet(L, 1); // t[name] = wrapper
return 0;
}
@@ -185,7 +168,13 @@ int __fastcall Script_HookSecureFunc(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("hooksecurefunc", &Script_HookSecureFunc);
}
// 4.3.4 registers it from FrameScript_Initialize, which runs for the glue
// state (Client.cpp) as well as the world (GameUI.cpp).
static void RegisterGlueFunctions() {
Game::Lua::RegisterGlueFunction("hooksecurefunc", &Script_HookSecureFunc);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlueFunctions};
} // namespace HookSecureFunc
+32
View File
@@ -0,0 +1,32 @@
// 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 one hook wrapper `hooksecurefunc` and `Frame:HookScript` both install —
// 4.3.4 uses its FrameScript_HookWrapper for both, so a hooked function and a
// hooked script behave the same way.
namespace HookSecureFunc {
// The wrapper closure body. Upvalue 1 = the original, upvalue 2 = the hook.
// Runs the original with the wrapper's args (its errors propagate), then the
// hook with the same args under the engine's script error handler, and
// returns the original's results. Exposed so readers can recognize the
// closure by its C function.
int __fastcall Wrapper(void *L);
// Pops the hook (top) and the original (below it) and pushes the wrapper
// closure over them.
void PushWrapper(void *L);
} // namespace HookSecureFunc
+6558 -681
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
+38 -55
View File
@@ -12,41 +12,37 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetActionInfo(slot)` — modern-WoW-style action descriptor lookup.
// Returns `actionType, id, subType` for the slot, or nil if empty.
// Returns `actionType, id, subType` for the slot, or nothing if the slot
// holds nothing the descriptor can describe (4.3.4 returns nothing there
// too, rather than a nil).
//
// The vanilla 1.12.1 engine stores action descriptors as packed
// uint32s in `VAR_ACTION_TABLE` (`0x00BC6980`, 120 slots). Top 4 bits
// are the type tag; the `0x40000000` tag is ambiguous between
// macros and bag-items and is disambiguated by checking the per-
// character macro-slot map at `VAR_MACRO_SLOT_MAP` (36 entries).
// are the type tag — the server's ACTION_BUTTON_* values shifted into the
// top byte: 0x00 spell, 0x40 macro, 0x80 item.
//
// empty slot: nil
// spell action: "spell", spellID, "spell"
// macro: "macro", macroIndex (1-based)
// bag item: "item", nil (see note below)
// item-by-id: "item", itemID
// item: "item", itemID
// equipment set: "equipmentset", setName
//
// The subType is always "spell" for spell-type entries — the engine
// helper hardcodes pet=0 for entries on this table. Pet-bar actions
// live in a separate table; that's `PetHasActionBar` + the pet-action
// helpers, not this function.
//
// The macro discriminator is the same trick SuperWoWhook uses in its
// modified `GetActionText`: walk the 36-entry macro-slot map and check
// whether `entry & 0xBFFFFFFF` matches one of those macro IDs. If yes,
// the slot holds a macro; if no, it's a bag-item.
//
// **Bag-item itemID note.** For real bag items (top-4 bits = `0x4`
// and the payload isn't in the macro-slot map), the engine stores a
// bag-instance key, not the itemID. Resolving to itemID requires
// walking the per-character action-item hash table at `[0x00BDCC54]`
// and reading a struct field whose offset we haven't fully derived
// yet. For now we report the action *type* ("item") but return nil
// for the id. Callers that need it can fall back to
// `GetActionTexture` + Lua-side item lookups.
// A 0x40 entry is a macro only while its macroID is in the macro-slot map
// (`FUN_ACTION_IS_MACRO`, which every engine reader gates its macro branch
// on). One that is not — a macro deleted out from under its button — is
// nothing, as in 4.3.4: its IsItem and IsMacro are both false, so it falls
// through to an empty return. There is no bag-item tag: items are always
// by-itemID (0x80), and an earlier reading of the 0x40 "miss" as a bag item
// was wrong.
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include "equipmentset/Data.h"
#include <cstdint>
@@ -62,19 +58,6 @@ uint32_t ReadEntry(int slot0) {
return base[slot0];
}
// Returns 1-based macro slot index if `key` matches any entry in the
// macro-slot map, or 0 if no match. `key` is the action entry with the
// `0x40000000` type bit stripped (i.e. `entry & 0xBFFFFFFF`).
int FindMacroSlot(uint32_t key) {
auto *map = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MACRO_SLOT_MAP));
for (int i = 0; i < Offsets::MACRO_SLOT_MAP_COUNT; ++i) {
if (map[i] == key)
return i + 1;
}
return 0;
}
int __fastcall Script_GetActionInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetActionInfo(slot)");
@@ -82,16 +65,12 @@ int __fastcall Script_GetActionInfo(void *L) {
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS) {
Game::Lua::PushNil(L);
return 1;
}
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
const uint32_t entry = ReadEntry(slot0);
if (entry == 0) {
Game::Lua::PushNil(L);
return 1;
}
if (entry == 0)
return 0;
const uint32_t type = entry & Offsets::ACTION_TYPE_MASK;
@@ -104,10 +83,8 @@ int __fastcall Script_GetActionInfo(void *L) {
static_cast<uintptr_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL));
uint32_t petFlag = 0;
const uint32_t spellID = fn(static_cast<uint32_t>(slot0), &petFlag);
if (spellID == 0) {
Game::Lua::PushNil(L);
return 1;
}
if (spellID == 0)
return 0;
(void)petFlag; // hardcoded 0 in the engine helper for this table
Game::Lua::PushString(L, "spell");
Game::Lua::PushNumber(L, static_cast<double>(spellID));
@@ -117,18 +94,13 @@ int __fastcall Script_GetActionInfo(void *L) {
if (type == Offsets::ACTION_TYPE_BAG_OR_MACRO) {
const uint32_t key = entry & Offsets::ACTION_PAYLOAD_MASK_BAG_OR_MACRO;
const int macroSlot = FindMacroSlot(key);
const int macroSlot = Action::Slot::MacroSlotForID(key);
if (macroSlot > 0) {
Game::Lua::PushString(L, "macro");
Game::Lua::PushNumber(L, static_cast<double>(macroSlot));
return 2;
}
// Not a macro — it's a bag item. We know the type but extracting
// the itemID from the bag-instance hash needs more reversing;
// see the file-level comment.
Game::Lua::PushString(L, "item");
Game::Lua::PushNil(L);
return 2;
return 0; // a macro button whose macro is gone — see the file comment
}
if (type == Offsets::ACTION_TYPE_ITEM_BY_ID) {
@@ -138,9 +110,20 @@ int __fastcall Script_GetActionInfo(void *L) {
return 2;
}
// Unknown type tag — surface as nil so callers don't get garbage.
Game::Lua::PushNil(L);
return 1;
if (type == Offsets::ACTION_TYPE_EQUIPMENT_SET) {
// 3.3.5 `Script_GetActionInfo` (`FUN_005a8f10`): the type and the
// set's name.
const EquipmentSet::Set *set = EquipmentSet::Data::FindByID(
entry & ~static_cast<uint32_t>(Offsets::ACTION_TYPE_EQUIPMENT_SET));
if (set == nullptr)
return 0;
Game::Lua::PushString(L, "equipmentset");
Game::Lua::PushString(L, set->name.c_str());
return 2;
}
// Unknown type tag — nothing, so callers don't get garbage.
return 0;
}
} // namespace
+117
View File
@@ -0,0 +1,117 @@
// 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/>.
// `GetActionCooldown` / `GetActionCount` / `IsConsumableAction` for macro
// slots whose `#showtooltip` / `#show` resolved to an ITEM. A spell
// resolution needs none of this — the engine reads a macro slot's spell from
// its primary-spell cache, which `Macro::ShowTooltip` writes — but the engine
// has no per-macro item field, so the item forms of these three are answered
// here, each with the engine's own helper for the item-by-ID slot case:
//
// - cooldown → `FUN_ITEM_QUERY_COOLDOWN` via `Item::Cooldown::PushCooldown`
// (the same triple `GetContainerItemCooldown` returns);
// - count → `FUN_ACTION_ITEM_COUNT`, the value `UseAction` caches per
// item-by-ID slot;
// - consumable → the rule of the inner `IsConsumableAction` (`0x004E5250`):
// ammo / thrown, or any on-use spell slot with negative
// charges.
//
// Registered in front of the engine globals (last registration wins) and a
// strict superset of them: every other slot tail-calls the engine function
// with the untouched stack.
#include "Game.h"
#include "Offsets.h"
#include "item/Cooldown.h"
#include "item/Record.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
namespace Action::ItemState {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
using ItemCount_t = uint32_t(__fastcall *)(uint32_t itemID);
int Engine(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// True (and fills `info`) when stack[1] is a slot whose macro resolved to an item.
bool ItemTarget(void *L, Macro::ShowTooltip::Info *info) {
if (!Game::Lua::IsNumber(L, 1))
return false;
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
return Macro::ShowTooltip::ForSlot(slot0, info) &&
info->target == Macro::ShowTooltip::Target::Item;
}
int __fastcall Script_GetActionCooldown(void *L) {
Macro::ShowTooltip::Info info;
if (!ItemTarget(L, &info))
return Engine(Offsets::FUN_SCRIPT_GET_ACTION_COOLDOWN, L);
Item::Cooldown::PushCooldown(L, info.itemID);
return 3;
}
int __fastcall Script_GetActionCount(void *L) {
Macro::ShowTooltip::Info info;
if (!ItemTarget(L, &info))
return Engine(Offsets::FUN_SCRIPT_GET_ACTION_COUNT, L);
const uint32_t count = reinterpret_cast<ItemCount_t>(Offsets::FUN_ACTION_ITEM_COUNT)(
static_cast<uint32_t>(info.itemID));
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
bool IsConsumable(int itemID) {
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return false;
const uint32_t invType = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
if (invType == Offsets::INVTYPE_AMMO || invType == Offsets::INVTYPE_THROWN)
return true;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
const uint32_t spellID = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_ID + i * 4);
const uint32_t trigger = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER + i * 4);
const int32_t charges = Game::Read<int32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_CHARGES + i * 4);
if (spellID != 0 && trigger == Offsets::ITEM_SPELLTRIGGER_ON_USE && charges < 0)
return true;
}
return false;
}
int __fastcall Script_IsConsumableAction(void *L) {
Macro::ShowTooltip::Info info;
if (!ItemTarget(L, &info))
return Engine(Offsets::FUN_SCRIPT_IS_CONSUMABLE_ACTION, L);
if (IsConsumable(info.itemID))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetActionCooldown", &Script_GetActionCooldown);
Game::Lua::RegisterGlobalFunction("GetActionCount", &Script_GetActionCount);
Game::Lua::RegisterGlobalFunction("IsConsumableAction", &Script_IsConsumableAction);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Action::ItemState
+54
View File
@@ -0,0 +1,54 @@
// 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/>.
// Dropping a spell on a slot that already holds it — 4.3.4's
// CGActionBar::PutActionInSlot:
//
// else if (CGActionBar::IsSpell(slot) && CGActionBar::GetSpell(slot, petSpell) == spellId)
// CGGameUI::DropCursorSpell();
//
// 1.12's FUN_ACTION_PLACE runs the same compare without the IsSpell gate,
// and FUN_ACTION_SLOT_TO_SPELL resolves a macro slot to the macro's spell.
// So a spell dropped on a `/cast <that spell>` macro was taken for a
// duplicate: the cursor cleared and the macro stayed. The gate is one call
// redirect: a slot that is not a spell action has no spell to match.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Action::Place {
namespace {
using SlotToSpell_t = uint32_t(__fastcall *)(uint32_t slot0, uint32_t *outPetFlag);
uint32_t __fastcall SpellSlotSpell(uint32_t slot0, uint32_t *outPetFlag) {
const uint32_t action = reinterpret_cast<const uint32_t *>(Offsets::VAR_ACTION_TABLE)[slot0];
if (action == 0 || (action & Offsets::ACTION_TYPE_MASK) != Offsets::ACTION_TYPE_SPELL)
return 0;
return reinterpret_cast<SlotToSpell_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL)(slot0, outPetFlag);
}
void Register() {
Common::RedirectCall(Offsets::CALL_ACTION_PLACE_SAME_SPELL, Offsets::FUN_ACTION_SLOT_TO_SPELL,
reinterpret_cast<const void *>(&SpellSlotSpell));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::Place
+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
+42
View File
@@ -0,0 +1,42 @@
// 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 "action/Slot.h"
#include "Offsets.h"
namespace Action::Slot {
int MacroSlotForID(uint32_t macroID) {
if (macroID == 0)
return 0;
// The engine's own scan: the 0-based slot, or 0xFFFFFFFF on a miss.
using IdToSlot_t = uint32_t(__fastcall *)(int macroID);
const uint32_t slot0 = reinterpret_cast<IdToSlot_t>(
static_cast<uintptr_t>(Offsets::FUN_MACRO_ID_TO_SLOT))(static_cast<int>(macroID));
return slot0 == 0xFFFFFFFFu ? 0 : static_cast<int>(slot0) + 1;
}
uint32_t MacroIDForSlot(int slot0) {
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE));
const uint32_t entry = table[slot0];
if (entry == 0 || (entry & Offsets::ACTION_TYPE_MASK) != Offsets::ACTION_TYPE_BAG_OR_MACRO)
return 0;
const uint32_t key = entry & Offsets::ACTION_PAYLOAD_MASK_BAG_OR_MACRO;
return MacroSlotForID(key) != 0 ? key : 0;
}
} // namespace Action::Slot
+36
View File
@@ -0,0 +1,36 @@
// 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>
// Action-slot ↔ macro decoding shared by every module that has to answer
// "which macro sits in this action slot" (`GetActionInfo`, the
// `#showtooltip` display overrides). The action table stores macroIDs under
// the `0x40000000` tag; the macro-slot map says which of those ids still
// name a macro (the rest are buttons whose macro is gone). Pure C — no Lua
// stack.
namespace Action::Slot {
// 1-based macro slot holding `macroID` — the same linear scan of
// `VAR_MACRO_SLOT_MAP` the engine's `FUN_MACRO_ID_TO_SLOT` does — or 0 when
// no macro has that id (0 is never a macroID).
int MacroSlotForID(uint32_t macroID);
// The macroID in action slot `slot0` (0-based, `< ACTION_TABLE_MAX_SLOTS`),
// or 0 when the slot is empty, isn't `0x40000000`-tagged, or its macro is gone.
uint32_t MacroIDForSlot(int slot0);
} // namespace Action::Slot
+37
View File
@@ -0,0 +1,37 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The `ActionButtonUseKeyDown` cvar — registered by 4.3.4's engine
// (GameUI_CVar.cpp, "Activate the action button on a keydown") and read only
// by FrameXML's action-button key handlers, which this project's addon ports
// (Util/ActionButton.lua). The engine half is the registration. The default
// is 0, keeping 1.12's action buttons firing on key release; 4.3.4 shipped 1.
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
namespace Action::UseKeyDown {
namespace {
void Register() {
CVar::Factory::Register("ActionButtonUseKeyDown", "Activate the action button on a keydown",
Offsets::CVAR_CATEGORY_GAME, "0");
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::UseKeyDown
+218 -202
View File
@@ -11,31 +11,47 @@
// 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/`
// into a generated header (`embedded_classicapi.h`) as a byte
// array per file plus a `{path, data, size}` manifest.
// 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.
//
// 3. We post-hook the engine's `FUN_ADDON_INIT`. After the engine's
// own `Interface\AddOns\` scan finishes, we call the TOC parser
// directly with `"!!!ClassicAPI"`. The parser opens its TOC via
// our hooked read function and registers the addon as a normal
// entry. The TOC parser is dedup-safe: if the user already has
// the addon on disk, the engine's scan picked it up, the
// hash-table lookup hits, and our call is a no-op. So the
// conditional "only if not on disk" behavior is free.
// 3. We hook the exists-check `FUN_FILE_EXISTS` so it answers the same
// way for the same paths. Without it every embedded file reads fine
// but reports as missing — the per-addon loader would skip an
// embedded `Bindings.xml`, and `Xml::ScriptPath` would never find an
// embedded `<Script file="sub\x.lua">`.
//
// 4. We pre-hook the engine's `FUN_ADDON_SCAN_FULL` and call the TOC
// parser with each embedded name before the scan runs. The registry
// is empty there (the login init resets it just before), so our
// 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
@@ -43,16 +59,19 @@
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
// embedded buffers.
#include "embedded_classicapi.h"
#include "embedded_resources.h"
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
#include "addons/EngineIO.h"
#include "addons/FlavorBindings.h"
#include "addons/FlavorToc.h"
#include "addons/Registry.h"
#include "addons/Toc.h"
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include "embed/Resource.h"
#include <cstdint>
#include <cstdio>
@@ -62,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 '\\';
@@ -94,37 +113,63 @@ 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;
}
const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
return &ClassicAPIFiles::kFiles[i];
int IndexOfAddon(const char *name) {
for (size_t i = 0; i < kAddonCount; ++i) {
if (PathEqualsCI(name, kAddons[i]))
return static_cast<int>(i);
}
return nullptr;
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};
}
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
// the __stdcall / ESP-drift hazard. A buffer we SMemAlloc here is freed cleanly
// by the caller's standard SMemFree in turn.
using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
// The trampoline to the original FUN_FILE_READ (installed by the co-hook below).
// The trampolines to the original FUN_FILE_READ / FUN_FILE_EXISTS (installed
// by the co-hooks below).
FileReadFn FileRead_o = nullptr;
FileExistsFn FileExists_o = nullptr;
// Extracts the value of the `## Version: X` line from a TOC byte
// buffer. Writes into `out` (size `outSize`) and returns true on
@@ -136,7 +181,7 @@ bool ExtractTocVersion(const char *content, size_t size,
size_t n = 0;
if (outSize == 0)
return false;
if (!AddOns::Toc::FindValue(content, size, "## Version:", &v, &n) || n == 0) {
if (!AddOns::Toc::FindValue(content, size, "Version", &v, &n) || n == 0) {
out[0] = '\0';
return false;
}
@@ -175,79 +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;
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(kAddonTocFile, ClassicAPIFiles::kFiles[i].path)) {
ExtractTocVersion(
reinterpret_cast<const char *>(ClassicAPIFiles::kFiles[i].data),
ClassicAPIFiles::kFiles[i].size,
g_embeddedVersion, sizeof(g_embeddedVersion));
return;
}
}
}
// True iff the on-disk addon folder contains the `.classicapi-dev`
// marker. Read via the ORIGINAL `FUN_FILE_READ` (bypassing our embed
// 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);
void *buf = nullptr;
size_t size = 0;
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
if (ok == 0 || buf == nullptr)
return false;
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(buf, __FILE__, __LINE__, 0);
return true;
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,8 +358,8 @@ 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 auto *entry = LookupEmbedded(suffix);
if (entry == nullptr) {
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
// pathological cases like an empty disk install + embedded
@@ -340,122 +370,99 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
const size_t totalSize = entry->size + extraBytes;
const size_t totalSize = entry.size + extraBytes;
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
if (buf == nullptr) return 0;
std::memcpy(buf, entry->data, entry->size);
std::memcpy(buf, entry.data, entry.size);
if (extraBytes > 0) {
std::memset(static_cast<uint8_t *>(buf) + entry->size, 0, extraBytes);
std::memset(static_cast<uint8_t *>(buf) + entry.size, 0, extraBytes);
}
if (outBuf != nullptr) *outBuf = buf;
if (outSize != nullptr) *outSize = entry->size;
if (outSize != nullptr) *outSize = entry.size;
return 1;
}
// `FUN_ADDON_INIT` — `__fastcall(accountName)`. We post-hook it: let
// the engine's normal `Interface\AddOns\` scan run first, then call
// the TOC parser with `"!!!ClassicAPI"`. Dedup-safe — see file header.
using AddonInit_t = void(__fastcall *)(const char *accountName);
AddonInit_t AddonInit_o = nullptr;
using TocParser_t = void(__fastcall *)(const char *name);
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
int position, int anchor);
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
// registry's linked list, and the engine's addon-load pass at
// `FUN_0051F600` walks that list in *insertion order* (NOT
// alphabetical order — the alphabetical sort only applies to the
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
// injection lands at the very end of the load order, after every
// disk-scanned addon. Addons that consume our globals from their
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
// hard-error during their boot.
//
// Fix: walk the linked list, find our entry by name, and re-link
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// Entries are pointed to by `[VAR_ADDON_LIST_HEAD]` (`0x00BE1B6C`),
// with the next-pointer field at `entry + 0x10` (computed as
// `*(value-at-VAR_ADDON_LIST_CTRL) + entry + 4` = `0xC + entry + 4`
// = `entry + 0x10`). Low-bit-1 means "sentinel" (end of list).
// Name pointer lives at `entry + 0x14`.
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
// `FUN_0051da70` walks the linked list and copies an entry into
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
// setting it hides `!!!ClassicAPI` from the character-select AddOns
// list — there's no checkbox, so it can't be toggled off. The builder
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
// 0`); our entry is non-secure, so a manual `1` here is stable across
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
// never reads `+0x29`) and does NOT break dependency resolution or
// by-name queries (those use the addon hash table, which keeps the
// entry regardless).
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
// when the player has no per-character override. Belt-and-suspenders
// alongside the resolver co-hook below (which forces enabled anyway).
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// Post-registration finalize for the embedded entry: move it to the
// head of the load-order list (so consumers that read our globals from
// their main chunk see them — see the block comment above the offsets),
// hide it from the addon list, and mark it default-enabled. Combined
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
// loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
while ((entry & 1) == 0 && entry != 0) {
const char *name = *reinterpret_cast<const char *const *>(
entry + OFF_ADDON_ENTRY_NAME_PTR);
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
// Hide from the character-select AddOns list and default it
// to enabled.
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return;
}
entry = *reinterpret_cast<const uintptr_t *>(
entry + linkOffset + 4);
// The exists-check counterpart of FileRead_h: the same source decision and
// the same fallbacks, so a path reports as existing exactly when FileRead_h
// 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 *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(addon);
if (g_source[addon] == Source::Disk && FileExists_o(path, mode) != 0)
return 1;
if (LookupEmbedded(relPath).data != nullptr)
return 1;
return FileExists_o(path, mode);
}
void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
// `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;
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 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 — `LoadAddOn` of a
// hidden load-on-demand entry still works).
//
// DefaultState (`+0x2b`) needs no write: the entry constructor
// `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 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 && 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);
}
@@ -465,10 +472,15 @@ const Game::HookAutoRegister _hookFileRead{
reinterpret_cast<void *>(&FileRead_h),
reinterpret_cast<void **>(&FileRead_o)};
const Game::HookAutoRegister _hookAddonInit{
Offsets::FUN_ADDON_INIT,
reinterpret_cast<void *>(&AddonInit_h),
reinterpret_cast<void **>(&AddonInit_o)};
const Game::HookAutoRegister _hookFileExists{
Offsets::FUN_FILE_EXISTS,
reinterpret_cast<void *>(&FileExists_h),
reinterpret_cast<void **>(&FileExists_o)};
const Game::HookAutoRegister _hookAddonScan{
Offsets::FUN_ADDON_SCAN_FULL,
reinterpret_cast<void *>(&AddonScan_h),
reinterpret_cast<void **>(&AddonScan_o)};
const Game::HookAutoRegister _hookEnableResolve{
Offsets::FUN_ADDON_ENABLE_RESOLVE,
@@ -477,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
+6 -24
View File
@@ -15,8 +15,6 @@
#include "addons/FlavorBindings.h"
#include "addons/Toc.h"
#include "Game.h"
#include "Offsets.h"
#include "turtle/Detect.h"
#include <cstddef>
@@ -72,40 +70,24 @@ int FlavorOrder(const char *(&order)[2]) {
return count;
}
// Engine file-exists probe FUN_00648a30 — __stdcall(path, mode) (RET 8).
using EngineIO::FileExistsFn;
FileExistsFn g_origExists = nullptr;
} // namespace
// Co-hook the exists-check so `Interface\AddOns\<Name>\Bindings.xml` reports as
// existing when the base file is absent but a flavor variant is present — so
// the loader (`FUN_004b6f70`, then our TryHandle redirect) runs for a
// flavor-only addon. Every non-Bindings path passes straight through.
int __stdcall FileExists_h(const char *path, int mode) {
bool TryExists(const char *path, int mode, FileExistsFn orig) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return g_origExists(path, mode);
if (g_origExists(path, mode) != 0)
return 1; // the plain Bindings.xml exists
return false;
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand) &&
g_origExists(cand, mode) != 0)
return 1;
orig(cand, mode) != 0)
return true;
}
return 0;
return false;
}
const Game::HookAutoRegister _hookExists{
Offsets::FUN_FILE_EXISTS,
reinterpret_cast<void *>(&FileExists_h),
reinterpret_cast<void **>(&g_origExists)};
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
const size_t prefixLen = AddonBindingsPrefix(path);
+10 -4
View File
@@ -19,6 +19,7 @@
namespace AddOns::FlavorBindings {
using EngineIO::FileExistsFn;
using EngineIO::FileReadFn;
// Flavor-specific addon Bindings selection — the Bindings.xml analog of
@@ -36,10 +37,10 @@ using EngineIO::FileReadFn;
// place if one exists (a selected flavor wins over a plain `Bindings.xml`,
// matching FlavorToc). Probe order `Bindings_Turtle.xml` (only when
// Turtle::Detected) then `Bindings_ClassicAPI.xml`.
// 2. A co-hook on the exists-check `FUN_00648a30` (inside FlavorBindings.cpp):
// report `Bindings.xml` as existing when the base is absent but a flavor
// variant is present — otherwise the loader is never called for a
// flavor-only addon.
// 2. TryExists, called from the exists-check `FUN_FILE_EXISTS` hook: report
// `Bindings.xml` as existing when the base is absent but a flavor variant
// is present — otherwise the loader is never called for a flavor-only
// addon.
//
// Only `_Turtle` / `_ClassicAPI` are selected — never `_Vanilla` / `_Classic`
// (which target the 1.15 Classic Era build; its bindings reference handlers
@@ -52,4 +53,9 @@ using EngineIO::FileReadFn;
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
// True when `path` is an addon `…\AddOns\<Name>\Bindings.xml` and a flavor
// variant exists through `orig` (the original exists-check). The caller asks
// `orig` about the base path itself; this answers only the flavor case.
bool TryExists(const char *path, int mode, FileExistsFn orig);
} // namespace AddOns::FlavorBindings
+72 -51
View File
@@ -26,6 +26,8 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/LoadState.h"
#include "addons/Registry.h"
#include <cstdint>
@@ -34,6 +36,7 @@ namespace AddOns::Info {
namespace {
using GetByIndex_t = void *(__fastcall *)(int idx0Based);
using GetAddOnCount_t = int(__fastcall *)();
using FieldByName_t = const char *(__fastcall *)(const char *name);
using EntryByteFn_t = uint8_t(__fastcall *)(const uint8_t *entry);
using GetSecurityIndex_t = uint32_t(__fastcall *)(const uint8_t *entry);
@@ -42,9 +45,8 @@ 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 base for string input; see `Script_GetAddOnOptionalDependencies`.
// (`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);
// Resolves the user's arg to a `const uint8_t *` that the engine's
@@ -52,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
@@ -251,19 +252,22 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
//
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
// matters for LoD addons whose load is split across multiple
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
// inside a single call, so the "loading" state is never observable
// from Lua. We return the same boolean twice — `loadedOrLoading`
// and `loaded` are identical here.
// Modern WoW distinguishes "load-in-progress" from "fully loaded", and
// the two really are distinguishable here: the engine's loaded byte
// (`entry+0x18`) is set at the START of a load, before the addon's files
// run, so the byte alone answers `loadedOrLoading`, not `loaded`. The
// window is reachable from Lua — an addon's own file-scope code, and any
// dependency pulled in during it, run while the byte already reads set.
//
// So `loadedOrLoading` is the engine's byte, and `loaded` additionally
// requires the addon not to be mid-load, which `AddOns::LoadState`
// 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.
// 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) {
@@ -273,8 +277,11 @@ int __fastcall Script_IsAddOnLoaded(void *L) {
}
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
Offsets::FUN_ADDON_IS_LOADED);
const bool loaded = isLoaded(entry) != 0;
Game::Lua::PushBool(L, loaded);
const bool loadedOrLoading = isLoaded(entry) != 0;
const bool loaded =
loadedOrLoading &&
!AddOns::LoadState::IsLoading(reinterpret_cast<const char *>(entry));
Game::Lua::PushBool(L, loadedOrLoading);
Game::Lua::PushBool(L, loaded);
return 2;
}
@@ -303,46 +310,20 @@ int __fastcall Script_DoesAddOnExist(void *L) {
// `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
// *optional* dep array instead of the required one. No optional deps →
// returns nothing.
// *optional* dep array instead of the required one. No optional deps or
// an unknown name → returns nothing.
//
// Vanilla parses `## OptionalDeps:` in the TOC parser `FUN_0051c9b0`
// into a `{cap, count, data, quantum}` name-array descriptor at
// `record+0x38` (count@+0x3C, data@+0x40), but ships no getter for it.
//
// Arg/error handling mirrors stock `Script_GetAddOnDependencies`
// (`0x0048E5E0`): numeric out-of-range or a non-string/non-number arg
// raises the usage error; an unknown *name* string returns nothing
// (no error), matching the engine's own not-found path.
//
// Resolution mirrors stock `Script_GetAddOnDependencies` exactly: the
// arg is first turned into a name-usable pointer (`ResolveAddOnName` —
// numeric via `FUN_ADDON_GET_BY_INDEX`, string is the raw string), then
// routed through the by-name hash lookup `FUN_ADDON_RESOLVE_REQ_DEPS`
// to reach the deps-bearing record. `FUN_ADDON_GET_BY_INDEX` returns a
// *name pointer*, NOT the record — reading dep 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. An unknown
// name yields NULL → return nothing (no error), like stock.
// 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) {
static constexpr const char *kUsage =
"Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")";
// NULL ⇒ out-of-range numeric index or a non-string/non-number arg;
// stock raises the usage error in both cases.
const uint8_t *nameOrEntry = ResolveAddOnName(L);
if (nameOrEntry == nullptr) {
Game::Lua::Error(L, "%s", kUsage);
const uint8_t *record = Addons::ResolveRecord(
L, "Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")");
if (record == nullptr)
return 0;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(reinterpret_cast<const char *>(nameOrEntry));
if (reqDesc == nullptr)
return 0; // unknown addon name — no error, push nothing
const uint8_t *record = reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_COUNT);
@@ -352,11 +333,30 @@ int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
record + Offsets::OFF_ADDON_OPTIONALDEPS_ARRAY);
if (names == nullptr)
return 0;
Game::Lua::CheckStack(L, static_cast<int>(count));
for (uint32_t i = 0; i < count; ++i)
Game::Lua::PushString(L, names[i]); // NULL → pushnil, per engine
return static_cast<int>(count);
}
// `C_AddOns.GetAddOnInterfaceVersion(indexOrName)` — the `## Interface:`
// number the engine parsed into `record+0x1C`, the same value its
// loadability check compares against the client version. A comma-list
// is already reduced to retail's pick (closest value at or below the
// 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 = Addons::ResolveRecord(
L, "Usage: C_AddOns.GetAddOnInterfaceVersion(index or \"name\")");
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ADDON_INTERFACE_VERSION)));
return 1;
}
// `Enum.AddOnSecurityStatus` — what `C_AddOns.GetAddOnSecurity`
// returns. Slots 0..2 match the engine's own ordering at
// `VAR_ADDON_SECURITY_TABLE`; slot 3 is our addition for
@@ -368,6 +368,18 @@ constexpr Game::Lua::EnumIntegerEntry kAddOnSecurityStatusEntries[] = {
{"NotAvailable", 3},
};
const Game::Doc::Field kInterfaceVersionArgs[] = {
Game::Doc::Req("name", "uiAddon", "An addon index or folder name."),
};
const Game::Doc::Field kInterfaceVersionRets[] = {
Game::Doc::Opt("interfaceVersion", "number", nullptr,
"The ## Interface: value from the TOC, or nil for an unknown name."),
};
const Game::Doc::Function kGetAddOnInterfaceVersion{
"Returns the Interface directive of an addon TOC. For a comma-separated list, "
"returns the closest value at or below the client interface version.",
kInterfaceVersionArgs, kInterfaceVersionRets};
} // namespace
static void RegisterLuaFunctions() {
@@ -385,8 +397,17 @@ static void RegisterLuaFunctions() {
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
// The stock global, mirrored into the namespace — the engine's own
// handler already has the Lua C ABI and the modern return shape.
Game::Lua::RegisterTableFunction(
"C_AddOns", "LoadAddOn",
reinterpret_cast<Game::Lua::CFunction>(
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_LOAD_ADDON)));
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
&Script_GetAddOnOptionalDependencies);
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnInterfaceVersion",
&Script_GetAddOnInterfaceVersion,
&kGetAddOnInterfaceVersion);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+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/>.
// Tracks in-progress addon loads so `C_AddOns.IsAddOnLoaded` can report
// `loadedOrLoading` and `loaded` separately. See `LoadState.h` for why the
// engine's own byte can't answer both.
//
// Loads nest — an addon's dependencies load inside its own call — so this
// keeps a stack rather than a single name. The name is copied rather than
// stored by pointer: `LoadAddOn` passes a Lua string, which does not
// outlive the call.
#include "addons/LoadState.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
namespace AddOns::LoadState {
namespace {
// Dependency chains are shallow; anything deeper simply isn't tracked
// (the depth counter still balances, so the stack can't desync).
constexpr int kMaxDepth = 16;
constexpr int kMaxName = 64;
char g_stack[kMaxDepth][kMaxName];
int g_depth = 0;
bool EqualsIgnoreCase(const char *a, const char *b) {
for (; *a != '\0' && *b != '\0'; ++a, ++b) {
char ca = *a;
char cb = *b;
if (ca >= 'A' && ca <= 'Z')
ca = static_cast<char>(ca - 'A' + 'a');
if (cb >= 'A' && cb <= 'Z')
cb = static_cast<char>(cb - 'A' + 'a');
if (ca != cb)
return false;
}
return *a == *b;
}
using LoadOne_t = unsigned int(__fastcall *)(const char *name, char flag,
void *progressCtx);
LoadOne_t s_loadOne_o = nullptr;
unsigned int __fastcall LoadOne_h(const char *name, char flag, void *progressCtx) {
const int slot = g_depth;
if (slot >= 0 && slot < kMaxDepth)
Common::BoundedCopy(g_stack[slot], name, kMaxName);
++g_depth;
const unsigned int result = s_loadOne_o(name, flag, progressCtx);
--g_depth;
if (g_depth < 0)
g_depth = 0; // defensive: never let the stack run negative
return result;
}
const Game::HookAutoRegister _hookLoadOne{
Offsets::FUN_ADDON_LOAD_ONE,
reinterpret_cast<void *>(&LoadOne_h),
reinterpret_cast<void **>(&s_loadOne_o)};
} // namespace
bool IsLoading(const char *name) {
if (name == nullptr || *name == '\0')
return false;
const int depth = (g_depth < kMaxDepth) ? g_depth : kMaxDepth;
for (int i = 0; i < depth; ++i)
if (EqualsIgnoreCase(g_stack[i], name))
return true;
return false;
}
} // namespace AddOns::LoadState
+33
View File
@@ -0,0 +1,33 @@
// 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
// `AddOns::LoadState` — tracks which addons are mid-load.
//
// The engine's own "loaded" byte (`entry+0x18`) is set at the START of a
// load, before the addon's files run, so on its own it can't tell
// "loading" from "loaded". That distinction is observable: an addon's
// file-scope code, and every dependency pulled in during it, execute
// while the byte already reads set.
//
// A co-hook on the per-addon loader keeps a stack of the names currently
// being loaded, which is the missing half of
// `C_AddOns.IsAddOnLoaded`'s `(loadedOrLoading, loaded)` pair.
namespace AddOns::LoadState {
// True while `name` is inside the engine's per-addon load call. Matching
// is case-insensitive, as addon-name matching is everywhere else.
bool IsLoading(const char *name);
} // namespace AddOns::LoadState
+6 -6
View File
@@ -12,17 +12,17 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_AddOns.GetAddOnLocalTable(name)` — the public, gated view of an addon's
// private namespace table (the `local _, addonTable = ...` second vararg the
// transpiler injects; see luasyntax/AddonNamespace.h).
// private namespace table (the `local _, addonTable = ...` second vararg each
// addon file is called with; see luasyntax/AddonNamespace.h).
//
// Retail added this in 11.0 alongside the `## AllowAddOnTableAccess` TOC
// directive: one addon can read another's local table ONLY when the target
// opts in. We mirror that gate:
// 1. the named addon is currently LOADED, and
// 2. its `.toc` declares `## AllowAddOnTableAccess: 1`.
// Either check failing returns nil. The internal preamble path (an addon
// receiving its OWN table) is a different, ungated function (`__addonns`) —
// an addon never needs permission for its own namespace.
// Either check failing returns nil. An addon receiving its OWN table is a
// different, ungated path (its files are called with it at load) — an addon
// never needs permission for its own namespace.
//
// Directive spelling verified against the 1.15.8 Classic Era binary (both the
// `AllowAddOnTableAccess:` TOC key and the `GetAddOnLocalTable` name are
@@ -77,7 +77,7 @@ bool TocAllowsTableAccess(const char *name) {
size_t n = 0;
bool allowed = false;
if (AddOns::Toc::FindValue(static_cast<const char *>(buf), size,
"## AllowAddOnTableAccess:", &v, &n)) {
"AllowAddOnTableAccess", &v, &n)) {
int value = 0;
bool anyDigit = false;
for (size_t k = 0; k < n && v[k] >= '0' && v[k] <= '9'; ++k) {
+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
+59
View File
@@ -0,0 +1,59 @@
// 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 "Offsets.h"
#include <cstdint>
#include <type_traits>
namespace Addons {
// Walk the engine's intrusive AddOn-registry linked list, calling `fn(entry)`
// for each `AddOnEntry *`. Head at `[VAR_ADDON_LIST_HEAD]`; the next-pointer
// field is at `entry + [VAR_ADDON_LIST_CTRL] + 4` (= `entry + 0x10`); a
// low-bit-1 sentinel or NULL terminates. This is the same traversal the
// engine's own load pass (`FUN_0051F600`) and scan tail loop use.
//
// `fn` may return `void` (always continue) or `bool` (return `false` to STOP
// early, e.g. once a by-name search finds its entry). A visitor must not
// unlink the entry it is given and then continue: the walk reads that
// entry's next-pointer after the call. Shared by `Addons::Embedded` (find the
// embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
template <typename Fn> void ForEachEntry(Fn fn) {
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
uintptr_t entry = *reinterpret_cast<const uintptr_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_HEAD));
while ((entry & 1) == 0 && entry != 0) {
if constexpr (std::is_void_v<decltype(fn(entry))>) {
fn(entry);
} else {
if (!fn(entry))
return;
}
entry = *reinterpret_cast<const uintptr_t *>(entry + linkOffset + 4);
}
}
// 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
+479
View File
@@ -0,0 +1,479 @@
// 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-like `/reload`: pick up new addon folders and new files without
// restarting the client. Two independent engine limitations block that on
// a stock 1.12 client, each fixed here with the engine's own machinery:
//
// 1. FILE VISIBILITY. Every relative-path read resolves through a
// loose-file hash index (see Offsets.h, `FUN_VFS_INDEX_CLEAR`);
// a file created after the index was built — a new addon's TOC, a
// new .lua in an existing addon — is invisible until it is rebuilt.
// We call the engine's own clear, the same one the engine calls
// after writing a new SavedVariables file: the next lookup rebuilds
// the index from disk as boot does, so new files appear and deleted
// ones drop out. New files in EXISTING addons need nothing more —
// the per-addon loader `FUN_0051F240` re-reads the TOC from disk on
// every load pass, so once the files are visible they load.
//
// 2. REGISTRY MEMBERSHIP. The addon registry is built once at login;
// new folders are never walked. We replay the login scan's disk
// walk verbatim (`FUN_ADDON_SCAN_DISK_DIRS` with the engine's own
// per-directory callback, which feeds the dedup-safe TOC parser),
// so new folders register as completely normal entries — then
// mirror the two registry structures the scan's OTHER passes would
// have filled: the reverse-LoadWith lists (scan tail loop) and the
// flat `GetNumAddOns` display array (`FUN_0051DA70` phase 2).
//
// 3. METADATA IMMUTABILITY. The parser's dedup guard makes a
// registered entry's `##` metadata read-once: an edited
// `## SavedVariables:` / `## Dependencies:` / any `##` line never
// re-parses. The engine's own complete per-entry destructor
// (`FUN_ADDON_ENTRY_DESTROY` — frees every owned allocation and
// self-unlinks from both the list and the hash; see Offsets.h)
// makes evict + re-register safe: destroy the entry so the dedup
// guard MISSES, re-feed the name to `FUN_TOC_PARSER`, and the
// entry rebuilds from the edited TOC exactly like a fresh
// registration. Gated on an actual `##` change (hash of the TOC's
// `##` lines only — file-reference edits load natively and never
// trigger the evict path). A TOC that no longer reads at all
// (folder deleted) evicts without re-registering — the deletion
// analog; a folder the scan still finds simply re-registers.
//
// Runs from the `ModuleAutoRegister` callback — LoadScriptFunctions
// post-hook, which fires inside FrameXML init (`FUN_0048FBF0`) BEFORE
// the AddOns.txt enable-state re-read, the load-progress count, and the
// addon load pass, on both login and every /reload. The engine then
// loads new entries natively: dep ordering, Bindings.xml, flavor TOCs,
// SavedVariables, ADDON_LOADED, the load-screen "Loading add-on %s".
// The window is also what makes eviction safe: the unload pass already
// ran (SavedVariables written from the OLD entry's SV lists, loaded
// bytes cleared), and the load pass hasn't — nothing holds entry state.
//
// Excluded from the evict path (everything else is fair game):
// - 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).
//
// NOTE the surgical discipline. A previous attempt re-invoked the
// login-only teardown+rescan (`LoadAddOnsForAccount`, `0x0051C740`)
// mid-/reload and corrupted the registry (duplicate loads + Lua memory explosion on the
// NEXT login). This module never runs bulk teardown; the only entry
// mutation beyond what a login scan produces is the per-entry evict
// above, which is (a) the engine's own complete destructor, (b) always
// preceded by the reverse-LoadWith scrub (the one reference the dtor
// can't clean), and (c) always followed by the same fix-ups a new
// registration gets. New entries come from the same parser call
// `Addons::Embedded` has exercised every login.
//
// Thread note: the loose-file index has no lock in 1.12. We clear it on
// the main thread during the reload loading screen — the same context
// in which the engine's own unload pass clears it.
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
#include "addons/EngineIO.h"
#include "addons/Registry.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
namespace Addons::Rescan {
namespace {
// ── Engine entry points (all verified by disassembly; see Offsets.h) ──
using IndexClear_t = void(__cdecl *)();
using ScanDiskDirs_t = int(__fastcall *)(const char *basePath,
const char *pattern, void *callback,
void *userParam, int includeHidden);
using ResolveByName_t = void *(__fastcall *)(const char *name);
using DescGrow_t = void(__thiscall *)(void *desc, uint32_t newCap);
using QuantumCalc_t = uint32_t(__thiscall *)(void *desc, uint32_t needed);
using Qsort_t = void(__cdecl *)(void *base, uint32_t num, uint32_t width,
void *compare);
using TocParser_t = void(__fastcall *)(const char *name);
using EntryDestroy_t = void(__stdcall *)(void *entry);
// The engine's ubiquitous growable-array descriptor.
struct Desc {
uint32_t cap;
uint32_t count;
uint32_t *data;
uint32_t quantum;
};
// Append one dword to a descriptor, mirroring the engine's inline
// grow-and-append (the scan tail loop and `FUN_0051DA70` phase 2 share
// it): round the needed cap up to the quantum — computed by the
// engine's own `FUN_DESC_QUANTUM_CALC` when the desc has none — grow
// via the site's grow instantiation (which reallocs `data` and writes
// `cap`), then `data[count++] = value`.
void DescAppend(Desc *desc, uint32_t value, DescGrow_t grow) {
uint32_t needed = desc->count + 1;
if (desc->cap < needed) {
uint32_t quantum = desc->quantum;
if (quantum == 0) {
auto calc =
reinterpret_cast<QuantumCalc_t>(Offsets::FUN_DESC_QUANTUM_CALC);
quantum = calc(desc, needed);
}
if (needed % quantum != 0)
needed += quantum - needed % quantum;
grow(desc, needed);
}
desc->data[desc->count++] = value;
}
// The registry linked-list walk lives in `addons/Registry.h`
// (`Addons::ForEachEntry`), shared with `Addons::Embedded`.
// By-name registry lookup via the engine's own hash resolver (returns
// the entry's RequiredDeps desc, so subtract its offset — same recovery
// `Addons::Info` uses).
uintptr_t ResolveEntryByName(const char *name) {
auto resolve =
reinterpret_cast<ResolveByName_t>(Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
const uintptr_t desc = reinterpret_cast<uintptr_t>(resolve(name));
return desc != 0 ? desc - Offsets::OFF_ADDON_REQDEPS_DESC : 0;
}
// ── Step 1: loose-file index refresh ──────────────────────────────────
// Drop the loose-file index; the next file lookup rebuilds it from disk.
void RefreshLooseFileIndex() {
reinterpret_cast<IndexClear_t>(Offsets::FUN_VFS_INDEX_CLEAR)();
}
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
// 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
// re-parse of already-current metadata.
std::unordered_map<std::string, uint32_t> g_metaHash;
// Read `<name>`'s base TOC exactly as the parser does — through the
// (hooked) `FUN_FILE_READ`, so flavor selection and TOC rewriting are
// reflected in what gets hashed. Caller frees `*buf` via Storm.
bool ReadToc(const char *name, void **buf, size_t *size) {
char path[260];
std::snprintf(path, sizeof(path), "Interface\\AddOns\\%s\\%s.toc", name,
name);
auto read = reinterpret_cast<AddOns::EngineIO::FileReadFn>(
static_cast<uintptr_t>(Offsets::FUN_FILE_READ));
*buf = nullptr;
*size = 0;
return read(0, path, buf, size, 1, 1, 0) != 0 && *buf != nullptr;
}
// FNV-1a over the TOC's `##` lines only. File-reference lines are
// excluded on purpose — the load pass re-reads those from disk every
// /reload anyway, so an edit there must not trigger the evict path.
// Line terminators are excluded (a CRLF↔LF conversion is not a
// metadata change); a per-line separator keeps adjacent lines from
// concatenating into the same hash. The BOM skip mirrors the parser.
uint32_t HashTocMetadata(const char *buf, size_t size) {
uint32_t h = 2166136261u;
size_t i = 0;
if (size >= 3 && static_cast<uint8_t>(buf[0]) == 0xEF &&
static_cast<uint8_t>(buf[1]) == 0xBB &&
static_cast<uint8_t>(buf[2]) == 0xBF)
i = 3;
while (i < size) {
size_t end = i;
while (end < size && buf[end] != '\n')
++end;
size_t stop = end;
while (stop > i && buf[stop - 1] == '\r')
--stop;
if (stop - i >= 2 && buf[i] == '#' && buf[i + 1] == '#') {
for (size_t k = i; k < stop; ++k) {
h ^= static_cast<uint8_t>(buf[k]);
h *= 16777619u;
}
h ^= '\n';
h *= 16777619u;
}
i = end + 1;
}
return h;
}
// 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 || Addons::Embedded::IsEmbedded(name) ||
*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_SECURE) != 0;
}
// Read + hash `<name>`'s `##` lines. False when the TOC doesn't read.
bool TryHashToc(const char *name, uint32_t *outHash) {
void *buf = nullptr;
size_t size = 0;
if (!ReadToc(name, &buf, &size))
return false;
*outHash = HashTocMetadata(static_cast<const char *>(buf), size);
reinterpret_cast<AddOns::EngineIO::SMemFreeFn>(
static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
return true;
}
// Remove every pointer to `victim` from the OTHER entries'
// reverse-LoadWith lists — the one reference `FUN_ADDON_ENTRY_DESTROY`
// cannot clean (it frees the victim's own list, not the victim's
// presence in others'). Without this, the loader's post-ADDON_LOADED
// loop dereferences freed memory.
void ScrubReverseLoadWith(uintptr_t victim) {
ForEachEntry([victim](uintptr_t entry) {
if (entry == victim)
return;
auto *desc = reinterpret_cast<Desc *>(
entry + Offsets::OFF_ADDON_REVLOADWITH_DESC);
uint32_t w = 0;
for (uint32_t r = 0; r < desc->count; ++r)
if (desc->data[r] != static_cast<uint32_t>(victim))
desc->data[w++] = desc->data[r];
desc->count = w;
});
}
// The refresh pass. Walks the registry comparing each entry's current
// `##` hash against the last-parsed one; on change (or an unreadable
// TOC — deleted folder), evicts via the engine's own destructor and,
// for changes, re-feeds the name to the parser. Re-registered entries
// are appended to `refreshed` so they join the same reverse-LoadWith /
// display-array fix-ups a new folder gets. Returns true when anything
// was actually evicted (the display array holds a freed name pointer
// until it's rebuilt).
//
// Victims are collected during the walk and processed after it — the
// destructor unlinks the entry the iterator stands on (its next-pointer
// is zeroed), so evicting mid-walk would silently truncate the walk.
// Names are copied for the same reason: the dtor frees the entry's
// name string. `g_metaHash` is committed only once the re-registration
// actually happened — updating it during the walk would absorb the edit
// forever if any later step failed, with no retry on the next /reload.
bool RefreshChangedMetadata(std::vector<uintptr_t> &refreshed) {
struct Victim {
std::string name;
uint32_t newHash;
bool reRegister; // false: TOC unreadable — evict only
};
std::vector<Victim> victims;
ForEachEntry([&victims](uintptr_t entry) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
if (IsRefreshExempt(entry, name))
return;
uint32_t h = 0;
if (!TryHashToc(name, &h)) {
victims.push_back({name, 0, false});
return;
}
auto it = g_metaHash.find(name);
if (it == g_metaHash.end())
g_metaHash.emplace(name, h); // first sighting — seed only
else if (it->second != h)
victims.push_back({name, h, true});
});
bool anyEvicted = false;
auto destroy =
reinterpret_cast<EntryDestroy_t>(Offsets::FUN_ADDON_ENTRY_DESTROY);
auto parse = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
for (const Victim &v : victims) {
const uintptr_t entry = ResolveEntryByName(v.name.c_str());
if (entry == 0)
continue;
ScrubReverseLoadWith(entry);
destroy(reinterpret_cast<void *>(entry));
anyEvicted = true;
if (!v.reRegister) {
g_metaHash.erase(v.name);
continue;
}
// The parser appends the fresh entry at the list TAIL, so a
// refreshed addon's position in the load pass changes for this
// reload. Declared dependencies still force-load first; only
// undeclared load-order assumptions between unrelated addons
// could observe the difference.
parse(v.name.c_str());
const uintptr_t fresh = ResolveEntryByName(v.name.c_str());
if (fresh != 0) {
refreshed.push_back(fresh);
g_metaHash[v.name] = v.newHash;
} else {
// Evicted but the parse didn't restore it (TOC became
// unreadable between hash and parse) — treat as deleted so
// a reappearing folder re-seeds instead of diffing against
// a hash the registry never absorbed.
g_metaHash.erase(v.name);
}
}
return anyEvicted;
}
// ── Step 2: registry rescan + mirrored fix-ups ────────────────────────
// The scan tail loop's reverse-LoadWith build, restricted to pairs
// involving a newly registered entry. Login already linked old→old
// pairs; a pair is missing exactly when one side is new (the login
// walk skipped `## LoadWith:` names that didn't resolve, and new
// entries were never walked at all) — so appending just those pairs
// reproduces what a login scan would have built, with no duplicates in
// the engine's no-dedup lists.
void FixupReverseLoadWith(const std::vector<uintptr_t> &added) {
auto isNew = [&added](uintptr_t e) {
return std::find(added.begin(), added.end(), e) != added.end();
};
auto grow =
reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_REVLOADWITH_GROW);
ForEachEntry([&](uintptr_t entry) {
const bool entryIsNew = isNew(entry);
const uint32_t count = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_LOADWITH_COUNT);
auto names = *reinterpret_cast<const char *const *const *>(
entry + Offsets::OFF_ADDON_LOADWITH_ARRAY);
for (uint32_t i = 0; i < count; ++i) {
const uintptr_t target = ResolveEntryByName(names[i]);
if (target == 0 || (!entryIsNew && !isNew(target)))
continue;
DescAppend(reinterpret_cast<Desc *>(
target + Offsets::OFF_ADDON_REVLOADWITH_DESC),
static_cast<uint32_t>(entry), grow);
}
});
}
// `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 the embedded addons hidden), then
// sort with the engine's own comparator to restore alphabetical order.
void RebuildDisplayArray() {
auto desc = reinterpret_cast<Desc *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_ARRAY_CAP));
auto grow = reinterpret_cast<DescGrow_t>(Offsets::FUN_ADDON_ARRAY_GROW);
desc->count = 0;
ForEachEntry([&](uintptr_t entry) {
if (*reinterpret_cast<const uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) != 0)
return;
DescAppend(desc,
*reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR),
grow);
});
auto qsort = reinterpret_cast<Qsort_t>(Offsets::FUN_CRT_QSORT);
qsort(desc->data, desc->count, 4,
reinterpret_cast<void *>(Offsets::FUN_ADDON_NAME_COMPARE));
}
void Run() {
// Registry populated (login scan ran) — always true by the first
// in-world LoadScriptFunctions, but it is the state the steps below
// mutate, so gate explicitly. No gate on the index being built: the
// unload pass clears it whenever it wrote a new SV file, and the
// clear below is safe either way.
if (*reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_INITIALIZED)) == 0)
return;
RefreshLooseFileIndex();
// Metadata refresh runs BEFORE the snapshot: a re-registered entry
// is alive by snapshot time, so it lands in `before` and can never
// double-count in `added` — even if the allocator reuses the old
// entry's address, `refreshed` tracks it independently of the diff.
std::vector<uintptr_t> refreshed;
const bool evicted = RefreshChangedMetadata(refreshed);
std::vector<uintptr_t> before;
ForEachEntry([&before](uintptr_t entry) { before.push_back(entry); });
// Replay login scan walk #2 verbatim: engine walker + engine
// callback + engine parser. The parser's dedup guard makes this a
// hash lookup per already-registered addon; new folders register
// as complete, normal entries (their TOC is readable now — and it
// goes through the FlavorToc/TocRewrite read hooks like any other).
//
// The dedup guard is why this replay can NEVER refresh or corrupt
// an existing entry's `##` metadata (verified in the parser's
// decompile): the guard is the function's FIRST block — hash
// lookup + name compare, early `return` on match BEFORE the TOC
// path is even built, so no file read and no entry write happen
// for a registered name. Both the Storm hash (`FUN_0064B3F0`,
// uppercases a–z and folds '/'→'\\' before hashing) and the
// compare (`SStrCmpI`) are case-insensitive, so a case-renamed
// folder still matches its existing entry rather than registering
// a duplicate.
auto scan =
reinterpret_cast<ScanDiskDirs_t>(Offsets::FUN_ADDON_SCAN_DISK_DIRS);
scan(reinterpret_cast<const char *>(Offsets::VAR_ADDON_PATH_PREFIX),
reinterpret_cast<const char *>(Offsets::VAR_ADDON_SCAN_PATTERN),
reinterpret_cast<void *>(Offsets::FUN_ADDON_DISK_DIR_CB),
/*userParam=*/nullptr, /*includeHidden=*/0);
std::vector<uintptr_t> added;
ForEachEntry([&](uintptr_t entry) {
if (std::find(before.begin(), before.end(), entry) == before.end())
added.push_back(entry);
});
// Seed the metadata map for entries this reload registered, so the
// next reload diffs a `##` edit against what was actually parsed
// (waiting for the next pass to seed would silently absorb an edit
// made between now and then).
for (uintptr_t entry : added) {
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_ADDON_ENTRY_NAME_PTR);
uint32_t h = 0;
if (!IsRefreshExempt(entry, name) && TryHashToc(name, &h))
g_metaHash[name] = h;
}
if (added.empty() && refreshed.empty() && !evicted)
return;
// Refreshed entries need the same link fix-ups a new folder gets:
// their old reverse-LoadWith pairs died with the old entry (scrub +
// dtor), and the parser doesn't build reverse links.
std::vector<uintptr_t> linked = added;
linked.insert(linked.end(), refreshed.begin(), refreshed.end());
if (!linked.empty())
FixupReverseLoadWith(linked);
// Rebuild whenever membership OR an entry identity changed — after
// any eviction (including a pure deletion) the display array still
// holds the dead entry's freed name pointer.
RebuildDisplayArray();
}
const Game::ModuleAutoRegister _autoreg{&Run};
} // namespace
} // namespace Addons::Rescan
+26 -34
View File
@@ -35,18 +35,19 @@
// The engine STILL runs its own SavedVariables load at the normal step, right
// after the files. Left alone, that re-load would overwrite any value the
// addon wrote to its SavedVariable at file scope before ADDON_LOADED. So we
// also co-hook FUN_LUA_LOAD_FILE and suppress that one redundant re-load: each
// path we pre-load is remembered, and the engine's imminent load of that exact
// path is skipped once. This makes the behavior match true
// LoadSavedVariablesFirst — file-scope reads AND writes both survive. Our own
// early load calls the trampoline directly, so it is never self-suppressed.
// FUN_LUA_LOAD_FILE loads FILES only (not RunScript string execution), so the
// extra hook is a cold, addon-load-time path.
// also suppress that one redundant re-load, through Script::LoadFile's shared
// FUN_LUA_LOAD_FILE hook: each path we pre-load is remembered, and the
// engine's imminent load of that exact path is skipped once. This makes the
// behavior match true LoadSavedVariablesFirst — file-scope reads AND writes
// both survive. Our own early load calls Script::LoadFile::Original, so it is
// never self-suppressed. FUN_LUA_LOAD_FILE loads FILES only (not RunScript
// string execution), so this is a cold, addon-load-time path.
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "addons/Toc.h"
#include "script/LoadFile.h"
#include <cstddef>
#include <cstdint>
@@ -67,14 +68,12 @@ using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemFreeFn;
using LoadTocFilesFn = uint32_t(__fastcall *)(char *tocPath, int *a2, int *a3);
using LuaLoadFileFn = uint32_t(__fastcall *)(const char *path, void *a2, void *a3);
using NameFn = const char *(*)(); // no-arg readers (return in EAX)
LoadTocFilesFn g_orig = nullptr;
LuaLoadFileFn g_origLuaLoad = nullptr;
// Full SV paths we pre-loaded whose imminent engine re-load must be skipped
// once. Populated in LoadSavedVarsEarly, drained by LuaLoad_h. Only ever holds
// once. Populated in LoadSavedVarsEarly, drained by SuppressReload. Only ever holds
// a handful of entries (one addon's SV files, briefly). Main-thread only.
std::vector<std::string> g_pendingSuppress;
@@ -148,18 +147,18 @@ SvFlags ReadSvFlags(const char *tocPath) {
const char *v = nullptr;
size_t n = 0;
if (AddOns::Toc::FindValue(b, size, "## LoadSavedVariablesFirst:", &v, &n))
if (AddOns::Toc::FindValue(b, size, "LoadSavedVariablesFirst", &v, &n))
f.first = NonzeroFlag(v, n);
// Gate each SV file on the DECLARATION, not mere file existence — mirroring
// the engine, which loads the account file iff `## SavedVariables` is
// declared and the per-char file iff `## SavedVariablesPerCharacter` is.
// (The trailing `:` keeps "## SavedVariables:" from matching the
// "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
// (FindValue requires `:` right after the key, so "SavedVariables" never
// matches the "## SavedVariablesPerCharacter:" line.) Otherwise a stale SV file from a
// scope the addon no longer declares would be executed early — Lua the
// engine never runs — and queued for a suppression the engine never drains.
f.account = AddOns::Toc::FindValue(b, size, "## SavedVariables:", &v, &n) && n > 0;
f.account = AddOns::Toc::FindValue(b, size, "SavedVariables", &v, &n) && n > 0;
f.perChar =
AddOns::Toc::FindValue(b, size, "## SavedVariablesPerCharacter:", &v, &n) && n > 0;
AddOns::Toc::FindValue(b, size, "SavedVariablesPerCharacter", &v, &n) && n > 0;
reinterpret_cast<SMemFreeFn>(static_cast<uintptr_t>(Offsets::FUN_STORM_SMEM_FREE))(
buf, __FILE__, __LINE__, 0);
@@ -173,12 +172,10 @@ bool FileExists(const char *path) {
// Load a SavedVariables file we found, then mark its path so the engine's own
// re-load of it (right after the addon's files) is suppressed — preserving any
// file-scope write. Calls the trampoline directly so this load is never itself
// suppressed by our LuaLoad_h hook.
// file-scope write. Calls the engine loader directly so this load is never
// itself suppressed by SuppressReload.
void RunLuaFile(const char *path) {
if (g_origLuaLoad == nullptr)
return;
g_origLuaLoad(path, nullptr, nullptr);
Script::LoadFile::Original(path, nullptr, nullptr);
g_pendingSuppress.emplace_back(path);
}
@@ -241,18 +238,16 @@ uint32_t __fastcall LoadTocFiles_h(char *tocPath, int *a2, int *a3) {
// Skip the engine's one redundant SavedVariables re-load per pre-loaded path.
// Every other file load passes straight through. Match is by full path
// (case-insensitive); a path is suppressed exactly once, so nothing else is
// affected. Our own early load bypasses this via the trampoline.
uint32_t __fastcall LuaLoad_h(const char *path, void *a2, void *a3) {
if (path != nullptr) {
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
g_pendingSuppress.erase(g_pendingSuppress.begin() +
static_cast<std::ptrdiff_t>(i));
return 0; // suppress — the addon's file-scope SV writes survive
}
// affected. Our own early load bypasses this through Original.
bool SuppressReload(const char *path) {
for (size_t i = 0; i < g_pendingSuppress.size(); ++i) {
if (SamePathCI(g_pendingSuppress[i].c_str(), path)) {
g_pendingSuppress.erase(g_pendingSuppress.begin() +
static_cast<std::ptrdiff_t>(i));
return true; // suppress — the addon's file-scope SV writes survive
}
}
return g_origLuaLoad(path, a2, a3);
return false;
}
const Game::HookAutoRegister _hook{
@@ -260,10 +255,7 @@ const Game::HookAutoRegister _hook{
reinterpret_cast<void *>(&LoadTocFiles_h),
reinterpret_cast<void **>(&g_orig)};
const Game::HookAutoRegister _hookLua{
Offsets::FUN_LUA_LOAD_FILE,
reinterpret_cast<void *>(&LuaLoad_h),
reinterpret_cast<void **>(&g_origLuaLoad)};
const Script::LoadFile::AutoSuppress _suppress{&SuppressReload};
} // namespace
+50 -27
View File
@@ -33,35 +33,58 @@ inline bool EqCI(const char *s, size_t n, const char *lit) {
return lit[n] == '\0';
}
// Scans an in-memory TOC buffer for `directive` (the full line prefix, e.g.
// "## Version:") at a line start, case-insensitively, and returns the value
// that follows via out-params: [*valStart, *valStart + *valLen), trimmed of
// leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
// Returns false (out-params untouched) when the directive is absent. Pure —
// reads no files; the caller owns the buffer (via FUN_FILE_READ, or an embedded
// copy). Shared by addons/LocalTable.cpp (AllowAddOnTableAccess) and
// addons/Embedded.cpp (Version) so the line-scan lives in one place.
inline bool FindValue(const char *buf, size_t size, const char *directive,
// Scans an in-memory TOC buffer for the `## <key>:` directive and returns the
// first one's value via out-params: [*valStart, *valStart + *valLen), trimmed
// of leading and trailing spaces/tabs and excluding the CR/LF/EOF terminator.
// `key` is the bare name ("Version", "SavedVariables"). Returns false
// (out-params untouched) when the directive is absent.
//
// Matches lines the way the engine's TOC parser `FUN_TOC_PARSER` (0x0051C9B0)
// does, so a directive the engine reads is never one we miss: a UTF-8 BOM at
// the start of the file is skipped; lines break at CR or LF; a line counts
// when it starts with "##", then any number of spaces, then `key`
// case-insensitively, IMMEDIATELY followed by ':' (the engine compares
// against literals like "SavedVariables:", which is also why
// "SavedVariables" never matches a "SavedVariablesPerCharacter:" line).
//
// Pure — reads no files; the caller owns the buffer (via FUN_FILE_READ, or an
// embedded copy). Shared by every addon module that reads a TOC directive so
// the line-scan lives in one place.
inline bool FindValue(const char *buf, size_t size, const char *key,
const char **valStart, size_t *valLen) {
const size_t dlen = std::strlen(directive);
if (dlen == 0)
const size_t klen = std::strlen(key);
if (klen == 0)
return false;
for (size_t i = 0; i + dlen <= size; ++i) {
const bool atLineStart = (i == 0) || buf[i - 1] == '\n';
if (!atLineStart || _strnicmp(buf + i, directive, dlen) != 0)
continue;
const char *p = buf + i + dlen;
const char *end = buf + size;
while (p < end && (*p == ' ' || *p == '\t'))
++p;
const char *v = p;
while (p < end && *p != '\r' && *p != '\n')
++p;
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
--p;
*valStart = v;
*valLen = static_cast<size_t>(p - v);
return true;
const char *end = buf + size;
const char *line = buf;
if (size >= 3 && static_cast<unsigned char>(buf[0]) == 0xEF &&
static_cast<unsigned char>(buf[1]) == 0xBB &&
static_cast<unsigned char>(buf[2]) == 0xBF)
line += 3;
while (line < end) {
const char *eol = line;
while (eol < end && *eol != '\r' && *eol != '\n')
++eol;
const char *p = line;
if (eol - p >= 2 && p[0] == '#' && p[1] == '#') {
p += 2;
while (p < eol && *p == ' ')
++p;
if (static_cast<size_t>(eol - p) > klen && EqCI(p, klen, key) &&
p[klen] == ':') {
p += klen + 1;
while (p < eol && (*p == ' ' || *p == '\t'))
++p;
const char *v = p;
p = eol;
while (p > v && (p[-1] == ' ' || p[-1] == '\t'))
--p;
*valStart = v;
*valLen = static_cast<size_t>(p - v);
return true;
}
}
line = eol + 1;
}
return false;
}
+18 -13
View File
@@ -78,13 +78,18 @@ uint32_t ClientInterfaceVersion() {
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
}
// If `line` is a `## Interface:` directive whose value is a comma-list that
// contains the client version but does NOT lead with it, append a normalized
// `<prefix>: <clientVersion>` line to `out` and return true. Vanilla's parser
// reads only the first number, so without this a later 11200 is never seen and
// the addon is marked out of date. Returns false for any other line, and for
// an interface line that needs no change (single value, no client version, or
// client version already first) — the caller then copies the line verbatim.
// If `line` is a `## Interface:` directive whose value is a comma-list, pick
// the retail value — the closest entry at or below the client version — and,
// when that is not already the first entry, append a normalized
// `<prefix>: <value>` line to `out` and return true. Vanilla's parser reads
// only the first number, so without this a later 11200 is never seen and the
// addon is marked out of date. Loadability stays an exact match on the client
// version, and the pick equals it iff it is in the list, so this only changes
// the stored value (what GetAddOnInterfaceVersion reads) for lists that were
// out of date anyway. Returns false for any other line, and for an interface
// line that needs no change (single value, nothing at or below the client
// version, or the pick already first) — the caller then copies the line
// verbatim.
bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
@@ -103,8 +108,8 @@ bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
const size_t prefixEnd = p; // "## Interface:" (author spacing preserved)
const uint32_t client = ClientInterfaceVersion();
long first = -1;
bool hasComma = false, hasClient = false;
long first = -1, best = -1;
bool hasComma = false;
size_t q = p;
while (q < len) {
while (q < len && (IsSpace(line[q]) || line[q] == ',')) {
@@ -120,17 +125,17 @@ bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
}
if (digit) {
if (first < 0) first = v;
if (static_cast<uint32_t>(v) == client) hasClient = true;
if (static_cast<uint32_t>(v) <= client && v > best) best = v;
} else if (q < len) {
++q; // skip a stray non-digit so the scan always advances
}
}
if (!hasComma || !hasClient || first == static_cast<long>(client))
return false; // single value / no client version / already first
if (!hasComma || best < 0 || first == best)
return false; // single value / nothing at or below client / already first
out.append(line, prefixEnd);
out.push_back(' ');
out += std::to_string(client);
out += std::to_string(best);
return true;
}
+7 -6
View File
@@ -59,12 +59,13 @@ namespace AddOns::TocRewrite {
// Multi-flavor `## Interface:` list. Retail lets a TOC list several
// interface versions (`## Interface: 120100, 50504, 11200`). Vanilla's
// parser is a plain atoi that reads only the FIRST number, so it never
// sees a later 11200 and marks the addon out of date. When the client
// version (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) appears anywhere in
// the list but is not first, the `## Interface:` line is rewritten to that
// single value so the engine marks the addon compatible. A list WITHOUT
// the client version is left unchanged — it still parses to its first
// value and stays out of date, exactly as before.
// sees a later 11200 and marks the addon out of date. The line is rewritten
// to retail's pick — the closest value at or below the client version
// (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) — when that is not already
// first. If 11200 is listed the pick is 11200 and the addon loads; if not,
// the addon stays out of date exactly as before, but the stored value
// (`GetAddOnInterfaceVersion`) matches retail. A list with nothing at or
// below the client version is left unchanged.
//
// Called from the FUN_FILE_READ hook after the read succeeds. Does
// nothing unless `path` is an addon `.toc` read AND its content contains a
+715
View File
@@ -0,0 +1,715 @@
// 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/>.
// 4.3.4's CSimpleAnim and its Alpha, Translation, Scale and Rotation
// subclasses (wowdecomp15595 CSimpleAnim.cpp, CSimple*Anim.cpp and their
// _Scripts.cpp), on Animation::Object, and the group's CreateAnimation.
// Path and its control points are in Path.cpp.
#include "animation/Classes.h"
#include "animation/Region.h"
#include "animation/Xml.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include <cmath>
#include <cstring>
namespace Animation {
namespace {
const char *const kAnimScripts[] = {"onload", "onplay", "onpause", "onstop", "onfinished", "onupdate"};
Class &ClassOf(AnimKind kind) {
switch (kind) {
case KIND_ALPHA: return Anim::s_alphaClass;
case KIND_TRANSLATION: return Anim::s_translationClass;
case KIND_SCALE: return Anim::s_scaleClass;
case KIND_ROTATION: return Anim::s_rotationClass;
case KIND_PATH: return Anim::s_pathClass;
default: return Anim::s_class;
}
}
} // namespace
AnimKind Anim::KindOf(const char *typeName) {
if (typeName == nullptr || *typeName == '\0')
return KIND_ANIMATION;
if (Ascii::EqualCI(typeName, "Alpha"))
return KIND_ALPHA;
if (Ascii::EqualCI(typeName, "Translation"))
return KIND_TRANSLATION;
if (Ascii::EqualCI(typeName, "Scale"))
return KIND_SCALE;
if (Ascii::EqualCI(typeName, "Rotation"))
return KIND_ROTATION;
if (Ascii::EqualCI(typeName, "Path"))
return KIND_PATH;
return KIND_ANIMATION;
}
// ---- Construction ---------------------------------------------------------------
// 4.3.4's constructor links the animation into the group's list; it joins an
// order list only once it has an order (NotifyAnimAdded, SortAnimations).
Anim::Anim(Group *group, AnimKind kind, const char *name) : Object(ClassOf(kind), group, name), kind(kind) {
if (group != nullptr)
group->animations.push_back(this);
}
// ~CSimplePathAnim: a path's control points go with it.
Anim::~Anim() {
for (ControlPoint *point : controlPoints)
delete point;
}
// ---- Playback --------------------------------------------------------------------
bool Anim::Play() {
if (targetName[0] != '\0')
target = Region::FindRegionByName(targetName);
if (playState != 1) {
playState = 1;
RunScript(A_ONPLAY, 0);
}
return true;
}
void Anim::Pause() {
GetGroup()->Pause();
if (playState == 1) {
playState = 2;
RunScript(A_ONPAUSE, 0);
}
}
void Anim::Stop(bool requested) {
if (playState == 0)
return;
loopState = STATE_NONE;
playState = 0;
elapsed = 0.0f;
progressWithDelay = 0.0f;
progress = 0.0f;
if (HasScript(A_ONSTOP)) {
Game::Lua::PushBool(Game::Lua::State(), requested);
RunScript(A_ONSTOP, 1);
}
}
bool Anim::IsDelaying() const {
if (progressWithDelay <= 0.0f || progressWithDelay >= 1.0f)
return false;
const float total = endDelay + duration + startDelay;
if (std::fabs(total) < 0.00000023841858f)
return false;
if (loopState == STATE_REVERSE) {
if (endDelay > elapsed)
return true;
if (endDelay + duration > elapsed)
return false;
} else {
if (startDelay > elapsed)
return true;
if (duration + startDelay > elapsed)
return false;
}
return total > elapsed;
}
float Anim::UpdateProgress(float elapsedSec) {
const float prevElapsed = elapsed;
elapsed += elapsedSec;
const float total = orderPadding + startDelay + endDelay + duration;
float now;
if (total > elapsed) {
now = elapsed;
progressWithDelay = now / total;
if (progressWithDelay > 1.0f)
progressWithDelay = 1.0f;
if (std::fabs(duration) < 0.00000023841858f) {
progress = 1.0f;
} else {
frameElapsed += elapsedSec;
if ((GetGroup()->flags & GROUP_IGNORE_THROTTLE) || frameElapsed >= g_frameInterval) {
const float delay = loopState == STATE_REVERSE ? orderPadding + endDelay : startDelay;
progress = (now - delay) / duration;
if (progress < 0.0f)
progress = 0.0f;
else if (progress >= 1.0f)
progress = 1.0f;
frameElapsed = 0.0f;
}
}
} else {
now = total;
progressWithDelay = 1.0f;
progress = 1.0f;
}
float p = loopState == STATE_REVERSE ? 1.0f - progress : progress;
if (hasSmoother) {
// CSimpleAnimSCurveSmoother::GetSmoothProgress.
if (std::fabs(smoothIn) >= 0.001f && std::fabs(smoothOut) >= 0.001f)
p = 0.5f - std::cos(p * 3.1415927f) * 0.5f;
else if (std::fabs(smoothIn) >= 0.001f)
p = 1.0f - std::cos(p * 1.5707964f);
else if (std::fabs(smoothOut) >= 0.001f)
p = std::cos((p + 1.0f) * 1.5707964f) * -1.0f;
}
smoothProgress = p;
return now - prevElapsed;
}
bool Anim::OnUpdate(float elapsedSec, float &consumed) {
consumed = 0.0f;
if (progressWithDelay >= 1.0f) {
Apply(smoothProgress);
return true;
}
if (playState == 0)
return true;
if (playState == 2) {
Apply(smoothProgress);
return false;
}
consumed = UpdateProgress(elapsedSec);
if (HasScript(A_ONUPDATE)) {
Game::Lua::PushNumber(Game::Lua::State(), elapsedSec);
RunScript(A_ONUPDATE, 1);
}
Apply(smoothProgress);
if (progressWithDelay < 1.0f)
return false;
RunScript(A_ONFINISHED, 0);
return true;
}
void Anim::Apply(float p) {
void *region = GetRegionParent();
if (region == nullptr)
return;
switch (kind) {
case KIND_ALPHA:
Region::AddAnimAlpha(region, target, static_cast<int16_t>(change * p));
break;
case KIND_TRANSLATION:
Region::AddAnimTranslation(region, target, offsetX * p, offsetY * p);
break;
case KIND_SCALE:
Region::AddAnimScale(region, target, originPoint, originX, originY, scaleChangeX * p, scaleChangeY * p);
break;
case KIND_ROTATION:
Region::AddAnimRotation(region, target, originPoint, originX, originY, radians * p);
break;
case KIND_PATH:
ApplyPath(p);
break;
default:
break;
}
}
// ---- Settings ----------------------------------------------------------------------
void Anim::SetStartDelay(float delay, bool notify) {
startDelay = delay < 0.0f ? 0.0f : delay;
if (notify && parent != nullptr)
GetGroup()->ComputeDuration();
}
void Anim::SetEndDelay(float delay, bool notify) {
endDelay = delay < 0.0f ? 0.0f : delay;
if (notify && parent != nullptr)
GetGroup()->ComputeDuration();
}
void Anim::SetDuration(float value, bool notify) {
duration = value;
if (notify && parent != nullptr)
GetGroup()->ComputeDuration();
}
void Anim::SetOrder(int value, bool notify, bool wrap) {
const int oldOrder = order;
if (wrap)
order = value < 0 || value > 99 ? 0 : value;
else
order = value < 0 ? 0 : (value > 99 ? 99 : value);
// 4.3.4 compares the old order with the argument, not the clamped value.
if (notify && parent != nullptr && oldOrder != value)
GetGroup()->NotifyOrderChanged(this, oldOrder);
}
void Anim::SetSmoothing(float in, float out) {
if ((in > 0.0f && in <= 1.0f) || (out > 0.0f && out <= 1.0f)) {
hasSmoother = true;
smoothIn = in < 0.0f ? 0.0f : (in < 1.0f ? in : 1.0f);
smoothOut = out < 0.0f ? 0.0f : (out < 1.0f ? out : 1.0f);
}
}
bool Anim::GetSmoothing(float &in, float &out) const {
in = hasSmoother ? smoothIn : 0.0f;
out = hasSmoother ? smoothOut : 0.0f;
return hasSmoother;
}
bool Anim::ChangeParent(Group *group) {
if (group == nullptr || parent == group)
return false;
GetGroup()->NotifyAnimRemoved(this);
parent = group;
group->NotifyAnimAdded(this);
return true;
}
void Anim::PostLoadXML() {
flags &= ~1;
if (kind == KIND_PATH) {
// CSimplePathAnim::PostLoadXML: the points' orders and positions.
BuildSpline();
UpdateControlPointPositions();
}
RunScript(A_ONLOAD, 0);
}
// ---- Lua methods ----------------------------------------------------------------------
namespace {
using StringToPoint_t = int(__fastcall *)(const char *name, int *point);
using PointToString_t = const char *(__fastcall *)(int point);
Anim *Self(void *L) { return static_cast<Anim *>(Resolve(L, 1, Anim::s_class)); }
Anim *SelfOf(void *L, Class &cls) { return static_cast<Anim *>(Resolve(L, 1, cls)); }
// Usage error when argument `idx` isn't a number; the number otherwise.
bool NumberArg(void *L, int idx, Anim *anim, const char *usage) {
if (Game::Lua::IsNumber(L, idx))
return true;
Game::Lua::Error(L, usage, anim->DisplayName());
return false;
}
int __fastcall Script_Play(void *L) {
if (Anim *a = Self(L))
a->Play();
return 0;
}
int __fastcall Script_Pause(void *L) {
if (Anim *a = Self(L))
a->Pause();
return 0;
}
int __fastcall Script_Stop(void *L) {
if (Anim *a = Self(L))
a->Stop(true);
return 0;
}
template <typename Fn> int PushBoolOf(void *L, Fn fn) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
Game::Lua::PushBool(L, fn(a));
return 1;
}
template <typename Fn> int PushNumberOf(void *L, Fn fn) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, fn(a));
return 1;
}
int __fastcall Script_IsDone(void *L) { return PushBoolOf(L, [](Anim *a) { return a->progressWithDelay >= 1.0f; }); }
int __fastcall Script_IsPlaying(void *L) { return PushBoolOf(L, [](Anim *a) { return a->playState == 1; }); }
int __fastcall Script_IsPaused(void *L) { return PushBoolOf(L, [](Anim *a) { return a->playState == 2; }); }
int __fastcall Script_IsStopped(void *L) { return PushBoolOf(L, [](Anim *a) { return a->playState == 0; }); }
int __fastcall Script_IsDelaying(void *L) { return PushBoolOf(L, [](Anim *a) { return a->IsDelaying(); }); }
int __fastcall Script_GetElapsed(void *L) { return PushNumberOf(L, [](Anim *a) { return a->elapsed; }); }
int __fastcall Script_GetStartDelay(void *L) { return PushNumberOf(L, [](Anim *a) { return a->startDelay; }); }
int __fastcall Script_GetEndDelay(void *L) { return PushNumberOf(L, [](Anim *a) { return a->endDelay; }); }
int __fastcall Script_GetDuration(void *L) { return PushNumberOf(L, [](Anim *a) { return a->duration; }); }
int __fastcall Script_GetSmoothProgress(void *L) {
return PushNumberOf(L, [](Anim *a) { return a->smoothProgress; });
}
int __fastcall Script_GetProgress(void *L) { return PushNumberOf(L, [](Anim *a) { return a->progress; }); }
int __fastcall Script_GetProgressWithDelay(void *L) {
return PushNumberOf(L, [](Anim *a) { return a->progressWithDelay; });
}
int __fastcall Script_GetOrder(void *L) {
return PushNumberOf(L, [](Anim *a) { return static_cast<double>(a->order + 1); });
}
int __fastcall Script_SetStartDelay(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetStartDelay(delaySec)"))
a->SetStartDelay(static_cast<float>(Game::Lua::ToNumber(L, 2)), true);
return 0;
}
int __fastcall Script_SetEndDelay(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetEndDelay(delaySec)"))
a->SetEndDelay(static_cast<float>(Game::Lua::ToNumber(L, 2)), true);
return 0;
}
int __fastcall Script_SetDuration(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetDuration(durationSec)"))
a->SetDuration(static_cast<float>(Game::Lua::ToNumber(L, 2)), true);
return 0;
}
int __fastcall Script_SetSmoothProgress(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetSmoothProgress(smoothProgress)"))
a->smoothProgress = static_cast<float>(Game::Lua::ToNumber(L, 2));
return 0;
}
int __fastcall Script_SetOrder(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetOrder(order)"))
a->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1, true, false);
return 0;
}
int __fastcall Script_SetSmoothing(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetSmoothing(\"smoothingType\")", a->DisplayName());
return 0;
}
float in, out;
if (StringToSmoothWeights(Game::Lua::ToString(L, 2), in, out)) {
a->SetSmoothing(in, out);
return 0;
}
Game::Lua::Error(L, "%s:SetSmoothing(): Unknown smoothing type specified", a->DisplayName());
return 0;
}
int __fastcall Script_GetSmoothing(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
float in, out;
a->GetSmoothing(in, out);
Game::Lua::PushString(L, SmoothWeightsToString(in, out));
return 1;
}
int __fastcall Script_SetParent(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL) {
Game::Lua::Error(L, "%s:SetParent(): Cannot set a 'nil' parent for animations", a->DisplayName());
return 0;
}
Group *group = nullptr;
if (Game::Lua::IsString(L, 2)) {
// CScriptObject::GetScriptObjectByName(name, AnimationGroup).
Game::Lua::PushString(L, Game::Lua::ToString(L, 2));
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
void *object = Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE ? Game::Lua::ResolveObject(L, -1) : nullptr;
Game::Lua::SetTop(L, -2);
if (object != nullptr && IsOurs(object) &&
Game::Lua::IsA(object, reinterpret_cast<uintptr_t>(&Group::s_class.typeId)))
group = static_cast<Group *>(object);
} else if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE) {
void *object = Game::Lua::ResolveObject(L, 2);
if (object == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find 'this' in parent object", a->DisplayName());
return 0;
}
if (!IsOurs(object) || !Game::Lua::IsA(object, reinterpret_cast<uintptr_t>(&Group::s_class.typeId))) {
Game::Lua::Error(L, "%s:SetParent(): Wrong parent object type, expected AnimationGroup", a->DisplayName());
return 0;
}
group = static_cast<Group *>(object);
}
if (group == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find animation group named '%s'", a->DisplayName(),
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "");
return 0;
}
const int oldOrder = a->order;
if (Game::Lua::IsNumber(L, 3)) {
a->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 3)) - 1, false, false);
} else {
int value = a->order;
if (value == -1)
value = group->GetMaxAnimOrder() + 1;
a->SetOrder(value, false, true);
}
if (!a->ChangeParent(group) && oldOrder != a->order)
group->NotifyOrderChanged(a, oldOrder);
return 0;
}
int __fastcall Script_GetRegionParent(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
void *region = a->GetRegionParent();
if (region != nullptr)
Game::Lua::PushObject(L, region);
else
Game::Lua::PushNil(L);
return 1;
}
// ---- Alpha ----
int __fastcall Script_SetChange(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr || !NumberArg(L, 2, a, "Usage: %s:SetChange(change)"))
return 0;
// CSimpleAlphaAnim::SetChange.
const float change = static_cast<float>(Game::Lua::ToNumber(L, 2));
const float value = change < -1.0f ? -1.0f : (change >= 1.0f ? 1.0f : change);
a->change = static_cast<int16_t>(value * 255.0f);
return 0;
}
int __fastcall Script_GetChange(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->change * (1.0f / 255.0f));
return 1;
}
// ---- Translation ----
int __fastcall Script_SetOffset(void *L) {
Anim *a = SelfOf(L, Anim::s_translationClass);
if (a == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetOffset(x, y)", a->DisplayName());
return 0;
}
const float x = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 2)));
const float y = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3)));
// CSimpleTranslationAnim::SetOffset: a near-zero offset is zero.
if (x * x + y * y <= 0.00000023841858f) {
a->offsetX = 0.0f;
a->offsetY = 0.0f;
} else {
a->offsetX = x;
a->offsetY = y;
}
return 0;
}
int __fastcall Script_GetOffset(void *L) {
Anim *a = SelfOf(L, Anim::s_translationClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, Game::InternalToPixel(a->offsetX));
Game::Lua::PushNumber(L, Game::InternalToPixel(a->offsetY));
return 2;
}
// ---- Scale and Rotation origin (ReadOrigin) ----
Anim *OriginSelf(void *L) {
Anim *a = static_cast<Anim *>(Resolve(L, 1, Anim::s_class));
if (a != nullptr && a->kind != KIND_SCALE && a->kind != KIND_ROTATION) {
// The type test of 4.3.4's Scale / Rotation SetOrigin.
Game::Lua::Error(L, "Wrong object type for member function");
return nullptr;
}
return a;
}
int __fastcall Script_SetOrigin(void *L) {
Anim *a = OriginSelf(L);
if (a == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2) || !Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4)) {
Game::Lua::Error(L, "Usage: %s:SetOrigin(point, offsetX, offsetY)", a->DisplayName());
return 0;
}
int point = Offsets::FRAMEPOINT_CENTER;
if (!reinterpret_cast<StringToPoint_t>(Offsets::FUN_STRING_TO_FRAME_POINT)(Game::Lua::ToString(L, 2), &point))
point = Offsets::FRAMEPOINT_CENTER;
a->originPoint = point;
a->originX = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3)));
a->originY = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 4)));
return 0;
}
// 4.3.4 pushes the stored offsets as they are, in layout units.
int __fastcall Script_GetOrigin(void *L) {
Anim *a = OriginSelf(L);
if (a == nullptr)
return 0;
Game::Lua::PushString(L, reinterpret_cast<PointToString_t>(Offsets::FUN_FRAME_POINT_TO_STRING)(a->originPoint));
Game::Lua::PushNumber(L, a->originX);
Game::Lua::PushNumber(L, a->originY);
return 3;
}
// ---- Scale ----
int __fastcall Script_SetScale(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetScale(x, y)", a->DisplayName());
return 0;
}
// CSimpleScaleAnim::SetScale.
const float x = static_cast<float>(Game::Lua::ToNumber(L, 2));
const float y = static_cast<float>(Game::Lua::ToNumber(L, 3));
a->scaleChangeX = 1.0f - (x >= 0.001f ? x : 0.001f);
a->scaleChangeY = 1.0f - (y >= 0.001f ? y : 0.001f);
return 0;
}
int __fastcall Script_GetScale(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, 1.0f - a->scaleChangeX);
Game::Lua::PushNumber(L, 1.0f - a->scaleChangeY);
return 2;
}
// ---- Rotation ----
int __fastcall Script_SetDegrees(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetDegrees(degrees)"))
a->radians = static_cast<float>(Game::Lua::ToNumber(L, 2) * 0.017453292f);
return 0;
}
int __fastcall Script_GetDegrees(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->radians * 57.29578f);
return 1;
}
int __fastcall Script_SetRadians(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetRadians(radians)"))
a->radians = static_cast<float>(Game::Lua::ToNumber(L, 2));
return 0;
}
int __fastcall Script_GetRadians(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->radians);
return 1;
}
const Game::Lua::FrameMethodEntry kAnimMethods[] = {
{"GetObjectType", &Script_GetObjectType},
{"IsObjectType", &Script_IsObjectType},
{"GetName", &Script_GetName},
{"GetParent", &Script_GetParent},
{"Play", &Script_Play},
{"Pause", &Script_Pause},
{"Stop", &Script_Stop},
{"IsDone", &Script_IsDone},
{"IsPlaying", &Script_IsPlaying},
{"IsPaused", &Script_IsPaused},
{"IsStopped", &Script_IsStopped},
{"IsDelaying", &Script_IsDelaying},
{"GetElapsed", &Script_GetElapsed},
{"SetStartDelay", &Script_SetStartDelay},
{"GetStartDelay", &Script_GetStartDelay},
{"SetEndDelay", &Script_SetEndDelay},
{"GetEndDelay", &Script_GetEndDelay},
{"SetDuration", &Script_SetDuration},
{"GetDuration", &Script_GetDuration},
{"GetSmoothProgress", &Script_GetSmoothProgress},
{"SetSmoothProgress", &Script_SetSmoothProgress},
{"GetProgress", &Script_GetProgress},
{"GetProgressWithDelay", &Script_GetProgressWithDelay},
{"SetOrder", &Script_SetOrder},
{"GetOrder", &Script_GetOrder},
{"SetSmoothing", &Script_SetSmoothing},
{"GetSmoothing", &Script_GetSmoothing},
{"SetParent", &Script_SetParent},
{"GetRegionParent", &Script_GetRegionParent},
{"HasScript", &Script_HasScript},
{"GetScript", &Script_GetScript},
{"SetScript", &Script_SetScript},
{"HookScript", &Script_HookScript},
};
const Game::Lua::FrameMethodEntry kAlphaMethods[] = {
{"SetChange", &Script_SetChange},
{"GetChange", &Script_GetChange},
};
const Game::Lua::FrameMethodEntry kTranslationMethods[] = {
{"SetOffset", &Script_SetOffset},
{"GetOffset", &Script_GetOffset},
};
const Game::Lua::FrameMethodEntry kScaleMethods[] = {
{"SetOrigin", &Script_SetOrigin},
{"GetOrigin", &Script_GetOrigin},
{"SetScale", &Script_SetScale},
{"GetScale", &Script_GetScale},
};
const Game::Lua::FrameMethodEntry kRotationMethods[] = {
{"SetOrigin", &Script_SetOrigin}, {"GetOrigin", &Script_GetOrigin}, {"SetDegrees", &Script_SetDegrees},
{"GetDegrees", &Script_GetDegrees}, {"SetRadians", &Script_SetRadians}, {"GetRadians", &Script_GetRadians},
};
template <size_t N> constexpr int Count(const Game::Lua::FrameMethodEntry (&)[N]) { return static_cast<int>(N); }
} // namespace
Class Anim::s_class{"Animation", nullptr, kAnimScripts, 6, kAnimMethods, Count(kAnimMethods), 0, 0};
Class Anim::s_alphaClass{"Alpha", &Anim::s_class, nullptr, 0, kAlphaMethods, Count(kAlphaMethods), 0, 0};
Class Anim::s_translationClass{"Translation", &Anim::s_class, nullptr, 0, kTranslationMethods,
Count(kTranslationMethods), 0, 0};
Class Anim::s_scaleClass{"Scale", &Anim::s_class, nullptr, 0, kScaleMethods, Count(kScaleMethods), 0, 0};
Class Anim::s_rotationClass{"Rotation", &Anim::s_class, nullptr, 0, kRotationMethods, Count(kRotationMethods), 0, 0};
// ---- AnimationGroup:CreateAnimation ------------------------------------------------------
int __fastcall Script_CreateAnimation(void *L) {
Group *group = static_cast<Group *>(Resolve(L, 1, Group::s_class));
if (group == nullptr)
return 0;
const char *type = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "Animation";
const char *name = Game::Lua::IsString(L, 3) ? Game::Lua::ToString(L, 3) : nullptr;
const void *templateNode = nullptr;
if (Game::Lua::Type(L, 4) == Game::Lua::TYPE_STRING) {
const char *inherits = Game::Lua::ToString(L, 4);
templateNode = Xml::Template(inherits);
if (templateNode == nullptr) {
Game::Lua::Error(L, "%s:CreateAnimation(): Couldn't find inherited node \"%s\"", group->DisplayName(),
inherits);
return 0;
}
}
Anim *anim = new Anim(group, Anim::KindOf(type), name);
if (templateNode != nullptr) {
Xml::LoadAnim(anim, templateNode, nullptr);
anim->PostLoadXML();
}
if (anim->order == -1)
anim->SetOrder(group->GetMaxAnimOrder(), false, true);
group->NotifyAnimAdded(anim);
Push(L, anim);
return 1;
}
} // namespace Animation
+213
View File
@@ -0,0 +1,213 @@
// 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 "animation/Object.h"
#include <cstdint>
#include <vector>
// 4.3.4's CSimpleAnimGroup and CSimpleAnim (and its Alpha, Translation,
// Scale, Rotation and Path subclasses, and Path's CSimpleControlPoint), ported from the decompiled client
// (wowdecomp15595 Engine/Source/Frame/CSimpleAnimGroup.cpp, CSimpleAnim.cpp).
// Field and function names follow it; the comments say where 1.12 differs.
namespace Animation {
class Anim;
class ControlPoint;
enum LoopType { LOOP_NONE = 0, LOOP_REPEAT = 1, LOOP_BOUNCE = 2 };
enum LoopState { STATE_NONE = 0, STATE_FORWARD = 1, STATE_REVERSE = 2 };
// m_flags.
constexpr uint8_t GROUP_LOADING = 0x01;
constexpr uint8_t GROUP_PENDING_FINISH = 0x02;
constexpr uint8_t GROUP_PAUSED = 0x04;
constexpr uint8_t GROUP_STOPPING = 0x08;
constexpr uint8_t GROUP_IGNORE_THROTTLE = 0x10;
// Script indexes (Class::scriptNames order).
enum GroupScript { G_ONLOAD, G_ONPLAY, G_ONPAUSE, G_ONSTOP, G_ONFINISHED, G_ONUPDATE, G_ONLOOP };
enum AnimScript { A_ONLOAD, A_ONPLAY, A_ONPAUSE, A_ONSTOP, A_ONFINISHED, A_ONUPDATE };
class Group : public Object {
public:
static Class s_class;
// `region`: the 1.12 region (frame, texture or font string) it animates.
Group(void *region, const char *name);
~Group();
void *RegionObject() const { return parent; }
bool IsDone() const;
bool IsPlaying() const { return loopState != STATE_NONE && !(flags & GROUP_PAUSED); }
bool Play(bool reverse);
void Pause();
void Stop(bool requested);
void Finish();
bool OnUpdate(float elapsedSec);
int GetMaxAnimOrder() const;
bool HasAlphaAnim() const;
bool HasPositionAnim() const;
void NotifyAnimAdded(Anim *anim);
void NotifyOrderChanged(Anim *anim, int oldOrder);
void NotifyAnimRemoved(Anim *anim);
void SortAnimations();
void ComputeDuration();
// XML: 4.3.4's PostLoadXML - every animation's, then sort, then OnLoad.
void PostLoadXML();
LoopType loopType = LOOP_NONE;
float duration = 0.0f;
float initialOffsetX = 0.0f, initialOffsetY = 0.0f; // layout units
LoopState loopState = STATE_NONE;
uint8_t flags = 0;
int currentOrder = -1;
float elapsed = 0.0f;
float progress = 0.0f;
std::vector<Anim *> animations; // m_animations, creation order
std::vector<std::vector<Anim *>> animLists; // m_animLists, by order
private:
std::vector<Anim *> *FindAnimList(int order);
std::vector<Anim *> *RegisterAnimList(int order);
bool AddAnimListNode(std::vector<Anim *> *list, Anim *anim);
bool RemoveAnimListNode(std::vector<Anim *> *list, Anim *anim);
bool AdvanceLoopState(float elapsedSec, float remaining);
bool DoGroupUpdateWork(float elapsedSec);
void ResetAnims();
};
enum AnimKind { KIND_ANIMATION, KIND_ALPHA, KIND_TRANSLATION, KIND_SCALE, KIND_ROTATION, KIND_PATH };
class Anim : public Object {
public:
static Class s_class; // "Animation"
static Class s_alphaClass; // "Alpha"
static Class s_translationClass; // "Translation"
static Class s_scaleClass; // "Scale"
static Class s_rotationClass; // "Rotation"
static Class s_pathClass; // "Path"
// The class of a CreateAnimation / XML type name; Animation for any
// other (4.3.4's CreateAnimation falls back to it).
static AnimKind KindOf(const char *typeName);
Anim(Group *group, AnimKind kind, const char *name);
~Anim();
Group *GetGroup() const { return static_cast<Group *>(parent); }
void *GetRegionParent() const { return parent != nullptr ? GetGroup()->RegionObject() : nullptr; }
bool Play();
void Pause();
void Stop(bool requested);
bool IsDelaying() const;
bool OnUpdate(float elapsedSec, float &consumed);
void Apply(float progress);
void SetStartDelay(float delay, bool notify);
void SetEndDelay(float delay, bool notify);
void SetDuration(float value, bool notify);
void SetOrder(int value, bool notify, bool wrap);
void SetSmoothing(float in, float out);
bool GetSmoothing(float &in, float &out) const;
bool ChangeParent(Group *group);
// XML: 4.3.4's PostLoadXML - OnLoad.
void PostLoadXML();
AnimKind kind;
float startDelay = 0.0f;
float endDelay = 0.0f;
float duration = 0.0f;
float frameElapsed = 0.0f;
float elapsed = 0.0f;
float progressWithDelay = 0.0f;
float progress = 0.0f;
float smoothProgress = 0.0f;
float orderPadding = 0.0f;
int order = -1;
uint8_t flags = 0;
int playState = 0; // 0 stopped, 1 playing, 2 paused
int loopState = STATE_NONE;
void *target = nullptr; // m_target, resolved from targetName on Play
char targetName[128] = {}; // m_targetName
bool hasSmoother = false;
float smoothIn = 0.0f, smoothOut = 0.0f;
// Alpha: m_change, -255..255.
int16_t change = 0;
// Translation: the offset; Scale: the scale and origin; Rotation: the
// angle and origin. Offsets in layout units, as 4.3.4 keeps them.
float offsetX = 0.0f, offsetY = 0.0f;
float scaleChangeX = 0.0f, scaleChangeY = 0.0f; // m_scaleChange: 1 - scale
float radians = 0.0f;
int originPoint = 4; // FRAMEPOINT_CENTER
float originX = 0.0f, originY = 0.0f;
// Path (CSimplePathAnim, Path.cpp): the control points it owns in link
// order (m_controlPoints), the same sorted by order (m_sortedPoints),
// and m_curve (false NONE, true SMOOTH).
std::vector<ControlPoint *> controlPoints;
std::vector<ControlPoint *> sortedPoints;
bool curve = false;
void BuildSpline();
void UpdateControlPointPositions();
int FindControlPoint(int order) const;
int GetMaxControlPointOrder() const;
int RegisterSplineControlPoint(ControlPoint *point);
void NotifyControlPointAdded(ControlPoint *point);
void NotifyControlPointRemoved(ControlPoint *point);
void NotifyControlPointOrderChanged(ControlPoint *point);
void ApplyPath(float progress);
private:
float UpdateProgress(float elapsedSec);
};
// CSimpleControlPoint: a point of a Path, an offset from where the region
// is, in layout units.
class ControlPoint : public Object {
public:
static Class s_class; // "ControlPoint"
// Links the point at the end of the path's points.
ControlPoint(Anim *path, const char *name);
~ControlPoint();
Anim *GetPath() const { return static_cast<Anim *>(parent); }
bool ChangeParent(Anim *path);
void SetOffset(float x, float y);
void SetOrder(int value, bool notify, bool wrap);
float offsetX = 0.0f, offsetY = 0.0f;
float position = 0.0f; // m_position: the path progress the point is at
int order = -1;
};
// 4.3.4's s_frameInterval: progress steps at most this often unless the
// group ignores the framerate throttle. 1/30 s until SetMaxAnimFramerate
// (Framerate.cpp) changes it.
extern float g_frameInterval;
// AnimationGroup:CreateAnimation (Anim.cpp, where the animation classes are).
int __fastcall Script_CreateAnimation(void *L);
const char *LoopTypeToString(LoopType type);
bool StringToLoopType(const char *text, LoopType &type);
const char *LoopStateToString(int state);
bool StringToSmoothWeights(const char *text, float &in, float &out);
const char *SmoothWeightsToString(float in, float out);
} // namespace Animation
+61
View File
@@ -0,0 +1,61 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `SetMaxAnimFramerate(framesPerSec)` / `GetMaxAnimFramerate()` - 4.3.4's
// globals over CSimpleAnim::s_frameInterval (SimpleFrameScript_Scripts.cpp),
// the throttle that Anim::UpdateProgress steps progress by. The setter stores
// 1 / fps unchecked, as 4.3.4 does: 0 stores infinity, so progress stops
// stepping until the animation ends.
#include "Classes.h"
#include "Game.h"
namespace Animation {
float g_frameInterval = 1.0f / 30.0f;
namespace {
int __fastcall Script_SetMaxAnimFramerate(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: SetMaxFramerate(framesPerSec)");
return 0;
}
g_frameInterval = 1.0f / static_cast<float>(Game::Lua::ToNumber(L, 1));
return 0;
}
int __fastcall Script_GetMaxAnimFramerate(void *L) {
Game::Lua::PushNumber(L, 1.0f / g_frameInterval);
return 1;
}
const Game::Doc::Field kSetArgs[] = {Game::Doc::Req("framesPerSec", "number")};
const Game::Doc::Field kGetRets[] = {Game::Doc::Req("framesPerSec", "number")};
const Game::Doc::Function kSet{"Sets how many times per second animations step their progress.", kSetArgs, {},
"AnimationGlobals"};
const Game::Doc::Function kGet{"Returns how many times per second animations step their progress.", {}, kGetRets,
"AnimationGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("SetMaxAnimFramerate", &Script_SetMaxAnimFramerate, &kSet);
Game::Lua::RegisterGlobalFunction("GetMaxAnimFramerate", &Script_GetMaxAnimFramerate, &kGet);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Animation
+672
View File
@@ -0,0 +1,672 @@
// 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/>.
// 4.3.4's CSimpleAnimGroup (wowdecomp15595 CSimpleAnimGroup.cpp and
// CSimpleAnimGroup_Scripts.cpp), on Animation::Object.
#include "animation/Classes.h"
#include "animation/Region.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include <algorithm>
#include <cmath>
namespace Animation {
namespace {
const char *const kGroupScripts[] = {"onload", "onplay", "onpause", "onstop", "onfinished", "onupdate", "onloop"};
} // namespace
// ---- Construction ---------------------------------------------------------------
Group::Group(void *region, const char *name) : Object(s_class, region, name) {
if (region != nullptr)
Region::RegisterGroup(region, this);
}
Group::~Group() {
for (Anim *anim : animations)
delete anim;
}
// ---- State --------------------------------------------------------------------
bool Group::IsDone() const {
if (duration <= 0.0001f)
return true;
if (loopType == LOOP_REPEAT || loopType == LOOP_BOUNCE)
return false;
if (loopState == STATE_FORWARD || loopState == STATE_REVERSE)
return false;
return progress >= 1.0f;
}
void Group::Finish() {
if (!IsDone() && loopState != STATE_NONE)
flags |= GROUP_PENDING_FINISH;
}
int Group::GetMaxAnimOrder() const {
if (animLists.empty())
return -1;
return animLists.back().front()->order;
}
bool Group::HasAlphaAnim() const {
for (const Anim *anim : animations)
if (anim->kind == KIND_ALPHA)
return true;
return false;
}
bool Group::HasPositionAnim() const {
for (const Anim *anim : animations)
if (anim->kind != KIND_ALPHA)
return true;
return false;
}
// ---- Order lists ----------------------------------------------------------------
std::vector<Anim *> *Group::FindAnimList(int order) {
if (order == -1)
return nullptr;
int low = 0;
int high = static_cast<int>(animLists.size()) - 1;
while (low <= high) {
const int middle = static_cast<int>(static_cast<unsigned>(high + low) >> 1);
std::vector<Anim *> &list = animLists[middle];
if (!list.empty()) {
const int listOrder = list.front()->order;
if (listOrder < order)
low = middle + 1;
else if (listOrder > order)
high = middle - 1;
else
return &list;
}
}
return nullptr;
}
std::vector<Anim *> *Group::RegisterAnimList(int order) {
if (order == -1)
return nullptr;
const size_t count = animLists.size();
if (count == 0) {
animLists.emplace_back();
return &animLists.back();
}
size_t index = count;
for (size_t i = 0; i < count; ++i) {
std::vector<Anim *> &list = animLists[i];
if (list.empty())
return &list;
const int listOrder = list.front()->order;
if (order == listOrder)
return &list;
if (order < listOrder) {
index = i;
break;
}
}
animLists.insert(animLists.begin() + static_cast<ptrdiff_t>(index), std::vector<Anim *>());
return &animLists[index];
}
bool Group::AddAnimListNode(std::vector<Anim *> *list, Anim *anim) {
if (list == nullptr)
return false;
list->push_back(anim);
return true;
}
bool Group::RemoveAnimListNode(std::vector<Anim *> *list, Anim *anim) {
if (list == nullptr)
return false;
const auto it = std::find(list->begin(), list->end(), anim);
if (it == list->end())
return false;
list->erase(it);
if (list->empty())
SortAnimations();
return true;
}
void Group::ComputeDuration() {
duration = 0.0f;
for (std::vector<Anim *> &list : animLists) {
float span = 0.0f;
for (const Anim *anim : list) {
const float total = anim->duration + anim->endDelay + anim->startDelay;
if (total > span)
span = total;
}
for (Anim *anim : list)
anim->orderPadding = span - (anim->duration + anim->endDelay + anim->startDelay);
duration += span;
}
}
void Group::NotifyAnimAdded(Anim *anim) {
if (anim == nullptr)
return;
// m_animations.LinkToTail: an animation already in the list moves to its tail.
animations.erase(std::remove(animations.begin(), animations.end(), anim), animations.end());
animations.push_back(anim);
if (AddAnimListNode(RegisterAnimList(anim->order), anim))
ComputeDuration();
}
void Group::NotifyOrderChanged(Anim *anim, int oldOrder) {
if (anim == nullptr)
return;
if (RemoveAnimListNode(FindAnimList(oldOrder), anim))
if (AddAnimListNode(RegisterAnimList(anim->order), anim))
ComputeDuration();
}
void Group::NotifyAnimRemoved(Anim *anim) {
if (anim == nullptr)
return;
animations.erase(std::remove(animations.begin(), animations.end(), anim), animations.end());
std::vector<Anim *> *list = FindAnimList(anim->order);
if (RemoveAnimListNode(list, anim)) {
// RemoveAnimListNode sorts when the list emptied; the list may be gone.
ComputeDuration();
}
}
void Group::SortAnimations() {
if (loopState == STATE_NONE || (flags & GROUP_PAUSED)) {
loopState = STATE_NONE;
flags &= 0xF0;
currentOrder = -1;
elapsed = 0.0f;
progress = 0.0f;
} else {
Stop(false);
}
if (animations.empty()) {
animLists.clear();
return;
}
for (std::vector<Anim *> &list : animLists)
list.clear();
int nextOrder = 0;
for (Anim *anim : animations) {
int order = anim->order;
if (order == -1) {
order = nextOrder++;
anim->order = order < 0 ? 0 : order;
} else {
nextOrder = order + 1;
}
if (nextOrder > 99)
nextOrder = 0;
AddAnimListNode(RegisterAnimList(order), anim);
}
while (!animLists.empty() && animLists.back().empty())
animLists.pop_back();
}
void Group::ResetAnims() {
for (Anim *anim : animations) {
anim->loopState = loopState > 0 && loopState <= 2 ? loopState : 0;
anim->playState = 0;
anim->elapsed = 0.0f;
anim->progressWithDelay = 0.0f;
anim->progress = 0.0f;
}
}
// ---- Playback --------------------------------------------------------------------
void Group::Pause() {
if (loopState == STATE_NONE || (flags & GROUP_PAUSED))
return;
flags |= GROUP_PAUSED;
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Pause();
RunScript(G_ONPAUSE, 0);
}
void Group::Stop(bool requested) {
if (loopState == STATE_NONE || (flags & GROUP_STOPPING))
return;
flags |= GROUP_STOPPING;
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Stop(requested);
Region::NotifyEnd(RegionObject(), this);
flags &= 0xF0;
loopState = STATE_NONE;
currentOrder = -1;
elapsed = 0.0f;
progress = 0.0f;
if (HasScript(G_ONSTOP)) {
Game::Lua::PushBool(Game::Lua::State(), requested);
RunScript(G_ONSTOP, 1);
}
}
bool Group::AdvanceLoopState(float /*elapsedSec*/, float /*remaining*/) {
const int count = static_cast<int>(animLists.size());
const bool reverse = loopState == STATE_REVERSE;
const int step = reverse ? -1 : 1;
const int first = reverse ? count - 1 : 0;
const int end = reverse ? -1 : count;
currentOrder += step;
if (currentOrder == end) {
if (loopType == LOOP_NONE || (flags & GROUP_PENDING_FINISH))
return true;
if (loopType == LOOP_BOUNCE) {
loopState = static_cast<LoopState>(!reverse + 1);
currentOrder -= step;
} else {
currentOrder = first;
}
elapsed = 0.0f;
progress = 0.0f;
ResetAnims();
if (HasScript(G_ONLOOP)) {
Game::Lua::PushString(Game::Lua::State(), LoopStateToString(loopState));
RunScript(G_ONLOOP, 1);
}
if (loopState == STATE_NONE || (flags & GROUP_PAUSED))
return true;
}
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Play();
return false;
}
bool Group::DoGroupUpdateWork(float elapsedSec) {
float remaining = elapsedSec < 0.0f ? 0.0f : (elapsedSec >= 60.0f ? 60.0f : elapsedSec);
while (true) {
for (Anim *anim : animLists[currentOrder]) {
void *region = anim->GetRegionParent();
if (region != nullptr)
Region::PreOnAnimUpdate(region);
}
for (int i = 0; i < currentOrder; ++i)
for (Anim *anim : animLists[i])
anim->Apply(1.0f);
float maxConsumed = 0.0f;
bool finished = true;
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list) {
float consumed;
finished = anim->OnUpdate(remaining, consumed) && finished;
if (consumed > maxConsumed)
maxConsumed = consumed;
}
if (!(flags & GROUP_PAUSED) && loopState != STATE_NONE) {
remaining -= maxConsumed;
elapsed += maxConsumed;
progress = elapsed < duration ? elapsed / duration : 1.0f;
if (finished && AdvanceLoopState(elapsedSec, remaining))
return true;
}
if (remaining <= 0.0001f || IsDone() || loopState == STATE_NONE || (flags & GROUP_PAUSED))
return false;
}
}
bool Group::OnUpdate(float elapsedSec) {
if (IsDone() || loopState == STATE_NONE)
return true;
const bool done = DoGroupUpdateWork(elapsedSec);
if (HasScript(G_ONUPDATE)) {
Game::Lua::PushNumber(Game::Lua::State(), elapsedSec);
RunScript(G_ONUPDATE, 1);
}
if (done) {
Region::NotifyEnd(RegionObject(), this);
const bool requested = (flags & GROUP_PENDING_FINISH) != 0;
loopState = STATE_NONE;
flags &= 0xF0;
currentOrder = -1;
elapsed = 0.0f;
progress = 0.0f;
if (HasScript(G_ONFINISHED)) {
Game::Lua::PushBool(Game::Lua::State(), requested);
RunScript(G_ONFINISHED, 1);
}
}
return IsDone();
}
bool Group::Play(bool reverse) {
if (animations.empty())
return false;
if (loopState != STATE_NONE && !(flags & GROUP_PAUSED))
return true;
if (flags & GROUP_PAUSED) {
flags &= ~GROUP_PAUSED;
} else {
flags &= 0xF0;
loopState = STATE_NONE;
currentOrder = reverse ? 0x7FFF : -1;
elapsed = 0.0f;
progress = 0.0f;
}
RunScript(G_ONPLAY, 0);
const int count = static_cast<int>(animLists.size());
if (count > 0) {
if (currentOrder < 0 || currentOrder >= count) {
if (currentOrder < 0) {
loopState = STATE_FORWARD;
currentOrder = 0;
} else {
loopState = STATE_REVERSE;
currentOrder = count - 1;
}
ResetAnims();
}
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Play();
Region::NotifyBegin(RegionObject(), this);
}
OnUpdate(0.0f);
return true;
}
void Group::PostLoadXML() {
flags &= ~GROUP_LOADING;
const std::vector<Anim *> list = animations;
for (Anim *anim : list)
anim->PostLoadXML();
SortAnimations();
ComputeDuration();
RunScript(G_ONLOAD, 0);
}
// ---- Strings ----------------------------------------------------------------------
namespace {
struct Named {
int value;
const char *name;
};
const Named kLoopTypes[] = {{LOOP_NONE, "NONE"}, {LOOP_REPEAT, "REPEAT"}, {LOOP_BOUNCE, "BOUNCE"}};
const Named kLoopStates[] = {{STATE_NONE, "NONE"}, {STATE_FORWARD, "FORWARD"}, {STATE_REVERSE, "REVERSE"}};
struct Weights {
float in, out;
const char *name;
};
const Weights kSmoothWeights[] = {
{0.0f, 0.0f, "NONE"}, {1.0f, 0.0f, "IN"}, {0.0f, 1.0f, "OUT"}, {1.0f, 1.0f, "IN_OUT"}, {1.0f, 1.0f, "OUT_IN"},
};
} // namespace
const char *LoopTypeToString(LoopType type) {
for (const Named &n : kLoopTypes)
if (n.value == type)
return n.name;
return "UNKNOWN";
}
bool StringToLoopType(const char *text, LoopType &type) {
for (const Named &n : kLoopTypes)
if (text != nullptr && Ascii::EqualCI(n.name, text)) {
type = static_cast<LoopType>(n.value);
return true;
}
return false;
}
const char *LoopStateToString(int state) {
for (const Named &n : kLoopStates)
if (n.value == state)
return n.name;
return "UNKNOWN";
}
bool StringToSmoothWeights(const char *text, float &in, float &out) {
for (const Weights &w : kSmoothWeights)
if (text != nullptr && Ascii::EqualCI(w.name, text)) {
in = w.in;
out = w.out;
return true;
}
return false;
}
const char *SmoothWeightsToString(float in, float out) {
for (const Weights &w : kSmoothWeights)
if (std::fabs(in - w.in) < 0.001f && std::fabs(out - w.out) < 0.001f)
return w.name;
return "UNKNOWN";
}
// ---- Lua methods (CSimpleAnimGroup_Scripts.cpp) -------------------------------
namespace {
Group *Self(void *L) { return static_cast<Group *>(Resolve(L, 1, Group::s_class)); }
int __fastcall Script_Play(void *L) {
if (Group *g = Self(L))
g->Play(Game::Lua::ToFlag(L, 2));
return 0;
}
int __fastcall Script_Pause(void *L) {
if (Group *g = Self(L))
g->Pause();
return 0;
}
int __fastcall Script_Stop(void *L) {
if (Group *g = Self(L))
g->Stop(true);
return 0;
}
int __fastcall Script_Finish(void *L) {
if (Group *g = Self(L))
g->Finish();
return 0;
}
int __fastcall Script_GetProgress(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, g->elapsed / g->duration);
return 1;
}
int __fastcall Script_IsDone(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, g->IsDone());
return 1;
}
int __fastcall Script_IsPlaying(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, g->IsPlaying());
return 1;
}
int __fastcall Script_IsPaused(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, (g->flags & GROUP_PAUSED) != 0);
return 1;
}
int __fastcall Script_IsPendingFinish(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, (g->flags & GROUP_PENDING_FINISH) != 0);
return 1;
}
int __fastcall Script_GetDuration(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, g->duration);
return 1;
}
int __fastcall Script_SetLooping(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetLooping(\"loopType\")", g->DisplayName());
return 0;
}
LoopType type;
if (StringToLoopType(Game::Lua::ToString(L, 2), type)) {
g->loopType = type;
return 0;
}
Game::Lua::Error(L, "%s:SetLooping(): Unknown loop type specified", g->DisplayName());
return 0;
}
int __fastcall Script_GetLooping(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushString(L, LoopTypeToString(g->loopType));
return 1;
}
int __fastcall Script_GetLoopState(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushString(L, LoopStateToString(g->loopState));
return 1;
}
int __fastcall Script_GetMaxOrder(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, g->GetMaxAnimOrder() + 1);
return 1;
}
int __fastcall Script_SetInitialOffset(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetInitialOffset(x, y)", g->DisplayName());
return 0;
}
const float x = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 2)));
const float y = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3)));
// 4.3.4's SetInitialOffset: a near-zero offset is zero.
if (x * x + y * y <= 0.00000023841858f) {
g->initialOffsetX = 0.0f;
g->initialOffsetY = 0.0f;
} else {
g->initialOffsetX = x;
g->initialOffsetY = y;
}
return 0;
}
int __fastcall Script_GetInitialOffset(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, Game::InternalToPixel(g->initialOffsetX));
Game::Lua::PushNumber(L, Game::InternalToPixel(g->initialOffsetY));
return 2;
}
int __fastcall Script_GetAnimations(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
const int count = static_cast<int>(g->animations.size());
if (!Game::Lua::CheckStack(L, count)) {
Game::Lua::Error(L, "%s:GetAnimations(): Stack overflow", g->DisplayName());
return 0;
}
for (Anim *anim : g->animations)
Push(L, anim);
return count;
}
int __fastcall Script_SetIgnoreFramerateThrottle(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (Game::Lua::ToFlag(L, 2))
g->flags |= GROUP_IGNORE_THROTTLE;
else
g->flags &= ~GROUP_IGNORE_THROTTLE;
return 0;
}
int __fastcall Script_GetIgnoreFramerateThrottle(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (g->flags & GROUP_IGNORE_THROTTLE)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"GetObjectType", &Script_GetObjectType},
{"IsObjectType", &Script_IsObjectType},
{"GetName", &Script_GetName},
{"GetParent", &Script_GetParent},
{"Play", &Script_Play},
{"Pause", &Script_Pause},
{"Stop", &Script_Stop},
{"Finish", &Script_Finish},
{"GetProgress", &Script_GetProgress},
{"IsDone", &Script_IsDone},
{"IsPlaying", &Script_IsPlaying},
{"IsPaused", &Script_IsPaused},
{"IsPendingFinish", &Script_IsPendingFinish},
{"GetDuration", &Script_GetDuration},
{"SetLooping", &Script_SetLooping},
{"GetLooping", &Script_GetLooping},
{"GetLoopState", &Script_GetLoopState},
{"GetMaxOrder", &Script_GetMaxOrder},
{"SetInitialOffset", &Script_SetInitialOffset},
{"GetInitialOffset", &Script_GetInitialOffset},
{"GetAnimations", &Script_GetAnimations},
{"CreateAnimation", &Script_CreateAnimation},
{"HasScript", &Script_HasScript},
{"GetScript", &Script_GetScript},
{"SetScript", &Script_SetScript},
{"HookScript", &Script_HookScript},
{"SetIgnoreFramerateThrottle", &Script_SetIgnoreFramerateThrottle},
{"GetIgnoreFramerateThrottle", &Script_GetIgnoreFramerateThrottle},
};
} // namespace
Class Group::s_class{"AnimationGroup", nullptr, kGroupScripts, 7, kMethods,
static_cast<int>(sizeof(kMethods) / sizeof(kMethods[0])), 0, 0};
} // namespace Animation
+338
View File
@@ -0,0 +1,338 @@
// 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 "animation/Object.h"
#include "HookSecureFunc.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include "frame/ScriptArgs.h"
#include <cstring>
namespace Animation {
namespace {
using Construct_t = void(__fastcall *)(void *object);
using Destruct_t = void(__fastcall *)(void *object);
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 GetName_t = const char *(__fastcall *)(const void *object);
// 4.3.4's CScriptObject type name, which every class's IsA(name) accepts.
constexpr const char *kObjectTypeName = "Object";
Object *Self(void *object) { return static_cast<Object *>(object); }
// ---- The vtable (the engine calls these on our objects) --------------------
void *__fastcall V_Delete(Object *object, void * /*edx*/, unsigned flags);
const char *__fastcall V_GetName(Object *object) { return object->Name(); }
// vtable+0x08: push the method `name` of the object's class or a base.
int __fastcall V_FindMethod(Object *object, void * /*edx*/, void *L, const char *name) {
for (const Class *c = object->cls; c != nullptr; c = c->base) {
if (c->methodsRef <= 0)
continue;
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, c->methodsRef);
Game::Lua::PushString(L, name);
Game::Lua::RawGet(L, -2);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
Game::Lua::Remove(L, -2);
return 1;
}
Game::Lua::SetTop(L, -3);
}
return 0;
}
// vtable+0x0C: 4.3.4's GetScriptByName - the base OnEvent first.
uint32_t *__fastcall V_ResolveScript(Object *object, void * /*edx*/, const char *name) {
uint32_t *slot =
reinterpret_cast<BaseResolve_t>(Offsets::FUN_SCRIPT_OBJECT_BASE_RESOLVE_SCRIPT)(object, nullptr, name);
return slot != nullptr ? slot : object->ScriptSlot(name);
}
int __fastcall V_IsA(Object *object, void * /*edx*/, int typeId) {
for (const Class *c = object->cls; c != nullptr; c = c->base)
if (c->typeId != 0 && c->typeId == typeId)
return 1;
return 0;
}
// vtable+0x14 differs per engine class; nothing calls it on ours.
int __fastcall V_Unused(Object * /*object*/) { return 0; }
int __fastcall V_IsAName(Object *object, void * /*edx*/, const char *typeName) {
if (typeName == nullptr)
return 0;
for (const Class *c = object->cls; c != nullptr; c = c->base)
if (Ascii::EqualCI(typeName, c->typeName))
return 1;
return Ascii::EqualCI(typeName, kObjectTypeName) ? 1 : 0;
}
const char *__fastcall V_GetObjectTypeName(Object *object) { return object->cls->typeName; }
void *g_vtable[] = {
reinterpret_cast<void *>(&V_Delete), reinterpret_cast<void *>(&V_GetName),
reinterpret_cast<void *>(&V_FindMethod), reinterpret_cast<void *>(&V_ResolveScript),
reinterpret_cast<void *>(&V_IsA), reinterpret_cast<void *>(&V_Unused),
reinterpret_cast<void *>(&V_IsAName), reinterpret_cast<void *>(&V_GetObjectTypeName),
};
// Slot 0, the engine's deleting destructor. The engine never deletes our
// objects - their owners (a region, a group) do, by their concrete type.
void *__fastcall V_Delete(Object *object, void * /*edx*/, unsigned /*flags*/) { return object; }
} // namespace
// ---- Object -------------------------------------------------------------------
Object::Object(const Class &cls, void *parent, const char *name) : cls(&cls), parent(parent) {
reinterpret_cast<Construct_t>(Offsets::FUN_SCRIPT_OBJECT_CONSTRUCT)(this);
vtable = g_vtable;
if (name != nullptr && *name != '\0')
this->name = CreateName(name, parent);
reinterpret_cast<Lock_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(this, nullptr, Name());
}
bool Object::s_detached = false;
Object::~Object() {
if (s_detached)
return;
for (auto &slot : scripts)
reinterpret_cast<Release_t>(Offsets::FUN_SCRIPT_RELEASE)(slot);
while (holders > 1)
reinterpret_cast<Unlock_t>(Offsets::FUN_SCRIPT_OBJECT_UNLOCK)(this, nullptr, nullptr);
if (holders > 0)
reinterpret_cast<Unlock_t>(Offsets::FUN_SCRIPT_OBJECT_UNLOCK)(this, nullptr, Name());
reinterpret_cast<Destruct_t>(Offsets::FUN_SCRIPT_OBJECT_DESTRUCT)(this);
}
uint32_t *Object::ScriptSlot(const char *scriptName) {
if (scriptName == nullptr)
return nullptr;
for (const Class *c = cls; c != nullptr; c = c->base) {
for (int i = 0; i < c->scriptCount; ++i)
if (Ascii::EqualCI(scriptName, c->scriptNames[i]))
return scripts[i];
if (c->scriptCount > 0)
break; // only one class in a chain declares the scripts
}
return nullptr;
}
void Object::RunScript(int index, int nargs) {
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int first = Game::Lua::GetTop(L) - nargs + 1;
if (HasScript(index))
Frame::ScriptArgs::RunSlotWithValues(L, scripts[index], this, first, nargs);
Game::Lua::SetTop(L, first - 1);
}
// ---- Helpers --------------------------------------------------------------------
int TypeId(Class &cls) { return Game::Lua::FrameScriptTypeId(reinterpret_cast<uintptr_t>(&cls.typeId)); }
Object *Resolve(void *L, int idx, Class &cls) {
return static_cast<Object *>(Game::Lua::ResolveTypedObject(L, idx, reinterpret_cast<uintptr_t>(&cls.typeId)));
}
void Push(void *L, Object *object) { Game::Lua::PushObject(L, object); }
bool IsOurs(const void *object) { return object != nullptr && Game::Read<void **>(object, 0) == g_vtable; }
std::string CreateName(const char *source, const void *parent) {
constexpr const char kParent[] = "$parent";
constexpr size_t kParentLen = sizeof(kParent) - 1;
if (std::strlen(source) < kParentLen || !Ascii::EqualCI(std::string(source, kParentLen).c_str(), kParent))
return source;
std::string name = "Top";
for (const void *p = parent; p != nullptr;) {
const char *pname = IsOurs(p) ? static_cast<const Object *>(p)->Name()
: Game::Read<GetName_t>(Game::Read<void *>(p, 0),
Offsets::VTBL_SCRIPTOBJECT_GET_NAME)(p);
if (pname != nullptr && *pname != '\0') {
name = pname;
break;
}
p = IsOurs(p) ? static_cast<const Object *>(p)->parent : Game::Read<void *>(p, Offsets::OFF_REGION_PARENT);
}
return name + (source + kParentLen);
}
void RegisterClass(Class &cls) {
void *L = Game::Lua::State();
TypeId(cls);
Game::Lua::NewTable(L);
for (int i = 0; i < cls.methodCount; ++i) {
Game::Lua::PushString(L, cls.methods[i].name);
Game::Lua::PushCClosure(L, cls.methods[i].func, 0);
Game::Lua::RawSet(L, -3);
}
cls.methodsRef = Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX);
}
// ---- The base methods -----------------------------------------------------------
namespace {
// The object at self, of any of our classes.
Object *AnySelf(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE)
return nullptr;
void *object = Game::Lua::ResolveObject(L, 1);
return IsOurs(object) ? Self(object) : nullptr;
}
} // namespace
int __fastcall Script_GetObjectType(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
Game::Lua::PushString(L, object->cls->typeName);
return 1;
}
int __fastcall Script_IsObjectType(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:IsObjectType(\"TYPE\")", object->DisplayName());
return 0;
}
if (V_IsAName(object, nullptr, Game::Lua::ToString(L, 2)))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_GetName(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
Game::Lua::PushString(L, object->Name());
return 1;
}
int __fastcall Script_GetParent(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (object->parent != nullptr)
Game::Lua::PushObject(L, object->parent);
else
Game::Lua::PushNil(L);
return 1;
}
// 4.3.4's HasScript: 1 when the class has the script, else nil.
int __fastcall Script_HasScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:HasScript(\"type\")", object->DisplayName());
return 0;
}
if (V_ResolveScript(object, nullptr, Game::Lua::ToString(L, 2)) != nullptr)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
// The engine's Script GetScript (0x00774780) on our object: the slot's
// context becomes current, then the handler (nil for none) is pushed.
int __fastcall Script_GetScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:GetScript(\"type\")", object->DisplayName());
return 0;
}
const uint32_t *slot = V_ResolveScript(object, nullptr, Game::Lua::ToString(L, 2));
if (slot == nullptr) {
Game::Lua::Error(L, "%s doesn't have a \"%s\" script", object->DisplayName(), Game::Lua::ToString(L, 2));
return 0;
}
if (slot[1] != 0 && Game::Read<int>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH) != 0)
Game::Ref<uint32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT) = slot[1];
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, static_cast<int>(slot[0]));
return 1;
}
// The engine's Script SetScript (0x007748D0) on our object: release the old
// handler, store a ref of the new one (0 for nil) with the current context.
int __fastcall Script_SetScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
const int handlerType = Game::Lua::Type(L, 3);
if (!Game::Lua::IsString(L, 2) ||
(handlerType != Game::Lua::TYPE_FUNCTION && handlerType != Game::Lua::TYPE_NIL &&
handlerType != -1 /* none */)) {
Game::Lua::Error(L, "Usage: %s:SetScript(\"type\", function)", object->DisplayName());
return 0;
}
uint32_t *slot = V_ResolveScript(object, nullptr, Game::Lua::ToString(L, 2));
if (slot == nullptr) {
Game::Lua::Error(L, "%s doesn't have a \"%s\" script", object->DisplayName(), Game::Lua::ToString(L, 2));
return 0;
}
reinterpret_cast<Release_t>(Offsets::FUN_SCRIPT_RELEASE)(slot);
Game::Lua::SetTop(L, 3);
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;
}
// Frame HookScript's (frame/Modern.cpp) on our object: no handler yet -> the
// hook is the handler; else the hooksecurefunc wrapper over (old, hook).
int __fastcall Script_HookScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2) || Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "Usage: %s:HookScript(\"type\", function)", object->DisplayName());
return 0;
}
Game::Lua::SetTop(L, 3); // (self, type, hook)
Game::Lua::PushValue(L, 1);
Game::Lua::PushValue(L, 2);
Game::Lua::PushCClosure(L, &Script_GetScript, 0);
Game::Lua::Insert(L, -3);
Game::Lua::Call(L, 2, 1); // (self, type, hook, old)
if (Game::Lua::Type(L, 4) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::Insert(L, 3); // (self, type, old, hook)
HookSecureFunc::PushWrapper(L); // (self, type, wrapper)
} else {
Game::Lua::SetTop(L, 3);
}
return Script_SetScript(L);
}
} // namespace Animation
+123
View File
@@ -0,0 +1,123 @@
// 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 "Game.h"
#include <cstdint>
#include <string>
// Animation groups and animations as real 1.12 script objects.
//
// 4.3.4's CSimpleAnimGroup and CSimpleAnim are CScriptObjects: the engine
// hands them to Lua as the same kind of table as a frame (`t[0]` = the
// object, metatable `__framescript_meta`), finds their methods through the
// object's vtable, resolves `self` with the object's IsA, and stores their
// scripts in {handler, context} slots it finds through the object's script
// resolver. 1.12 has the same machinery (benilla-re ui/methods, ui/script):
// - ConstructScriptObject (0x00701AE0) builds the 0x24-byte header below;
// DestructScriptObject (0x00701B40) releases it;
// - LockScriptObject / UnlockScriptObject (0x00701BD0 / 0x00701CD0) make and
// free the object's table and its name global;
// - `__framescript_meta.__index` (0x007020B0) calls vtable+0x08 (L, name)
// to push a method; ResolveTypedObject's type test calls vtable+0x10;
// - SetScriptObjectScript (0x007025C0) compiles an XML script body into
// the slot vtable+0x0C returns, and FrameScriptReleaseScript (0x00702670)
// frees a slot.
// So an animation object is an engine script object whose vtable is ours.
// It has no C++ virtual functions - its first bytes are the engine header -
// and its class (Class) carries what differs per type.
namespace Animation {
// The 1.12 script-object header, as ConstructScriptObject fills it.
struct ScriptObjectHeader {
void **vtable;
int32_t holders; // +0x04, LockScriptObject's count
int32_t luaRef; // +0x08, the table's registry ref (-2 when none)
uint32_t onEvent[2]; // +0x0C, the base OnEvent slot
uint32_t events[4]; // +0x14, the event index array
};
static_assert(sizeof(ScriptObjectHeader) == 0x24, "the engine's script-object header");
struct Class {
const char *typeName; // GetObjectType, IsObjectType
const Class *base;
// The class's own script names, lower case; a slot per name.
const char *const *scriptNames;
int scriptCount;
const Game::Lua::FrameMethodEntry *methods;
int methodCount;
int typeId; // from the engine's type counter, on first use
int methodsRef; // registry ref of the method table, per Lua state
};
constexpr int kMaxScripts = 8;
class Object : public ScriptObjectHeader {
public:
const Class *cls;
// The script object this one hangs from: a group's region, an
// animation's group (GetParent, "$parent").
void *parent;
std::string name;
uint32_t scripts[kMaxScripts][2] = {}; // {handler ref, context}
// The object's script slot for `name` (any case), or nullptr.
uint32_t *ScriptSlot(const char *name);
bool HasScript(int index) const { return scripts[index][0] != 0; }
// Runs script `index` with `nargs` values pushed on top of the stack,
// and pops them.
void RunScript(int index, int nargs);
const char *Name() const { return name.empty() ? nullptr : name.c_str(); }
// While set, a destroyed object leaves the engine alone: its table and
// handlers belonged to a Lua state that is gone (/reload leftovers).
static bool s_detached;
const char *DisplayName() const { return name.empty() ? "<unnamed>" : name.c_str(); }
protected:
// Registers the object's table, under its name when it has one ("$parent"
// resolved from `parent`).
Object(const Class &cls, void *parent, const char *name);
~Object();
};
// The class's type id (the engine's counter, assigned on first use).
int TypeId(Class &cls);
// The object at `idx` when it is of `cls` or a class derived from it, else
// raises the engine's errors (as every method does) and returns nullptr.
Object *Resolve(void *L, int idx, Class &cls);
// Pushes the object's table.
void Push(void *L, Object *object);
// Whether a script object is one of ours.
bool IsOurs(const void *object);
// 4.3.4's CScriptObject::CreateName: "$parent" at the start of `source`
// becomes the nearest named ancestor's name, "Top" when none.
std::string CreateName(const char *source, const void *parent);
// Builds every class's method table in the current Lua state.
void RegisterClass(Class &cls);
// The base methods every class has (4.3.4's CScriptObject methods), and the
// script methods groups and animations share.
int __fastcall Script_GetObjectType(void *L);
int __fastcall Script_IsObjectType(void *L);
int __fastcall Script_GetName(void *L);
int __fastcall Script_GetParent(void *L);
int __fastcall Script_HasScript(void *L);
int __fastcall Script_GetScript(void *L);
int __fastcall Script_SetScript(void *L);
int __fastcall Script_HookScript(void *L);
} // namespace Animation
+501
View File
@@ -0,0 +1,501 @@
// 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/>.
// 4.3.4's CSimplePathAnim and CSimpleControlPoint (wowdecomp15595
// CSimplePathAnim.cpp, CSimpleControlPoint.cpp and their _Scripts.cpp).
//
// A Path moves the region through its control points: each point is an
// offset from where the region is, the points are spread evenly over the
// animation's progress (point i of n at (i + 1) / n), and the path starts
// at offset 0. Between points the offset is a straight line, or with the
// SMOOTH curve a Catmull-Rom spline. The offset goes through the same
// AddAnimTranslation as a Translation.
#include "animation/Classes.h"
#include "animation/Region.h"
#include "animation/Xml.h"
#include "Game.h"
#include "baselib/Ascii.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
namespace Animation {
// ---- CSimpleControlPoint ------------------------------------------------------------
ControlPoint::ControlPoint(Anim *path, const char *name) : Object(s_class, path, name) {
if (path != nullptr)
path->controlPoints.push_back(this);
}
ControlPoint::~ControlPoint() = default;
void ControlPoint::SetOffset(float x, float y) {
if (x * x + y * y <= 0.00000023841858f) {
offsetX = 0.0f;
offsetY = 0.0f;
} else {
offsetX = x;
offsetY = y;
}
}
void ControlPoint::SetOrder(int value, bool notify, bool wrap) {
const int oldOrder = order;
if (wrap)
order = value < 0 || value > 99 ? 0 : value;
else
order = value < 0 ? 0 : (value > 99 ? 99 : value);
if (parent != nullptr && notify && oldOrder != order)
GetPath()->NotifyControlPointOrderChanged(this);
}
bool ControlPoint::ChangeParent(Anim *path) {
if (path == nullptr || GetPath() == path)
return false;
// LinkToTail unlinks the point from the old path's list first.
Anim *old = GetPath();
if (old != nullptr)
old->controlPoints.erase(std::remove(old->controlPoints.begin(), old->controlPoints.end(), this),
old->controlPoints.end());
path->controlPoints.push_back(this);
if (old != nullptr)
old->NotifyControlPointRemoved(this);
parent = path;
path->NotifyControlPointAdded(this);
return true;
}
// ---- CSimplePathAnim -------------------------------------------------------------------
int Anim::GetMaxControlPointOrder() const { return sortedPoints.empty() ? -1 : sortedPoints.back()->order; }
int Anim::FindControlPoint(int value) const {
if (value == -1)
return -1;
int low = 0;
int high = static_cast<int>(sortedPoints.size()) - 1;
while (low <= high) {
const int middle = static_cast<int>(static_cast<unsigned>(high + low) >> 1);
const ControlPoint *point = sortedPoints[middle];
if (point == nullptr)
break; // 4.3.4 spins here; the array has no holes after BuildSpline
if (point->order < value)
low = middle + 1;
else if (point->order > value)
high = middle - 1;
else
return middle;
}
return -1;
}
void Anim::UpdateControlPointPositions() {
const size_t count = sortedPoints.size();
if (count == 0)
return;
const float step = 1.0f / static_cast<float>(count);
for (size_t i = 0; i < count; ++i) {
float position = static_cast<float>(i + 1) * step;
position = position < 0.0f ? 0.0f : (position >= 1.0f ? 1.0f : position);
sortedPoints[i]->position = position;
}
}
int Anim::RegisterSplineControlPoint(ControlPoint *point) {
const int count = static_cast<int>(sortedPoints.size());
if (count == 0) {
sortedPoints.push_back(point);
return 0;
}
int index = count;
for (int i = 0; i < count; ++i) {
ControlPoint *existing = sortedPoints[i];
if (existing == nullptr) {
sortedPoints[i] = point;
return i;
}
if (point->order == existing->order)
return i;
if (point->order < existing->order) {
index = i;
break;
}
}
sortedPoints.insert(sortedPoints.begin() + index, point);
return index;
}
void Anim::BuildSpline() {
Stop(false);
if (controlPoints.empty()) {
sortedPoints.clear();
return;
}
std::fill(sortedPoints.begin(), sortedPoints.end(), nullptr);
// Points take their order in link order; a point without one, or with
// one below the next free order, takes the next free order.
int nextOrder = 0;
for (ControlPoint *point : controlPoints) {
int value = point->order;
if (value == -1 || value < nextOrder) {
value = nextOrder++;
point->order = value < 0 ? 0 : value;
} else {
nextOrder = value + 1;
}
if (nextOrder > 99)
nextOrder = 0;
RegisterSplineControlPoint(point);
}
while (!sortedPoints.empty() && sortedPoints.back() == nullptr)
sortedPoints.pop_back();
}
void Anim::NotifyControlPointAdded(ControlPoint *point) {
if (point == nullptr)
return;
const int index = FindControlPoint(point->order);
if (index < 0) {
if (RegisterSplineControlPoint(point) >= 0)
UpdateControlPointPositions();
return;
}
// The order is taken: the point goes before the one that has it and
// takes the order; BuildSpline moves the rest up.
ControlPoint *existing = sortedPoints[index];
controlPoints.erase(std::remove(controlPoints.begin(), controlPoints.end(), point), controlPoints.end());
controlPoints.insert(std::find(controlPoints.begin(), controlPoints.end(), existing), point);
BuildSpline();
UpdateControlPointPositions();
}
void Anim::NotifyControlPointRemoved(ControlPoint *point) {
if (point != nullptr && FindControlPoint(point->order) >= 0) {
BuildSpline();
UpdateControlPointPositions();
}
}
void Anim::NotifyControlPointOrderChanged(ControlPoint *point) {
if (point == nullptr)
return;
const int index = FindControlPoint(point->order);
if (index >= 0 && sortedPoints[index] != point) {
ControlPoint *existing = sortedPoints[index];
controlPoints.erase(std::remove(controlPoints.begin(), controlPoints.end(), point), controlPoints.end());
controlPoints.insert(std::find(controlPoints.begin(), controlPoints.end(), existing), point);
}
BuildSpline();
UpdateControlPointPositions();
}
void Anim::ApplyPath(float progress) {
const int count = static_cast<int>(sortedPoints.size());
if (count == 0)
return;
void *region = GetRegionParent();
if (region == nullptr)
return;
const float t = std::fabs(progress);
// The segment: 4.3.4 converts count * t with the 2^16 + x float trick,
// which rounds to 1/128 before it truncates. Kept, so a progress just
// under a point's lands on the same segment.
const float scaled = static_cast<float>(count) * t;
const float biased = std::fabs(scaled) + 65536.0f;
int32_t bits;
std::memcpy(&bits, &biased, sizeof(bits));
uint32_t scaledBits;
std::memcpy(&scaledBits, &scaled, sizeof(scaledBits));
const int32_t sign = static_cast<int32_t>(scaledBits >> 31);
const int index = static_cast<int16_t>((((bits >> 7) & 0x7FFF) ^ sign) - sign);
float x = 0.0f, y = 0.0f;
const auto offsetOf = [this](int i, float &ox, float &oy) {
ox = sortedPoints[i]->offsetX;
oy = sortedPoints[i]->offsetY;
};
if (static_cast<unsigned>(index) >= static_cast<unsigned>(count)) {
offsetOf(count - 1, x, y);
} else if (curve && count > 1) {
// Catmull-Rom through p1 -> p2; the start is offset 0.
float p0x = 0.0f, p0y = 0.0f, p1x = 0.0f, p1y = 0.0f, p2x, p2y, p3x, p3y, t0, t1;
if (index != 0) {
offsetOf(index - 1, p1x, p1y);
t0 = sortedPoints[index - 1]->position;
offsetOf(index, p2x, p2y);
t1 = sortedPoints[index]->position;
if (index != 1)
offsetOf(index - 2, p0x, p0y);
if (index == count - 1) {
p3x = p2x * 2.0f - p1x;
p3y = p2y * 2.0f - p1y;
} else {
offsetOf(index + 1, p3x, p3y);
}
} else {
t0 = 0.0f;
offsetOf(0, p2x, p2y);
t1 = sortedPoints[0]->position;
p0x = -p2x;
p0y = -p2y;
offsetOf(1, p3x, p3y);
}
const float f = (t - t0) / (t1 - t0);
const float f2 = f * f;
const float f3 = f2 * f;
const float ax = -p0x + p1x * 3.0f - p2x * 3.0f + p3x;
const float ay = -p0y + p1y * 3.0f - p2y * 3.0f + p3y;
const float bx = p0x * 2.0f - p1x * 5.0f + p2x * 4.0f - p3x;
const float by = p0y * 2.0f - p1y * 5.0f + p2y * 4.0f - p3y;
const float cx = -p0x + p2x;
const float cy = -p0y + p2y;
x = (cx * f + p1x * 2.0f + bx * f2 + ax * f3) * 0.5f;
y = (cy * f + p1y * 2.0f + by * f2 + ay * f3) * 0.5f;
} else {
float ax = 0.0f, ay = 0.0f, bx, by, t0;
if (index != 0) {
offsetOf(index - 1, ax, ay);
t0 = sortedPoints[index - 1]->position;
} else {
t0 = 0.0f;
}
offsetOf(index, bx, by);
const float t1 = sortedPoints[index]->position;
const float f = (t - t0) / (t1 - t0);
x = ax + (bx - ax) * f;
y = f * (by - ay) + ay;
}
if (progress >= 0.0f)
Region::AddAnimTranslation(region, target, x, y);
else
Region::AddAnimTranslation(region, target, -x, -y);
}
// ---- Lua methods ---------------------------------------------------------------------
namespace {
Anim *PathSelf(void *L) { return static_cast<Anim *>(Resolve(L, 1, Anim::s_pathClass)); }
ControlPoint *PointSelf(void *L) { return static_cast<ControlPoint *>(Resolve(L, 1, ControlPoint::s_class)); }
// LoadXML.cpp's s_curveTypes.
bool StringToCurve(const char *text, bool &curve) {
if (Ascii::EqualCI(text, "NONE")) {
curve = false;
return true;
}
if (Ascii::EqualCI(text, "SMOOTH")) {
curve = true;
return true;
}
return false;
}
int __fastcall Script_SetCurve(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetCurve(\"curveType\")", path->DisplayName());
return 0;
}
if (!StringToCurve(Game::Lua::ToString(L, 2), path->curve))
Game::Lua::Error(L, "%s:SetCurve(): Unknown curve type specified", path->DisplayName());
return 0;
}
int __fastcall Script_GetCurve(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
Game::Lua::PushString(L, path->curve ? "SMOOTH" : "NONE");
return 1;
}
int __fastcall Script_GetMaxOrder(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
Game::Lua::PushNumber(L, path->GetMaxControlPointOrder() + 1);
return 1;
}
int __fastcall Script_GetControlPoints(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
const int count = static_cast<int>(path->sortedPoints.size());
if (count == 0)
return 0;
if (!Game::Lua::CheckStack(L, count)) {
Game::Lua::Error(L, "%s:GetControlPoints(): Stack overflow", path->DisplayName());
return 0;
}
for (ControlPoint *point : path->sortedPoints)
Push(L, point);
return count;
}
int __fastcall Script_CreateControlPoint(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
const char *name = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
const void *templateNode = nullptr;
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING) {
const char *inherits = Game::Lua::ToString(L, 3);
templateNode = Xml::Template(inherits);
if (templateNode == nullptr) {
Game::Lua::Error(L, "%s:CreateControlPoint(): Couldn't find inherited node \"%s\"", path->DisplayName(),
inherits);
return 0;
}
}
ControlPoint *point = new ControlPoint(path, name);
if (templateNode != nullptr)
Xml::LoadControlPoint(point, templateNode, nullptr);
if (Game::Lua::IsNumber(L, 4))
point->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 4)) - 1, false, false);
// The decompiled CreateControlPoint passes the highest order itself,
// which takes the last point's place and reverses points made one after
// another; one past it, as SetParent does, puts the point last.
if (point->order == -1)
point->SetOrder(path->GetMaxControlPointOrder() + 1, false, true);
path->NotifyControlPointAdded(point);
Push(L, point);
return 1;
}
int __fastcall Script_PointSetParent(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL) {
Game::Lua::Error(L, "%s:SetParent(): Cannot set a 'nil' parent for control points", point->DisplayName());
return 0;
}
const uintptr_t pathType = reinterpret_cast<uintptr_t>(&Anim::s_pathClass.typeId);
Anim *path = nullptr;
if (Game::Lua::IsString(L, 2)) {
// CScriptObject::GetScriptObjectByName(name, Path).
Game::Lua::PushString(L, Game::Lua::ToString(L, 2));
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
void *object = Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE ? Game::Lua::ResolveObject(L, -1) : nullptr;
Game::Lua::SetTop(L, -2);
if (object != nullptr && IsOurs(object) && Game::Lua::IsA(object, pathType))
path = static_cast<Anim *>(object);
} else if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE) {
void *object = Game::Lua::ResolveObject(L, 2);
if (object == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find 'this' in parent object", point->DisplayName());
return 0;
}
if (!IsOurs(object) || !Game::Lua::IsA(object, pathType)) {
Game::Lua::Error(L, "%s:SetParent(): Wrong parent object type, expected Path", point->DisplayName());
return 0;
}
path = static_cast<Anim *>(object);
}
if (path == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find Path named '%s'", point->DisplayName(),
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "");
return 0;
}
const int oldOrder = point->order;
if (Game::Lua::IsNumber(L, 3)) {
point->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 3)) - 1, false, false);
} else {
int value = point->order;
if (value == -1)
value = path->GetMaxControlPointOrder() + 1;
point->SetOrder(value, false, true);
}
if (!point->ChangeParent(path) && oldOrder != point->order)
path->NotifyControlPointOrderChanged(point);
return 0;
}
int __fastcall Script_PointSetOffset(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetOffset(x, y)", point->DisplayName());
return 0;
}
point->SetOffset(Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 2))),
Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3))));
return 0;
}
int __fastcall Script_PointGetOffset(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
Game::Lua::PushNumber(L, Game::InternalToPixel(point->offsetX));
Game::Lua::PushNumber(L, Game::InternalToPixel(point->offsetY));
return 2;
}
int __fastcall Script_PointSetOrder(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetOrder(order)", point->DisplayName());
return 0;
}
point->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1, true, false);
return 0;
}
int __fastcall Script_PointGetOrder(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
Game::Lua::PushNumber(L, point->order + 1);
return 1;
}
const Game::Lua::FrameMethodEntry kPathMethods[] = {
{"SetCurve", &Script_SetCurve},
{"GetCurve", &Script_GetCurve},
{"GetControlPoints", &Script_GetControlPoints},
{"CreateControlPoint", &Script_CreateControlPoint},
{"GetMaxOrder", &Script_GetMaxOrder},
};
// CScriptObject's methods, then CSimpleControlPoint's.
const Game::Lua::FrameMethodEntry kPointMethods[] = {
{"GetObjectType", &Script_GetObjectType}, {"IsObjectType", &Script_IsObjectType},
{"GetName", &Script_GetName}, {"GetParent", &Script_GetParent},
{"SetParent", &Script_PointSetParent}, {"SetOffset", &Script_PointSetOffset},
{"GetOffset", &Script_PointGetOffset}, {"SetOrder", &Script_PointSetOrder},
{"GetOrder", &Script_PointGetOrder},
};
template <size_t N> constexpr int Count(const Game::Lua::FrameMethodEntry (&)[N]) { return static_cast<int>(N); }
} // namespace
Class Anim::s_pathClass{"Path", &Anim::s_class, nullptr, 0, kPathMethods, Count(kPathMethods), 0, 0};
Class ControlPoint::s_class{"ControlPoint", nullptr, nullptr, 0, kPointMethods, Count(kPointMethods), 0, 0};
} // namespace Animation
+833
View File
@@ -0,0 +1,833 @@
// 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 region side of the animation system, and the region methods
// CreateAnimationGroup / GetAnimationGroups / StopAnimating.
//
// 4.3.4 (wowdecomp15595 CScriptRegion.cpp, CSimpleFrame.cpp, CSimpleRegion.cpp,
// CSimpleRender.cpp, CLayoutFrame.cpp):
// - a region owns its groups (m_animGroups) and lists the playing ones
// (m_playingAnimGroups);
// - CSimpleFrame::OnLayerUpdate runs OnUpdate, then every group of the
// frame, then every group of each of its regions;
// - when a group starts, the region stores the state its animations change
// (AnimActivated: a frame its own alpha, a texture or font string its
// vertex alpha), unless an earlier group already did; a frame does this
// for every frame and region below it too;
// - every update first puts that state back (PreOnAnimUpdate), then the
// animations add to it (AddAnimAlpha: a frame's own alpha becomes
// change + its drawn alpha; a region's vertex alpha change + its own);
// - when the last group with that kind of animation on the region or any
// ancestor ends, the stored state goes back (AnimDeactivated).
// So, as in 4.3.4, GetAlpha returns the animated alpha while it plays.
//
// Position animations (Translation, Scale, Rotation) move only what
// is DRAWN, never a frame's rect: a frame hands them to every texture and font
// string below it. A texture moves the four corners its draw record points at
// (+0xD4, TL BL TR BR, the order of 4.3.4's m_positions); a font string moves
// its text block's string position, and is turned and scaled about its origin
// when its glyphs are baked (4.3.4 bakes them again whenever the string's
// rotation or scale changes; so does this, through the engine's node
// invalidate). The glyphs are scaled, not drawn at a larger font size: one
// size per update would fill the glyph cache. Their stored base is what the engine
// itself lays out (4.3.4's StoreAnimPosition): a texture's corners from its
// rect through the engine's corner store, a font string's inset x layout
// scale + its rect's min corner. When the layout moves a region mid-animation,
// the co-hooks on the two OnFrameSizeChanged callbacks move the base and keep
// the animation's offset, as 4.3.4's own OnFrameSizeChanged legs do.
//
// 1.12's regions have no room for these, so they are kept here, keyed by the
// region. A frame's own alpha is Frame::Alpha's (3.x's m_alpha), set through
// the frame's vtable setter as SetAlpha sets it; a region's vertex alpha is the
// engine's region color (FUN_REGION_GET_COLOR / FUN_FONTSTRING_SET_COLOR).
// The groups update from a co-hook of CSimpleFrame::OnFrameUpdate, which the
// engine calls for every shown frame after OnUpdate, so a hidden frame's
// groups wait, as in 4.3.4. Groups die with their frame (Frame::Destruction).
#include "animation/Region.h"
#include "animation/Classes.h"
#include "animation/Xml.h"
#include "Game.h"
#include "Offsets.h"
#include "frame/Alpha.h"
#include "frame/Destruction.h"
#include "texture/Transform.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <unordered_map>
#include <vector>
namespace Animation::Region {
namespace {
struct State {
std::vector<Group *> groups; // m_animGroups (owned)
std::vector<Group *> playing; // m_playingAnimGroups
};
// CAnimationData.
struct AnimData {
bool hasAlpha = false;
uint8_t alpha = 0xFF;
bool hasPosition = false;
// m_position[4]: a texture's four corners {x, y, z}; a font string uses
// the first as its string position.
float position[12] = {};
// A font string's animated rotation and scale (4.3.4's m_rotation /
// m_scale over the animation base), and what its glyphs were last baked
// with.
float rotation = 0.0f;
float scale = 1.0f;
float bakedRotation = 0.0f;
float bakedScale = 1.0f;
};
std::unordered_map<void *, State> g_states;
std::unordered_map<void *, AnimData> g_data;
// Font strings whose rotation or scale an update reset: checked against what
// they were baked with once the update has run.
std::vector<void *> g_touchedStrings;
// Regions (not frames) with groups: their frame's update has to look at its
// regions.
int g_regionsWithGroups = 0;
using FrameUpdate_t = void(__fastcall *)(void *frame, void *edx, float elapsed);
using SetFrameAlpha_t = void(__fastcall *)(void *frame, void *edx, uint32_t alpha);
using GetColor_t = void(__fastcall *)(void *region, void *edx, uint32_t *bgra);
using SetColor_t = void(__fastcall *)(void *region, void *edx, const uint32_t *bgra);
using StoreCorners_t = void(__fastcall *)(void *region, void *edx, float *rect);
using Crop_t = void(__fastcall *)(void *region, void *edx, float *rect);
using SetStringPos_t = void(__fastcall *)(void *block, const float *position);
using NdcToDdc_t = void(__fastcall *)(float *outX, float *outY, float x, float y);
using SizeChanged_t = void(__fastcall *)(void *layout, void *edx, const float *oldRect);
using InvalidateNode_t = void(__fastcall *)(void *node);
FrameUpdate_t g_origFrameUpdate = nullptr;
SizeChanged_t g_origTextureSizeChanged = nullptr;
SizeChanged_t g_origFontStringSizeChanged = nullptr;
bool IsFrame(void *region) { return Game::Lua::IsA(region, Offsets::VAR_FRAME_LUA_TYPE_ID); }
bool IsTexture(void *region) { return Game::Lua::IsA(region, Offsets::VAR_TEXTURE_LUA_TYPE_ID); }
bool IsFontString(void *region) { return Game::Lua::IsA(region, Offsets::VAR_FONTSTRING_LUA_TYPE_ID); }
void *ParentOf(void *region) { return Game::Read<void *>(region, Offsets::OFF_REGION_PARENT); }
State *Find(void *region) {
const auto it = g_states.find(region);
return it != g_states.end() ? &it->second : nullptr;
}
AnimData *DataOf(void *region) {
const auto it = g_data.find(region);
return it != g_data.end() ? &it->second : nullptr;
}
template <typename Fn> void ForEachChild(void *frame, Fn fn) {
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_CHILD_LIST, fn);
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_REGION_LIST, fn);
}
// ---- The state alpha animations change -----------------------------------------
uint8_t OwnAlpha(void *frame) { return Frame::Alpha::OwnAlpha(frame); }
void SetOwnAlpha(void *frame, uint8_t alpha) {
void **vtable = *static_cast<void ***>(frame);
reinterpret_cast<SetFrameAlpha_t>(vtable[Offsets::VTBL_FRAME_SET_FRAME_ALPHA / sizeof(void *)])(frame, nullptr,
alpha);
}
uint8_t DrawnParentAlpha(void *frame) {
void *parent = ParentOf(frame);
return parent != nullptr ? Game::Read<uint8_t>(parent, Offsets::OFF_FRAME_ALPHA) : 0xFF;
}
uint32_t VertexColor(void *region) {
uint32_t bgra = 0xFFFFFFFF;
reinterpret_cast<GetColor_t>(Offsets::FUN_REGION_GET_COLOR)(region, nullptr, &bgra);
return bgra;
}
void SetVertexAlpha(void *region, uint8_t alpha) {
const uint32_t bgra = (VertexColor(region) & 0x00FFFFFF) | (static_cast<uint32_t>(alpha) << 24);
reinterpret_cast<SetColor_t>(Offsets::FUN_FONTSTRING_SET_COLOR)(region, nullptr, &bgra);
}
// ---- The state position animations change --------------------------------------
float *Corners(void *texture) { return Game::Ptr<float>(texture, Offsets::OFF_SIMPLETEXTURE_CORNERS); }
// CSimpleTexture::SetPosition(m_rect, m_positions): the corners from the rect,
// written the way the texture's layout callback (FUN_TEXTURE_ON_FRAME_SIZE_CHANGED)
// writes them - cropped when SetTexCoordModifiesRect is on - so the corner
// store's co-hooks (Texture::Transform) shape them as they do on a layout.
void StoreTextureCorners(void *texture) {
float rect[4];
std::memcpy(rect, Game::Ptr<float>(texture, Offsets::OFF_REGION_RECT), sizeof(rect));
if (Game::Read<int>(texture, Offsets::OFF_REGION_TEXCOORD_MODIFIES_RECT) != 0)
reinterpret_cast<Crop_t>(Offsets::FUN_REGION_TEXCOORD_CROP)(texture, nullptr, rect);
reinterpret_cast<StoreCorners_t>(Offsets::FUN_REGION_STORE_CORNERS)(texture, nullptr, rect);
}
void *StringBlock(void *fontString) { return Game::Read<void *>(fontString, Offsets::OFF_FONTSTRING_TEXT_BLOCK); }
// Where the engine lays the string out: inset x layout scale + the rect's min
// corner (the position FUN_FONTSTRING_ON_FRAME_SIZE_CHANGED and SetTextOffset
// pass), as 4.3.4's StoreAnimPosition computes it.
void StringBase(void *fontString, float out[3]) {
const float *rect = Game::Ptr<const float>(fontString, Offsets::OFF_REGION_RECT);
const float scale = Game::Read<float>(fontString, Offsets::OFF_LAYOUT_SCALE);
out[0] = Game::Read<float>(fontString, Offsets::OFF_FONTSTRING_INSET_X) * scale + rect[1];
out[1] = Game::Read<float>(fontString, Offsets::OFF_FONTSTRING_INSET_Y) * scale + rect[0];
out[2] = 0.0f;
}
// TextBlockGetStringPos: the string node's position, back in DDC.
bool GetStringPos(void *block, float out[3]) {
const void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
if (node == nullptr)
return false;
const float *position = Game::Ptr<const float>(node, Offsets::OFF_TEXT_NODE_POSITION);
reinterpret_cast<NdcToDdc_t>(Offsets::FUN_COORD_NDC_TO_DDC)(&out[0], &out[1], position[0], position[1]);
out[2] = position[2];
return true;
}
void SetStringPos(void *block, const float position[3]) {
reinterpret_cast<SetStringPos_t>(Offsets::FUN_TEXTBLOCK_SET_STRING_POS)(block, position);
}
// CSimpleTexture / CSimpleFontString::StoreAnimPosition.
void StoreAnimPosition(void *region, AnimData &data) {
if (IsTexture(region)) {
StoreTextureCorners(region);
std::memcpy(data.position, Corners(region), sizeof(data.position));
data.hasPosition = true;
} else if (IsFontString(region)) {
StringBase(region, data.position);
data.hasPosition = true;
}
}
void *StringNode(void *fontString) {
void *block = StringBlock(fontString);
return block != nullptr ? Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE) : nullptr;
}
// GxuFontStringInvalidate after GxuFontSetRotation / GxuFontSetScale: the
// glyphs are baked again on the next paint, where FontStringBaked turns and
// scales them. Only when the values differ from the baked ones.
void RebakeIfChanged(void *fontString, const AnimData &data) {
if (data.rotation == data.bakedRotation && data.scale == data.bakedScale)
return;
if (void *node = StringNode(fontString))
reinterpret_cast<InvalidateNode_t>(Offsets::FUN_TEXT_NODE_INVALIDATE)(node);
}
// AnimData_ResetPositionData: the region goes back to where the layout puts it.
void ResetPosition(void *region, AnimData &data) {
data.hasPosition = false;
if (IsTexture(region)) {
StoreTextureCorners(region);
} else if (IsFontString(region)) {
// 4.3.4 leaves the string where the last update put it until the next
// layout; it goes back now, as the corners of a texture do.
if (void *block = StringBlock(region)) {
float base[3];
StringBase(region, base);
SetStringPos(block, base);
}
data.rotation = 0.0f;
data.scale = 1.0f;
RebakeIfChanged(region, data);
}
}
// CSimpleFrame / CSimpleRegion::AnimActivated.
void AnimActivated(void *region, bool alpha, bool position) {
const bool frame = IsFrame(region);
AnimData &data = g_data[region]; // AnimData_Init
if (alpha) {
data.hasAlpha = true;
data.alpha = frame ? OwnAlpha(region) : static_cast<uint8_t>(VertexColor(region) >> 24);
}
// A frame stores no position of its own, but it is marked, so the end of
// its last position group resets everything below it (NotifyEnd reads
// the mark), as the end of an alpha group does.
if (position) {
if (frame)
data.hasPosition = true;
else
StoreAnimPosition(region, data);
}
if (frame)
ForEachChild(region, [alpha, position](void *child) { AnimActivated(child, alpha, position); });
}
// CSimpleFrame / CSimpleRegion::AnimDeactivated.
void AnimDeactivated(void *region, bool alpha, bool position) {
const bool frame = IsFrame(region);
AnimData *data = DataOf(region);
if (alpha) {
const uint8_t stored = data != nullptr ? data->alpha : 0xFF; // AnimData_GetAlpha
if (frame) {
if (stored != OwnAlpha(region))
SetOwnAlpha(region, stored);
} else {
SetVertexAlpha(region, stored);
}
if (data != nullptr) {
data->hasAlpha = false;
data->alpha = 0xFF;
}
}
if (position && data != nullptr) {
if (frame)
data->hasPosition = false;
else
ResetPosition(region, *data);
}
if (frame)
ForEachChild(region, [alpha, position](void *child) { AnimDeactivated(child, alpha, position); });
}
// CScriptRegion::HasActiveAlphaAnim / HasActivePositionAnim: a playing group
// with that kind of animation on the region or an ancestor.
template <typename Has> bool HasActiveAnim(void *region, Has has) {
for (void *r = region; r != nullptr; r = ParentOf(r)) {
const State *state = Find(r);
if (state == nullptr)
continue;
for (const Group *group : state->playing)
if (has(group))
return true;
}
return false;
}
// ---- The update ----------------------------------------------------------------------
void UpdateGroups(void *region, float elapsed) {
State *state = Find(region);
if (state == nullptr)
return;
const std::vector<Group *> groups = state->groups;
for (Group *group : groups)
group->OnUpdate(elapsed);
}
void __fastcall FrameUpdate_h(void *frame, void *edx, float elapsed) {
g_origFrameUpdate(frame, edx, elapsed);
if (g_states.empty())
return;
UpdateGroups(frame, elapsed);
if (g_regionsWithGroups > 0)
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_REGION_LIST,
[elapsed](void *region) { UpdateGroups(region, elapsed); });
// The font strings this update turned or scaled, or put back.
for (void *fontString : g_touchedStrings)
if (const AnimData *data = DataOf(fontString))
RebakeIfChanged(fontString, *data);
g_touchedStrings.clear();
}
const Game::HookAutoRegister _frameUpdateHook{Offsets::FUN_FRAME_ON_UPDATE, reinterpret_cast<void *>(&FrameUpdate_h),
reinterpret_cast<void **>(&g_origFrameUpdate)};
// ---- A layout during a position animation -----------------------------------------
//
// The layout writes the region's unanimated position. As in 4.3.4's
// OnFrameSizeChanged legs, the stored base becomes that new position and the
// drawn position keeps the animation's offset from it.
AnimData *PositionData(void *region) {
if (g_data.empty())
return nullptr;
AnimData *data = DataOf(region);
return data != nullptr && data->hasPosition ? data : nullptr;
}
void __fastcall TextureSizeChanged_h(void *layout, void *edx, const float *oldRect) {
void *texture = static_cast<char *>(layout) - Offsets::OFF_REGION_ANCHOR;
AnimData *data = PositionData(texture);
if (data == nullptr) {
g_origTextureSizeChanged(layout, edx, oldRect);
return;
}
float animated[12];
std::memcpy(animated, Corners(texture), sizeof(animated));
g_origTextureSizeChanged(layout, edx, oldRect);
float *corners = Corners(texture);
for (int i = 0; i < 12; ++i) {
const float base = corners[i];
corners[i] = animated[i] + (base - data->position[i]);
data->position[i] = base;
}
}
void __fastcall FontStringSizeChanged_h(void *layout, void *edx, const float *oldRect) {
void *fontString = static_cast<char *>(layout) - Offsets::OFF_REGION_ANCHOR;
AnimData *data = PositionData(fontString);
if (data == nullptr) {
g_origFontStringSizeChanged(layout, edx, oldRect);
return;
}
float animated[3];
void *before = StringBlock(fontString);
const bool hadString = before != nullptr && GetStringPos(before, animated);
g_origFontStringSizeChanged(layout, edx, oldRect);
float base[3];
StringBase(fontString, base);
void *block = StringBlock(fontString);
if (hadString && block != nullptr) {
float position[3];
for (int i = 0; i < 3; ++i)
position[i] = animated[i] + (base[i] - data->position[i]);
SetStringPos(block, position);
}
std::memcpy(data->position, base, sizeof(base));
}
const Game::HookAutoRegister _textureSizeHook{Offsets::FUN_TEXTURE_ON_FRAME_SIZE_CHANGED,
reinterpret_cast<void *>(&TextureSizeChanged_h),
reinterpret_cast<void **>(&g_origTextureSizeChanged)};
const Game::HookAutoRegister _fontStringSizeHook{Offsets::FUN_FONTSTRING_ON_FRAME_SIZE_CHANGED,
reinterpret_cast<void *>(&FontStringSizeChanged_h),
reinterpret_cast<void **>(&g_origFontStringSizeChanged)};
// ---- Lifetime ------------------------------------------------------------------------
void DestroyGroupsOf(void *region) {
const auto it = g_states.find(region);
if (it != g_states.end()) {
if (!it->second.groups.empty() && !IsFrame(region))
--g_regionsWithGroups;
const std::vector<Group *> groups = std::move(it->second.groups);
g_states.erase(it);
for (Group *group : groups)
delete group;
}
g_data.erase(region);
}
// The frame and its regions die here; its child frames run their own
// destructors.
void OnFrameDestroyed(void *frame) {
if (g_states.empty() && g_data.empty())
return;
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_REGION_LIST, [](void *region) { DestroyGroupsOf(region); });
DestroyGroupsOf(frame);
}
const Frame::Destruction::AutoSubscribe _destructionSub{&OnFrameDestroyed};
// Groups on frames that outlived a /reload: their tables and handlers were
// in the old Lua state, so they are dropped without touching it.
void DropLeftovers() {
Object::s_detached = true;
for (auto &entry : g_states)
for (Group *group : entry.second.groups)
delete group;
Object::s_detached = false;
g_states.clear();
g_data.clear();
g_regionsWithGroups = 0;
}
// ---- Region methods (CScriptRegion_Scripts.cpp) -------------------------------------------
void StopAnimating(void *region) {
if (State *state = Find(region)) {
const std::vector<Group *> playing = state->playing;
for (Group *group : playing)
group->Stop(true);
}
// CSimpleFrame::StopAnimating: and everything below it.
if (IsFrame(region))
ForEachChild(region, [](void *child) { StopAnimating(child); });
}
int __fastcall Script_CreateAnimationGroup(void *L) {
void *region = Game::Lua::ResolveRegion(L);
if (region == nullptr)
return 0;
const char *name = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
const void *templateNode = nullptr;
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING) {
const char *inherits = Game::Lua::ToString(L, 3);
templateNode = Xml::Template(inherits);
if (templateNode == nullptr) {
Game::Lua::Error(L, "%s:CreateAnimationGroup(): Couldn't find inherited node \"%s\"",
Game::Lua::ObjectDisplayName(region), inherits);
return 0;
}
}
Group *group = new Group(region, name);
if (templateNode != nullptr) {
Xml::LoadGroup(group, templateNode, nullptr);
group->PostLoadXML();
}
Push(L, group);
return 1;
}
int __fastcall Script_GetAnimationGroups(void *L) {
void *region = Game::Lua::ResolveRegion(L);
if (region == nullptr)
return 0;
const State *state = Find(region);
if (state == nullptr)
return 0;
const int count = static_cast<int>(state->groups.size());
if (!Game::Lua::CheckStack(L, count)) {
Game::Lua::Error(L, "%s:GetAnimationGroups(): Stack overflow", Game::Lua::ObjectDisplayName(region));
return 0;
}
for (Group *group : state->groups)
Push(L, group);
return count;
}
int __fastcall Script_StopAnimating(void *L) {
void *region = Game::Lua::ResolveRegion(L);
if (region != nullptr)
StopAnimating(region);
return 0;
}
const Game::Lua::FrameMethodEntry kRegionMethods[] = {
{"CreateAnimationGroup", &Script_CreateAnimationGroup},
{"GetAnimationGroups", &Script_GetAnimationGroups},
{"StopAnimating", &Script_StopAnimating},
};
const Game::Doc::Field kCreateArgs[] = {
Game::Doc::Opt("name", "string", nullptr, "The group's name; \"$parent\" is the region's."),
Game::Doc::Opt("templateName", "string", nullptr, "A virtual <AnimationGroup> to inherit."),
};
const Game::Doc::Field kCreateRets[] = {
Game::Doc::Req("animationGroup", "AnimationGroup"),
};
const Game::Doc::Field kGroupsRets[] = {
Game::Doc::Vararg("AnimationGroup", "Every animation group of the region, in creation order."),
};
const Game::Doc::Function kCreateAnimationGroup{"Creates an animation group on the region.", kCreateArgs,
kCreateRets};
const Game::Doc::Function kGetAnimationGroups{"Returns the region's animation groups.", {}, kGroupsRets};
const Game::Doc::Function kStopAnimating{
"Stops every playing animation group of the region and, for a frame, of everything inside it.", {}, {}};
const Game::Doc::Method kRegionDocs[] = {
{"CreateAnimationGroup", &kCreateAnimationGroup},
{"GetAnimationGroups", &kGetAnimationGroups},
{"StopAnimating", &kStopAnimating},
};
void RegisterLuaFunctions() {
DropLeftovers();
RegisterClass(Group::s_class);
RegisterClass(Anim::s_class);
RegisterClass(Anim::s_alphaClass);
RegisterClass(Anim::s_translationClass);
RegisterClass(Anim::s_scaleClass);
RegisterClass(Anim::s_rotationClass);
RegisterClass(Anim::s_pathClass);
RegisterClass(ControlPoint::s_class);
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY), kRegionMethods,
static_cast<int>(sizeof(kRegionMethods) / sizeof(kRegionMethods[0])),
kRegionDocs, static_cast<int>(sizeof(kRegionDocs) / sizeof(kRegionDocs[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
// ---- The CScriptRegion animation members ---------------------------------------------
void RegisterGroup(void *region, Group *group) {
State &state = g_states[region];
if (state.groups.empty() && !IsFrame(region))
++g_regionsWithGroups;
state.groups.push_back(group);
}
void NotifyBegin(void *region, Group *group) {
if (group == nullptr)
return;
State &state = g_states[region];
const AnimData *data = DataOf(region);
const bool alpha = !(data != nullptr && data->hasAlpha) && group->HasAlphaAnim();
const bool position = !(data != nullptr && data->hasPosition) && group->HasPositionAnim();
AnimActivated(region, alpha, position);
state.playing.push_back(group);
}
void NotifyEnd(void *region, Group *group) {
State *state = Find(region);
if (group == nullptr || state == nullptr)
return;
state->playing.erase(std::remove(state->playing.begin(), state->playing.end(), group), state->playing.end());
const AnimData *data = DataOf(region);
const bool alpha = data != nullptr && data->hasAlpha &&
!HasActiveAnim(region, [](const Group *g) { return g->HasAlphaAnim(); });
const bool position = data != nullptr && data->hasPosition &&
!HasActiveAnim(region, [](const Group *g) { return g->HasPositionAnim(); });
AnimDeactivated(region, alpha, position);
}
void PreOnAnimUpdate(void *region) {
const bool frame = IsFrame(region);
const AnimData *data = DataOf(region);
if (data != nullptr && data->hasAlpha) {
if (frame) {
if (data->alpha != OwnAlpha(region))
SetOwnAlpha(region, data->alpha);
} else {
SetVertexAlpha(region, data->alpha);
}
}
if (data != nullptr && data->hasPosition) {
if (IsTexture(region)) {
std::memcpy(Corners(region), data->position, sizeof(data->position));
} else if (IsFontString(region)) {
if (void *block = StringBlock(region))
SetStringPos(block, data->position);
// CSimpleFontString::PreOnAnimUpdate: back to the base rotation
// and scale.
AnimData *own = DataOf(region);
own->rotation = 0.0f;
own->scale = 1.0f;
g_touchedStrings.push_back(region);
}
}
if (frame)
ForEachChild(region, [](void *child) { PreOnAnimUpdate(child); });
}
void AddAnimTranslation(void *region, void *target, float x, float y) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimTranslation: the frame itself does not move;
// everything below it does, or only the target below it.
if (target == region)
target = nullptr;
ForEachChild(region, [target, x, y](void *child) { AddAnimTranslation(child, target, x, y); });
return;
}
if (target != nullptr && target != region)
return;
// Only a region whose base is stored: PreOnAnimUpdate puts it back before
// the next add, so nothing builds up.
const AnimData *data = DataOf(region);
if (data == nullptr || !data->hasPosition)
return;
if (IsTexture(region)) {
// ApplyTranslationAnim(m_positions, translation).
float *corners = Corners(region);
for (int i = 0; i < 4; ++i) {
corners[i * 3 + 0] += x;
corners[i * 3 + 1] += y;
}
} else if (void *block = StringBlock(region)) {
float position[3];
if (!GetStringPos(block, position))
return;
position[0] += x;
position[1] += y;
SetStringPos(block, position);
}
}
void AddAnimAlpha(void *region, void *target, int16_t alpha) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimAlpha: a target below the frame takes it.
if (target != nullptr && target != region) {
ForEachChild(region, [target, alpha](void *child) { AddAnimAlpha(child, target, alpha); });
return;
}
const uint8_t own = OwnAlpha(region);
const int value = alpha + static_cast<uint8_t>(own * DrawnParentAlpha(region) / 255);
const uint8_t newAlpha = static_cast<uint8_t>(value > 255 ? 255 : (value < 0 ? 0 : value));
if (newAlpha != own)
SetOwnAlpha(region, newAlpha);
return;
}
// CSimpleRegion::AddAnimAlpha.
if (target != nullptr && target != region)
return;
const int value = alpha + static_cast<int>(VertexColor(region) >> 24);
SetVertexAlpha(region, static_cast<uint8_t>(value > 255 ? 255 : (value < 0 ? 0 : value)));
}
namespace {
// The corners (TL, BL, TR, BR) as 2D points.
float CornerX(const float *corners, int i) { return corners[i * 3 + 0]; }
float CornerY(const float *corners, int i) { return corners[i * 3 + 1]; }
// GetOriginOffset (4.3.4 CSimpleRegion.cpp): the origin point on the drawn
// quad, then the offset along the quad's bottom edge direction, so the
// offset turns with a quad that is already turned.
void GetOriginOffset(int origin, const float *c, float offsetX, float offsetY, float &x, float &y) {
const auto mid = [c](int a, int b, float &mx, float &my) {
mx = (CornerX(c, b) - CornerX(c, a)) * 0.5f + CornerX(c, a);
my = (CornerY(c, b) - CornerY(c, a)) * 0.5f + CornerY(c, a);
};
switch (origin) {
case Offsets::FRAMEPOINT_TOPLEFT: x = CornerX(c, 0); y = CornerY(c, 0); break;
case Offsets::FRAMEPOINT_TOP: mid(0, 2, x, y); break;
case Offsets::FRAMEPOINT_TOPRIGHT: x = CornerX(c, 2); y = CornerY(c, 2); break;
case Offsets::FRAMEPOINT_LEFT: mid(0, 1, x, y); break;
case Offsets::FRAMEPOINT_CENTER: mid(0, 3, x, y); break;
case Offsets::FRAMEPOINT_RIGHT: mid(2, 3, x, y); break;
case Offsets::FRAMEPOINT_BOTTOMLEFT: x = CornerX(c, 1); y = CornerY(c, 1); break;
case Offsets::FRAMEPOINT_BOTTOM: mid(1, 3, x, y); break;
case Offsets::FRAMEPOINT_BOTTOMRIGHT: x = CornerX(c, 3); y = CornerY(c, 3); break;
default: x = 0.0f; y = 0.0f; break;
}
if (offsetX * offsetX + offsetY * offsetY > 0.00000011920929f) {
float dx = CornerX(c, 3) - CornerX(c, 1);
float dy = CornerY(c, 3) - CornerY(c, 1);
const float length = dy * dy + dx * dx;
if (length > 0.00000023841858f) {
const float scale = 1.0f / std::sqrt(length);
dx *= scale;
dy *= scale;
}
x = offsetX * dx - offsetY * dy + x;
y = offsetY * dx + offsetX * dy + y;
}
}
// The font string that takes a position animation, or nullptr.
AnimData *AnimatedString(void *region, void *target) {
if (target != nullptr && target != region)
return nullptr;
AnimData *data = DataOf(region);
if (data == nullptr || !data->hasPosition || !IsFontString(region))
return nullptr;
return data;
}
// The texture that takes a position animation (the target filter of the
// region overrides), or nullptr.
float *AnimatedCorners(void *region, void *target) {
if (target != nullptr && target != region)
return nullptr;
const AnimData *data = DataOf(region);
if (data == nullptr || !data->hasPosition || !IsTexture(region))
return nullptr;
return Corners(region);
}
} // namespace
void AddAnimScale(void *region, void *target, int origin, float offsetX, float offsetY, float x, float y) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimScale.
if (target == region)
target = nullptr;
ForEachChild(region, [=](void *child) { AddAnimScale(child, target, origin, offsetX, offsetY, x, y); });
return;
}
// CSimpleFontString::AddAnimScale: the text grows from its origin by the
// x scale; the last scale animation of the update wins.
if (AnimData *data = AnimatedString(region, target)) {
data->scale = 1.0f - x;
return;
}
// CSimpleTexture::AddAnimScale -> ApplyScaleAnim.
float *c = AnimatedCorners(region, target);
if (c == nullptr)
return;
float cx, cy;
GetOriginOffset(origin, c, offsetX, offsetY, cx, cy);
for (int i = 0; i < 4; ++i) {
c[i * 3 + 0] = (cx - c[i * 3 + 0]) * x + c[i * 3 + 0];
c[i * 3 + 1] = (cy - c[i * 3 + 1]) * y + c[i * 3 + 1];
}
}
void AddAnimRotation(void *region, void *target, int origin, float offsetX, float offsetY, float radians) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimRotation.
if (target == region)
target = nullptr;
ForEachChild(region, [=](void *child) { AddAnimRotation(child, target, origin, offsetX, offsetY, radians); });
return;
}
// CSimpleFontString::AddAnimRotation: the text turns about its origin;
// the last rotation animation of the update wins.
if (AnimData *data = AnimatedString(region, target)) {
data->rotation = radians;
return;
}
// CSimpleTexture::AddAnimRotation -> ApplyRotationAnim.
float *c = AnimatedCorners(region, target);
if (c == nullptr)
return;
float cx, cy;
GetOriginOffset(origin, c, offsetX, offsetY, cx, cy);
const float cs = std::cos(radians);
const float sn = std::sin(radians);
for (int i = 0; i < 4; ++i) {
const float x = c[i * 3 + 0] - cx;
const float y = c[i * 3 + 1] - cy;
c[i * 3 + 0] = x * cs - y * sn + cx;
c[i * 3 + 1] = x * sn + y * cs + cy;
}
}
void FontStringBaked(void *node, void *fontString) {
if (g_data.empty())
return;
AnimData *data = DataOf(fontString);
if (data == nullptr)
return;
if (data->rotation != 0.0f || data->scale != 1.0f)
Texture::Transform::ScaleRotateFontStringNode(node, data->scale, data->rotation);
data->bakedRotation = data->rotation;
data->bakedScale = data->scale;
}
float *TextureBaseCorners(void *texture) {
if (g_data.empty())
return nullptr;
AnimData *data = DataOf(texture);
return data != nullptr && data->hasPosition && IsTexture(texture) ? data->position : nullptr;
}
bool StringAnimated(void *fontString) {
if (g_data.empty())
return false;
const AnimData *data = DataOf(fontString);
return data != nullptr && (data->rotation != 0.0f || data->scale != 1.0f || data->bakedRotation != 0.0f ||
data->bakedScale != 1.0f);
}
void *FindRegionByName(const char *name) {
void *L = Game::Lua::State();
if (L == nullptr || name == nullptr)
return nullptr;
Game::Lua::PushString(L, name);
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
void *object = Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE ? Game::Lua::ResolveObject(L, -1) : nullptr;
Game::Lua::SetTop(L, -2);
if (object == nullptr || IsOurs(object) || !Game::Lua::IsA(object, Offsets::VAR_REGION_LUA_TYPE_ID))
return nullptr;
return object;
}
} // namespace Animation::Region
+62
View File
@@ -0,0 +1,62 @@
// 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>
// The region side of the animation system: 4.3.4's CScriptRegion animation
// members (m_animGroups, m_playingAnimGroups, the CLayoutFrame animation
// data) and their virtuals on CSimpleFrame and CSimpleRegion, for 1.12's
// frames, textures and font strings, which have no room for them.
namespace Animation {
class Group;
}
namespace Animation::Region {
// CScriptRegion::RegisterAnimGroup - the region owns the group from now on.
void RegisterGroup(void *region, Group *group);
// CScriptRegion::NotifyAnimBegin / NotifyAnimEnd.
void NotifyBegin(void *region, Group *group);
void NotifyEnd(void *region, Group *group);
// CScriptRegion::PreOnAnimUpdate: put the animated state back to its base
// before the animations apply themselves for this update.
void PreOnAnimUpdate(void *region);
// CScriptRegion::AddAnimAlpha(target, alpha) on the group's region.
void AddAnimAlpha(void *region, void *target, int16_t alpha);
// CScriptRegion::AddAnimTranslation(target, translation), in layout units:
// moves the drawn textures and font strings, never a rect.
void AddAnimTranslation(void *region, void *target, float x, float y);
// CScriptRegion::AddAnimScale(target, origin, offset, scale): `x`/`y` are the
// share of the way to the origin (1 - scale at full progress).
void AddAnimScale(void *region, void *target, int origin, float offsetX, float offsetY, float x, float y);
// CScriptRegion::AddAnimRotation(target, origin, offset, radians),
// counter-clockwise.
void AddAnimRotation(void *region, void *target, int origin, float offsetX, float offsetY, float radians);
// A font string's glyphs were just baked (the draw builder): turn and scale
// them by its animation, and remember what they were baked with.
void FontStringBaked(void *node, void *fontString);
// The stored corners (TL BL TR BR {x, y, z}) of a texture a position group
// is playing on, or nullptr. Each update draws the animation on top of them,
// so a change to the texture's own shape (Texture::Transform) goes here, not
// to the drawn corners.
float *TextureBaseCorners(void *texture);
// Whether the font string's glyphs carry an animation's rotation or scale:
// then a change to its own rotation has to bake them again.
bool StringAnimated(void *fontString);
// CScriptObject::GetScriptObjectByName for a region (Animation:Play's
// target lookup): the region named `name`, or nullptr.
void *FindRegionByName(const char *name);
} // namespace Animation::Region
+377
View File
@@ -0,0 +1,377 @@
// 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/>.
// 4.3.4's animation XML (CScriptRegion::LoadXML_Animations,
// CSimpleAnimGroup::LoadXML, CSimpleAnim::LoadXML and the subclasses',
// LoadXML_AnimOrigin; the attributes BinaryXML.cpp parses), on 1.12 nodes.
// A script body compiles through the engine's own SetScriptObjectScript, so
// it is named "<object>:<script>" and handled like a frame's.
#include "animation/Xml.h"
#include "animation/Classes.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include "xml/FrameLoad.h"
#include "xml/KeyValues.h"
#include "xml/Mixin.h"
#include <cstdio>
#include <cstdlib>
#include <string>
namespace Animation::Xml {
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);
const void *FindChild(const void *node, const char *tag) {
for (const void *c = ::Xml::FirstChild(node); c != nullptr; c = ::Xml::NextSibling(c))
if (::Xml::TagIs(c, tag))
return c;
return nullptr;
}
bool FloatAttr(const void *node, const char *name, float &out) {
const char *v = ::Xml::Attr(node, name);
if (v == nullptr || *v == '\0')
return false;
out = static_cast<float>(std::atof(v));
return true;
}
// object[parentKey] = child, as 4.3.4's LoadXML does for both.
void BindParentKey(void *object, Object *child, const char *key) {
if (key == nullptr || *key == '\0')
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
if (::Xml::PushWrapper(L, object)) {
Game::Lua::PushString(L, key);
Push(L, child);
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTop(L, top);
}
// <Scripts>: each element's body, its `function` global, or its `method`
// field on the object.
void LoadScripts(Object *object, const void *node) {
const void *scripts = FindChild(node, "Scripts");
if (scripts == nullptr)
return;
for (const void *s = ::Xml::FirstChild(scripts); s != nullptr; s = ::Xml::NextSibling(s)) {
const char *script = ::Xml::Tag(s);
if (script == nullptr)
continue;
const char *function = ::Xml::Attr(s, "function");
const char *method = ::Xml::Attr(s, "method");
const bool byFunction = function != nullptr && *function != '\0';
if (byFunction || (method != nullptr && *method != '\0')) {
uint32_t *slot = object->ScriptSlot(script);
void *L = Game::Lua::State();
if (slot == nullptr || L == nullptr)
continue;
if (byFunction) {
Game::Lua::PushString(L, function);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
} else {
Push(L, object);
Game::Lua::PushString(L, method);
Game::Lua::GetTable(L, -2);
Game::Lua::Remove(L, -2);
}
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, -2);
continue;
}
reinterpret_cast<void(__stdcall *)(uint32_t *)>(Offsets::FUN_SCRIPT_RELEASE)(slot);
slot[0] = static_cast<uint32_t>(Game::Lua::Ref(L, Game::Lua::REGISTRY_INDEX));
slot[1] = 0;
continue;
}
const char *body = Game::Read<const char *>(s, Offsets::OFF_XML_NODE_TEXT);
if (body != nullptr && *body != '\0')
reinterpret_cast<SetScriptText_t>(Offsets::FUN_SCRIPT_OBJECT_SET_SCRIPT_TEXT)(object, nullptr, script,
body);
}
}
// <KeyValues> on a group or an animation, as on later clients' schema.
void LoadKeyValues(Object *object, const void *node, void *status) {
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
Push(L, object);
::Xml::KeyValues::Apply(L, top + 1, node, status, object->DisplayName());
Game::Lua::SetTop(L, top);
}
// LoadXML_AnimOrigin: <Origin point="..."><Offset x= y=/></Origin>.
void LoadOrigin(Anim *anim, const void *node) {
const void *origin = FindChild(node, "Origin");
if (origin == nullptr)
return;
int point = Offsets::FRAMEPOINT_CENTER;
float x = 0.0f, y = 0.0f;
const char *pointName = ::Xml::Attr(origin, "point");
if (pointName != nullptr &&
reinterpret_cast<StringToPoint_t>(Offsets::FUN_STRING_TO_FRAME_POINT)(pointName, &point)) {
if (const void *offset = FindChild(origin, "Offset")) {
const void *abs = FindChild(offset, "AbsDimension");
const void *dim = abs != nullptr ? abs : offset;
FloatAttr(dim, "x", x);
FloatAttr(dim, "y", y);
}
} else {
point = Offsets::FRAMEPOINT_CENTER;
}
anim->originPoint = point;
anim->originX = Game::PixelToInternal(x);
anim->originY = Game::PixelToInternal(y);
}
void LoadAnimChildren(Group *group, const void *node, void *status) {
for (const void *child = ::Xml::FirstChild(node); child != nullptr; child = ::Xml::NextSibling(child)) {
const char *tag = ::Xml::Tag(child);
if (tag == nullptr || ::Xml::TagIs(child, "Scripts"))
continue;
// 4.3.4 knows Animation, Translation, Rotation, Scale, Path and Alpha.
if (!::Xml::TagIs(child, "Animation") && !::Xml::TagIs(child, "Translation") &&
!::Xml::TagIs(child, "Rotation") && !::Xml::TagIs(child, "Scale") && !::Xml::TagIs(child, "Alpha") &&
!::Xml::TagIs(child, "Path"))
continue;
Anim *anim = new Anim(group, Anim::KindOf(tag), ::Xml::Attr(child, "name"));
anim->flags |= 1;
LoadAnim(anim, child, status);
}
}
} // namespace
const void *Template(const char *name) { return ::Xml::Template(name); }
namespace {
// LoadGroup re-enters itself for each inherited template; the mixins of the
// whole chain go in once, from the outermost call, before any node's
// KeyValues or scripts (see Xml::Mixin).
int g_groupDepth = 0;
void LoadGroupNode(Group *group, const void *node, void *status);
} // namespace
void LoadGroup(Group *group, const void *node, void *status) {
if (g_groupDepth == 0) {
if (void *L = Game::Lua::State()) {
const int top = Game::Lua::GetTop(L);
Push(L, group);
::Xml::Mixin::ApplyChain(L, top + 1, node, status, group->DisplayName(), false);
Game::Lua::SetTop(L, top);
}
}
++g_groupDepth;
LoadGroupNode(group, node, status);
--g_groupDepth;
}
namespace {
void LoadGroupNode(Group *group, const void *node, void *status) {
group->flags |= GROUP_LOADING;
if (const char *inherits = ::Xml::Attr(node, "inherits"); inherits != nullptr && *inherits != '\0') {
const void *base = ::Xml::Template(inherits);
if (base == nullptr)
::Xml::Warn(status, "Couldn't find inherited node: %s", inherits);
else
LoadGroupNode(group, base, status);
}
BindParentKey(group->RegionObject(), group, ::Xml::Attr(node, "parentKey"));
if (const char *looping = ::Xml::Attr(node, "looping"); looping != nullptr && *looping != '\0') {
LoopType type;
if (StringToLoopType(looping, type))
group->loopType = type;
else
::Xml::Warn(status, "AnimationGroup %s: Invalid looping value: %s", group->DisplayName(), looping);
}
float x = 0.0f, y = 0.0f;
FloatAttr(node, "initialOffsetX", x);
FloatAttr(node, "initialOffsetY", y);
x = Game::PixelToInternal(x);
y = Game::PixelToInternal(y);
if (x * x + y * y <= 0.00000023841858f) {
group->initialOffsetX = 0.0f;
group->initialOffsetY = 0.0f;
} else {
group->initialOffsetX = x;
group->initialOffsetY = y;
}
LoadScripts(group, node);
LoadKeyValues(group, node, status);
LoadAnimChildren(group, node, status);
}
} // namespace
void LoadAnim(Anim *anim, const void *node, void *status) {
// The subclass reads its values, then CSimpleAnim::LoadXML (inherits
// first, then the shared attributes) - the order 4.3.4's overrides use.
float x = 0.0f, y = 0.0f;
switch (anim->kind) {
case KIND_ALPHA:
break; // CSimpleAlphaAnim::LoadXML reads `change` after the base.
case KIND_TRANSLATION: {
// CSimpleTranslationAnim::LoadXML sets the offset, 0 for a missing axis.
FloatAttr(node, "offsetX", x);
FloatAttr(node, "offsetY", y);
const float ox = Game::PixelToInternal(x), oy = Game::PixelToInternal(y);
const bool zero = ox * ox + oy * oy <= 0.00000023841858f;
anim->offsetX = zero ? 0.0f : ox;
anim->offsetY = zero ? 0.0f : oy;
break;
}
case KIND_ROTATION:
if (FloatAttr(node, "degrees", x))
anim->radians = x * 0.017453292f;
if (FloatAttr(node, "radians", y))
anim->radians = y;
break;
case KIND_SCALE: {
float sx = 1.0f, sy = 1.0f;
if (FloatAttr(node, "scaleX", sx) && sx < 0.001f)
::Xml::Warn(status, "%s: Invalid scaleX value: %s. Value must be at least 0.001.", anim->DisplayName(),
::Xml::Attr(node, "scaleX"));
if (FloatAttr(node, "scaleY", sy) && sy < 0.001f)
::Xml::Warn(status, "%s: Invalid scaleY value: %s. Value must be at least 0.001.", anim->DisplayName(),
::Xml::Attr(node, "scaleY"));
anim->scaleChangeX = 1.0f - (sx >= 0.001f ? sx : 0.001f);
anim->scaleChangeY = 1.0f - (sy >= 0.001f ? sy : 0.001f);
break;
}
default:
break;
}
// CSimpleAnim::LoadXML.
anim->flags |= 1;
if (const char *inherits = ::Xml::Attr(node, "inherits"); inherits != nullptr && *inherits != '\0') {
const void *base = ::Xml::Template(inherits);
if (base == nullptr)
::Xml::Warn(status, "Couldn't find inherited node: %s", inherits);
else
LoadAnim(anim, base, status);
}
BindParentKey(anim->GetGroup(), anim, ::Xml::Attr(node, "parentKey"));
if (const char *target = ::Xml::Attr(node, "target"); target != nullptr && *target != '\0') {
const std::string name = CreateName(target, anim);
if (name.size() + 1 > sizeof(anim->targetName))
::Xml::Warn(status, "target key too long: %s", name.c_str());
std::snprintf(anim->targetName, sizeof(anim->targetName), "%s", name.c_str());
}
if (FloatAttr(node, "startDelay", x))
anim->startDelay = x < 0.0f ? 0.0f : x;
if (FloatAttr(node, "endDelay", x))
anim->endDelay = x < 0.0f ? 0.0f : x;
if (FloatAttr(node, "duration", x))
anim->duration = x;
if (const char *smoothing = ::Xml::Attr(node, "smoothing"); smoothing != nullptr && *smoothing != '\0') {
float in, out;
if (StringToSmoothWeights(smoothing, in, out))
anim->SetSmoothing(in, out);
else
::Xml::Warn(status, "%s: Invalid smoothing value: %s", anim->DisplayName(), smoothing);
}
if (const char *order = ::Xml::Attr(node, "order"); order != nullptr && *order != '\0') {
const int value = std::atoi(order) - 1;
if (value < 0 || value > 99)
::Xml::Warn(status, "%s: Invalid order value: %s. Order must be between 1 and 100.", anim->DisplayName(),
order);
anim->order = value < 0 ? 0 : (value > 99 ? 99 : value);
}
LoadScripts(anim, node);
LoadKeyValues(anim, node, status);
// The subclasses' reads after the base.
// CSimpleAlphaAnim::LoadXML sets the change, 0 when it is missing.
if (anim->kind == KIND_ALPHA) {
x = 0.0f;
if (FloatAttr(node, "change", x) && (x < -1.0f || x > 1.0f)) {
::Xml::Warn(status, "%s: Invalid change value: %s. Value must be between -1 and 1, inclusive.",
anim->DisplayName(), ::Xml::Attr(node, "change"));
x = 0.0f;
}
anim->change = static_cast<int16_t>((x < -1.0f ? -1.0f : (x >= 1.0f ? 1.0f : x)) * 255.0f);
}
if (anim->kind == KIND_SCALE || anim->kind == KIND_ROTATION)
LoadOrigin(anim, node);
// CSimplePathAnim::LoadXML: the curve, then <ControlPoints>.
if (anim->kind == KIND_PATH) {
if (const char *curve = ::Xml::Attr(node, "curve"); curve != nullptr && *curve != '\0') {
if (Ascii::EqualCI(curve, "NONE"))
anim->curve = false;
else if (Ascii::EqualCI(curve, "SMOOTH"))
anim->curve = true;
else
::Xml::Warn(status, "Path %s: Invalid curve value: %s", anim->DisplayName(), curve);
}
if (const void *points = FindChild(node, "ControlPoints")) {
for (const void *child = ::Xml::FirstChild(points); child != nullptr; child = ::Xml::NextSibling(child)) {
if (!::Xml::TagIs(child, "ControlPoint")) {
::Xml::Warn(status, "Path %s: Error loading Origin element", anim->DisplayName());
continue;
}
LoadControlPoint(new ControlPoint(anim, ::Xml::Attr(child, "name")), child, status);
}
}
}
}
void LoadControlPoint(ControlPoint *point, const void *node, void *status) {
// CSimpleControlPoint::LoadXML: inherits first, then parentKey (on the
// path), then the offset - 0 on an axis the node does not give.
if (const char *inherits = ::Xml::Attr(node, "inherits"); inherits != nullptr && *inherits != '\0') {
const void *base = ::Xml::Template(inherits);
if (base == nullptr)
::Xml::Warn(status, "Couldn't find inherited node: %s", inherits);
else
LoadControlPoint(point, base, status);
}
BindParentKey(point->GetPath(), point, ::Xml::Attr(node, "parentKey"));
float x = 0.0f, y = 0.0f;
FloatAttr(node, "offsetX", x);
FloatAttr(node, "offsetY", y);
point->SetOffset(Game::PixelToInternal(x), Game::PixelToInternal(y));
}
void LoadAnimations(void *region, const void *node, void *status) {
const void *animations = FindChild(node, "Animations");
if (animations == nullptr)
return;
for (const void *g = ::Xml::FirstChild(animations); g != nullptr; g = ::Xml::NextSibling(g)) {
if (!::Xml::TagIs(g, "AnimationGroup"))
continue;
Group *group = new Group(region, ::Xml::Attr(g, "name"));
LoadGroup(group, g, status);
group->PostLoadXML();
}
}
} // namespace Animation::Xml
+42
View File
@@ -0,0 +1,42 @@
// 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
// <Animations>, <AnimationGroup> and the animation elements: 4.3.4's
// CScriptRegion::LoadXML_AnimGroup, CSimpleAnimGroup::LoadXML and
// CSimpleAnim::LoadXML (and the subclasses'), on 1.12's XML nodes. A
// virtual top-level <AnimationGroup> or animation element is a template:
// 1.12's file loader registers every virtual named top-level node
// (RegisterXMLTemplate), whatever its tag, so `inherits` resolves through
// Xml::Template as 4.3.4's XMLHashNodeRef does.
namespace Animation {
class Group;
class Anim;
class ControlPoint;
} // namespace Animation
namespace Animation::Xml {
// The <Animations> child of a region's node, if any: each <AnimationGroup>
// becomes a group of `region`, loaded and post-loaded (OnLoad runs).
void LoadAnimations(void *region, const void *node, void *status);
// CSimpleAnimGroup::LoadXML / CSimpleAnim::LoadXML of one node, its
// `inherits` first.
void LoadGroup(Group *group, const void *node, void *status);
void LoadAnim(Anim *anim, const void *node, void *status);
void LoadControlPoint(ControlPoint *point, const void *node, void *status);
// The template node `name` resolves to, or nullptr.
const void *Template(const char *name);
} // namespace Animation::Xml
+693
View File
@@ -0,0 +1,693 @@
// 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/>.
// API documentation: the record of every Lua registration plus its
// descriptor, exported on demand as Blizzard `APIDocumentation` tables.
//
// C_APIDocumentation.GetSystems() -> sorted array of system Names
// C_APIDocumentation.GetSystem(name) -> one System table (Blizzard shape), or nil
// _classicapi_UndocumentedAPI() -> array of registrations lacking a
// descriptor, and its count
//
// The registrars in Game.cpp call `Record*` (see Documentation.h). Recording
// runs once per Lua state — the first in-game pass and the first glue pass —
// because the set of registrations is static: every module registers the
// same names on every `/reload`. The recorded list is never materialized
// into Lua wholesale; `GetSystem` builds ONE system's table per call, which
// is how Blizzard's browser loads too (its generated data addon is
// LoadOnDemand). Eager export would create thousands of tables on every
// `/reload` for a feature used from a slash command.
//
// System grouping is derived, never authored, except for two strings:
// - `C_Spell.X` -> System "Spell", Namespace "C_Spell" (strip `C_`)
// - `table.insert` -> System "table", Namespace "table"
// - a global -> `doc->system` ("SpellGlobals"); a global whose
// descriptor names no system has no home and is
// listed by the coverage function
// - a frame method -> Blizzard's widget system for its registry
// ("SimpleTextureAPI", Type "ScriptObject")
// - an enum / structure /
// event -> the System named in its declaration
// A system's `Environment` is "All" when its functions were recorded in both
// Lua states, else "Game" or "Glue"; a function whose own environment is
// narrower than its system's gets a Documentation line saying so.
#include "api/Documentation.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
namespace Api::Documentation {
using Game::Doc::Field;
using Game::Doc::FieldList;
using Game::Doc::Function;
using Game::Doc::Method;
using Game::Doc::Structure;
// NOT `using Game::Doc::Event` — that name would shadow the global `Event`
// namespace for the whole file, so `Event::Custom::…` would resolve to the
// type. Same trap as `Time::Tick` vs the `Tick` namespace (see CLAUDE.md).
using EventDoc = Game::Doc::Event;
// Head of the self-registering `Structure` list. Constant-initialized, so it
// is valid before any `Structure` constructor runs, whatever the cross-TU
// static-init order.
const Structure *g_structureHead = nullptr;
namespace {
enum Kind : uint8_t {
KIND_GLOBAL, // RegisterGlobalFunction / RegisterGlueFunction / RegisterGlobalAlias
KIND_TABLE, // RegisterTableFunction
KIND_FRAME_METHOD, // RegisterFrameMethods, one per entry
KIND_ENUM, // RegisterIntegerEnum
KIND_ORPHAN_METHOD_DOC // a Doc::Method naming no entry in its table
};
struct Entry {
Kind kind;
unsigned env; // Env bits the registration was recorded in
const char *table; // KIND_TABLE: the namespace; KIND_ENUM: the parent ("Enum")
const char *name; // registered name / method name / enum sub-name
const void *registry; // frame methods only
const Function *doc; // nullptr = undocumented
const char *enumSystem;
const Game::Lua::EnumIntegerEntry *enumEntries;
int enumCount;
};
std::vector<Entry> g_entries;
Env g_pass = ENV_NONE;
unsigned g_sealed = 0;
constexpr const char *kDebugPrefix = "_classicapi_";
bool IsDebugName(const char *name) {
return std::strncmp(name, kDebugPrefix, std::strlen(kDebugPrefix)) == 0;
}
bool SameText(const char *a, const char *b) {
if (a == nullptr || b == nullptr)
return a == b;
return std::strcmp(a, b) == 0;
}
bool SameTextNoCase(const char *a, const char *b) {
if (a == nullptr || b == nullptr)
return a == b;
return _stricmp(a, b) == 0;
}
Entry *Find(Kind kind, const char *table, const char *name, const void *registry) {
for (Entry &e : g_entries) {
if (e.kind == kind && e.registry == registry && SameText(e.table, table) &&
SameText(e.name, name))
return &e;
}
return nullptr;
}
void Record(Kind kind, const char *table, const char *name, const void *registry,
const Function *doc) {
if (g_pass == ENV_NONE || name == nullptr)
return;
if (Entry *e = Find(kind, table, name, registry)) {
e->env |= g_pass;
if (e->doc == nullptr)
e->doc = doc;
return;
}
Entry e{};
e.kind = kind;
e.env = g_pass;
e.table = table;
e.name = name;
e.registry = registry;
e.doc = doc;
g_entries.push_back(e);
}
// ---- System resolution ------------------------------------------------------
// Blizzard's names for the widget-method systems, keyed by the engine
// registry each `FrameMethodEntry[]` is bound to.
struct WidgetSystem {
uintptr_t registry;
const char *system;
};
const WidgetSystem kWidgetSystems[] = {
{Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY, "GameTooltipAPI"},
{Offsets::VAR_REGION_METHOD_REGISTRY, "ScriptRegionAPI"},
{Offsets::VAR_FRAME_METHOD_REGISTRY, "SimpleFrameAPI"},
{Offsets::VAR_TEXTURE_METHOD_REGISTRY, "SimpleTextureAPI"},
{Offsets::VAR_FONTSTRING_METHOD_REGISTRY, "SimpleFontStringAPI"},
{Offsets::VAR_FONT_METHOD_REGISTRY, "SimpleFontAPI"},
{Offsets::VAR_EDITBOX_METHOD_REGISTRY, "SimpleEditBoxAPI"},
{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;
}
// "C_Spell" -> "Spell"; anything else is its own system name.
const char *SystemNameForTable(const char *table) {
if (table[0] == 'C' && table[1] == '_' && table[2] != '\0')
return table + 2;
return table;
}
// The System an entry contributes a function to, or nullptr when it has
// none (an undocumented global, a frame method on an unmapped registry).
const char *SystemOf(const Entry &e) {
switch (e.kind) {
case KIND_TABLE:
return SystemNameForTable(e.table);
case KIND_GLOBAL:
return e.doc != nullptr ? e.doc->system : nullptr;
case KIND_FRAME_METHOD:
return WidgetSystemFor(e.registry);
case KIND_ENUM:
return e.enumSystem;
default:
return nullptr;
}
}
struct SystemInfo {
const char *name;
const char *ns; // nullptr for global and widget systems
const char *type; // "System" or "ScriptObject"
unsigned env; // Env bits; 0 when nothing recorded (structures only)
};
// Merge one entry's contribution into the system list.
void AddSystem(std::vector<SystemInfo> &systems, const char *name, const char *ns,
const char *type, unsigned env) {
if (name == nullptr)
return;
for (SystemInfo &s : systems) {
if (SameTextNoCase(s.name, name)) {
if (s.ns == nullptr)
s.ns = ns;
if (type != nullptr)
s.type = type;
s.env |= env;
return;
}
}
systems.push_back(SystemInfo{name, ns, type != nullptr ? type : "System", env});
}
std::vector<SystemInfo> CollectSystems() {
std::vector<SystemInfo> systems;
for (const Entry &e : g_entries) {
switch (e.kind) {
case KIND_TABLE:
AddSystem(systems, SystemNameForTable(e.table), e.table, "System", e.env);
break;
case KIND_GLOBAL:
AddSystem(systems, SystemOf(e), nullptr, "System", e.env);
break;
case KIND_FRAME_METHOD:
AddSystem(systems, SystemOf(e), nullptr, "ScriptObject", e.env);
break;
case KIND_ENUM:
AddSystem(systems, e.enumSystem, nullptr, nullptr, 0);
break;
default:
break;
}
}
for (const Structure *s = g_structureHead; s != nullptr; s = s->next)
AddSystem(systems, s->system, nullptr, nullptr, 0);
const int events = Event::Custom::ReservedCount();
for (int i = 0; i < events; ++i) {
const EventDoc *doc = Event::Custom::ReservedDoc(i);
if (doc != nullptr)
AddSystem(systems, doc->system, nullptr, nullptr, 0);
}
return systems;
}
const SystemInfo *FindSystem(const std::vector<SystemInfo> &systems, const char *query) {
for (const SystemInfo &s : systems)
if (SameTextNoCase(s.name, query) || SameTextNoCase(s.ns, query))
return &s;
return nullptr;
}
const char *EnvironmentName(unsigned env) {
if (env == (ENV_GAME | ENV_GLUE) || env == 0)
return "All";
return env == ENV_GLUE ? "Glue" : "Game";
}
// ---- Export builders (each leaves exactly what its comment says) -------------
namespace Lua = Game::Lua;
// Sets `Documentation = { line1 [, line2 [, line3]] }` on the table at
// stack[-1]; no-op when every line is null.
void SetDocumentation(void *L, const char *l1, const char *l2 = nullptr,
const char *l3 = nullptr) {
if (l1 == nullptr && l2 == nullptr && l3 == nullptr)
return;
Lua::PushString(L, "Documentation");
Lua::NewTable(L);
int n = 0;
for (const char *line : {l1, l2, l3}) {
if (line == nullptr)
continue;
Lua::PushNumber(L, static_cast<double>(++n));
Lua::PushString(L, line);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// A Lua literal from a descriptor's `defaultValue`, pushed as the value it
// denotes: `true`/`false` -> boolean, a quoted string -> string, else number.
void PushDefault(void *L, const char *literal) {
if (std::strcmp(literal, "true") == 0) {
Lua::PushBool(L, true);
} else if (std::strcmp(literal, "false") == 0) {
Lua::PushBool(L, false);
} else if (literal[0] == '"') {
const size_t len = std::strlen(literal);
Lua::PushLString(L, literal + 1, static_cast<unsigned>(len >= 2 ? len - 2 : 0));
} else {
Lua::PushNumber(L, std::strtod(literal, nullptr));
}
}
// Pushes one field record: { Name, Type, Nilable [, Default] [, Documentation] }.
void PushField(void *L, const Field &f) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", f.name);
Lua::SetFieldString(L, "Type", f.type);
Lua::SetFieldBool(L, "Nilable", f.nilable);
if (f.defaultValue != nullptr) {
Lua::PushString(L, "Default");
PushDefault(L, f.defaultValue);
Lua::SetTable(L, -3);
}
SetDocumentation(L, f.doc);
}
// Sets `stack[-1][key] = { field records }`; no-op for an empty list.
void SetFieldArray(void *L, const char *key, FieldList list) {
if (list.count <= 0)
return;
Lua::PushString(L, key);
Lua::NewTable(L);
for (int i = 0; i < list.count; ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
PushField(L, list.data[i]);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
constexpr const char *kExtensionNote = "ClassicAPI extension - not part of the Blizzard API.";
constexpr const char *kGlueOnlyNote = "Available only on the glue screen.";
constexpr const char *kGameOnlyNote = "Available only in-game.";
// An undocumented function lists with no arguments, which is indistinguishable
// from a function that genuinely takes none. Say which it is, so an empty
// signature is never read as a real one.
constexpr const char *kUndocumentedNote =
"Not documented yet. Its arguments and return values are not listed here.";
// Pushes one function record. An undocumented entry still lists by name so
// `/classicapi <System> list` shows the whole surface; its shape arrives with
// the descriptor.
void PushFunction(void *L, const Entry &e, unsigned systemEnv) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.name);
Lua::SetFieldString(L, "Type", "Function");
const char *envNote = nullptr;
if (e.env != systemEnv && e.env != 0)
envNote = (e.env == ENV_GLUE) ? kGlueOnlyNote : kGameOnlyNote;
if (e.doc == nullptr) {
SetDocumentation(L, kUndocumentedNote, envNote);
return;
}
SetDocumentation(L, e.doc->summary, e.doc->extension ? kExtensionNote : nullptr, envNote);
SetFieldArray(L, "Arguments", e.doc->args);
SetFieldArray(L, "Returns", e.doc->rets);
}
// Pushes { Name, Type = "Enumeration", NumValues, MinValue, MaxValue,
// Fields = { { Name, Type = <enum>, EnumValue } } }.
void PushEnumeration(void *L, const Entry &e) {
int minValue = 0, maxValue = 0;
for (int i = 0; i < e.enumCount; ++i) {
const int v = e.enumEntries[i].value;
if (i == 0 || v < minValue)
minValue = v;
if (i == 0 || v > maxValue)
maxValue = v;
}
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.name);
Lua::SetFieldString(L, "Type", "Enumeration");
Lua::SetFieldNumber(L, "NumValues", e.enumCount);
Lua::SetFieldNumber(L, "MinValue", minValue);
Lua::SetFieldNumber(L, "MaxValue", maxValue);
Lua::PushString(L, "Fields");
Lua::NewTable(L);
for (int i = 0; i < e.enumCount; ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.enumEntries[i].key);
Lua::SetFieldString(L, "Type", e.name);
Lua::SetFieldNumber(L, "EnumValue", e.enumEntries[i].value);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// Pushes { Name, Type = "Structure" [, Documentation], Fields = { … } }.
void PushStructure(void *L, const Structure &s) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", s.name);
Lua::SetFieldString(L, "Type", "Structure");
SetDocumentation(L, s.summary);
SetFieldArray(L, "Fields", s.fields);
}
// "LOSS_OF_CONTROL_ADDED" -> "LossOfControlAdded" (Blizzard's event Name).
void PascalCase(const char *literal, char *out, size_t cap) {
size_t o = 0;
bool upper = true;
for (const char *p = literal; *p != '\0' && o + 1 < cap; ++p) {
if (*p == '_') {
upper = true;
continue;
}
const unsigned char c = static_cast<unsigned char>(*p);
out[o++] = static_cast<char>(upper ? std::toupper(c) : std::tolower(c));
upper = false;
}
out[o] = '\0';
}
// Pushes { Name, Type = "Event", LiteralName [, Documentation] [, Payload] }.
void PushEvent(void *L, const char *literal, const EventDoc &doc) {
char name[128];
PascalCase(literal, name, sizeof name);
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", name);
Lua::SetFieldString(L, "Type", "Event");
Lua::SetFieldString(L, "LiteralName", literal);
SetDocumentation(L, doc.summary);
SetFieldArray(L, "Payload", doc.payload);
}
// Pushes the whole System table for `sys`.
void PushSystem(void *L, const SystemInfo &sys) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", sys.name);
Lua::SetFieldString(L, "Type", sys.type);
if (sys.ns != nullptr)
Lua::SetFieldString(L, "Namespace", sys.ns);
Lua::SetFieldString(L, "Environment", EnvironmentName(sys.env));
Lua::PushString(L, "Functions");
Lua::NewTable(L);
int n = 0;
for (const Entry &e : g_entries) {
if (e.kind != KIND_GLOBAL && e.kind != KIND_TABLE && e.kind != KIND_FRAME_METHOD)
continue;
if (!SameTextNoCase(SystemOf(e), sys.name) || IsDebugName(e.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushFunction(L, e, sys.env);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
Lua::PushString(L, "Events");
Lua::NewTable(L);
n = 0;
const int events = Event::Custom::ReservedCount();
for (int i = 0; i < events; ++i) {
const EventDoc *doc = Event::Custom::ReservedDoc(i);
if (doc == nullptr || !SameTextNoCase(doc->system, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushEvent(L, Event::Custom::ReservedName(i), *doc);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
Lua::PushString(L, "Tables");
Lua::NewTable(L);
n = 0;
for (const Structure *s = g_structureHead; s != nullptr; s = s->next) {
if (!SameTextNoCase(s->system, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushStructure(L, *s);
Lua::SetTable(L, -3);
}
for (const Entry &e : g_entries) {
if (e.kind != KIND_ENUM || !SameTextNoCase(e.enumSystem, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushEnumeration(L, e);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// ---- Coverage ---------------------------------------------------------------
std::vector<std::string> Undocumented() {
std::vector<std::string> out;
const std::vector<SystemInfo> systems = CollectSystems();
for (const Entry &e : g_entries) {
if (IsDebugName(e.name))
continue;
switch (e.kind) {
case KIND_GLOBAL:
if (e.doc == nullptr) {
out.push_back(e.name);
} else if (e.doc->system == nullptr) {
out.push_back(std::string(e.name) + " (doc has no system)");
} else if (const SystemInfo *s = FindSystem(systems, e.doc->system);
s != nullptr && s->ns != nullptr) {
out.push_back(std::string(e.name) + " (system '" + e.doc->system +
"' is namespaced)");
}
break;
case KIND_TABLE:
if (e.doc == nullptr)
out.push_back(std::string(e.table) + "." + e.name);
break;
case KIND_FRAME_METHOD: {
const char *sys = WidgetSystemFor(e.registry);
if (sys == nullptr)
out.push_back(std::string(e.name) + " (method registry not mapped)");
else if (e.doc == nullptr)
out.push_back(std::string(sys) + ":" + e.name);
break;
}
case KIND_ORPHAN_METHOD_DOC: {
const char *sys = WidgetSystemFor(e.registry);
out.push_back(std::string(sys != nullptr ? sys : "?") + ":" + e.name +
" (doc for unregistered method)");
break;
}
case KIND_ENUM:
if (e.enumSystem == nullptr)
out.push_back(std::string(e.table) + "." + e.name);
break;
}
}
const int events = Event::Custom::ReservedCount();
for (int i = 0; i < events; ++i)
if (Event::Custom::ReservedDoc(i) == nullptr)
out.push_back(std::string("event ") + Event::Custom::ReservedName(i));
return out;
}
// ---- Lua surface ------------------------------------------------------------
int __fastcall Script_GetSystems(void *L) {
std::vector<SystemInfo> systems = CollectSystems();
std::sort(systems.begin(), systems.end(), [](const SystemInfo &a, const SystemInfo &b) {
return _stricmp(a.name, b.name) < 0;
});
Lua::NewTable(L);
for (size_t i = 0; i < systems.size(); ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::PushString(L, systems[i].name);
Lua::SetTable(L, -3);
}
return 1;
}
int __fastcall Script_GetSystem(void *L) {
if (!Lua::IsString(L, 1)) {
Lua::Error(L, "Usage: C_APIDocumentation.GetSystem(name)");
return 0;
}
const char *query = Lua::ToString(L, 1);
const std::vector<SystemInfo> systems = CollectSystems();
const SystemInfo *sys = FindSystem(systems, query);
if (sys == nullptr)
return 0; // nil
Lua::CheckStack(L, 32);
PushSystem(L, *sys);
return 1;
}
int __fastcall Script_UndocumentedAPI(void *L) {
const std::vector<std::string> names = Undocumented();
Lua::NewTable(L);
for (size_t i = 0; i < names.size(); ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::PushString(L, names[i].c_str());
Lua::SetTable(L, -3);
}
Lua::PushNumber(L, static_cast<double>(names.size()));
return 2;
}
const Field kGetSystemsRets[] = {
Game::Doc::Req("systems", "table", "Array of system Names, sorted without regard to case."),
};
const Function kGetSystems{
"Returns the name of every documented system.",
{}, kGetSystemsRets, nullptr, true};
const Field kGetSystemArgs[] = {
Game::Doc::Req("system", "string", "A system Name or Namespace; case does not matter."),
};
const Field kGetSystemRets[] = {
Game::Doc::Opt("system", "table", nullptr,
"The system in Blizzard's APIDocumentation table shape, or nil when unknown."),
};
const Function kGetSystem{
"Returns one system's documentation table.",
kGetSystemArgs, kGetSystemRets, nullptr, true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_APIDocumentation", "GetSystems", &Script_GetSystems,
&kGetSystems);
Game::Lua::RegisterTableFunction("C_APIDocumentation", "GetSystem", &Script_GetSystem,
&kGetSystem);
Game::Lua::RegisterGlobalFunction("_classicapi_UndocumentedAPI", &Script_UndocumentedAPI);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
// ---- Recording (called from Game.cpp) ----------------------------------------
void BeginPass(Env env) {
g_pass = (g_sealed & env) != 0 ? ENV_NONE : env;
}
void EndPass() {
if (g_pass == ENV_NONE)
return;
g_sealed |= g_pass;
g_pass = ENV_NONE;
}
void RecordGlobal(const char *name, const Function *doc) {
Record(KIND_GLOBAL, nullptr, name, nullptr, doc);
}
void RecordTable(const char *table, const char *name, const Function *doc) {
Record(KIND_TABLE, table, name, nullptr, doc);
}
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Method *docs, int docCount) {
if (g_pass == ENV_NONE)
return;
for (int i = 0; i < count; ++i) {
const Function *doc = nullptr;
for (int d = 0; d < docCount; ++d) {
if (std::strcmp(docs[d].name, table[i].name) == 0) {
doc = docs[d].doc;
break;
}
}
Record(KIND_FRAME_METHOD, nullptr, table[i].name, registry, doc);
}
for (int d = 0; d < docCount; ++d) {
bool matched = false;
for (int i = 0; i < count && !matched; ++i)
matched = std::strcmp(docs[d].name, table[i].name) == 0;
if (!matched)
Record(KIND_ORPHAN_METHOD_DOC, nullptr, docs[d].name, registry, docs[d].doc);
}
}
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;
if (Entry *e = Find(KIND_ENUM, parent, sub, nullptr)) {
e->env |= g_pass;
if (e->enumSystem == nullptr)
e->enumSystem = system;
return;
}
Entry e{};
e.kind = KIND_ENUM;
e.env = g_pass;
e.table = parent;
e.name = sub;
e.enumSystem = system;
e.enumEntries = entries;
e.enumCount = count;
g_entries.push_back(e);
}
} // namespace Api::Documentation
// The Structure constructor lives here so the list head is private to this
// TU; declared in Game.h beside the other descriptor types.
Game::Doc::Structure::Structure(const char *n, const char *s, FieldList f, const char *sum)
: name(n), system(s), fields(f), summary(sum), next(Api::Documentation::g_structureHead) {
Api::Documentation::g_structureHead = this;
}
+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
#include "Game.h"
// Recording side of the API documentation. The registrars in Game.cpp call
// these; `Documentation.cpp` keeps the list, resolves systems, and exports
// Blizzard-shaped `APIDocumentation` tables through `C_APIDocumentation`.
// The descriptor model is the `Game::Doc` block in Game.h.
namespace Api::Documentation {
enum Env : unsigned { ENV_NONE = 0, ENV_GAME = 1, ENV_GLUE = 2 };
// Bracket one `Run*Registrations()` pass. Recording happens only inside a
// pass, and only the FIRST pass per environment records — every later pass
// (each `/reload`, each logout→login) is a no-op. `EndPass` seals the
// environment and logs one summary line to the debug log.
void BeginPass(Env env);
void EndPass();
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);
} // namespace Api::Documentation
+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
+6 -9
View File
@@ -68,12 +68,9 @@ namespace AuctionHouse::PostItem {
namespace {
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
const Event::Custom::AutoReserve _r1{kEvtStart};
const Event::Custom::AutoReserve _r2{kEvtUpdate};
const Event::Custom::AutoReserve _r3{kEvtFailure};
const Event::Custom::AutoReserve _evtStart{"AUCTION_MULTISELL_START"};
const Event::Custom::AutoReserve _evtUpdate{"AUCTION_MULTISELL_UPDATE"};
const Event::Custom::AutoReserve _evtFailure{"AUCTION_MULTISELL_FAILURE"};
// Generous per-step abort bound. A split or a post is one server round-trip
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
@@ -206,7 +203,7 @@ bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
void Finish(bool success) {
g_job.active = false;
if (!success)
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
Event::Custom::Fire(_evtFailure.Slot(), "");
// success needs no event: the last PostWait already fired
// AUCTION_MULTISELL_UPDATE(total, total).
}
@@ -279,7 +276,7 @@ void Tick() {
// Post confirmed once the server pulls the item out of the slot.
if (w.item == nullptr || w.guid != g_job.postGuid) {
++g_job.postedStacks;
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
Event::Custom::Fire(_evtUpdate.Slot(), "%d%d",
g_job.postedStacks, g_job.totalStacks);
g_job.phase = Phase::Idle;
g_job.waitTicks = 0;
@@ -400,7 +397,7 @@ int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
g_job.runTime = runTime;
g_job.phase = Phase::Idle;
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
Event::Custom::Fire(_evtStart.Slot(), "");
Game::Lua::PushBool(L, true);
return 1;
}
+520 -42
View File
@@ -19,23 +19,29 @@
//
// Filter parsing (`ParseFilters`) tokenizes the modern AuraFilters string:
// tokens separated by `|` and/or whitespace, each optionally negated with a
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
// leading `!`, compared ignoring case as 4.3.4's parser does. Whole-token
// matching, so `RAID_PLAYER_DISPELLABLE` is not mistaken for `PLAYER` and
// `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (the aura's real polarity, read however this
// server encodes it — see `Aura::Data::IsSlotHarmful`), `PLAYER` /
// `!PLAYER` (caster is / isn't the local player or a unit they own, from the
// Aura::Source cache), `RAID` (the engine's raid filter — see
// `Aura::Data::IsRaidAura`), `CANCELABLE` / `NOT_CANCELABLE` (the descriptor's
// cancelable bit),
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
// Poison, matching the server's DISPEL_ALL_MASK), and `CROWD_CONTROL` /
// `!CROWD_CONTROL` (is / isn't a hard control effect, via the shared
// `Spell::CrowdControl` classifier that also backs C_LossOfControl). Every
// other modern token (`RAID`, `CANCELABLE`, `INCLUDE_NAME_PLATE_ONLY`, `MAW`,
// …) is accepted and ignored — they need a class-dispel matrix or systems
// vanilla has no data for.
// other modern token (`INCLUDE_NAME_PLATE_ONLY`, `MAW`, …) is accepted and
// ignored — vanilla has no data for them.
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
#include "aura/Instance.h"
#include "spell/Lookup.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
@@ -46,14 +52,10 @@ namespace Aura::Api {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
return static_cast<const uint8_t *>(fn(token));
return static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
}
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
@@ -77,8 +79,10 @@ struct ParsedFilter {
Data::CasterMode caster = Data::CasterMode::Any;
Data::DispelMode dispel = Data::DispelMode::Any;
Data::CcMode cc = Data::CcMode::Any;
Data::CancelMode cancel = Data::CancelMode::Any;
bool raid = false;
Data::Match ToMatch() const { return Data::Match{caster, dispel, cc}; }
Data::Match ToMatch() const { return Data::Match{caster, dispel, cc, cancel, raid}; }
};
// Reduces the parsed range tokens to the single `Data::Filter` the indexed /
@@ -90,8 +94,8 @@ Data::Filter RangeFilter(const ParsedFilter &pf) {
: Data::Filter::Helpful;
}
// Tokenizes the filter string. Case-sensitive (modern documents the tokens as
// upper-case constants). `HELPFUL`/`HARMFUL` are positive range selectors;
// Tokenizes the filter string, ignoring case (4.3.4's Script_GetAuraInfo
// compares tokens with SStrCmpI). `HELPFUL`/`HARMFUL` are positive range selectors;
// negating them is meaningless for our slot-based ranges, so a leading `!` is
// ignored on those. `PLAYER` honors negation (`!PLAYER` → NotPlayer). Any
// unrecognized token is accepted and skipped.
@@ -118,17 +122,25 @@ ParsedFilter ParseFilters(const char *filter) {
}
tok[n] = '\0';
if (strcmp(tok, "HELPFUL") == 0)
if (_stricmp(tok, "HELPFUL") == 0)
out.helpful = true;
else if (strcmp(tok, "HARMFUL") == 0)
else if (_stricmp(tok, "HARMFUL") == 0)
out.harmful = true;
else if (strcmp(tok, "PLAYER") == 0)
else if (_stricmp(tok, "PLAYER") == 0)
out.caster = negate ? Data::CasterMode::NotPlayer
: Data::CasterMode::PlayerOnly;
else if (strcmp(tok, "DISPELLABLE") == 0)
else if (_stricmp(tok, "RAID") == 0)
out.raid = !negate;
else if (_stricmp(tok, "CANCELABLE") == 0)
out.cancel = negate ? Data::CancelMode::NotCancelable
: Data::CancelMode::CancelableOnly;
else if (_stricmp(tok, "NOT_CANCELABLE") == 0)
out.cancel = negate ? Data::CancelMode::CancelableOnly
: Data::CancelMode::NotCancelable;
else if (_stricmp(tok, "DISPELLABLE") == 0)
out.dispel = negate ? Data::DispelMode::NotDispellable
: Data::DispelMode::DispellableOnly;
else if (strcmp(tok, "CROWD_CONTROL") == 0)
else if (_stricmp(tok, "CROWD_CONTROL") == 0)
out.cc = negate ? Data::CcMode::NotCrowdControl
: Data::CcMode::CrowdControlOnly;
// else: accepted and ignored (retail-only / unimplemented token).
@@ -183,6 +195,31 @@ int PushAuraByIndex(void *L, const char *unitToken, int index,
return 1;
}
// Positional (multi-return) sibling of `PushAuraByIndex` for the
// `C_UnitAuras.UnitAura` family: pushes the Classic-Era `UnitAura` 15-tuple with
// NO table allocation (the zero-GC path). Returns 15 on a hit, or 1 (a single
// nil) on a miss. The bool-returning push helpers leave exactly the 15-tuple on a
// hit and nothing on a miss, so the miss `PushNil` + `return 1` needs no cleanup.
int PushUnitAuraPositional(void *L, const char *unitToken, int index,
Data::Filter filter, Data::Match match = {}) {
const uint8_t *unit = ResolveUnit(unitToken);
if (unit != nullptr) {
const int slot = Data::FindNthSlot(unit, index, filter, match);
if (slot >= 0) {
Data::Push(L, unit, slot, Data::Emit::Positional);
return 15;
}
if (Data::PushNthCacheFallback(L, unit, index, filter, match,
Data::Emit::Positional))
return 15;
} else if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
filter, match, Data::Emit::Positional)) {
return 15;
}
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_GetAuraDataByIndex(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
@@ -215,6 +252,50 @@ int __fastcall Script_GetDebuffDataByIndex(void *L) {
return PushAuraByIndex(L, unit, index, Data::Filter::Harmful);
}
// Zero-allocation positional accessors — Classic-Era `UnitAura`/`UnitBuff`/
// `UnitDebuff` shape, namespaced under `C_UnitAuras` to avoid clashing with the
// native `UnitBuff`/`UnitDebuff` globals (which keep their vanilla short return).
// `UnitAura` reads the range from the filter (helpful by default); `UnitBuff` /
// `UnitDebuff` lock the range and still honor the filter's PLAYER/DISPELLABLE/
// CROWD_CONTROL predicates.
int __fastcall Script_UnitAura(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
const char *filterStr = ArgOptString(L, 3);
if (unit == nullptr || index < 1) {
Game::Lua::PushNil(L);
return 1;
}
const ParsedFilter pf = ParseFilters(filterStr);
return PushUnitAuraPositional(L, unit, index, RangeFilter(pf), pf.ToMatch());
}
int __fastcall Script_UnitBuff(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
const char *filterStr = ArgOptString(L, 3);
if (unit == nullptr || index < 1) {
Game::Lua::PushNil(L);
return 1;
}
const ParsedFilter pf = ParseFilters(filterStr);
return PushUnitAuraPositional(L, unit, index, Data::Filter::Helpful,
pf.ToMatch());
}
int __fastcall Script_UnitDebuff(void *L) {
const char *unit = ArgUnit(L, 1);
const int index = ArgInt(L, 2);
const char *filterStr = ArgOptString(L, 3);
if (unit == nullptr || index < 1) {
Game::Lua::PushNil(L);
return 1;
}
const ParsedFilter pf = ParseFilters(filterStr);
return PushUnitAuraPositional(L, unit, index, Data::Filter::Harmful,
pf.ToMatch());
}
int __fastcall Script_GetUnitAuraBySpellID(void *L) {
const char *unitToken = ArgUnit(L, 1);
const int spellID = ArgInt(L, 2);
@@ -287,22 +368,177 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
return 1;
}
// Iterates one slot range and pushes AuraData tables into `outer` at
// sequential keys starting from `nextKey`. Updates `nextKey` so a
// follow-up call can append to the same outer table.
// C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])
// -> continuationToken, slot1, slot2, ...
//
// Enumerates the opaque slot ids of every aura on `unit` matching `filter`, in
// the order the by-index getters visit them, `maxSlots` at a time (nil / 0 =
// all). The first return is the token to pass back for the next batch, or nil
// when this batch reached the end — retail's batching contract, and what makes
// AuraUtil.ForEachAura linear: one enumeration per batch plus a direct by-slot
// fetch per aura, instead of a fresh from-slot-0 walk per index. The token is
// the 1-based ordinal to resume at; opaque to callers. Encoding of the slot ids
// is documented at `Data::OPAQUE_STRIDE`.
int __fastcall Script_GetAuraSlots(void *L) {
const char *unitToken = ArgUnit(L, 1);
const char *filterStr = ArgOptString(L, 2);
const int maxSlots = ArgInt(L, 3);
const int token = ArgInt(L, 4);
// ClassicAPI extension: a table as the 5th argument selects FILL mode —
// the slot ids are written into it (t[1..n], stale tail cleared, t.n = n)
// and the call returns `continuationToken, n` instead of the vararg list.
// Lua 5.0 builds an `arg` table for every vararg CALL, so a Lua helper
// that receives the retail multi-return allocates one table per batch;
// filling the caller's table here is what keeps a per-frame scan
// allocation-free (AuraUtil and pfUI's ScanAuraSlots use this form).
const bool fill = Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE;
int slots[Data::OPAQUE_SLOTS_MAX];
int total = 0;
if (unitToken != nullptr) {
const uint8_t *unit = ResolveUnit(unitToken);
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
const ParsedFilter pf = ParseFilters(filterStr);
total = Data::CollectSlots(unit, guid, RangeFilter(pf), pf.ToMatch(), slots,
Data::OPAQUE_SLOTS_MAX);
}
int start = (token > 0) ? token - 1 : 0;
if (start > total)
start = total;
int n = total - start;
if (maxSlots > 0 && n > maxSlots)
n = maxSlots;
const bool more = start + n < total;
if (fill) {
for (int i = 0; i < n; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
Game::Lua::RawSet(L, 5);
}
// Clear what a previous, longer fill left past n. The array is dense
// by construction, so stop at the first hole.
for (int k = n + 1;; ++k) {
Game::Lua::PushNumber(L, static_cast<double>(k));
Game::Lua::RawGet(L, 5);
const bool hole = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
Game::Lua::SetTop(L, -2);
if (hole)
break;
Game::Lua::PushNumber(L, static_cast<double>(k));
Game::Lua::PushNil(L);
Game::Lua::RawSet(L, 5);
}
Game::Lua::PushString(L, "n");
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::RawSet(L, 5);
Game::Lua::SetTop(L, 0);
if (more)
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 2;
}
// Vararg form. Everything below only pushes, so the args can go; a full
// batch is up to OPAQUE_SLOTS_MAX + 1 values, past Lua's guaranteed C-call
// headroom.
Game::Lua::SetTop(L, 0);
if (Game::Lua::CheckStack(L, n + 1) == 0) {
Game::Lua::PushNil(L);
return 1;
}
if (more)
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
else
Game::Lua::PushNil(L);
for (int i = 0; i < n; ++i)
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
return 1 + n;
}
// Shared body of GetAuraDataBySlot (table) / UnitAuraBySlot (positional):
// pushes the aura an opaque slot id from GetAuraSlots names, or a single nil
// for an id that no longer names one.
int PushAuraBySlot(void *L, Data::Emit emit) {
const char *unitToken = ArgUnit(L, 1);
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::PushNil(L);
return 1;
}
const int slot = ArgInt(L, 2);
const uint8_t *unit = ResolveUnit(unitToken);
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
if (Data::PushBySlot(L, unit, guid, slot, emit))
return (emit == Data::Emit::Positional) ? 15 : 1;
Game::Lua::PushNil(L);
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);
}
// C_UnitAuras.UnitAuraBySlot(unit, slot) -> the 15 positional UnitAura values | nil
// Zero-allocation sibling of GetAuraDataBySlot, namespaced under C_UnitAuras
// like C_UnitAuras.UnitAura. What AuraUtil's non-packed iteration reads.
int __fastcall Script_UnitAuraBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Positional);
}
// Iterates the auras of one polarity (in `WalkOrder`, the same order the
// by-index getters use) and pushes AuraData tables into `outer` at
// sequential keys starting from `nextKey`. Updates `nextKey` so a follow-up
// call can append to the same outer table.
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Filter filter, int &nextKey, Data::Match match) {
const int start = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter == Data::Filter::Harmful)
? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
for (int slot = start; slot < end; ++slot) {
if (!Data::IsSlotPopulated(unit, slot))
continue;
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
Data::ReadSpellID(unit, slot)))
int order[Offsets::UNIT_AURA_TOTAL];
Data::WalkOrder(unit, filter, order);
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = order[i];
if (!Data::SlotMatchesFilter(unit, slot, filter, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
Data::Push(L, unit, slot);
@@ -417,25 +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, &kUnitAura);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitBuff",
&Script_UnitBuff, &kUnitBuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
&Script_UnitDebuff, &kUnitDebuff);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraSlots",
&Script_GetAuraSlots, &kGetAuraSlots);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
&Script_GetAuraDataBySlot, &kGetAuraDataBySlot);
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataByAuraInstanceID",
&Script_GetAuraDataByAuraInstanceID,
&kGetAuraDataByAuraInstanceID);
Game::Lua::RegisterTableFunction("C_UnitAuras", "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};
+19 -3
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};
@@ -227,8 +241,10 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
Offsets::VAR_SPELL_DURATION_COUNT,
static_cast<uint32_t>(idx));
if (row != nullptr) {
baseMs = *reinterpret_cast<const int32_t *>(row + 0x4);
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
baseMs = *reinterpret_cast<const int32_t *>(
row + Offsets::OFF_SPELLDURATION_BASE_MS);
maxMs = *reinterpret_cast<const int32_t *>(
row + Offsets::OFF_SPELLDURATION_MAX_MS);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Defer to a registered resolver (the known values are
+999 -328
View File
File diff suppressed because it is too large Load Diff
+195 -40
View File
@@ -10,6 +10,8 @@
#pragma once
#include "Offsets.h"
#include <cstdint>
// Shared aura-table primitives consumed by `C_UnitAuras.*` (and any
@@ -18,29 +20,66 @@
// `unit + OFF_CGUNIT_OBJECT_FIELDS`; the layout is documented in
// `Offsets.h` under `OFF_UNIT_FIELD_AURA*`.
//
// Slot indexing convention used here matches the engine's:
// Slots are absolute 0..47, and how polarity is read out of them DEPENDS ON THE
// SERVER. `IsSlotHarmful` owns that decision; nothing else should look at either
// signal directly.
//
// slot 0..31 → buffs (helpful)
// slot 32..47 → debuffs (harmful)
// Normally the slot range IS the polarity: positive auras are written to 0..31
// and negative ones to 32..47 by a strict either/or, so nothing crosses over.
// The flag nibble is no help — in a stock descriptor those bits record which
// spell effect indices carry an aura, not whether the aura is good or bad.
//
// Callers that take a 1-based Lua index into "buffs" or "debuffs"
// translate to the absolute 0..47 slot before calling in.
// Turtle changes both halves. Once the 16 harmful slots are full it spills
// further debuffs into 0..31 (setting UNIT_FLAG_AURAS_VISIBLE so the client
// still renders them), which makes the range unreliable — and it repurposes the
// nibble to carry the true polarity so the information survives the spill.
// There, the nibble is authoritative. See `Offsets::UNIT_AURA_FLAG_HARMFUL`.
//
// Either way `Filter` selects on the aura's REAL polarity, so a spilled debuff
// is reported as a debuff. The engine's own UnitBuff/UnitDebuff split purely by
// slot range and would call it a buff; that is a 1.12 UI limitation we
// deliberately do not mirror, since retail's contract is actual polarity. A
// polarity walk visits its home range first and then the other range, so
// indices are unchanged wherever no spill has happened — which is everywhere,
// on a server that does not spill.
//
// Callers that take a 1-based Lua index into "buffs" or "debuffs" translate
// to the absolute 0..47 slot before calling in.
namespace Aura::Data {
// Filter for slot iteration. The engine's `UnitBuff` / `UnitDebuff`
// pick one direction; modern `C_UnitAuras.GetAuraDataByIndex` defaults
// to helpful when no filter is specified.
// Filter for slot iteration: the aura's polarity per its flag nibble (see the
// file header). The engine's `UnitBuff` / `UnitDebuff` pick one direction;
// modern `C_UnitAuras.GetAuraDataByIndex` defaults to helpful when no filter
// is specified.
enum class Filter { Helpful, Harmful };
// How a resolved aura is emitted onto the Lua stack by the single-aura push
// paths. `Table` builds the modern `AuraData` table (net stack +1); `Positional`
// 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. `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
// (`PLAYER`); `NotPlayer` = only auras NOT cast by the local player
// (`!PLAYER`). Caster attribution comes from the `Aura::Source` cache; a
// cache miss counts as "not the player" (so `PlayerOnly` excludes it and
// `NotPlayer` includes it — matching `IsPlayerCast`'s miss semantics).
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast,
// or a unit the player owns cast — pet, guardian, totem, charmed unit
// (`PLAYER`); `NotPlayer` = the complement (`!PLAYER`). This is 4.3.4's
// `CGSpellBook::IsAuraCaster`. Caster attribution comes from the
// `Aura::Source` cache; a cache miss counts as "not the player" (so
// `PlayerOnly` excludes it and `NotPlayer` includes it — matching
// `IsPlayerCast`'s miss semantics).
enum class CasterMode { Any, PlayerOnly, NotPlayer };
// Restriction from the `CANCELABLE` / `NOT_CANCELABLE` aura filter tokens:
// the descriptor nibble's cancelable bit, the test `GetPlayerBuff`'s own
// filter makes (and 4.3.4's GetSortedAuraSlot).
enum class CancelMode { Any, CancelableOnly, NotCancelable };
// Restriction from the `DISPELLABLE` / `!DISPELLABLE` aura filter tokens.
// `Any` = no restriction; `DispellableOnly` = only auras that can be
// dispelled / purged / stolen by SOME mechanism (dispel type Magic, Curse,
@@ -62,8 +101,16 @@ struct Match {
CasterMode caster = CasterMode::Any;
DispelMode dispel = DispelMode::Any;
CcMode cc = CcMode::Any;
CancelMode cancel = CancelMode::Any;
// `RAID`: a helpful aura the player can cast on others, a harmful one the
// player can dispel — the engine's raid filter (see `IsRaidAura`).
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);
@@ -75,11 +122,40 @@ uint32_t ReadSpellID(const uint8_t *unit, int slot);
// to decide whether to surface an aura through Lua.
bool IsSlotPopulated(const uint8_t *unit, int slot);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
// matching `filter`. Returns -1 if no such aura. Walks
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
// the local player cast (per `IsPlayerCast`) are counted/returned.
// The aura's real polarity, read the way this server encodes it: the slot range
// normally, the flag nibble on Turtle (see the file header). False for an empty
// slot or a null unit. This is what `Filter` selects on, and the ONE place the
// two encodings are chosen between.
bool IsSlotHarmful(const uint8_t *unit, int slot);
// The engine's TOOLTIP visibility gate for a slot: occupied, visible nibble,
// and the spell passes `FUN_GAMETOOLTIP_AURA_VISIBLE` — exactly what
// `SetUnitBuff` / `SetUnitDebuff` count while turning an index into a slot.
// Differs from `IsSlotPopulated` (the UnitBuff gate), so use THIS to compute
// the index the engine's tooltip methods expect for a given slot.
bool IsSlotTooltipVisible(const uint8_t *unit, int slot);
// Fills `out` with all UNIT_AURA_TOTAL absolute slots of `unit` in the order a
// `filter` walk visits them, the order 4.3.4's sorted aura list gives
// (`CGUnit_C::InsertSortedAura`):
// 1. auras the player, or a unit the player owns, cast (`IsPlayerCast`);
// 2. auras the player could apply (`canApplyAura`);
// 3. the rest.
// Within each group, the polarity's home range comes first (helpful 0..31,
// harmful 32..47), then the other range, where spilled debuffs live. Every
// enumeration that hands out indices or slot lists uses this so they agree on
// order. The groups are cached per unit and rebuilt when its aura array changes
// or a new frame starts, so a by-index loop pays for them once.
void WalkOrder(const uint8_t *unit, Filter filter, int *out);
// `SlotMatches` plus the polarity test: populated, of `filter`'s polarity, and
// passing `match`.
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
const Match &match);
// Finds the absolute slot of the `oneBasedIndex`-th populated aura of
// `filter`'s polarity that passes `match`. Returns -1 if no such aura. Walks
// all 48 slots in `WalkOrder`.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
Match match = {});
@@ -91,30 +167,55 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, Match match = {});
// Finds the absolute slot of a populated aura whose locale-resolved
// spell name exactly matches `spellName`. Case-sensitive. Otherwise
// spell name matches `spellName`, ignoring case (Storm's SStrCmpI, as
// 4.3.4's GetSortedAuraSlot compares names). Otherwise
// same semantics as `FindSlotBySpellID`. Returns -1 if not found
// or if `spellName` is null/empty.
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, Match match = {});
// True iff the aura at `slot` was cast by the local player, per the
// `Aura::Source` cache. False on a cache miss (caster unknown) — so a
// PLAYER-filtered query excludes auras whose cast we didn't observe.
// True iff the aura at `slot` was cast by the local player or by a unit the
// player owns (see `CasterMode`), per the `Aura::Source` cache. False on a
// cache miss (caster unknown) — so a PLAYER-filtered query excludes auras
// whose cast we didn't observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// Applies a `CasterMode` to a per-aura "was cast by the local player" answer.
// `Any` → always true; `PlayerOnly` → the answer; `NotPlayer` → its negation.
// 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.
bool CasterMatches(CasterMode caster, bool isPlayerCast);
// The engine's raid filter — UnitBuff/UnitDebuff's third argument, and
// 4.3.4's `RAID` token: a helpful aura passes when the player can cast its
// spell on others (`FUN_SPELL_IS_CASTABLE_AURA`), a harmful one when its
// dispel type is in the player's dispel list (`FUN_DISPEL_TYPE_KNOWN`).
bool IsRaidAura(uint32_t spellID, bool harmful);
// True iff the spell's dispel type is one a dispel/purge/steal can remove —
// Magic, Curse, Disease, or Poison (`Spell.dbc` Dispel ∈ 1..4). None,
// Stealth, Invisibility, Enrage, etc. are not dispellable. Verified against
// the server's DISPEL_ALL_MASK.
bool IsDispellable(uint32_t spellID);
// Applies a full `Match` (caster + dispel predicates) to one aura, given the
// per-aura "was cast by the local player" answer and its spell ID.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
// Applies a full `Match` to one aura, given the per-aura "was cast by the
// local player" answer, its spell ID, its polarity, and whether it is
// cancelable.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID,
bool harmful, bool cancelable);
// True iff descriptor `slot` on `unit` is populated (per `IsSlotPopulated`) AND
// passes `match`. The caster answer (`IsPlayerCast` — a scan of the
// `Aura::Source` cache, ~1000 entries in a raid) is computed ONLY when `match`
// restricts on caster, so the common filter-less walk never pays for it.
bool SlotMatches(const uint8_t *unit, int slot, const Match &match);
// Display stack count for the given slot (engine stores stacks-1,
// we add 1). Returns 0 if the unit is null.
@@ -146,16 +247,25 @@ const char *DispelName(uint32_t dispelTypeID);
// sourceGUID (caster "0x..." GUID string; nil on miss — a ClassicAPI
// extension, not in retail AuraData)
//
// And these from the spell and the player's spells:
// isFromPlayerOrPlayerPet (caster GUID is a player or a pet),
// canApplyAura (the player or pet knows the spell and it applies an aura),
// isRaid (the aura passes the RAID filter, `IsRaidAura`),
// isStealable (4.3.4's CanStealAura: a buff on an attackable unit whose
// 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,
// isStealable=false, isBossAura=false, isFromPlayerOrPlayerPet=false,
// isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, canApplyAura=false,
// shouldConsolidate=false, isRaid=false
// charges=0, maxCharges=0, points=nil,
// isBossAura=false, isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, shouldConsolidate=false
//
// Net stack effect: +1 (the table).
void Push(void *L, const uint8_t *unit, int slot);
// Net stack effect: +1 for `Emit::Table` (the table), +15 for `Emit::Positional`
// (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
// (rogue stealth, party range fluctuation) but it's still active server-side.
@@ -165,9 +275,11 @@ void Push(void *L, const uint8_t *unit, int slot);
// order). On a hit it pushes the AuraData table and returns true; on a miss it
// pushes nothing and returns false (caller pushes nil). Only entries whose
// helpful/harmful classification matches `filter` and that aren't already in a
// populated descriptor slot are considered.
// populated descriptor slot are considered. `emit` selects the table vs the
// positional tuple leaf (see `Emit`); the pushed count follows `Push`.
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, Match match = {});
Filter filter, Match match = {},
Emit emit = Emit::Table);
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
@@ -196,10 +308,11 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
// to dedup against.
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
// matching `filter`. On a hit pushes the table and returns true; otherwise
// pushes nothing and returns false (caller pushes nil).
// matching `filter`. On a hit pushes the aura and returns true; otherwise
// pushes nothing and returns false (caller pushes nil). `emit` selects the
// table vs the positional tuple leaf (see `Emit`).
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
Match match = {});
Match match = {}, Emit emit = Emit::Table);
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
// restricted to one filter range (nullptr = both, helpful first). Returns true
@@ -208,7 +321,7 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, Match match = {});
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
// (ignoring case), mirroring `FindSlotBySpellName`.
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, Match match = {});
@@ -218,4 +331,46 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
int outerIdx, int &nextKey);
// ── Opaque aura slots (GetAuraSlots / GetAuraDataBySlot) ────────────────────
//
// The by-index getters re-walk the descriptor from slot 0 on every call, so a
// full scan through them is quadratic in the aura count. `CollectSlots`
// enumerates once into opaque slot ids and `PushBySlot` fetches one aura by id
// with no walk — the modern `C_UnitAuras.GetAuraSlots` / `GetAuraDataBySlot`
// batching contract. An id is valid for the frame it was enumerated in.
//
// Encoding (kind = id / OPAQUE_STRIDE, k = id % OPAQUE_STRIDE):
// kind 0 live descriptor slot k (0..UNIT_AURA_TOTAL-1)
// kind 1 k-th eligible helpful cache-fallback entry (OPAQUE_CACHE_HELPFUL)
// kind 2 k-th eligible harmful cache-fallback entry (OPAQUE_CACHE_HARMFUL)
// kind 3 out-of-range group-array slot k (0..47) (OPAQUE_GROUP)
// kind 4 k-th helpful group cache-fallback entry (OPAQUE_GROUP_CACHE_HELPFUL)
// kind 5 k-th harmful group cache-fallback entry (OPAQUE_GROUP_CACHE_HARMFUL)
// Fallback ordinals count the UNFILTERED eligible sequence (visible, not
// already in the live array), so `PushBySlot` needs no filter; `CollectSlots`
// applies the caller's `match` only when choosing which ids to emit.
constexpr int OPAQUE_STRIDE = 0x100;
constexpr int OPAQUE_CACHE_HELPFUL = 1 * OPAQUE_STRIDE;
constexpr int OPAQUE_CACHE_HARMFUL = 2 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP = 3 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP_CACHE_HELPFUL = 4 * OPAQUE_STRIDE;
constexpr int OPAQUE_GROUP_CACHE_HARMFUL = 5 * OPAQUE_STRIDE;
// Upper bound on ids one `CollectSlots` call can yield: the live array (or the
// group array) plus its cache fallback, each at most UNIT_AURA_TOTAL entries.
constexpr int OPAQUE_SLOTS_MAX = 2 * Offsets::UNIT_AURA_TOTAL;
// Writes the opaque ids of every aura on the unit matching `filter` + `match`
// into `out` (at most `cap`), in the order the by-index getters visit them:
// live slots (or, with no live CGUnit, the group array) first, then the cache
// fallback. `unit` null selects the out-of-range group path for `guid`. Returns
// the count written.
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
int *out, int cap);
// Pushes the aura an opaque id names — table or positional per `emit` — and
// returns true; pushes nothing and returns false for an id that names nothing
// (stale, malformed, or the wrong path for the unit's current state).
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit);
} // namespace Aura::Data
+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
+78
View File
@@ -0,0 +1,78 @@
// 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/>.
// Paladin judgement melee refresh — client mirror of the server's white-swing
// rule (pfUI#45: the judgement debuff's timer ran to zero while the debuff
// stayed up through the paladin's auto-attacks).
//
// The server rule (tortoise-wow Unit::DealMeleeDamage, vmangos lineage): a
// melee swing that dealt ANY damage (`totalDamage > 0` — misses/dodges/parries
// deal none) refreshes every aura on the victim that the attacker cast whose
// spell is SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT — the judgement
// marker (all 14 Judgement of Light/Wisdom/Justice/Crusader debuff ranks carry
// it, verified in the client Spell.dbc). RefreshHolder resets the holder to
// its own full duration.
//
// The edit itself is packet-silent (UpdateAuraDuration is self-scoped — the
// same blindness every Turtle::DurationMods rule works around), but its
// TRIGGER is not: DealMeleeDamage's only caller is Unit::AttackerStateUpdate
// (white swings), which then broadcasts SMSG_ATTACKERSTATEUPDATE to everyone
// in range — attacker, victim, and totalDamage are its first fields (wire
// format verified against the server's own SendAttackStateUpdate writer; see
// Offsets.h at SMSG_ATTACKERSTATEUPDATE). So the swing packet is an exact
// client-side stand-in for the refresh, for ANY paladin's judgements — ours
// or a raid member's.
//
// Ability damage (Crusader/Holy Strike) does NOT reach that server block —
// melee-class spell damage never routes through DealMeleeDamage on this core
// — so no rule is registered for it. Re-judging is covered separately: the
// judgement debuff is cast as a real triggered spell, its SMSG_SPELL_GO
// refreshes the cache entry via StoreFromCast.
//
// Not gated on Turtle::Detected(): the block is vmangos-lineage core (the
// classic "judgements are refreshed by the paladin's melee attacks"
// mechanic), and the refresh only ever touches existing judgement entries
// with a matching known caster — on a server without the mechanic the debuff
// would genuinely expire, removing the entry via OnAuraRemoved before drift
// could show.
#include "Offsets.h"
#include "aura/Source.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include <cstdint>
namespace Aura::JudgementRefresh {
namespace {
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode != Offsets::SMSG_ATTACKERSTATEUPDATE || packet == nullptr)
return;
Net::Read<uint32_t>(packet); // hitInfo — the damage gate below subsumes it
const uint64_t attacker = Net::ReadPackedGuid(packet);
const uint64_t victim = Net::ReadPackedGuid(packet);
const uint32_t totalDamage = Net::Read<uint32_t>(packet);
// The server's exact gate: any nonzero melee damage refreshes; a fully
// avoided/absorbed swing (totalDamage 0) does not.
if (totalDamage == 0 || attacker == 0 || victim == 0)
return;
Aura::Source::RefreshJudgements(victim, attacker);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace Aura::JudgementRefresh
+1081 -66
View File
File diff suppressed because it is too large Load Diff
+136 -6
View File
@@ -60,15 +60,55 @@ constexpr int SLOT_UNBOUND = -1;
bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// `Get`'s caster for every descriptor slot at once, in one pass over the
// cache: `outCaster[slot]` is the caster `Get(unitGuid, slotSpellIds[slot],
// slot, ...)` would report, or 0. `slotSpellIds` and `outCaster` hold
// `Offsets::UNIT_AURA_TOTAL` entries. For walks that need every slot's caster
// (the own-auras-first sort), where 48 separate `Get` scans would cost 48
// passes.
void CastersForSlots(uint64_t unitGuid, const uint32_t *slotSpellIds,
uint64_t *outCaster);
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
// if nothing matched. For a mechanic whose duration edit reaches the client
// too late to be attributed to a cast packet, so no rule can express it.
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
// SpellIconID, at least one of the two — to a full duration from now. Returns
// the spellID refreshed, or 0 if nothing matched. For a mechanic whose
// duration edit reaches the client too late to be attributed to a cast packet,
// so no rule can express it.
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
uint64_t mask, uint32_t icon,
uint64_t casterGuid);
// Mirrors the server's melee-swing judgement refresh (tortoise-wow
// Unit::DealMeleeDamage): a white swing from `attackerGuid` that dealt any
// damage refreshes every aura it cast on `unitGuid` whose spell is
// SPELLFAMILY_PALADIN with SPELL_ATTR_EX3_ALWAYS_HIT (the judgement-debuff
// marker — all Judgement of Light/Wisdom/Justice/Crusader ranks) back to its
// own applied duration. Caster-strict: entries with an unknown caster are
// left alone (a swing gives no proof of ownership). The caster comes from
// the Judgement-cast attribution inside the SpellGo path — the debuff itself
// is cast server-side with no client-visible SPELL_GO, so the Judgement cast
// (20271) is what names the owner: it refresh-with-adoption's existing
// entries (re-judge = in-place server refresh, no aura event) and arms the
// fresh application's attribution. Returns the number refreshed. Fed by
// Aura::JudgementRefresh's SMSG_ATTACKERSTATEUPDATE subscriber.
int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid);
// Re-anchors the expiration of every cached aura the local player cast with
// `spellId` to `now + remainingMs`. Mirrors the server's channel pushback
// (Spell::DelayedChannel → Unit::DelaySpellAuraHolder): a hit shortens the
// channel AND every hit target's holder of the channel spell by the same
// amount, then tells the caster the new remaining time via MSG_CHANNEL_UPDATE
// — the packet Spell::Cast feeds this from. Without it a pushed-back channel's
// target-side aura (Dark Harvest, the drains) keeps its full-length SpellGo
// expiration and reads late by the accumulated pushback. `durationMs` (the
// applied total, the bar's full width) is deliberately untouched. Self-target
// holders are also corrected natively by the server (it re-sends
// SMSG_UPDATE_AURA_DURATION), so restamping those entries just keeps the two
// sources agreeing. Caster-only packet ⇒ only the player's channels are
// correctable; remote pushback stays invisible (see spell/Cast.cpp).
void RestampPlayerChannel(uint32_t spellId, uint32_t remainingMs);
// Op codes for AddDurationMod (mirror the Lua op strings).
enum DurationModOp {
DURATION_MOD_REFRESH = 0,
@@ -78,8 +118,10 @@ enum DurationModOp {
};
// Register a server duration-modifier rule from C++. The trigger is matched by
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
// exact `triggerSpellId`; the affected aura by SpellFamilyName plus an
// `affectedMask` family-flag overlap and/or an `affectedIcon`, at least one of
// the two (0 drops that half of the test; a custom spell with no family flags
// is named by its icon alone). `valueMs` is the reduce/set
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
// modules for the server's built-in mods (the family/school-matched variant is
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
@@ -88,6 +130,58 @@ bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs);
// Register a server "triggered application" rule from C++: `triggerSpellId`'s
// SMSG_SPELL_GO — local player casts only — arms its hit targets for a short
// window, and a caster-less aura application on an armed target matching
// `affectedFamily` + a family-flag overlap with `affectedMask` is attributed
// to the player at `durationPct` percent of the aura's own player-modified
// base duration (the server's `max(1, CalculateDuration(caster) * pct / 100)`).
// For server mechanics that AddAura an aura off another spell's hit with no
// cast packet of its own (Turtle's Stinging Nettle: Mongoose Bite / fire-trap
// effects apply the highest known Serpent Sting rank at 20/40% duration).
// A re-trigger over an EXISTING matching aura is also handled: the server's
// re-add reuses the descriptor slot (no application event), so the trigger
// additionally refreshes the player's existing cached entry in place,
// honoring the server's keep-the-longer-remaining guard.
// `gateSpellId` (0 = none) keeps the rule live only while the player knows
// that spell (a talent-granted passive), read from the spell-knowledge bitmap
// at trigger time so respecs are honored; rules matching the same trigger are
// tried in registration order and the first live one arms (register the
// higher talent rank first). Returns false on bad input or a full table.
bool AddTriggeredApplication(uint32_t triggerSpellId, uint32_t gateSpellId,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t durationPct);
// Family-mask trigger form of the above: the trigger matches any cast whose
// spell is SpellFamilyName `triggerFamily` with a family-flag overlap of
// `triggerMask` — rank-proof when the bit selects exactly the trigger set
// (verify against the DBC that no unrelated spell shares it first: hunter
// 0x4 is exactly the fire-trap effects, but 0x2 is Mongoose Bite AND Raptor
// Strike, so that trigger needs the exact-ID form).
bool AddTriggeredApplicationByFamily(uint32_t triggerFamily,
uint64_t triggerMask, uint32_t gateSpellId,
uint32_t affectedFamily,
uint64_t affectedMask, int32_t durationPct);
// Register a tick-speed compression rule from C++: while an aura matching
// (triggerFamily, triggerMask) is live on a target, every cached aura on that
// target CAST BY THE SAME CASTER matching (affectedFamily, affectedMask) — and
// whose record carries a periodic damage/leech effect, the server's aura-type
// gate — drains an extra `pct`% of real time: its expiration moves earlier by
// pct% of the elapsed overlap. Mirrors a server that preserves an accelerated
// dot's tick COUNT, so ticking pct% faster consumes it pct% faster with no
// client-visible aura change (Turtle's Dark Harvest; see
// src/turtle/DarkHarvest.cpp for the live-behavior derivation). Caster-paired
// like the server's own check, so another warlock's observed DH compresses
// THEIR dots' cached timing too; caster-less entries never compress. The
// trigger's cache-entry lifetime is the compression window — created by its
// SpellGo, shortened by channel pushback (RestampPlayerChannel), evicted by
// OnAuraRemoved on interrupt / early end — so the window tracks every edge the
// server's has. Returns false on bad input or a full table.
bool AddTickCompression(uint32_t triggerFamily, uint64_t triggerMask,
uint32_t affectedFamily, uint64_t affectedMask,
int32_t pct);
// One cached aura, as returned by `Enumerate`.
struct CachedAura {
uint32_t spellId;
@@ -143,4 +237,40 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
// any real SpellGo timing we already hold, so this never downgrades better data.
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 — or its
// duration set again while it stays (Refreshed, see `SignalRefreshed`).
enum class ChangeType { Added, Removed, Stacks, Refreshed };
struct Change {
ChangeType type;
void *unit;
int slot;
uint32_t spellId;
uint64_t caster; // 0 when unknown
bool harmful;
int oldStacks; // Stacks only: the count before the change
int newStacks; // Stacks only: the count now
};
using ChangeCallback = void (*)(const Change &change);
// Declare a file-scope `static const Aura::Source::AutoObserve _obs{&fn};`.
// Added and Stacks reach the callback after the cache has stamped the aura,
// Removed before the cache evicts it, so `caster` is the cache's in all three.
struct AutoObserve {
explicit AutoObserve(ChangeCallback cb);
ChangeCallback callback;
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
+64 -52
View File
@@ -16,22 +16,38 @@
// (added 5.4.x) with exact same-semantic coalescing: addons listen
// to DELAYED instead of BAG_UPDATE and rescan once per frame.
//
// See TODO #67 for the full investigation history.
// Architecture: three MinHooks on the engine's three BAG_UPDATE
// emitters, plus one tick subscription. Names are benilla-re's.
//
// Architecture: three MinHooks plus one tick subscription.
//
// 1. `FUN_004F91A0` — bag-slot diff loop. Hook just sets the
// `g_pending` flag.
// 2. `FUN_004F9370` — item→bag resolver (most common path). Hook
// just sets the `g_pending` flag.
// 3. `FUN_004F8DB0` — bag-descriptor change callback. Covers the
// keyring `BAG_UPDATE(slot)` direct-fire branch that doesn't
// route through `FUN_004F9370`.
// 1. `RaiseBagEvents` (`0x004F91A0`) — compares the ten bag slots
// (4 bags, 6 bank bags) with the cached container GUIDs and
// fires BAG_CLOSED / BAG_UPDATE per changed slot. Called by the
// bag-slot field watcher and once at login.
// 2. `RaiseBagUpdate` (`0x004F9370`) — fires BAG_UPDATE for the
// player or one of the ten cached containers; the most common
// path. Its callers are the backpack and container slot
// watchers, and the item-field watcher `0x004F9480`, which
// passes an item's container — the PLAYER for equipped gear, so
// a durability change on worn gear fires BAG_UPDATE(0) and
// therefore DELAYED. That is the engine's behaviour, kept.
// 3. `OnBackpackSlotChanged` (`0x004F8DB0`) — the field watcher on
// backpack slots 23..38 and keyring slots 81..112. Keyring
// changes fire BAG_UPDATE directly here; backpack changes go
// through `RaiseBagUpdate`. Standard watcher ABI
// `__fastcall(offset, unused, guidLo, guidHi, previous*, user)`,
// ret 0x10 — the same shape as `OnContainerSlotChanged`.
// 4. `Tick::WorldTick::AutoSubscribe` — shared per-frame
// subscription (the engine's world-subsystem update at
// `FUN_0066FD50`, single-hooked in `src/tick/WorldTick.cpp`).
// Our callback drains `g_pending` and fires DELAYED if set.
//
// The hooks set `g_pending` after every call rather than only when
// an event fired. The one call that fires nothing is
// `RaiseBagUpdate` for a container outside the cache, which does not
// occur in practice; mirroring that check here would be a parallel
// copy of the engine's test. The bank's main slots 39..62 have no
// engine watcher, so they never fire BAG_UPDATE or DELAYED.
//
// All hook targets are quiet (≤3 callers each, in code regions
// other DLLs don't touch). The previous attempt at this feature
// hooked `FUN_FIRE_EVENT` (100+ callers, in the chat path) and a
@@ -68,7 +84,12 @@ namespace {
constexpr const char *kEventName = "BAG_UPDATE_DELAYED";
const Event::Custom::AutoReserve _reserve{kEventName};
const Game::Doc::Event kUpdateDelayedDoc{
"Container",
"Fires once in each frame in which one or more BAG_UPDATE events fired, "
"after all of them.",
{}};
const Event::Custom::AutoReserve _reserve{kEventName, &kUpdateDelayedDoc};
// Set by the bag-update hooks, drained by the world-tick hook.
// Single bool, no atomics needed — the engine is single-threaded.
@@ -83,40 +104,31 @@ AutoSubscribe::AutoSubscribe(Callback cb) : cb(cb), next(g_subs) {
g_subs = this;
}
// `FUN_004F91A0` — `__cdecl void()`. Bag-slot diff loop. Walks the
// 10 bag slots, fires BAG_UPDATE for each that changed.
using BagSlotDiff_t = void(__cdecl *)();
BagSlotDiff_t BagSlotDiff_o = nullptr;
void __cdecl BagSlotDiff_h() {
BagSlotDiff_o();
// `RaiseBagEvents` — `__cdecl void()`.
using RaiseBagEvents_t = void(__cdecl *)();
RaiseBagEvents_t RaiseBagEvents_o = nullptr;
void __cdecl RaiseBagEvents_h() {
RaiseBagEvents_o();
g_pending = true;
}
// `FUN_004F9370` — `__stdcall void(int guidLo, int guidHi)`. Most
// common BAG_UPDATE path — fires from every per-item descriptor
// field change.
using BagItemToBag_t = void(__stdcall *)(int guidLo, int guidHi);
BagItemToBag_t BagItemToBag_o = nullptr;
void __stdcall BagItemToBag_h(int guidLo, int guidHi) {
BagItemToBag_o(guidLo, guidHi);
// `RaiseBagUpdate` — `__stdcall void(containerGuidLo, containerGuidHi)`.
using RaiseBagUpdate_t = void(__stdcall *)(int guidLo, int guidHi);
RaiseBagUpdate_t RaiseBagUpdate_o = nullptr;
void __stdcall RaiseBagUpdate_h(int guidLo, int guidHi) {
RaiseBagUpdate_o(guidLo, guidHi);
g_pending = true;
}
// `FUN_004F8DB0` — `__thiscall unsigned(this, a1, a2, a3, a4)` in the
// Octo build. Wired with `__fastcall` + dummy EDX to match the
// thiscall ABI: ECX takes `this`, EDX unused, stack takes the four
// dword args, callee cleans 16 bytes (`RET 0x10`). Same trick
// `Frame::RegisterEvent` uses in DllMain, just with four stack args
// instead of one — the Octo function is wider than the vanilla
// signature Ghidra showed when this hook was first written.
using KeyringDescChange_t = unsigned(__fastcall *)(void *bagDesc, void *edx_unused,
unsigned a1, unsigned a2,
unsigned a3, unsigned a4);
KeyringDescChange_t KeyringDescChange_o = nullptr;
unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
unsigned a1, unsigned a2,
unsigned a3, unsigned a4) {
const unsigned result = KeyringDescChange_o(bagDesc, edx, a1, a2, a3, a4);
// `OnBackpackSlotChanged` — field-watcher ABI, `__fastcall(offset,
// unused, guidLo, guidHi, previous*, user)`, callee cleans 0x10.
using BackpackSlotChanged_t = unsigned(__fastcall *)(unsigned offset, unsigned unused,
unsigned guidLo, unsigned guidHi,
const void *previous, void *user);
BackpackSlotChanged_t BackpackSlotChanged_o = nullptr;
unsigned __fastcall BackpackSlotChanged_h(unsigned offset, unsigned unused, unsigned guidLo,
unsigned guidHi, const void *previous, void *user) {
const unsigned result = BackpackSlotChanged_o(offset, unused, guidLo, guidHi, previous, user);
g_pending = true;
return result;
}
@@ -143,7 +155,7 @@ void OnWorldTick() {
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
return;
g_pending = false;
const int slot = Event::Custom::Lookup(kEventName);
const int slot = _reserve.Slot();
if (slot >= 0)
Event::Custom::Fire(slot, "");
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
@@ -151,20 +163,20 @@ void OnWorldTick() {
node->cb();
}
static const Game::HookAutoRegister _hookSlotDiff{
Offsets::FUN_BAG_SLOT_DIFF,
reinterpret_cast<void *>(&BagSlotDiff_h),
reinterpret_cast<void **>(&BagSlotDiff_o)};
static const Game::HookAutoRegister _hookRaiseBagEvents{
Offsets::FUN_RAISE_BAG_EVENTS,
reinterpret_cast<void *>(&RaiseBagEvents_h),
reinterpret_cast<void **>(&RaiseBagEvents_o)};
static const Game::HookAutoRegister _hookItemToBag{
Offsets::FUN_BAG_ITEM_TO_BAG,
reinterpret_cast<void *>(&BagItemToBag_h),
reinterpret_cast<void **>(&BagItemToBag_o)};
static const Game::HookAutoRegister _hookRaiseBagUpdate{
Offsets::FUN_RAISE_BAG_UPDATE,
reinterpret_cast<void *>(&RaiseBagUpdate_h),
reinterpret_cast<void **>(&RaiseBagUpdate_o)};
static const Game::HookAutoRegister _hookKeyringDesc{
Offsets::FUN_BAG_KEYRING_DESC_CHANGE,
reinterpret_cast<void *>(&KeyringDescChange_h),
reinterpret_cast<void **>(&KeyringDescChange_o)};
static const Game::HookAutoRegister _hookBackpackSlot{
Offsets::FUN_ON_BACKPACK_SLOT_CHANGED,
reinterpret_cast<void *>(&BackpackSlotChanged_h),
reinterpret_cast<void **>(&BackpackSlotChanged_o)};
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
+7 -5
View File
@@ -24,15 +24,15 @@
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
#include "baselib/Mixin.h"
#include "Game.h"
namespace BaseLib::Mixin {
namespace {
// Shallow-copy every pair from the table at `srcIdx` onto the table at
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
void MixinInto(void *L, int dstIdx, int srcIdx) {
void Into(void *L, int dstIdx, int srcIdx) {
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, srcIdx) != 0) {
// stack top: ..., key (-2), value (-1). Copy both so the originals
@@ -44,6 +44,8 @@ void MixinInto(void *L, int dstIdx, int srcIdx) {
}
}
namespace {
int __fastcall Script_Mixin(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
@@ -52,7 +54,7 @@ int __fastcall Script_Mixin(void *L) {
}
for (int i = 2; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, 1, i);
Into(L, 1, i);
Game::Lua::PushValue(L, 1); // return object
return 1;
}
@@ -63,7 +65,7 @@ int __fastcall Script_CreateFromMixins(void *L) {
const int objIdx = top + 1;
for (int i = 1; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, objIdx, i);
Into(L, objIdx, i);
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
return 1;
}
+22
View File
@@ -0,0 +1,22 @@
// 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 BaseLib::Mixin {
// dst[k] = v for every field of the table at `srcIdx` — Mixin()'s copy.
// Both indices must be absolute.
void Into(void *L, int dstIdx, int srcIdx);
} // namespace BaseLib::Mixin
+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
+188 -44
View File
@@ -13,7 +13,8 @@
// String helpers that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.match(s, pattern [, init])` — first-match extraction (5.1),
// also the global `strmatch`, as `string.find` is `strfind`.
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
@@ -21,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
@@ -37,6 +47,7 @@
#include "Game.h"
#include "Offsets.h"
#include "text/Utf8.h"
#include <cstddef>
#include <cstring>
@@ -91,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;
@@ -130,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
@@ -383,21 +463,85 @@ 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);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
// The bare `gsub` global (1.12 exposes the string functions as globals
// too) must match `string.gsub`, so addons calling either form get the
// table-replacement upgrade.
Game::Lua::RegisterGlobalFunction("gsub", &Script_string_gsub);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
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 =
// strsplit`, `str.join = strjoin`), so `strtrim == string.trim`. A second
// Register* call would mint a second closure over the same C function, so
// the globals alias the table entries by value instead. The string-table
// half is what lets `(" x "):trim()` work and libraries capture it at
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`); the bare `gsub`
// gets the table-replacement upgrade by being the same function.
Game::Lua::RegisterGlobalAlias("gsub", "string", "gsub");
Game::Lua::RegisterGlobalAlias("strmatch", "string", "match");
Game::Lua::RegisterGlobalAlias("strsplit", "string", "split");
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("strrev", &Script_string_reverse);
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
+94
View File
@@ -0,0 +1,94 @@
// 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/>.
// `success, ... = xpcall(f, msgh [, arg1, ...])` — call `f` under `msgh`,
// forwarding the trailing arguments to it.
//
// 1.12's Lua takes only `xpcall(f, msgh)`. Its implementation is explicit
// about it: `lua_settop(L, 2)` throws any further arguments away, then
// `lua_pcall(L, 0, ...)` calls `f` with none. So the argument-forwarding form
// doesn't merely fail — it silently calls `f` with nothing, and a function
// that reads its parameters sees nil. (Note this is NOT a 5.0-vs-5.1
// difference: both take `(f, msgh)`. Forwarding arrived later.)
//
// Callers work around it with a capturing closure —
// `xpcall(function() return f(a, b) end, msgh)` — which is what
// `ExecuteFrameScript` in the embedded addon does. That stays correct; this
// just removes the need for it.
//
// Why a C function and not a Lua wrapper over the original: a Lua wrapper
// would allocate a closure AND an `arg` table on every call (see the vararg
// note in FunctionUtil.lua), putting an allocation on any error-guarded path,
// including per-frame ones. `HookSecureFunc`'s `kUnhookableNames` also lists
// `xpcall` precisely because shadowing a core primitive with a Lua closure
// diverges from what C-side callers get. Replacing it with a real C function
// has neither problem, and matches how `select` and `unpack` are backported
// in this directory.
//
// A two-argument call is bit-for-bit the old behavior (zero forwarded args),
// so this is purely additive for existing code.
#include "Game.h"
namespace BaseLib::XPCall {
namespace {
int __fastcall Script_xpcall(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 2) {
Game::Lua::Error(L, "bad argument #2 to 'xpcall' (value expected)");
return 0; // unreachable
}
// `lua_pcall` wants the callee and its arguments contiguous on top, and
// the handler at a fixed index. Incoming order is
// `f(1), msgh(2), a1(3)..aN(top)`, so the handler sits between the two —
// shuffle it below `f`:
//
// PushValue(2) → f, msgh, a1..aN, msgh
// Insert(1) → msgh, f, msgh, a1..aN (top moved down to index 1)
// Remove(3) → msgh, f, a1..aN
//
// 5.2 does the same reshuffle with a single `lua_rotate`, which this Lua
// has no equivalent of.
Game::Lua::PushValue(L, 2);
Game::Lua::Insert(L, 1);
Game::Lua::Remove(L, 3);
const int status =
Game::Lua::PCall(L, /*nargs*/ top - 2, Game::Lua::MULTRET, /*errfunc*/ 1);
// The stack now holds the handler plus either f's results or the handler's
// return. Both cases want the status boolean in front of them, and the
// handler itself dropped from the return set — which returning
// `GetTop - 1` does, since results are taken from the top.
Game::Lua::PushBool(L, status == 0);
Game::Lua::Insert(L, 2);
return Game::Lua::GetTop(L) - 1;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("xpcall", &Script_xpcall);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("xpcall", &Script_xpcall);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::XPCall
+320 -491
View File
@@ -12,391 +12,271 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Backports the direct-action and override binding APIs introduced in 2.0.
// Normal bindings are stored by vanilla's SetBinding implementation using the
// later-client SPELL/ITEM/MACRO/CLICK command strings. The resolved-command
// hook executes those four command families; every native binding command is
// passed through unchanged.
// The binding API 4.3.4 has and 1.12 lacks (decomp Ui/UIBindings.cpp,
// UIBindings_Scripts.cpp):
//
// Override bindings live in a separate owner-scoped layer in front of the
// native key lookup. They are deliberately never written to the saved binding
// table, so clearing an owner immediately reveals the next override or the
// unchanged native binding underneath it.
// SetBindingSpell / Item / Macro / Click — "SPELL x" etc. into the
// current set through the engine's own SetBinding.
// SetOverrideBinding[Spell / Item / Macro / Click], ClearOverrideBindings
// — owner-scoped bindings in front of the saved ones, never saved.
// GetBindingAction(key [, checkOverride]) — sees overrides when asked.
// GetBindingByKey(key) — what a press of the key runs.
//
// Overrides — 4.3.4's OVERRIDEKEYBINDING: per key (case-insensitive), two
// lists (normal, priority) of {owner, command}; setting an owner's binding
// unlinks its old one from both and links the new one at the head of its
// priority's list, so the newest priority binding wins, then the newest
// normal one. 4.3.4 consults them inside its key lookup, before the saved
// binding, for every key it tries.
//
// Reduced keys — 4.3.4's GetReducedKeyBinding. 1.12's key dispatch
// (FUN_BINDING_KEY_DISPATCH) tries only two keys: the pressed one, then the
// pressed one minus its first modifier. 4.3.4 tries every subset of the
// modifiers (ALT-SHIFT-Q: ALT-SHIFT-Q, SHIFT-Q, ALT-Q, Q). The co-hook walks
// 4.3.4's list, tries an override, then the saved binding
// (FUN_BINDINGS_GET_COMMAND_ACTION), for each key in turn — skipping one whose
// command is unknown, as 4.3.4 does — and lets the engine run a saved binding
// it finds.
//
// 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.
#include "Game.h"
#include "Offsets.h"
#include "macro/Execute.h"
#include "bindings/Command.h"
#include "input/Modifier.h"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <list>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
namespace Bindings::Api {
namespace {
constexpr const char kSpellPrefix[] = "SPELL ";
constexpr const char kItemPrefix[] = "ITEM ";
constexpr const char kMacroPrefix[] = "MACRO ";
constexpr const char kClickPrefix[] = "CLICK ";
constexpr const char kDefaultMouseButton[] = "LeftButton";
using KeyDispatch_t = int(__fastcall *)(void *mgr, void *edx, const char *key, int isDown);
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 ScriptSetBinding_t = int(__fastcall *)(void *L);
using KeyDispatch_t = int(__thiscall *)(void *mgr, const char *key, int isDown);
using CommandExecute_t = int(__thiscall *)(void *mgr, const char *cmd, int isDown);
KeyDispatch_t g_keyDispatchOriginal = nullptr;
CommandExecute_t g_commandExecuteOriginal = nullptr;
KeyDispatch_t KeyDispatch_o = nullptr;
CommandExecute_t CommandExecute_o = nullptr;
// --- override table (OVERRIDEKEYBINDING) ------------------------------------
enum class CommandType {
None,
Spell,
Item,
Macro,
Click,
};
struct ParsedCommand {
CommandType type = CommandType::None;
const char *argument = nullptr;
};
struct OverrideEntry {
const void *owner = nullptr;
std::string key;
struct Entry {
const void *owner;
std::string command;
bool priority = false;
uint64_t sequence = 0;
};
std::vector<OverrideEntry> g_overrides;
std::unordered_map<std::string, std::string> g_activeOverrides;
uint64_t g_nextSequence = 0;
struct OverrideKey {
std::string key;
std::list<Entry> lists[2]; // [0] normal, [1] priority; head = newest
};
// Normalizes a binding key to the engine's own canonical form so a stored
// override matches the string FUN_BINDING_KEY_DISPATCH builds at keypress. The
// engine's key-string builder (FUN_004b6630) always emits modifiers in the
// fixed order ALT-CTRL-SHIFT- ahead of the base key, regardless of the order
// the caller passed them; vanilla SetBinding never reorders, so the override
// layer does it here. Both the store and the lookup run through this, so they
// stay consistent while also matching the engine's keypress form.
std::string CanonicalKey(const char *key) {
std::string result = key ? key : "";
for (char &character : result) {
if (character >= 'a' && character <= 'z')
character = static_cast<char>(character - 'a' + 'A');
}
std::vector<OverrideKey> g_overrides;
// Strip the three modifier prefixes in whatever order/repetition they
// appear, then re-emit them in the engine's fixed order. Prefix-stripping
// (rather than splitting on '-') keeps the minus base key correct, e.g.
// "SHIFT--" -> modifier SHIFT + base "-".
bool alt = false, ctrl = false, shift = false;
size_t base = 0;
for (bool matched = true; matched;) {
matched = false;
if (result.compare(base, 4, "ALT-") == 0) {
alt = true;
base += 4;
matched = true;
} else if (result.compare(base, 5, "CTRL-") == 0) {
ctrl = true;
base += 5;
matched = true;
} else if (result.compare(base, 6, "SHIFT-") == 0) {
shift = true;
base += 6;
matched = true;
}
}
if (!alt && !ctrl && !shift)
return result;
std::string canonical;
if (alt)
canonical += "ALT-";
if (ctrl)
canonical += "CTRL-";
if (shift)
canonical += "SHIFT-";
canonical += result.substr(base);
return canonical;
OverrideKey *FindKey(const char *key) {
for (OverrideKey &entry : g_overrides)
if (_stricmp(entry.key.c_str(), key) == 0)
return &entry;
return nullptr;
}
std::string MakeCommand(const char *prefix, const char *arg) {
std::string cmd = prefix;
cmd += arg;
return cmd;
// OVERRIDEKEYBINDING::SetOverrideBinding.
void SetOverride(OverrideKey &binding, const void *owner, bool priority, const char *command) {
for (auto &list : binding.lists)
list.remove_if([owner](const Entry &entry) { return entry.owner == owner; });
if (command != nullptr && *command != '\0')
binding.lists[priority ? 1 : 0].push_front(Entry{owner, command});
}
std::string MakeClickCommand(const char *btnName, const char *mouseBtn) {
std::string cmd = MakeCommand(kClickPrefix, btnName);
if (mouseBtn != nullptr) {
cmd += ':';
cmd += mouseBtn;
// OVERRIDEKEYBINDING::GetActiveBinding.
const char *ActiveOverride(const char *key) {
const OverrideKey *binding = FindKey(key);
if (binding == nullptr)
return nullptr;
for (int i = 1; i >= 0; --i)
if (!binding->lists[i].empty())
return binding->lists[i].front().command.c_str();
return nullptr;
}
// CGUIBindings::SetOverrideBinding.
void SetOverrideBinding(const void *owner, bool priority, const char *key, const char *command) {
OverrideKey *binding = FindKey(key);
if (binding == nullptr) {
g_overrides.push_back(OverrideKey{key, {}});
binding = &g_overrides.back();
}
return cmd;
SetOverride(*binding, owner, priority, command);
}
ParsedCommand ParseCommand(const char *cmd) {
if (cmd == nullptr)
// --- key dispatch -------------------------------------------------------------
void *Manager() { return Game::Read<void *>(Offsets::VAR_BINDINGS_MANAGER); }
const char *SavedCommand(void *mgr, const char *key) {
return reinterpret_cast<GetCommandAction_t>(Offsets::FUN_BINDINGS_GET_COMMAND_ACTION)(mgr,
key);
}
// Whether the command table has `command` — the walk the engine inlines in
// FUN_BINDING_COMMAND_EXECUTE (see OFF_BINDINGS_COMMAND_TABLE).
bool IsKnownCommand(void *mgr, const char *command) {
using HashHT_t = uint32_t(__stdcall *)(const char *);
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)
return false;
const uint32_t hash = reinterpret_cast<HashHT_t>(Offsets::FUN_SSTR_HASH_HT)(command);
const uintptr_t bucket =
Game::Read<uintptr_t>(table + Offsets::OFF_HASHTABLE_SLOTS) + (hash & mask) * 12;
const uint32_t link = Game::Read<uint32_t>(bucket);
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 &&
Game::StrCmpI(Game::Read<const char *>(node + Offsets::OFF_HASHTABLE_NODE_NAME),
command) == 0)
return true;
}
return false;
}
// CGUIBindings::GetKeyBinding: the key's active override, else its saved
// binding — and none when that command is neither a direct action nor a known
// command (a saved one is never checked when set). 4.3.4's retry under the
// generic modifier names (LSHIFT- → SHIFT-) is left out: 1.12 builds chords
// from ALT-, CTRL- and SHIFT- only, so a pressed key is already generic.
struct KeyBinding {
const char *command = nullptr;
bool isOverride = false;
};
KeyBinding FindKeyBinding(void *mgr, const char *key) {
KeyBinding binding{ActiveOverride(key), true};
if (binding.command == nullptr)
binding = {SavedCommand(mgr, key), false};
if (binding.command == nullptr ||
!(Command::IsDirectAction(binding.command) || IsKnownCommand(mgr, binding.command)))
return {};
return binding;
}
struct PrefixEntry {
const char *prefix;
size_t length;
CommandType type;
};
static constexpr PrefixEntry prefixes[] = {
{kSpellPrefix, sizeof(kSpellPrefix) - 1, CommandType::Spell},
{kItemPrefix, sizeof(kItemPrefix) - 1, CommandType::Item},
{kMacroPrefix, sizeof(kMacroPrefix) - 1, CommandType::Macro},
{kClickPrefix, sizeof(kClickPrefix) - 1, CommandType::Click},
};
for (const PrefixEntry &entry : prefixes) {
if (std::strncmp(cmd, entry.prefix, entry.length) == 0)
return {entry.type, cmd + entry.length};
// CGUIBindings::GetReducedKeyBinding: up to three leading modifiers split off
// at '-', then the key with each subset of them in 4.3.4's fixed order (bit j
// keeps modifier j) — ALT-SHIFT-Q tries ALT-SHIFT-Q, SHIFT-Q, ALT-Q, Q. The
// first reduced key with a binding wins and is left in `reduced`.
KeyBinding FindReducedKeyBinding(void *mgr, const char *key, std::string &reduced) {
std::string modifiers[3];
size_t count = 0;
const char *rest = key;
while (count < 3) {
const char *separator = std::strchr(rest, '-');
if (separator == nullptr || separator == rest)
break;
modifiers[count++].assign(rest, separator - rest + 1);
rest = separator + 1;
}
static constexpr uint8_t kCombinations[4][9] = {
{1, 0},
{2, 1, 0},
{4, 3, 2, 1, 0},
{8, 7, 6, 5, 3, 4, 2, 1, 0},
};
for (uint8_t i = 0; i < kCombinations[count][0]; ++i) {
reduced.clear();
for (size_t j = 0; j < count; ++j)
if ((1u << j) & kCombinations[count][i + 1])
reduced += modifiers[j];
reduced += rest;
const KeyBinding binding = FindKeyBinding(mgr, reduced.c_str());
if (binding.command != nullptr)
return binding;
}
return {};
}
bool CallGlobalString(void *L, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
if (!Game::Lua::PushGlobalFunction(L, func)) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
int ExecuteCommand(void *mgr, const char *command, int isDown) {
if (Command::IsDirectAction(command))
return Command::RunKey(command, isDown);
return g_commandExecuteOriginal(mgr, nullptr, command, isDown);
}
bool CallTableString(void *L, const char *table, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, table);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return false;
}
const int tableIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, func);
Game::Lua::GetTable(L, tableIndex);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
void InvokeButtonClick(void *L, const char *btnName, size_t btnNameLength, const char *mouseBtn) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushLString(L, btnName, static_cast<unsigned int>(btnNameLength));
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return;
}
const int frameIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "Click");
Game::Lua::GetTable(L, frameIndex);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, frameIndex);
Game::Lua::PushString(L, mouseBtn);
Game::Lua::PCall(L, 2, 0, 0);
}
Game::Lua::SetTop(L, top);
}
void ExecuteClick(void *L, const char *arg) {
const char *separator = std::strrchr(arg, ':');
if (separator == arg || (separator != nullptr && separator[1] == '\0'))
return;
const char *mouseBtn = kDefaultMouseButton;
size_t btnNameLength = std::strlen(arg);
if (separator != nullptr) {
btnNameLength = static_cast<size_t>(separator - arg);
mouseBtn = separator + 1;
}
if (btnNameLength == 0)
return;
InvokeButtonClick(L, arg, btnNameLength, mouseBtn);
}
// Returns true for every recognized direct-action command, including malformed
// or stale commands. Vanilla cannot execute these namespaces, so a bad command
// is consumed as a no-op instead of falling through to an unrelated handler.
bool ExecuteSpecialCommand(const char *cmd, int isDown) {
const ParsedCommand parsed = ParseCommand(cmd);
if (parsed.type == CommandType::None)
return false;
if (!isDown || parsed.argument == nullptr || *parsed.argument == '\0')
return true;
void *L = Game::Lua::State();
if (L == nullptr)
return true;
switch (parsed.type) {
case CommandType::Spell:
CallGlobalString(L, "CastSpellByName", parsed.argument);
break;
case CommandType::Item:
CallTableString(L, "C_Item", "UseItemByName", parsed.argument);
break;
case CommandType::Macro:
Macro::Execute::Saved(L, parsed.argument);
break;
case CommandType::Click:
ExecuteClick(L, parsed.argument);
break;
default:
break;
}
return true;
}
const OverrideEntry *FindEffectiveOverride(const std::string &key) {
const OverrideEntry *best = nullptr;
for (const OverrideEntry &entry : g_overrides) {
if (entry.key != key)
continue;
if (best == nullptr || entry.priority > best->priority ||
(entry.priority == best->priority && entry.sequence > best->sequence))
best = &entry;
}
return best;
}
void SetOverride(const void *owner, bool priority, const std::string &key, const std::string &cmd) {
auto existing =
std::find_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner && entry.key == key;
});
if (existing == g_overrides.end()) {
g_overrides.push_back(
{owner, key, cmd, priority, ++g_nextSequence});
return;
}
existing->command = cmd;
existing->priority = priority;
existing->sequence = ++g_nextSequence;
}
void RemoveOverride(const void *owner, const std::string &key) {
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner &&
entry.key == key;
}),
g_overrides.end());
}
int ExecuteCommand(void *mgr, const char *cmd, int isDown) {
if (ExecuteSpecialCommand(cmd, isDown))
return 1;
return CommandExecute_o(mgr, cmd, isDown);
}
int __fastcall CommandExecute_h(void *mgr, void *, const char *cmd, int isDown) {
return ExecuteCommand(mgr, cmd, isDown);
}
// Runs an override-resolved command with the same execution context the engine
// wraps around a native keypress. FUN_BINDING_KEY_DISPATCH saves the frame-
// script exec-context, zeroes it for the nested command, marks the manager as
// mid-command (`+0xD8`), then restores the context and floors the nesting
// depth. Native-table bindings already run inside this (the engine sets it up
// before calling FUN_BINDING_COMMAND_EXECUTE, which we co-hook); overrides
// bypass that path, so we mirror it here. Without it, a native Bindings.xml
// command dispatched through an override would run without the context its
// handler expects. Special SPELL/ITEM/MACRO/CLICK commands run harmlessly
// inside the same wrapper.
int DispatchOverrideCommand(void *mgr, const char *cmd, int isDown) {
volatile uint32_t *context =
reinterpret_cast<volatile uint32_t *>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
volatile uint32_t *depth = reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
volatile uint32_t *executing =
Game::Ptr<volatile uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
const uint32_t savedContext = *context;
*depth = *depth + 1;
if (*depth != 0)
*context = 0;
*executing = 1;
const int result = ExecuteCommand(mgr, cmd, isDown);
*executing = 0;
if (*depth != 0)
*context = savedContext;
if (static_cast<int32_t>(*depth) - 1 > 0)
*depth = *depth - 1;
else
*depth = 0;
// An override's command, run in the frame the engine dispatch gives a saved
// one: exec context saved and cleared, depth bumped, the manager's executing
// flag up (FUN_BINDING_KEY_DISPATCH, see Offsets.h).
int RunOverride(void *mgr, const char *command, int isDown) {
const std::string copy = command; // the command may clear its own override
auto &context = Game::Ref<uint32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
auto &depth = Game::Ref<int32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
auto &executing = Game::Ref<uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
const uint32_t saved = context;
if (++depth != 0)
context = 0;
executing = 1;
const int result = ExecuteCommand(mgr, copy.c_str(), isDown);
executing = 0;
if (depth != 0)
context = saved;
depth = depth - 1 > 0 ? depth - 1 : 0;
return result;
}
int __fastcall KeyDispatch_h(void *mgr, void *, const char *key, int isDown) {
const std::string canonicalKey = CanonicalKey(key);
// CGUIBindings::ExecKey. A saved binding is run by the engine, handed the
// reduced key: it uses the key only for its table lookup, and finds it as the
// first of its own two. No binding runs nothing, as in 4.3.4.
int __fastcall KeyDispatch_h(void *mgr, void *edx, const char *key, int isDown) {
if (key == nullptr || *key == '\0')
return g_keyDispatchOriginal(mgr, edx, key, isDown);
// Keep a key-down paired with its override command even if that override is
// cleared while the command executes. The newly revealed native binding
// must not receive only the key-up half of the same physical press.
if (!isDown) {
auto active = g_activeOverrides.find(canonicalKey);
if (active != g_activeOverrides.end()) {
DispatchOverrideCommand(mgr, active->second.c_str(), 0);
g_activeOverrides.erase(active);
return 1;
}
}
std::string reduced;
const KeyBinding binding = FindReducedKeyBinding(mgr, key, reduced);
if (binding.command == nullptr)
return 0;
// 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);
}
const OverrideEntry *entry = FindEffectiveOverride(canonicalKey);
if (entry == nullptr)
return KeyDispatch_o(mgr, key, isDown);
// Direct actions only for a key (executing flag up); RunBinding reaches the
// engine's command table, which has none of them — as in 4.3.4.
int __fastcall CommandExecute_h(void *mgr, void *edx, const char *command, int isDown) {
if (Game::Read<uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING) != 0 &&
Command::IsDirectAction(command))
return Command::RunKey(command, isDown);
return g_commandExecuteOriginal(mgr, edx, command, isDown);
}
if (isDown)
g_activeOverrides[canonicalKey] = entry->command;
// --- Lua ----------------------------------------------------------------------
return DispatchOverrideCommand(mgr, entry->command.c_str(), isDown);
const char *OptString(void *L, int idx) {
return Game::Lua::IsString(L, idx) ? Game::Lua::ToString(L, idx) : nullptr;
}
// SetBinding(key, command) through the engine's own Script_SetBinding: its
// key normalization, current-set write, UPDATE_BINDINGS and 1/nil result.
int SetBinding(void *L, const std::string &key, const std::string &command) {
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
return reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_SET_BINDING)(L);
}
int SetBindingCommand(void *L, const char *prefix, const char *usage) {
@@ -404,216 +284,165 @@ int SetBindingCommand(void *L, const char *prefix, const char *usage) {
Game::Lua::Error(L, usage);
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const std::string command = MakeCommand(prefix, Game::Lua::ToString(L, 2));
// Preserve the stock SetBinding return value, binding-set selection,
// key normalization, UPDATE_BINDINGS event, and SaveBindings behavior.
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
}
const char *OptionalMouseButton(void *L, int index, const char *usage) {
if (Game::Lua::GetTop(L) < index || Game::Lua::Type(L, index) == Game::Lua::TYPE_NIL)
return nullptr;
if (!Game::Lua::IsString(L, index)) {
Game::Lua::Error(L, usage);
return nullptr;
}
return Game::Lua::ToString(L, index);
return SetBinding(L, Game::Lua::ToString(L, 1),
std::string(prefix) + Game::Lua::ToString(L, 2));
}
int __fastcall Script_SetBindingSpell(void *L) {
return SetBindingCommand(L, kSpellPrefix, "Usage: SetBindingSpell(key, spell)");
return SetBindingCommand(L, "SPELL ", "Usage: SetBindingSpell(\"KEY\", \"spellname\")");
}
int __fastcall Script_SetBindingItem(void *L) {
return SetBindingCommand(L, kItemPrefix, "Usage: SetBindingItem(key, item)");
return SetBindingCommand(L, "ITEM ", "Usage: SetBindingItem(\"KEY\", \"itemname\")");
}
int __fastcall Script_SetBindingMacro(void *L) {
return SetBindingCommand(L, "MACRO ",
"Usage: SetBindingMacro(\"KEY\", \"macroname\"|macroid)");
}
int __fastcall Script_SetBindingMacro(void *L) {
return SetBindingCommand(L, kMacroPrefix, "Usage: SetBindingMacro(key, macro)");
std::string ClickCommand(const char *button, const char *mouseButton) {
return std::string("CLICK ") + button + ":" +
(mouseButton != nullptr ? mouseButton : "LeftButton");
}
int __fastcall Script_SetBindingClick(void *L) {
constexpr const char *usage = "Usage: SetBindingClick(key, buttonName [, mouseButton])";
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, usage);
Game::Lua::Error(
L, "Usage: SetBindingClick(\"KEY\", \"buttonName\"[, \"mouseButton\"])");
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const char *buttonName = Game::Lua::ToString(L, 2);
const char *mouseButton = OptionalMouseButton(L, 3, usage);
const std::string command = MakeClickCommand(buttonName, mouseButton);
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
return SetBinding(L, Game::Lua::ToString(L, 1),
ClickCommand(Game::Lua::ToString(L, 2), OptString(L, 3)));
}
bool ReadOverrideHeader(void *L, const char *usage, const void **owner,
bool *priority, std::string *key) {
*owner = Game::Lua::ResolveObject(L, 1);
if (*owner == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_BOOLEAN ||
!Game::Lua::IsString(L, 3)) {
Game::Lua::Error(L, usage);
return false;
}
// The owner argument: a frame, as 4.3.4 requires.
const void *Owner(void *L) { return Game::Lua::ResolveFrame(L, 1, /*raiseError*/ false); }
*priority = Game::Lua::ToBoolean(L, 2) != 0;
*key = CanonicalKey(Game::Lua::ToString(L, 3));
return true;
}
int SetOverrideBindingCommand(void *L, const char *prefix, const char *usage) {
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
int __fastcall Script_SetOverrideBinding(void *L) {
const void *owner = Owner(L);
if (owner == nullptr || !Game::Lua::IsString(L, 3)) {
Game::Lua::Error(L, "Usage: SetOverrideBinding(owner, isPriority, \"KEY\"[, \"COMMAND\"])");
return 0;
}
SetOverride(owner, priority, key,
MakeCommand(prefix, Game::Lua::ToString(L, 4)));
SetOverrideBinding(owner, Game::Lua::ToFlag(L, 2), Game::Lua::ToString(L, 3), OptString(L, 4));
return 0;
}
int __fastcall Script_SetOverrideBinding(void *L) {
constexpr const char *usage = "Usage: SetOverrideBinding(owner, isPriority, key [, command])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (Game::Lua::GetTop(L) < 4 ||
Game::Lua::Type(L, 4) == Game::Lua::TYPE_NIL) {
RemoveOverride(owner, key);
return 0;
}
if (!Game::Lua::IsString(L, 4)) {
int SetOverrideCommand(void *L, const char *prefix, const char *usage) {
const void *owner = Owner(L);
if (owner == nullptr || !Game::Lua::IsString(L, 3) || !Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
SetOverride(owner, priority, key, Game::Lua::ToString(L, 4));
const std::string command = std::string(prefix) + Game::Lua::ToString(L, 4);
SetOverrideBinding(owner, Game::Lua::ToFlag(L, 2), Game::Lua::ToString(L, 3), command.c_str());
return 0;
}
int __fastcall Script_SetOverrideBindingSpell(void *L) {
return SetOverrideBindingCommand(
L, kSpellPrefix,
"Usage: SetOverrideBindingSpell(owner, isPriority, key, spell)");
return SetOverrideCommand(
L, "SPELL ", "Usage: SetOverrideBindingSpell(owner, isPriority, \"KEY\", \"spellname\")");
}
int __fastcall Script_SetOverrideBindingItem(void *L) {
return SetOverrideBindingCommand(
L, kItemPrefix,
"Usage: SetOverrideBindingItem(owner, isPriority, key, item)");
return SetOverrideCommand(
L, "ITEM ", "Usage: SetOverrideBindingItem(owner, isPriority, \"KEY\", \"itemname\")");
}
int __fastcall Script_SetOverrideBindingMacro(void *L) {
return SetOverrideBindingCommand(
L, kMacroPrefix,
"Usage: SetOverrideBindingMacro(owner, isPriority, key, macro)");
return SetOverrideCommand(
L, "MACRO ",
"Usage: SetOverrideBindingMacro(owner, isPriority, \"KEY\", \"macroname\"|macroid)");
}
int __fastcall Script_SetOverrideBindingClick(void *L) {
constexpr const char *usage =
"Usage: SetOverrideBindingClick(owner, "
"isPriority, key, buttonName [, mouseButton])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
const void *owner = Owner(L);
if (owner == nullptr || !Game::Lua::IsString(L, 3) || !Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, "Usage: SetOverrideBindingClick(owner, isPriority, \"KEY\", "
"\"buttonName\"[, \"mouseButton\"])");
return 0;
}
const char *buttonName = Game::Lua::ToString(L, 4);
const char *mouseButton = OptionalMouseButton(L, 5, usage);
SetOverride(owner, priority, key,
MakeClickCommand(buttonName, mouseButton));
const std::string command = ClickCommand(Game::Lua::ToString(L, 4), OptString(L, 5));
SetOverrideBinding(owner, Game::Lua::ToFlag(L, 2), Game::Lua::ToString(L, 3), command.c_str());
return 0;
}
int __fastcall Script_ClearOverrideBindings(void *L) {
const void *owner = Game::Lua::ResolveObject(L, 1);
const void *owner = Owner(L);
if (owner == nullptr) {
Game::Lua::Error(L, "Usage: ClearOverrideBindings(owner)");
return 0;
}
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner;
}),
g_overrides.end());
for (OverrideKey &binding : g_overrides)
SetOverride(binding, owner, false, nullptr);
return 0;
}
void RegisterLuaFunctions() {
// A fresh FrameScript state makes every owner pointer from the previous
// state stale. Permanent bindings remain in vanilla's binding table.
g_overrides.clear();
g_activeOverrides.clear();
g_nextSequence = 0;
// 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) && Game::Lua::ToFlag(L, 2)) {
if (const char *command = ActiveOverride(Game::Lua::ToString(L, 1))) {
Game::Lua::PushString(L, command);
return 1;
}
}
return reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_GET_BINDING_ACTION)(L);
}
// GetBindingByKey(key): the command a press of `key` runs — the reduced-key
// lookup ExecKey makes, overrides included.
int __fastcall Script_GetBindingByKey(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: GetBindingByKey(\"action\"[, mode])");
return 0;
}
std::string reduced;
const KeyBinding binding = FindReducedKeyBinding(Manager(), Game::Lua::ToString(L, 1), reduced);
if (binding.command == nullptr)
return 0;
Game::Lua::PushString(L, binding.command);
return 1;
}
const Game::Doc::Field kGetBindingByKeyArgs[] = {
Game::Doc::Req("key", "string", "A key with its modifiers, such as \"ALT-SHIFT-Q\"."),
};
const Game::Doc::Field kGetBindingByKeyRets[] = {
Game::Doc::Opt("command", "string", nullptr,
"The command a press of the key runs, else nil. When the key itself has no "
"binding, the key with fewer modifiers is tried."),
};
const Game::Doc::Function kGetBindingByKey{"Returns the command that a key press runs.",
kGetBindingByKeyArgs, kGetBindingByKeyRets,
"BindingsGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetBindingByKey", &Script_GetBindingByKey,
&kGetBindingByKey);
Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
Game::Lua::RegisterGlobalFunction("SetBindingClick", &Script_SetBindingClick);
Game::Lua::RegisterGlobalFunction("SetOverrideBinding",
&Script_SetOverrideBinding);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell",
&Script_SetOverrideBindingSpell);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem",
&Script_SetOverrideBindingItem);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro",
&Script_SetOverrideBindingMacro);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick",
&Script_SetOverrideBindingClick);
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings",
&Script_ClearOverrideBindings);
Game::Lua::RegisterGlobalFunction("SetOverrideBinding", &Script_SetOverrideBinding);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell", &Script_SetOverrideBindingSpell);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem", &Script_SetOverrideBindingItem);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro", &Script_SetOverrideBindingMacro);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick", &Script_SetOverrideBindingClick);
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings", &Script_ClearOverrideBindings);
Game::Lua::RegisterGlobalFunction("GetBindingAction", &Script_GetBindingAction);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::HookAutoRegister _hookKeyDispatch{
Offsets::FUN_BINDING_KEY_DISPATCH, reinterpret_cast<void *>(&KeyDispatch_h),
reinterpret_cast<void **>(&KeyDispatch_o)};
// Owners are frames of the Lua state being torn down; their overrides go with it.
void PrepareForReload() { g_overrides.clear(); }
const Game::ReloadAutoRegister _reload{&PrepareForReload};
const Game::HookAutoRegister _hookKeyDispatch{Offsets::FUN_BINDING_KEY_DISPATCH,
reinterpret_cast<void *>(&KeyDispatch_h),
reinterpret_cast<void **>(&g_keyDispatchOriginal)};
const Game::HookAutoRegister _hookCommandExecute{
Offsets::FUN_BINDING_COMMAND_EXECUTE,
reinterpret_cast<void *>(&CommandExecute_h),
reinterpret_cast<void **>(&CommandExecute_o)};
Offsets::FUN_BINDING_COMMAND_EXECUTE, reinterpret_cast<void *>(&CommandExecute_h),
reinterpret_cast<void **>(&g_commandExecuteOriginal)};
} // namespace

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