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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Read PLAYER_CRIT_PERCENTAGE / PLAYER_RANGED_CRIT_PERCENTAGE from the
CGPlayer sub-struct, the same way the engine's GetDodgeChance reads its
neighbouring field.
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).
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
/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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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).
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.
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".
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.
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.
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).
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
* 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.
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.
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.
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).
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.
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.
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.
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().
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
* 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>
* 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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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.
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.
* 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- <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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
* 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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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".
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.
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.
* 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>
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.
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().
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.
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}.
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.
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.
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.
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.
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.
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.
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.
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.
* 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>
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.
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.
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.
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).
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.
* 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>
* 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>
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).
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
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.
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.
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.
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.
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.
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
* 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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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").
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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
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.
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.
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.
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.
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.
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.
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.
`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.
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.
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.
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.
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.
`#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.
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.
`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.
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.
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.
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.
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.
`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
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.
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.
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.
* 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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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)".
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)".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`-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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Add GetPlayerMapPosition, GetWorldPosFromMapPos, GetMapPosFromWorldPos,
and GetMapRectOnMap on the existing AreaTable-id=uiMapID scheme, reusing
Map::Area's world<->map projection. Positions returned as Vector2DMixin.
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.
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.
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).
_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.
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.
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().
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.
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.
Fixesbrues-code/pfUI#50
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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
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.
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.
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.
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.
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.
* 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).
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.
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
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".
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
Fixesbrues-code/pfUI#43
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.
Fixesbrues-code/pfUI#45
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.
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).
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
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
* 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>
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
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.
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.
RegisterStateDriver / RegisterUnitWatch / attribute drivers, driven by a
Lua SecureCmdOptionParse whose macro-conditional grammar is reverse-
engineered from the 3.3.5 client. Builds entirely on the shipped frame
attribute system -- no DLL change.
- MacroOptions.lua: parse-once cached SecureCmdOptionParse. 33 conditions
mapped to 1.12 / ClassicAPI queries; states 1.12 lacks (flying, vehicle)
are constant-false, an unknown keyword warns once and never matches.
- SecureStateDriver.lua: 0.2s-poll manager with instant event rescans,
state-visibility Show/Hide, state-* attribute drivers, unit-existence
watch, SecureButton_GetAttribute/GetUnit. Registers via plain tables +
an immediate top-level resolve instead of the upstream attribute
bootstrap, so the synchronous OnAttributeChanged recursion guard never
swallows a target frame's handler.
Verified in-game: parser grammar, both drivers, and druid [stance:N] bar
numbering.
Backport frame:CreateMaskTexture plus texture:AddMaskTexture /
RemoveMaskTexture / GetNumMaskTextures / GetMaskTexture -- positioned,
rect-based masks, the object form later clients use (SetMask(path) stays
the simple single-shape form).
Positioned masks sample via texgen (object-space vertex position through
a per-unit texture matrix), the mechanism of the engine's own minimap
disc mask, so no vertex UV stream is consumed per mask. That lifts both
v1 limits at once:
- Up to 7 masks apply at once (device stage cap); a texture shows only
the intersection of all of them.
- mask:SetRotation rotates the clip shape (folded into the matrix), and a
mask can move/resize each frame, so the clip animates.
State is restored in a co-hook on the indexed submit (FUN_GX_PRIM_INDEXED)
the engine issues after the stream call, not inside the stream hook --
restoring earlier reverts the state the queued draw is flushed with.
Two incidental fixes: a SetTexCoord-cropped base now masks against the
same rect the engine's corner store uses, and a recycled mask region is
detached from every base still holding it.
Offsets.h: add the texgen / coord-transform / texture-matrix / state-journal
/ indexed-submit constants (decoded from the D3D9 backend state flush
FUN_005a2f00), and fix the FUN_GX_TEXUNIT_ENABLE/_DISABLE mislabel (they
are texture-matrix push/pop, not stage enable/disable).
Fold the thin "Modern text rendering" and "Modern addon loading" sections into
one "Modern client behaviors" table, and showcase two more event-driven
features:
- Event-driven nameplates: the C_NamePlate API + NAME_PLATE_UNIT_ADDED/REMOVED
events + nameplateN unit tokens.
- Focus target: FocusUnit/ClearFocus, PLAYER_FOCUS_CHANGED, the focus /
focustarget tokens, and the predefined FOCUSTARGET / TARGETFOCUS keybinds.
Highlights is now two groups: Lua 5.1 compatibility and Modern client behaviors.
Two README tweaks:
- Remove the "SavedVariables loaded first" row from the Modern addon loading
highlights -- compatibility plumbing, not a showcase feature. Still fully
documented in docs/API.md.
- Move Installation below Full API reference, so the page leads with the whole
showcase (features + the complete API surface) before install steps.
Drop the Table lengths and Environment protection rows from the Lua 5.1
compatibility table -- both are compatibility plumbing, not features anyone
installs for. They stay fully documented in docs/API.md. The showcase now
lists Syntax, String methods, and Script-handler arguments.
Mention the scale up front (550+ functions and 50+ events) and bump the Calls
catalog summary from the stale "400+" to match. Counts are approximate on
purpose so they don't go stale on every addition.
Rewrite the prose the restructure introduced so it reads naturally instead of
like a controlled-language manual: merge the intro's clipped two-sentence
opener, drop the "transparent / no API to call / addons use it automatically"
clunk from the Highlights blurb, and replace "The complete surface." in the API
reference intro. No structural or content changes.
Restructure so the flagship features surface first. The page used to open
with a ~60-row function catalog and buried the headline behaviors (Lua 5.1,
inline textures, modern addon loading) in a table at the very bottom.
- Promote the former "Behaviors" table to a top "Highlights" section,
reordered Lua-5.1-first (syntax, string methods, table lengths, environment
protection, script-handler arguments), then modern text rendering, then
modern addon loading. No rows dropped.
- Move Installation up, ahead of the reference.
- Group the full catalog under "Full API reference" with each table (Calls,
GlueXML, Macros, Console, Events, Globals, Unit tokens, Bindings) behind a
<details> block. Content is byte-for-byte unchanged -- every function,
event, global, and token stays in the file and all docs/API.md links are
intact.
Cartographer_Notes gates a WorldMap release-spirit hook behind the Ace2-era
`lua51` probe (loadstring("return function(...) return ... end")), treating a
5.1-capable Lua as a modern client:
if lua51 then
WorldMapDeathRelease:SetScript("OnMouseUp", func)
end
Our 5.1 syntax backport makes that probe pass, so Cartographer takes the modern
branch and hooks WorldMapDeathRelease -- a WorldMap frame 1.12 never had. The
nil index crashes Cartographer_Notes:OnEnable. On real vanilla the probe was
false and the line never ran.
Same 5.1-probe fallout as the Dewdrop table-length heal: making the client pass
5.1 probes makes addons assume modern-client frames exist. Provide an inert,
hidden WorldMapDeathRelease stub (gated on Cartographer being installed) so the
hook succeeds and does nothing -- the vanilla outcome. The embedded
!!!ClassicAPI addon loads first, so the frame exists before Cartographer's
OnEnable.
Fixes#29
Backports the modern Texture:SetMask, clipping a texture to a mask's alpha
shape - round portraits, round minimap buttons, and other shaped art. Vanilla
1.12 has no general masking (only the special-cased minimap disc).
Mirrors the engine's own masked-quad draw (the minimap blip mask FUN_004eae10):
a co-hook on the vertex-stream primitive FUN_0058a2a0 recovers the owning
CSimpleTexture from the batch positions pointer (region = positions -
OFF_SIMPLETEXTURE_CORNERS), and for a region with a mask set binds the mask on
texture unit 1 with a MODULATE combiner, then re-runs the engine's own submit
with uv1 = uv0 so unit 1 samples the mask at the texture's own coordinates. The
unit is enabled/disabled around the draw so the mask never leaks onto other
textures. A cold co-hook on the region ctor clears a region's mask on pooled
reuse; a kill switch and SEH latch guard the draw path.
The recipe was pinned by an in-game probe on the live GL backend and matched
against FUN_004eae10; the full RE is in the "Texture masking" block in Offsets.h
and the Mask.cpp header. Masks must be BLP or uncompressed 32-bit TGA with the
shape in the alpha channel; two round masks ship in the client, other shapes are
addon-supplied content.
A ScrollingMessageFrame re-SetTexts every visible line on each new message, and
the engine builds each line lazily at paint. The icon regions are separate
objects whose placement is only known once a line builds mid-paint - after the
frame's texture pass already drew - so on a content change the new glyphs
appeared one frame before their icons caught up.
Co-hook the SMF display refresh (FUN_00788750), which runs pre-render from the
frame's own methods. After it, force each icon-bearing line to resolve+build via
the engine's own Realize(0) -> RebuildString -> ensure-built path and apply its
icon placement synchronously, before the frame draws. The placement math
(ComputePlacementsForNode) and the per-fs apply (ApplyDirtyBranch) are shared
with the existing paint-flush / FrameTick pipeline, so the later flush dedups to
a no-op.
Additive and best-effort: any line whose rect is not resolvable in time falls
back to the unchanged paint + tick pipeline (correct, one-frame lagged). Gated by
a new _classicapi_InlineTexEarly toggle (default on) with an SEH latch; the whole
feature keeps its _classicapi_InlineTexEnable kill switch.
* README behaviors row + API.md section for the 5.1 table-length
healing now describe the behavior (cleared tables count as empty
without table.setn; arg tables and setn callers unaffected) instead
of the implementation and the Dewdrop/FuBar bug story -- that history
lives in the commit message and the Length.cpp comment.
* Release workflow ships the linker map as
USE_FOR_DEBUGGING_CAPI_CRASHES.map (copy before upload), so users
know to attach it to crash reports -- it resolves the logical
addresses in an ERROR #132 stack trace to function names.
Tokenize never cleared its output vector, and RunPasses re-lexes into
the SAME vector after each pass that rewrites the buffer -- so every
chunk that triggered two rewriting passes walked a DOUBLED token
stream: stale tokens at old-buffer positions, then fresh tokens
starting back at 0. Two field-real failure modes:
* CRASH (hex pass -> vararg pass): a fresh token's t.start lands
behind the splice cursor, `t.start - p` underflows as size_t, and
append() throws std::length_error("string too long") straight
through the loadbuffer hook into the engine -- fatal ERROR #132 at
RaiseException. Reported from the field on WeakAuras' bundled
Chomp\Internal.lua (hex BITS table + varargs); reproduced offline
byte-for-byte with a standalone harness compiled from this file
against the actual upstream Internal.lua.
* SILENT CORRUPTION (vararg pass -> ops pass): the same doubled stream
made the operator pass read stale in-string positions as code -- a
`%` INSIDE a string literal became __mod, emitting syntactically
invalid output (diff-proven: Chomp's `channel:gsub("^[^%:]+", ...)`
shipped as `channel:__mod(__mod(gsub("^[^,:]+",)) ...)`). Such files
failed to compile behind WoW's soft error() -- dead file, half-loaded
addon, no visible error. Any chunk combining `...` with `#`/`%` was
at risk.
Fixes:
1. Tokenize clears at entry (the root fix). Verified on the real
Chomp file: full pipeline transpiles cleanly -- hex -> exact
decimals, (...) -> (unpack(arg)), #q -> __len(q), string-literal
`%` untouched, the previously-mangled gsub line correct.
2. Catch-all guards at both hook boundaries (LoadBuffer_h and the
_classicapi_Transpile diagnostic): any future C++ exception from
the rewrite machinery falls back to compiling the ORIGINAL buffer
untouched -- worst case the chunk behaves as stock 1.12, never a
process kill.
The 5.1 syntax backport made the Ace2-era Lua-version probe pass:
local lua51 = loadstring("return function(...) return ... end")
and true or false
Every Ace2 library gates on it, and Dewdrop-2.0 (the menu library
behind FuBarPlugin minimap buttons -- SuperAPI, NampowerSettings) uses
it to no-op table.setn: correct on real 5.1, where lengths are computed
borders, but wrong on this VM, whose table functions still read 5.0's
out-of-band length. Cleared-and-reused menu tables kept stale lengths,
table.insert appended past the nil'd slots, and the menus silently
broke ("clicking the minimap button does nothing"). Bisect confirmed:
forcing the probe back to 5.0 (VarargExpansion off) restores them.
Since we made the client PASS 5.1 probes, 5.1 length semantics must
hold. The engine funnels every length read through luaL_getn
(LUAL_GETN = 0x6F5050 -- table.insert/getn/remove/concat/sort/foreachi,
plus our unpack), so one co-hook closes the gap for every consumer:
1. reported slot populated -> unchanged (one rawgeti of cost);
2. t[n] nil but t.n is a number -> unchanged: an explicit n field is
the 5.0 vararg-arg contract, where trailing nils are intentional
(unpack({10,nil,30,n=3}) keeps all three slots);
3. otherwise the out-of-band length is stale (keys cleared without
setn) -> return the true border, the 5.1 answer. Read-only, no
write-back: reads never mutate state, and the engine's own
table.insert re-syncs the stored length on the next append.
table.setn keeps working; the heal only changes the answer when the
stored length points past the last populated slot -- exactly the case
where 5.0 and 5.1 disagree.
Docs: API.md "Stale table lengths (5.1 healing)" section + README
behaviors row.
Fixes#29
Systematic sweep of the Lua 5.1 stdlib delta against what ClassicAPI
already backports found five gaps, all confirmed by in-game probes:
* unpack(list [, i [, j]]) -- 5.0's unpack silently ignored the range
args, so unpack(t, 2) returned the WHOLE table (wrong values, no
error; the only silent gap). Replaced with a port of 5.1's
luaB_unpack: ranges via luaL_getn + lua_rawgeti, so a vararg arg
table's n field keeps its embedded-nil count. Both states.
* string.gsub table replacement -- 5.0 accepts only string/function
and errors on a table. Shim on string.gsub AND the bare gsub global:
a table becomes an adapter closure over the engine's own gsub
(FUN_LUA_STR_GSUB = 0x007FD0E0, pinned by registration-table
position, name string, and decode). Because this engine's add_value
drops a non-string function result (match -> empty) where 5.1 keeps
the match on nil/false, the shim wraps the pattern in one outer
capture (^/$ anchors kept outside) so the closure receives the whole
match and can hand it back for missing keys -- exact 5.1 keep-match.
Patterns with a %1..%9 backref skip the wrap (renumbering hazard);
their missing-key behavior stays the engine's (documented residual).
* collectgarbage 5.1 options -- "count" -> gcinfo, "collect" ->
original collectgarbage(0), "step" -> loop-safe true, stop/restart/
setpause/setstepmul -> no-op 0, numbers forwarded untouched. The
originals are function objects stashed in the state's registry
before the overwrite, so no new engine offsets and each /reload
re-stashes the fresh engine original. Both states.
* table.maxn -- 5.1 luaB_maxn port (largest positive numeric key,
full next-walk so nil holes don't stop it). Both states.
* coroutine.running -- returns the running coroutine, nil on the main
thread. 5.0 has no lua_pushthread, so the thread TValue (tag 8 +
the running L, this build's {tt@0, value@+8} layout) is written to
the stack raw; main-thread detection via the registration-time L.
Verified in-game: ranged/holed/n-field unpack shapes, all four gsub
shapes (populated table, keep-match on missing key, whole-match key,
anchored pattern), maxn past a nil hole, collectgarbage("count"/
"collect"), and coroutine.running from main + inside a wrap.
Docs: five new API.md sections + TOC entries; README Lua list updated.
Follow-up hardening for the 5.1 string-method backport (9b9d80d):
* _classicapi_SetStringMethods(false) -- diagnostic kill switch on both
states, mirroring _classicapi_SetTranspileOption. While off the hook
forwards everything to the original resolver: exact stock behavior.
* Close both stale-capture windows by clearing each slot BEFORE its
state can die, instead of relying on "no Lua runs in the window":
- game slot: PrepareForReload (new StringMethods.h), called from
FrameScript_Initialize_h before the world Lua state is reset --
covers /reload AND /logout;
- glue slot: cleared at the top of the in-game registration -- once
world scripts are loading the glue state is gone, and the next glue
boot re-captures before any glue Lua runs, so a recreated glue
global_State at the same address can never match a stale capture.
A cleared slot fails closed (stock "attempt to index" error) until
its re-capture.
* StringLib.cpp: replace the obsolete "method sugar can't work" header
NOTE -- both call forms resolve through the same string table now.
* docs: kill-switch bullet in the API.md String methods section.
Lua 5.0 has no string methods at all -- the shared string metatable with
__index = string is a 5.1 addition, and both halves of the would-be
mechanism in this build match stock 5.0 exactly: lua_setmetatable
(0x006F4020) accepts only tables/userdata, and luaT_gettmbyobj
(0x006F7BD0) hardcodes &nilobject for every other type. So nothing can
be re-enabled; the 5.1 read path has to be added.
Transplant 5.1's mechanism at the one choke every string index reaches:
co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0, luaV_gettable's non-table
continuation; 3 call sites -- the gettable else-branch plus
luaV_execute's OP_GETTABLE/OP_SELF fallbacks). When the indexed object
is a string, answer with luaV_gettable(L, <string library table>, key,
loop) -- byte-for-byte what the original's own TM-table branch does had
gettmbyobj returned that table, loop passed through unchanged. The
engine's unmodified lookup then yields exact 5.1 semantics, including
nil (not an error) for unknown keys. Non-strings fall through to the
original; settable on a string is a different continuation, untouched.
The site is cold: table __index dispatch (all frame/OO traffic) reads
the table's own metatable via fasttm (FUN_006F7D60) and never lands
here -- today's only callers are about-to-error non-table indexes.
One captured slot per Lua state (in-game + glue), keyed by global_State
so coroutine threads resolve too; each capture anchors the string table
in that state's registry against GC and re-runs per /reload and per
glue boot. Since baselib/StringLib already registers string.match /
gmatch / reverse into the same table, modern idioms like
msg:match("^!(%w+)") work with no further top-up.
Docs: new API.md "String methods" section; replaced the now-obsolete
"only string.foo(s), never s:foo()" caveat under string.match with a
pointer to it.
Verified in-game: literal and variable receivers, :format, 5.1 nil-miss,
and resolution inside a coroutine thread.
{rtN}/{skull}/etc. emitted a hardcoded :14:14 size, so a marker never
tracked the chat font-size setting. Emit :0:0 instead — the inline
emitter resolves height/width to the target line's font pixel height at
draw time, so markers grow and shrink with the chat font and scale up in
speech bubbles, matching retail's ICON_LIST `...Icon_%d:0`. The sheet
crop is size-independent, so cell selection is unchanged.
The startup gate that defers item queries until the engine's dispatch is
ready used a fixed 60-frame count, which is FPS-dependent: on a fast
machine it could fire before dispatch was ready (orphan risk) and on a
slow login it stretched well past its intended ~1s, leaving bag icons as
"?" longer than needed.
Anchor the delay to VAR_IN_WORLD (set by the enter-world initializer,
cleared on every transition) and measure a fixed 1000 ms past world-live,
so it is identical on any machine and only starts once the loading screen
is gone. The engine-response fast-latch is untouched: a real item
response still settles the gate instantly, so a warm WDB has no delay and
only a cold WDB waits the floor. Drops the now-unused frame counter.
A bag item's icon comes from its ItemStats cache entry (m_displayInfoID);
on a cold WDB an uncached bag item resolves displayID 0, and the icon
path draws INV_Misc_QuestionMark ("?"). Nothing in the bag-draw path
queries stats (GetContainerItemInfo and the icon getter both do
null-callback lookups), and the server pushes no item stats on login, so
a carried item stayed "?" until a hover, a loot, or an addon GetItemInfo
happened to query it. Equipped items are exempt (icon from the
server-pushed visible-items table).
Seed the request queue with every carried item once the startup gate
settles (one-shot per enter-world, re-armed on login and /reload) on the
existing owned-priority lane. This gives complete carried-item coverage
ahead of any addon's background flood, stays bounded by the same owned
budget so it adds no burst (issue #25), and is still deferred past
g_settled so it cannot orphan a cache entry before the engine's dispatch
is ready.
* feat(lua): backport 5.1 `#` and `%` operators via source transpile
Vanilla's Lua is 5.0, which lacks the `#` (length) and `%` (modulo)
operators, so modern addon ports that use them fail to COMPILE. Rather than
patch the 5.0 parser/VM (no OP_LEN/OP_MOD, no free opcode), rewrite the
SOURCE before it reaches the parser.
src/luasyntax/Transpile.cpp co-hooks luaL_loadbuffer (0x006F5690) — the one
function every compile funnels through (file scripts, loadstring, XML
handlers) — tokenizes each chunk with a small Lua-aware lexer, and runs a
precedence-climbing parser that rewrites, in one pass:
#operand -> __len(operand)
a % b -> __mod(a, b)
`#` is prefix but `%` is binary infix, so its operands are delimited by
PRECEDENCE: a*b%c -> __mod(a*b,c), a+b%c -> a+__mod(b,c), a%b%c chains
left-assoc as __mod(__mod(a,b),c), a%b^c -> __mod(a,b^c). The two operators
compose in the same pass (#t%2 -> __mod(__len(t),2)). No newlines are
inserted, so error line numbers are preserved.
Runtime helpers (registered on both the in-game and glue states):
- __len: string length, or a table border via bisection (5.1 `#`
semantics — ignores any table.setn count).
- __mod: a - floor(a/b)*b. NOT the engine's math.mod, which is C fmod
(truncated toward zero) and disagrees with `%` for negatives
(-1 % 3 == 2, but fmod(-1,3) == -1).
Safety: the rewrite runs only when a chunk contains `#`/`%`, and either as an
operator already fails to compile on 5.0 — so it cannot regress working code
except by misreading one inside a string/comment ("%d", "%a+", --[[ # ]]).
The lexer's string/comment skipping is the one safety-critical part and is
exact. Gated behind default-on toggles (_classicapi_SetLengthOperator /
SetModuloOperator); diagnostic _classicapi_TranspileLength returns the
rewrite. `...` is a separate, larger phase.
Adds FUN_LUAL_LOADBUFFER to Offsets.h with its derivation.
* feat(lua): honor the 5.1 `__environment` metatable field in getfenv/setfenv
Lua 5.1 lets a sandbox hide an environment: put `__environment` in the env
table's metatable and `getfenv` returns that value instead of the real env,
while `setfenv` refuses to change it. 1.12's engine already has this
protection but reads a RAW `__fenv` field on the env table itself — the older
5.0 form. Modern addons ported from 5.1 use the metatable `__environment`
spelling, which 1.12 silently ignored, so their sandboxes leaked.
getfenv (0x00702AC0) and setfenv (0x00702BF8) share one predicate
(0x00702BA0) that reads the marker and, when present, substitutes it / errors
"cannot change a protected environment". src/luaenv/Fenv.cpp co-hooks and
fully replaces that predicate to read `rawget(getmetatable(env),
"__environment")` first, falling back to the raw `env.__fenv` field. One hook
updates both consumers. The reimplementation preserves the predicate's exact
stack contract (push env, push marker, net +2, boolean return) that getfenv
(settop -2) and setfenv (settop -3) depend on; both metatable and env lookups
are RAW, so an `__index` metatable on the env is not disturbed.
The 5.1 mechanism was verified against 3.3.5's `FUN_0084F2F0` (raw
`__environment` off the metatable; no `"__fenv"` string exists there — Blizzard
renamed it and moved it to the metatable at the 5.0->5.1 jump). The change is
additive and inert: nothing on disk sets either marker, so every existing
getfenv/setfenv caller keeps getting the real env — it only activates when
code deliberately marks an environment.
Adds LUA_GET_METATABLE, LUA_GET_FENV, and FUN_LUA_ENV_PROTECT_PREDICATE to
Offsets.h.
* Use Lua length operator for class count
* feat(lua): backport the 5.1 `...` vararg expression via source transpile
This build is pure Lua 5.0 varargs: `function(...)` parses and populates the
`arg` table (with `arg.n`), but `...` used as an EXPRESSION is a parse error
("unexpected symbol near '...'") — verified in-game. So modern 5.1 addon code
using `...` (`{...}`, `local a,b = ...`, `f(...)`, `return ...`,
`select('#', ...)`) fails to compile.
Add a vararg pass to the transpiler (RewriteChunk runs it before the # / %
precedence parser) that rewrites the `...` EXPRESSION to `unpack(arg)`. This is
a faithful, position-independent substitution: `unpack(arg)` is itself a
multi-value expression, so it spreads in tail position and truncates to one
value elsewhere exactly as `...` does, and this build's `unpack(arg)` preserves
embedded nils via `arg.n` (verified: `f(1,nil,3)` -> `1 nil 3`). No precedence
analysis needed — unlike `%`, it's a token substitution once one case is
excluded: the `...` in a function's PARAMETER LIST (the vararg declaration that
creates `arg`) is left intact, found by scanning each `function` keyword and
skipping its param parens. Nested vararg functions work for free — each
`function(...)` makes its own `arg` and `unpack(arg)` binds to the nearest one
(verified: inner arg count 2, outer 3).
Safety mirrors # / %: only chunks containing `...` are touched, and `...` as an
expression already fails to compile on 5.0, so it can't regress working code;
`...` inside strings/comments is skipped by the lexer, and `.` (field) / `..`
(concat) are distinguished by run length. Gated behind the default-on
`_classicapi_SetVarargExpansion` toggle; `_classicapi_TranspileLength` now shows
the full rewrite (vararg + # + %).
File-scope `...` (the `local name, addon = ...` idiom) transpiles fine but only
yields values if the client passes varargs to addon chunks — a loader question,
separate from this syntax rewrite.
* feat(lua): provide addon-args so file-scope `local name, ns = ...` works
Modern addons open files with `local name, ns = ...` — the addon name plus a
per-addon shared namespace table, passed by the loader as the chunk's varargs.
Vanilla 1.12 never passed those (its main chunk has no `arg`), so with the
`...` backport in place the idiom resolved to `unpack(nil)` and errored. This
supplies the args ourselves.
In the luaL_loadbuffer hook, for an addon-file chunk (chunkname
`@Interface\AddOns\<Name>\...`, parsed case-insensitively) that uses a real
`...`, prepend a chunk-local `arg = {"<Name>", __addonns("<Name>"), n=2}`. The
existing `... -> unpack(arg)` pass then makes `local name, ns = ...` yield the
name and the namespace table. The preamble is newline-free (line numbers
preserved), placed after any UTF-8 BOM, and only injected for AddOns\ chunks
(FrameXML and loadstring are untouched); the addon name is rejected if it
contains characters that would break the string literal.
`__addonns(name)` returns the per-addon table — created once and returned
identically for every file of the addon (backed by a name->table map in the
Lua registry), matching modern WoW's shared second vararg. Registered in-game.
Verified in-game: an addon file's `local name, ns = ...` yields
`("<AddonName>", <table>)` with `ns == __addonns(name)` (shared across the
addon's files). File-scope `...` was the last piece; the in-function case was
already handled by the `...` pass.
* feat(lua): add C_AddOns.GetAddOnLocalTable(name)
Public alias of the addon-args namespace: returns the same per-addon table the
`local name, ns = ...` idiom yields — create-once and shared across every file
of the addon, backed by the same registry map as the internal `__addonns`
(both call Script_AddonNS). Lets code fetch an addon's namespace by name.
Registered via RegisterTableFunction on C_AddOns (non-destructive; coexists
with the existing C_AddOns.* functions regardless of module init order).
Currently ungated — it returns a table for any name, create-on-demand. TODO #99
records the follow-up to match retail: return nil unless the addon is loaded
AND its TOC declares `## AllowAddOnTableAccess: 1`, gating only this public
entry point while leaving the internal preamble path ungated.
* feat(lua): gate C_AddOns.GetAddOnLocalTable behind loaded + TOC opt-in
GetAddOnLocalTable was a raw alias of the addon-args namespace accessor:
it returned any addon's private table by name, loaded or not, with no
access control. Retail (11.0) gates this -- one addon reads another's
local table only when the target opts in via the `## AllowAddOnTableAccess`
TOC directive. Mirror that.
The public accessor now lives in addons/LocalTable.cpp and returns the
table only when the named addon is loaded (FUN_ADDON_IS_LOADED, by-name)
AND its .toc declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
FUN_FILE_READ, read as a TOC boolean flag). Either check failing returns
nil. Directive spelling and function name both verified present in the
1.15.8 Classic Era binary.
The internal preamble path (an addon receiving its OWN table via the
injected `local _, ns = ...`) stays ungated -- an addon never needs
permission for its own namespace. Both paths now resolve to the same
per-addon table through LuaSyntax::PushAddonNamespace (AddonNamespace.h),
the single owner of the registry-backed name->table map.
Also close the bypass in the `__addonns` global itself. The map lives in
the Lua registry (unreachable from Lua -- this build ships no
debug.getregistry / debug introspection), so `__addonns` was the sole
Lua-visible door, and it was ungated. It is now context-gated: it answers
only while an addon's own chunk is mid-load, for its own name, one-shot.
At runtime it denies every name. Verified safe against the loader: the
addon file loader is strictly per-file load-then-run
(FUN_006edb90 -> FUN_006ede10 -> FUN_00704bc0 -> FUN_00704ae0, where
luaL_loadbuffer is immediately followed by lua_pcall), so nothing runs
between the grant being set and the preamble consuming it.
Net result: the only runtime path to another addon's local table is the
TOC-gated GetAddOnLocalTable.
* fix(lua): close the __addonns loadstring-forgery vector
The addon-args grant (`g_loadingAddon`) that lets a chunk's injected
preamble fetch its own namespace via the `__addonns` global was armed
whenever luaL_loadbuffer saw an `Interface\AddOns\...` chunkname with a
`...`. That chunkname is forgeable: `loadstring(s, chunkname)` takes a
caller-controlled chunkname (arg 2) and compiles WITHOUT running, so
loadstring("return ...", "@Interface\AddOns\Victim\x")
__addonns("Victim") -- returned Victim's private table
armed the grant and leaked another addon's local table, with no
`## AllowAddOnTableAccess` opt-in.
Fix: arm the grant only when the loadbuffer call comes from the engine's
immediate-run file funnel FUN_00704AE0 (it pcalls the chunk right after
compiling, so the preamble consumes the grant before anything else runs).
The hook checks `_ReturnAddress()` against RET_LUA_FILE_COMPILE
(0x00704B11, the instruction after that funnel's `call luaL_loadbuffer`).
`loadstring` (FUN_00703280) and `RunScript` (FUN_00704C70) call from
different return addresses, so neither can arm — even a loadstring nested
inside a RunScript body that itself runs through the file funnel. The two
callers that DO reach 0x00704B11 (FUN_00704bc0 DoFile, FUN_00704cd0
inline-XML-script) both supply engine-built chunknames, not caller data.
Verified in-game: forged loadstring compiles but yields nil.
Also fix two lexer/scan bugs found in review:
- SkipNumber treated e/E as an exponent marker in hex literals, so
`0xE-1 % 2` lexed `0xE-1` as one token and rewrote to __mod(0xE-1,2)
(=1) instead of 0xE-(1%2) (=13). Hex literals now use p/P only.
- The addon-path marker matched a backslash separator only while its
own terminator scan accepted both; it now matches `addons\` or
`addons/`, consistent with addons/Embedded.cpp.
* refactor(addons,lua): dedup CI helpers, share TOC scan, table-driven toggles
Review cleanups, behavior-preserving:
- Drop the hand-rolled LowerAscii/EqualsCI copies in luasyntax/Transpile.cpp
and addons/LocalTable.cpp for `_stricmp`/`_strnicmp` (the codebase norm).
- Add addons/Toc.h `FindValue(buf, size, directive, ...)` — the `## Key:`
line-start scan in one place. Embedded.cpp (Version) and LocalTable.cpp
(AllowAddOnTableAccess) both consume it; the file-read stays per-caller
since Embedded's is entangled with its disk-vs-embedded decision.
- Collapse the six per-switch transpile toggle functions to a {name, bool*}
table plus one `_classicapi_SetTranspileOption(name, bool)` /
`_classicapi_GetTranspileOption(name)` pair (name = Length / Modulo /
VarargExpansion). Nothing outside Transpile.cpp referenced the old names.
* feat(addons): load multi-flavor addons via _Vanilla/_Classic TOC selection
Modern addons target several game flavors from one folder by shipping
`<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and frequently no
plain `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such
an addon never registers -- it is invisible, not merely mis-loaded.
Backport the retail client's flavor-file selection at the file-read layer.
The scan's TOC parser FUN_0051C9B0 reads Interface\AddOns\<Name>\<Name>.toc
via FUN_FILE_READ (0x00648620) and registers the addon only if that read
succeeds; the load pass and GetAddOnMetadata read the same path. We already
co-hook FUN_FILE_READ for the embedded addon, so FileRead_h now calls
AddOns::FlavorToc::TryHandle in its non-!!!ClassicAPI branch: for a base-TOC
read it probes <Name>_Vanilla.toc then <Name>_Classic.toc via the original
reader and serves the first that exists in place of the base (the parser
passes outSize=NULL, so it just forwards args with a different path).
Precedence mirrors retail: flavor wins over an existing base, and _Vanilla
beats _Classic. Does NOT fall back to _Mainline/_Cata/etc -- an addon shipping
only those doesn't support vanilla and stays unlisted. Folder enumeration
already lists flavor-only folders, so only the TOC read needed redirecting.
Verified in-game: a folder with only FlavorTest_Vanilla.toc registers and
loads; GetAddOnInfo returns the _Vanilla title/notes.
* feat(addons): prefer _Turtle.toc flavor on Turtle-lineage clients
Extend multi-flavor TOC selection with a _Turtle.toc suffix, a ClassicAPI
extension (not a retail flavor) for addons targeting Turtle's server content.
Probe order becomes _Turtle -> _Vanilla -> _Classic, with _Turtle offered
first and only when Turtle::Detected() is true, so stock 1.12 never prefers
Turtle-only code.
The redirect fires at both the registration scan (glue/char-select) and the
in-world load pass, which must select the same flavor -- so detection has to
read consistently at glue-scan time and in-world. The in-world signal
(TURTLE_WOW_VERSION, engine-set) is absent on the glue state, so Turtle::Detect
now also recognizes Turtle's glue-time TURTLE_* GlueXML string globals
(AUTH_TURTLE_WEBSITE / TURTLE_ARMORY / TURTLE_KNOWLEDGE_DATABASE, set in
GlueStrings.lua at glue boot, before the scan). Clones keep those names and
only rebrand the URL values, so name presence is clone-robust where realmList
is not. Detected() now latches on either signal and works in both states;
Version() still fills from TURTLE_WOW_VERSION once in-world.
Verified in-game: a folder shipping only FlavorTest2_Turtle.toc registers and
loads on Turtle (GetAddOnInfo returns the _Turtle title), while the earlier
_Vanilla-only test still works.
* feat(addons): narrow flavor TOC selection to _Turtle + _ClassicAPI
Drop `_Vanilla`/`_Classic` from the flavor probe list. Those are the 1.15
Classic Era client's suffixes -- a modern engine with the full modern API and
modern FrameXML templates. Adopting them told a multi-flavor addon "I'm 1.15",
so its `_Vanilla` build would assume APIs/templates this 1.12 + partial-backport
client doesn't fully have, load, and then break. A clean non-load is better.
Select only the two flavors that describe THIS environment, most specific first:
_Turtle -- Turtle client/content, only when Turtle::Detected();
_ClassicAPI -- 1.12 + ClassicAPI, always (ClassicAPI is present by
definition -- it is the code doing the redirect).
Both are ClassicAPI conventions the addon author opts into, not an attempt to
impersonate a retail flavor. If neither exists, TryHandle returns false and the
caller does the normal base read, which chains through any other FUN_FILE_READ
hooker before reaching the engine.
Verified in-game: a folder with only `<Name>_ClassicAPI.toc` registers/loads; a
`_Turtle`-only folder registers on Turtle; a `_Vanilla`-only folder no longer
registers (correctly, now that `_Vanilla` is not a selected flavor).
* feat(addons): backport per-line TOC directives (conditions + variables)
Single-TOC multi-flavor addons gate individual file-reference lines and
expand path variables:
[Family]\Init.lua -> Classic\Init.lua
Vanilla.lua [AllowLoadGameType vanilla] kept
Mainline.lua [AllowLoadGameType mainline] dropped
Localization\[TextLocale].lua -> Localization\enUS.lua
Vanilla 1.12's TOC readers understand none of it: the scan parser
FUN_0051C9B0 only parses `##` metadata, and the load pass FUN_006edb90
hands each file line to FUN_006ede10 verbatim, where a trailing ` [...]`
breaks the extension check so the file silently never loads.
New module AddOns::TocRewrite rewrites the TOC buffer at the FUN_FILE_READ
chokepoint (alongside FlavorToc/Embedded), so the engine's own load loop
sees clean paths and both parsers are covered by one edit.
On this client the tokens resolve to: [AllowLoadGameType] keeps iff the
list has `vanilla`; [AllowLoadTextLocale] keeps iff it has the client
locale; [AllowLoad] keeps `game` and drops `glue`; [Family]->Classic,
[Game]->Vanilla, [TextLocale]->the client locale code. Locale comes from
VAR_LOCALE_NAME_TABLE (0x008558A4), the exact source Script_GetLocale
reads, so it matches GetLocale() byte-for-byte.
Gated to ...\AddOns\...\*.toc reads (a .lua `t[1]` would otherwise be
mangled); bracket-free TOCs stay zero-copy via a memchr bail; a rewritten
TOC goes into a fresh Storm buffer the engine frees like its own. Unknown
conditions fail safe (drop the line); gated-out lines become `#` comments.
Verified in-game on Turtle: 8 keep-cases loaded, 4 drop-cases (bare
mainline, glue, deDE, unknown keyword) did not.
* docs: document the branch's Lua 5.1 and TOC compatibility features
User-facing docs for the feat/lua-syntax-transpile branch.
docs/API.md:
- New "Lua 5.1 syntax: length, modulo, and vararg" section: the source
rewrite for `#`, `%`, and `...`-as-expression; what each form becomes
(`#` = border, ignores table.setn; `%` = 5.1 sign-of-b, contrasted with
math.mod); the `local name, tbl = ...` addon-args idiom; limits
(string/comment safety, first-close long-bracket matching, preserved
line numbers) and the diagnostic kill-switch.
- New "getfenv / setfenv environment protection" section: the 5.1
`__environment` metatable field, with the `__fenv` fallback noted.
- New "Conditional and multi-flavor TOC loading" section under AddOns:
`_ClassicAPI` / `_Turtle` flavor file selection and per-line
`[AllowLoadGameType]` / `[AllowLoadTextLocale]` / `[AllowLoad]`
conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables,
with the token resolution table and limits.
- Table-of-contents entries for all three.
README.md:
- Add C_AddOns.GetAddOnLocalTable to the AddOns calls row (was missing).
- Add three rows to the Behaviors table (Lua 5.1 syntax, Lua 5.1
environment protection, multi-flavor & conditional TOC loading), each
linking to its API.md section.
* feat(addons): accept multi-flavor `## Interface:` version lists
A retail TOC can list several interface versions on one line
(`## Interface: 120100, 50504, 38002, 20506, 11200`). Vanilla's parse is a
plain atoi (FUN_0064ade0) that reads only the first number into entry+0x1C,
and the loadability resolver FUN_0051e780 compares that to the client
version FUN_0051d7d0() (0x2BC0 = 11200): a mismatch is the OUT_OF_DATE
reason that blocks loading while checkAddonVersion is on. So a list whose
first entry isn't 11200 never loads even when 11200 appears later in it.
TocRewrite now special-cases the `## Interface:` line: parse the whole
list, and if the client version (read live via
FUN_ADDON_CLIENT_INTERFACE_VERSION, not hardcoded) is present but not
first, rewrite the value to that single number so the engine stores 11200
and marks the addon compatible. A list without 11200 (or already leading
with it) is left verbatim, so it stays out of date exactly as before. The
fast-path gate widened to `[` or `,`; the content-compare bail keeps
comma-bearing TOCs (e.g. `## Dependencies: A, B`) zero-copy.
Docs (API.md, README.md) describe the behavior and the 11200-only
compatibility rule.
Verified in-game on Turtle: GetAddOnInfo on an addon listing
120100,50504,38002,20506,11200 returned no reason (loaded); the ...,11509
control returned INTERFACE_VERSION (out of date).
* feat(addons): honor ## LoadSavedVariablesFirst
Modern addons use `## LoadSavedVariablesFirst: 1` so their SavedVariables
are restored before their Lua runs, letting file-scope code read config.
Vanilla always runs an addon's files first, then loads SavedVariables,
then fires ADDON_LOADED (verified in FUN_0051f240), and never parses the
directive — so file-scope SV is always nil.
New module AddOns::SavedVarsFirst co-hooks the per-addon file loader
FUN_ADDON_LOAD_FILES (FUN_006edb90). For an addon whose TOC declares the
directive, it loads the SavedVariables first, mirroring the engine's own
path construction two steps later:
account : WTF\Account\<account>\SavedVariables\<Name>.lua
per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua
(falling back to the realm-less path)
account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME, character =
FUN_GET_LOGIN_ACCOUNT_NAME (whose offset label is a misnomer — it is the
character name). Then it calls the original so the files run with SV
present. FUN_FILE_EXISTS picks the same per-char variant the engine would;
FUN_LUA_LOAD_FILE no-ops on a missing file.
v1 caveat (documented): the engine still runs its own SV load right after
the files, so a file-scope READ works but a file-scope WRITE is overwritten
before ADDON_LOADED. Writes belong in an ADDON_LOADED handler, as usual.
Docs: API.md "SavedVariables loaded first" + a README behaviors row.
Verified in-game on Turtle (login -> /reload): flagged SVFirst saw its DB
at file scope (sawAtFileScope=true), control SVNormal did not (false).
* feat(addons): suppress the redundant SavedVariables re-load (LoadSavedVariablesFirst v2)
v1 loaded a flagged addon's SavedVariables before its files, but the engine
still ran its OWN SV load right after the files — overwriting any value the
addon wrote to its SavedVariable at file scope before ADDON_LOADED.
v2 co-hooks FUN_LUA_LOAD_FILE and skips that one redundant re-load per
pre-loaded path: RunLuaFile loads each SV via the trampoline (bypassing the
hook) and records its full path; LuaLoad_h matches the engine's imminent
FUN_00704bc0(svPath, 0, 0) (case-insensitive, one-shot erase) and returns 0
without loading. The SV-load call sites ignore the return and the SV file
feeds nothing into the TOC checksum, so the skip is side-effect-free.
FUN_00704bc0 loads files only (RunScript runs strings through a different
path), so the extra hook is a cold addon-load-time path.
Now file-scope reads AND writes both survive — matching true
LoadSavedVariablesFirst. Docs (API.md) drop the write-clobber caveat.
Verified in-game on Turtle (fresh SavedVariables, repeated /reload): flagged
SVFirst's file-scope write persists (runs counter climbs 2, 3, 4), control
SVNormal stays 1.
* feat(addons): flavor-specific addon Bindings (Bindings_Turtle/_ClassicAPI.xml)
The Bindings.xml analog of FlavorToc. A modern addon may ship a
flavor-specific keybinding file instead of / alongside a plain
Bindings.xml. Vanilla only ever builds Interface\AddOns\<Name>\Bindings.xml,
gates it on an exists-check (FUN_00648a30), and — only then — loads it via
FUN_004b6f70, which reads through FUN_FILE_READ. So flavor selection needs
two pieces (a FileRead redirect alone can't help a flavor-only addon,
because the exists-check is on the base name):
- AddOns::FlavorBindings::TryHandle, called from the FUN_FILE_READ hook
after FlavorToc: for a …\AddOns\<Name>\Bindings.xml read, serve a flavor
variant if present (flavor wins over a plain Bindings.xml). Probe order
Bindings_Turtle.xml (Turtle only) -> Bindings_ClassicAPI.xml.
- A co-hook on FUN_FILE_EXISTS: report Bindings.xml as existing when the
base is absent but a flavor variant is present, so the loader runs for a
flavor-only addon. Non-Bindings paths pass straight through.
Suffixes are _Turtle / _ClassicAPI, never _Vanilla / _Classic (a _Vanilla
bindings file pairs with the 1.15 build we don't load, so its handlers
wouldn't exist). Docs: API.md flavor section + README behaviors row.
Verified in-game: on Turtle all three cases load (flavor-only ClassicAPI,
flavor-only Turtle, flavor-over-base with the flavor winning); on stock the
Turtle-only one correctly drops while the ClassicAPI ones still load.
* feat(lua): backport 5.1 hexadecimal number literals via source transpile
Lua 5.0's lexer reads only decimal digits + one '.' + 'e' exponent, so a
`0x...` literal is a compile error (hence addons resort to
`tonumber("0x...", 16)`). Lua 5.1 added hex literals. As with `#` / `%` /
`...`, we rewrite the SOURCE at the luaL_loadbuffer chokepoint: each hex
INTEGER token becomes the exact decimal it denotes (unsigned), which 5.0's
lexer accepts and reads as the same double 5.1 would.
RewriteHex runs first in the transpile pipeline, gated on a "0x"/"0X"
presence scan; it reuses the tokenizer (so strings/comments are never
touched) and inserts no newlines (line numbers preserved). Hex floats
(0x1.8p3) and > 64-bit literals are left as-is — vanishingly rare, and they
fail exactly as today (no regression). Also fixed SkipNumber to stop a
number at a `..` so `0xFF.."x"` (hex then concat) tokenizes cleanly.
Toggle: _classicapi_SetTranspileOption("HexLiterals", false).
Docs: API.md "Lua 5.1 syntax" section + README behaviors row.
Verified in-game (19/19 harness asserts) and cross-checked against retail:
0xFF->255, 0xFFFFFFFF->4294967295, bit.band(0xF0,0x0F)->0, "0xFF" untouched
all match modern WoW exactly.
* feat(frame): enable modern positional script args by default
Modern handler signatures — `function(self, delta)` / OnEvent
`(self, event, ...)` — are core Lua 5.1 behavior, so flip Frame::ScriptArgs
on by default instead of opt-in. Ports that use them now work with no
`SetModernScriptArgs(true)` call.
It's purely additive (the `this`/`arg1`/`event` globals are still set, so
vanilla-style handlers keep working). `SetModernScriptArgs(false)` remains
the escape valve — it reimplements the tail of the engine's hottest Lua
path (fires for every OnUpdate frame), and disabling it makes both runners
a straight passthrough to exact vanilla dispatch.
Docs + code comments updated from "default OFF (opt-in)" to "default ON".
* docs: surface modern script-handler arguments as a behavior
Add a README Behaviors-table row for the now-default modern positional
script arguments (function(self, delta), OnEvent (self, event, ...)),
alongside the Lua 5.1 syntax row. The SetModernScriptArgs / GetModernScriptArgs
functions stay in the Frame calls table as the off-switch.
* fix(lua): correct transpiler lexer edge cases and operator env-independence
Review findings against the Lua 5.1 syntax transpiler:
- Tokenize `.`/`..`/`...` as multi-char tokens like Lua's own lexer, so a
`...` vararg adjacent to a `..` concat (e.g. `x .. ...`) is recognized
instead of forming an unsplittable five-dot run that left `...`
un-rewritten and failed to compile. Guard the `#`/`%` field-access check
to a single-char `.` so `a..b` isn't misread as `a.b`.
- Nest level-0 `[[ ]]` long brackets in SkipLongBracket to match the engine's
own long-string reader (FUN_00700010 keeps a nesting counter). First-close
matching mis-identified nested-string boundaries and could rewrite `#`/`%`
bytes the parser treats as string content.
- Capture `__len`/`__mod`/`unpack` as chunk-locals in the load-time preamble
so the lowered `#`/`%`/`...` operators resolve under a setfenv sandbox with
no _G fall-through, mirroring real 5.1's environment-independent operators.
- Skip precompiled bytecode (0x1B chunk signature) instead of tokenizing it
as source and corrupting the chunk.
- Cap parse recursion at 200 (Lua's LUAI_MAXCCALLS) so a pathologically
nested chunk fails to compile cleanly instead of overflowing the C stack
inside the hook.
- Case-fold the addon-namespace map key in PushAddonNamespace so a
case-mismatched C_AddOns.GetAddOnLocalTable request lands on the addon's
real table instead of minting a fresh empty one.
The `.. ...` vararg and nested-long-bracket fixes verified in-game via
_classicapi_TranspileLength.
* fix(addons): gate LoadSavedVariablesFirst pre-load on the declared scope
LoadSavedVarsEarly chose which SavedVariables files to pre-load from file
existence alone, but the engine loads the account file only when the addon
declares `## SavedVariables` and the per-char file only when it declares
`## SavedVariablesPerCharacter`. So a stale file for a scope the addon no
longer declares got executed early (Lua the engine never runs) and queued in
g_pendingSuppress, which the engine then never drained — a leak of one entry
per stale file per /reload.
Mirror the engine: read the TOC once (ReadSvFlags, replacing
TocWantsSavedVarsFirst) for the reorder flag plus whether each SV scope is
declared, and load each file only when its directive is declared and the file
exists. Every suppress entry we queue is now a file the engine re-loads and
drains.
* fix(turtle): only trust glue markers on the glue Lua state
Probe() checked the `TURTLE_*` glue markers whenever detection wasn't yet
latched, on whatever Lua state was current — including in-world. Those names
are set by Turtle's GlueXML and are only trustworthy on the glue state; in
world any loaded addon can define a global by one of them (a Turtle-UI port
shipping the strings, say), which permanently mis-latched Turtle detection on
a stock client and skewed flavor TOC/Bindings selection for the session.
Gate the marker probe on a `g_onGlue` phase flag driven by the engine's own
per-state script-registration hooks: the in-world ModuleAutoRegister (fires
before the addon load pass) clears it, each glue boot's GlueModuleAutoRegister
sets it. Neither reads a forgeable Lua global. Turtle still detects via the
markers at the glue scan and via TURTLE_WOW_VERSION in-world; a stock client
can no longer be latched by an in-world global.
Also make Version() return nullptr (not "") in the glue-detected-but-no-version
window, matching the header contract.
* revert MAX_CLASSES
* fix(frame): only pass positional script args to handlers that declare them
Default-ON modern script args changed behavior for every handler on the
engine's hottest Lua path, and the docs' "purely additive" claim was wrong:
a handler that DECLARES a parameter received a real value where vanilla always
passed nil.
Gate the reimplemented positional path on the handler's arity: a Lua closure
that declares a parameter or is vararg (function(self, delta) / function(self,
...)) takes it; a param-less handler (the vast majority of vanilla handlers,
which read this/arg1 globals) and a C-closure handler tail-call the original
engine runner unchanged. So param-less handlers are provably identical to
vanilla AND off the hot path (also the efficiency win: OnUpdate handlers that
gain nothing no longer pay the modern tail every frame). Only param-declaring
handlers observe the (self, [event,] arg1..N) shape.
Arity is read from the handler's Proto; offsets verified against the engine's
own luaD_precall (numparams @ +0x45, is_vararg @ +0x46), lua_dump, and
lua_iscfunction/lua_tocfunction (closure @ TValue+8, isC @ +6, Proto @ +0xC).
The param-declaring-vanilla-handler case (a function reused as both a direct
call and a handler) is inherently ambiguous and still gets the value; documented
honestly in ScriptArgs.cpp, docs/API.md, and README with SetModernScriptArgs
(false) as the opt-out. Verified in-game across both runners, both paths, the
toggle, and the caveat.
* refactor(addons): centralize engine-IO typedefs and CI string helpers
The `__stdcall` FUN_FILE_READ / FUN_FILE_EXISTS / Storm-allocator function
types were copied into ~8 places across the addon files, and Lower/EqCI into
three. These are ABI-critical: a copy declared __cdecl by mistake drifts ESP
per call and kills the process with no crash log. Hoist them to one home:
- new addons/EngineIO.h — FileReadFn / FileExistsFn / SMemAllocFn / SMemFreeFn,
used by Embedded, FlavorToc.h, FlavorBindings.h/.cpp, SavedVarsFirst,
LocalTable, TocRewrite.
- addons/Toc.h gains Lower / EqCI, used by FlavorBindings, SavedVarsFirst,
TocRewrite.
Also drop EvalCondition's dead `known` out-parameter in TocRewrite (nothing
read it — an unknown keyword already returns false). Pure refactor, no behavior
change.
* perf(lua): tokenize each chunk once and gate the #/% parse on an operator token
The transpiler ran on every compiled chunk at login/reload, and:
- RewriteHex / RewriteVararg / RewriteAll each re-tokenized the whole chunk, so
a file that trips more than one pre-check was lexed two or three times;
- RewriteAll's fast-bail was a raw memchr for '%', which is present in nearly
every chunk via format strings ("%d"), so almost every chunk paid a full
recursive parse only to discard an empty edit list.
New RunPasses tokenizes once and shares the stream across the passes, re-lexing
only after a pass actually rewrites the buffer (rare). RewriteAll now gates the
parse on an actual '#'/'%' OPERATOR token — a '%' inside a string is a TK_STRING
token, not a punct, so the format-string case skips the parse entirely.
RunPasses also unifies the previously duplicated RewriteChunk / LoadBuffer_h
pipelines. Output is identical (verified in-game via _classicapi_TranspileLength:
vararg-next-to-concat, nested long brackets, and a format-string vs operator '%'
all rewrite exactly as before).
* feat(lua): backport 5.1 leveled long brackets; fix top-level vararg fallback
Adversarial testing of the transpiler surfaced two gaps beyond PR 28:
- Leveled long brackets ([=[ ]=], [==[ ]==]) are the one 5.1 syntax
construct still missing. The 5.0 engine lexer has no =-level handling
(verified in FUN_006ff610), so they fail to compile. A new source-walking
pre-pass rewrites a leveled long string to [[body]] when the body is safe
(byte-exact, keeps the leading-newline strip) or to a quoted literal with
\-newline continuations otherwise, and blanks leveled long comments to
spaces. Line numbers preserved. Gated by the new LongBrackets toggle.
- Top-level ... outside the addon file funnel (RunScript, XML <OnLoad>,
loadstring) expanded to unpack(arg) and threw on nil arg, where 5.1
yields nothing. Non-addon vararg chunks now get a local arg={n=0}
fallback.
Adds a /capitranspile self-test suite (19 cases, all passing in-game) and
updates docs/API.md (also fixing a stale limits note that described plain
long strings as non-nesting: they nest by depth, matching the engine) and
README.
Tightens the pfUI compatibility shim in AddOnCompat.lua. The temporary CreateFrame wrapper now leaves the maintained brues pfUI fork alone when either its X-Website tag or the pfUI.handlesHookScript capability flag is present, and only applies the HookScript workaround when neither signal exists.
VanillaFixes creates the process suspended and injects every dlls.txt
DLL sequentially via remote threads before resuming. The fallback
worker spawned from DllMain starts the moment our own LoadLibrary
releases the loader lock -- i.e. while VF is still injecting the NEXT
dlls.txt DLL -- so its ~90-prologue install (plus the MH_ApplyQueued
thread-freeze) raced that DLL's DllMain patching the same engine
functions. SuperWoWhook.dll failed to load whenever ClassicAPI
preceded it in dlls.txt.
DllMain now skips the worker when VfPatcher.dll is present in the
process: under VF the `Load` export is the only install trigger, and
it is inherently race-free -- VfPatcher calls it on the game's main
thread after the process resumes, when every DllMain in the injection
chain has already completed, serialized in dlls.txt order. VfPatcher
is always injected before any dlls.txt DLL, and the Load mechanism
shipped in the same VF release (v1.4) as dlls.txt support itself, so
VF present implies Load will fire. Non-VF injectors keep the worker
unchanged.
The wrap-stepper co-hook special-cased auto-width fontstrings (single-anchor,
no explicit width — pfUI addon-list labels, TwitchEmotes labels): when the whole
line fit within 1.5 glyphs of exactly filling its budget it applied zero shrink,
to avoid a spurious wrap from ~1-glyph drift between the width and wrap hooks'
icon-sum computations. The cost was that real-width frames landing within 1.5
glyphs of their edge also gave up their shrink and let icons overflow instead of
wrapping.
The trailing-icon trim, outline-ink threading, and exact-K measure work have
since tightened that drift under half a glyph, so the existing 0.5-glyph
feasibility tolerance now carries the auto-width case on its own. Verified
in-game: pfUI/TwitchEmotes labels no longer spuriously wrap, and near-full
real-frame lines wrap correctly instead of overflowing.
DllMain enabled ~90 hooks individually, and every MH_EnableHook freezes all
process threads (CreateToolhelp32Snapshot + SuspendThread each) under the
Windows loader lock. On machines whose security stack intercepts thread
suspension, 90 serialized freezes ran past VanillaFixes' 10-second
remote-LoadLibrary deadline, so VF read the still-running injection thread as
STILL_ACTIVE (259) and showed its generic "compatible client" error — a false
diagnosis of a slow load that blocked launch entirely for affected users.
Move the whole install off the loader lock. DllMain now only spawns a worker
thread and returns immediately. Hooks install via a latched EnsureInitialized()
reached from two triggers: VanillaFixes' `Load` export (called on the game's
main thread after injection — VF's sanctioned extension point) or the fallback
worker (for any non-VF injector). Whichever arrives first installs; the other
observes the cached result. All hooks are queued and activated in a single
MH_ApplyQueued, so ~90 thread-freezes collapse to one.
- src/DllMain.cpp: minimal DllMain + InstallHooks/EnsureInitialized/Load export
- src/Game.cpp: RunHookRegistrations queues instead of enabling
- src/ClassicAPI.def + CMake /DEF: export `Load` undecorated
- src/Common.h: drop the now-dead HOOK_FUNCTION/UNHOOK_FUNCTION macros
User-facing API docs should describe the contract (behavior, inputs,
outputs, errors, observable caveats), not how a method is built or which
engine path it uses. Drop the "engine already computes this internally /
only adds the binding / comes from the engine's own wrap math / reads
what it last drew and compares" narration from the FontString entries,
keeping every observable behavior and caveat (the setup requirements,
the must-have-rendered-once note, the icon-inclusive widths, the <=1px
rounding note).
SetFormattedText resolved _G.string.format and called it, so an addon
that replaced string.format could hijack or break it. Push the engine's
own str_format C function instead (strlib "format" entry 0x007FD3D0,
verified by decode) and pcall it -- immune to global replacement, and
matching retail, whose SetFormattedText formats in C. Also simpler: one
PushCClosure replaces the _G table walk and its two failure branches.
Verified in-game: with both string.format and the format global replaced
by a stub, SetFormattedText("%d apples", 5) still renders "5 apples".
The trade-skill link frame's rank and summary FontStrings built their
text with SetText(string.format(...)). Use the backported
FontString:SetFormattedText instead -- one call, and it routes through
the engine's own SetText path.
Backport three FontString methods, each working WITH the engine's own
text-layout machinery instead of reconstructing it:
- IsTruncated(): did the engine cut the text off with an ellipsis?
Reads render truth -- the built text node keeps a copy of the string
it actually laid out (node+0x48), the ellipsized form on truncation
-- and compares it to the source. Same "displayed vs full" check
retail does (5.4.8 inner fn 0x0045E001). Re-calling the display-text
resolver from Lua cannot work: it reads the laid-out rect (fs+0x64),
which is zero between frames, so it never sees the bound.
- SetMaxLines(n)/GetMaxLines(): cap the fontstring to n wrapped lines,
ellipsizing the rest. Writes the engine's own per-fontstring maxLines
field (fs+0x128, already honored by the truncation resolver) and
re-invalidates the layout the way SetText does (drop caches, release
node, notify region). Vanilla has no SetWordWrap -- the fs->layout
flag translator exposes no no-wrap bit -- so SetMaxLines(1) is how you
make a single-line, ellipsized label.
Also correct a mislabeled bit in the inline-texture notes: fs+0x120 bit
0x1000 (gxu/node bit 0x40) is NON-SPACE-WRAP (what SetNonSpaceWrap
toggles), not a dedicated "editable" flag. The inline-texture code uses
it as an editbox proxy, which works because multi-line editors enable
non-space-wrap while display text does not; the comments now say so,
with the rare-edge-case caveat. Documentation only, no logic change.
Verified in-game: IsTruncated true/false across bounded/unbounded boxes;
SetMaxLines caps of 1/2/9/0 with matching GetNumLines and IsTruncated.
The rendered width: the widest wrapped line -- the method that
completes the width trio (GetStringWidth and GetUnboundedStringWidth
both measure the string on ONE line; verified in-game they agree to
float noise). Uses the tooltip auto-size's own recipe: break positions
from FUN_FONTSTRING_BREAK_ARRAY, widest per-segment measure through
FUN_FONTSTRING_MEASURE_SUBSTRING -- both icon-aware through the
existing co-hooks. Segments are trimmed of the whitespace the wrap
consumed (the render never draws it, and a trailing space flips the
last real letter from ink to full-advance treatment, +~1.5px measured).
The wrap budget (shared with GetNumLines' unbuilt fallback via
WrapBudget) is rectWidth / fs+0x7C, calibrated EMPIRICALLY against the
render with a three-point probe on a 100px box at ~0.91 scale, using
text whose candidate lines measure 78.1 / 94.6 / 101.6 px:
raw rectW -> effective budget ~91px -> broke one word EARLY
rectW/scale^2 -> effective budget ~110px -> broke one word LATE
rectW/scale -> effective budget 100px -> breaks exactly where the
render does (94.6 <= 100 < 101.6)
All three observations fit budget_px = pxOf(param) x scale. A paper
derivation from the rebuild's node-budget chain predicted /scale^2 --
one hop of that chain models live behaviour wrong; the probe data is
recorded in the code comment so the broken paper chain doesn't get
re-derived. (FUN_00772B60 multiplying its width arg by fs+0x7C before
gxu, and FUN_0044DB50 normalizing through FUN_0041AE40, are verified
decompiles.)
Verified in-game: 100px box, wrapped 93.8 / one-line 165.7 / 2 lines,
break at the render's own position. Residual <=1px vs a direct measure
of the line text -- the engine's own per-glyph measure conventions
(ink-vs-advance, kerning-variant flag), the same class GetStringWidth
carries; documented in API.md.
The entry claimed GetStringWidth measures inside the wrap box -- it
does not, on any client: both methods measure the string on ONE line
(verified in-game: a fontstring wrapping in a 100px box reports ~165px
from both). They differ only on modern clients and only for
ellipsis-truncated text, where GetStringWidth reports the displayed
width; 1.12's getter never applies truncation, so the two always agree
here. The as-rendered width (widest wrapped line) is GetWrappedWidth,
which 1.12 lacks.
Four modern FontString bindings 1.12 never shipped, all thin wrappers
over engine machinery already verified in this codebase
(fontstring/Metrics.cpp grows from the GetStringHeight backport into
the general metrics module):
- GetUnboundedStringWidth(): the substring measure
(FUN_FONTSTRING_MEASURE_SUBSTRING) over the whole string (len 0).
Icon-aware for free -- the call routes through the InlineTexture
co-hook. Verified in-game it agrees with GetStringWidth to float
noise, which is retail parity: both measure unwrapped (the classic
gotcha; the rendered width is GetWrappedWidth, a future backport).
- GetNumLines(): render truth from the node's built-line count
(OFF_TEXT_NODE_BUILT_LINES = 0x9C, the draw builder's own counter /
early-out gate) once painted; for an unbuilt node, the engine's
break-array computer (FUN_FONTSTRING_BREAK_ARRAY = 0x00772B60, the
tooltip auto-size's own segment source -- icon-aware via the
wrap-stepper hook) against the fs's rect width. Empty text 0; no
wrap constraint 1. Verified: 165px text in a 100px box reports 2.
- GetLineHeight(): the fs font-height getter (FUN_007727b0) through
the standard push conversion; excludes spacing, like retail.
- SetFormattedText(fmt, ...): resolves the LIVE string.format (addon
replacements honoured), pcalls it over the varargs, propagates
format errors as real Lua errors, and sets through the engine's
FUN_FONTSTRING_SET_TEXT so sanitizer/escape handling is identical
to SetText.
New Offsets entries carry the derivations; docs/API.md documents all
four under the FontString section.
Three inline-|T measure fixes, the first one load-bearing for chat.
1) Wrap shrink was overscaled by the render aspect. The wrap stepper's
inputs are gxu-normalized PER AXIS -- fontH normalized-Y (x rasterY =
pen px), wrapWidth/outWidth normalized-X (x rasterX = pen px); both
caller flavors land there (the draw builder passes node+0x1C/+0x3C,
pre-normalized at block creation via FUN_0041ae50/ae40, and the
fs-level callers x-normalize inside FUN_0044d670 before gxu, verified
by decompile). The hook converted the pen-x icon sum with fontH/fontHPx
-- a Y-AXIS factor -- overshrinking the wrap budget by rasterX/rasterY:
x1.33 at the 4:3 setups it was calibrated on (subtle, and the
glyph-relative tolerances were inflated by the same factor, masking
it), x2.37 at 2560x1080 -- icon-bearing chat lines wrapped a word or
two early per icon. The correct pen-x -> width-units factor is
1/rasterX; the auto-width bail, feasibility band, escalation step and
bisect threshold now derive from fontHPx/rasterX so a "half glyph"
tolerance is an actual glyph width.
2) The substring measure (FUN_00772AE0, new Offsets entry) is now
icon-aware. An earlier note claimed nothing consumed it; its xrefs
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of
a wrap-enabled line through it (breaks from FUN_00772B60, already
icon-aware via the stepper hook) and takes the max as the tooltip width
-- so icon-bearing wrapped tooltip lines undersized their tooltip. Same
pen-exact adjustment as the width hook; the editbox caret/selection
callers (10 of its 11 xrefs) keep measuring raw via the established
editable-bit / focused-buffer gates.
3) A string-final icon now reports its INK edge instead of its full
advance (TrailingIconTrimPen: subtract the trail half-pad + half the
outline ink when the text ends in a well-formed span), matching the
engine's own last-glyph convention -- the measure loop ends on ink, not
advance. Render/justify/wrap keep the full advance (the pen genuinely
moves); only the reported width changes. The visible beneficiary is the
money-string shape ("...|TCoin:0|t"): a right-anchored auto-width money
fs sizes to the coin's drawn edge instead of ~2px past it. Remaining
width residual is sub-pixel (region placement rounding + the base
loop's ~0 payload-0 token contribution).
Icon regions placed at fractional pen positions through the anchor
math, so 1:1-sized art (a 16px emote at :16) sampled between texels and
rendered soft. Snap the drawn rect in the flush, gated on the node's
pixel-snap mode (bit-7 clear -- the same condition the engine's glyphs
snap under): position rounds to whole pen px, and SIZE rounds
independently of position so a :16 icon is exactly 16px, never 15/17
from the two edges rounding apart. Floors at 1px.
An integral pen rect survives to the screen because pen px = render
px, the fsLeft/fsBottom terms cancel exactly through the anchor
round-trip (the engine adds the same rect corner back at resolve), and
the pool's width-convergence multiplier is linear.
Two changes to the inline-|T pipeline, both anchored in the engine's
own conventions.
1) Post-icon text rendered on half-pixel positions. Native glyphs
always sit on INTEGER pen pixels -- the origin finalize rounds
(FUN_005cdf70) and every glyph advance is truncated (the emitter's
__ftol when node bit-7 is clear) -- but IconAdvancePen added a
fractional advance (w + 1.5 x fontH x 0.18 + ...), shifting every glyph
AFTER an icon onto half-pixel positions: bilinear-filtered, fuzzy or
ghosted post-icon text, with visibility depending on the machine's
filtering config (the "artifacting only on the text after the icon"
report). IconAdvancePen now truncates when the node snaps, the
centre/right justify pre-shift snaps the same way, and the wrap
stepper's shrink probes carry the real snap flag so the budget matches
the render advance exactly.
2) The fs-level GetStringWidth / GetStringHeight hooks now compute the
icon contribution in TRUE PEN PX -- the exact numbers the emitter
reserves, outline ink and snap truncation included -- and bridge it
into the getter's return space via the font height known in both
spaces (sumPen x fontHInt / fontHPen). ResolveFsPenFont replicates the
render chain hop for hop: nodeFontSize = internal x fs+0x7C /
[0x832A48] (the rebuild feeds internal x 0x7C to FUN_0044d420, which
normalizes it through FUN_0041ae50 -- a hop whose omission on first
flight undersized fontHPen ~40%, growing icon-bearing chat lines with
phantom tall-icon overflow and pushing money-tooltip coins off their
right anchor), floored at [0x801628]/rasterY = 2 pen px (the node
ctor), realized by the emitter's own height helper. The outline ink
resolves through face = [[fs+0xE0]+0x20] -- verified stored verbatim at
node+0x44 by FUN_005c1c30 -> FUN_005cd6d0. fs-built nodes are always in
snap mode (no fs-flag maps to node bit 7 in FUN_0044d420), so the
snapped sums are exact for every fontstring. Replaces the old
SetPoint-push-factor bridge, whose units cancelled for :0 icons but
could not express the truncation or the ink.
New Offsets: OFF_FONTSTRING_FONT_HANDLE (0xE0), OFF_FONT_HANDLE_FACE
(0x20), FUN_TEXT_NODE_INVALIDATE derivation notes extended; the 2.0
outline constant's double duty as the minimum font size is documented.
Remaining measure residual: only the engine's own trailing-icon
ink-vs-advance <=1px.
The glue AddonList greys a disabled addon's title through SetTextColor
RGB alone (gold <-> gray, alpha stays 1.0). An icon-bearing title
followed neither channel: the icons only ever mirrored the colour's
ALPHA byte, and the TEXT was frozen at its bake-time colour -- a
segmented (bit-3) node carries baked per-glyph colours, and the
engine's colour setter (FUN_005CCB40) skips the rebuild-invalidate for
bit-3 nodes because UNIFORM bit-3 text recolours live at paint. Plain
titles swapped fine; icon-bearing ones didn't.
Two mechanisms, matching what each colour channel means:
- Text: re-bake on base-RGB change, everywhere. The emitter stamps the
node's base colour (node+0x2c) at bake; the flush compares it against
the live value and, on an RGB divergence, calls the engine's own node
invalidate (FUN_TEXT_NODE_INVALIDATE = 0x005CDEF0 -- exactly what the
colour setter does for accumulation nodes: clears the built state and
frees the page buffers, so the next paint re-emits). The re-bake
recolours base-coloured glyphs while |c runs keep their own colours
-- baked per-glyph RGB cannot be patched in place. Alpha-only changes
still take the cheap in-place alpha mirror (fades animate per frame;
every baked glyph carries the node alpha, so the byte-patch is safe).
- Icons: brightness-follow on the GLUE screen only. The pool's fade
signal widens from the colour's alpha byte to the whole folded BGRA
colors[0] dword (FsColorKey), so a pure-RGB SetTextColor now trips
the change detector at all; FadedColor then scales the icon by the
text colour's VALUE (max of R/G/B) when no local player exists.
Value-follow dims but can never tint -- gold and red titles have a
255-max channel, factor 1.0 -- and in-world icons keep following
alpha only (retail semantics: guild-green chat must not tint emotes).
Verified on the glue addon list: toggling now swaps title text AND
icons together (gold + bright <-> gray + dimmed), |c-coloured segments
keep their colours through the re-bake.
Current design gains the verified coordinate-space story (pen units are
render-target pixels; per-axis K = raster / anchorExtent from four live
globals, with the failed empirical and SetPoint-factor forms recorded),
the glyph baked-alpha mirror, and the outline-ink pads. The bit-3
section documents the per-glyph colour consequence, the positioning
steps reference the per-axis conversion, and the width-hook section
explains why its own ~1468 factor is a different, self-cancelling
conversion from the placement K.
Inline |T icon regions were positioned right only at the resolution the
placement math was calibrated on. Root cause: the pen->anchor scale (K)
was derived EMPIRICALLY (node origin / fs rect justify edge) -- chat
could never derive its own (its rect edge sits near screen x = 0 and
failed the divide-guard), so it rode a global cache seeded by whichever
fontstring derived first and clamped to +/-10% forever. Wrong seed =
icon size and position drifting together, amplified by screen distance
(the skull rendering lines away from its chat message).
The engine's own conversion chain, decompiled end to end this session:
FUN_0041ade0: block/node positions are stored NORMALIZED -- fs-local
anchor x / [0x832A44] (screen width in anchor units), y / [0x832A48]
(its previously unmapped y sibling).
FUN_005cdf70: node origin (+0x70/+0x74) = round(normalized x
[0xC2B9A4 / 0xC2B9A0]) -- integer render-target pixel dimensions,
justify shift and vertical align folded in.
FUN_005c2b50: writes those raster ints from the live render-target
rect; every font re-rasterizes when they change.
So pen units are render-target pixels and K = raster / anchorExtent,
per axis, from four live globals -- no calibration, no cache, no fs
scale term. Verified against a live probe: rasterX/anchorW matched the
measured origin/rect-edge quotient to four digits, and icons now land
correctly at 2560x1080 and 1024x768 (previously 1080p-only).
Also fixes the coin-into-digits clip the exact K exposed: outlined
faces (pfUI money bars) draw INK past the glyph advance, which the old
miscalibrated K had been masking with accidental slack. The engine
quantifies that ink itself (emitter prologue: face+0x180 bit 3 -> 4.0
pen px, bit 0 -> 2.0, both .rdata constants); OutlineInkPen reads the
same flags and constants, half leading and half trailing every icon,
threaded through the shared advance helper (emitter advance, justify
pre-shift, wrap-stepper shrink probes). The fs-level GetStringWidth
adjustment passes ink 0 (it cannot cheaply resolve the gxu face) --
outlined icon text measures 2-4px short per icon there; render-side is
exact.
New Offsets: VAR_TEXT_RASTER_X/Y, VAR_UI_ANCHOR_SCREEN_H,
OFF_FONTFACE_FLAGS, FLOAT_OUTLINE_EXTRA_THICK/THIN, each with the
derivation chain.
An axis-aligned glyph quad samples its atlas cell on exact texel
boundaries, so bilinear filtering never crosses into a neighbouring
glyph. A quad rotated by FontString:SetRotation samples along
diagonals, and at the cell border the filter pulls in texels from
ADJACENT cells -- rendering as dotted fragments of other glyphs strung
along the rotated text's edges. Machine-dependent (renderer, driver
filtering, resolution/UI-scale phase decide whether samples land inside
the cell), which is why it reproduced on a user's 1440p setup but not
locally.
The engine has the same hazard with its bit-7 font nodes and ships the
fix: FUN_TEXT_EMITTER's init-once block (0x005CCBE0) sets four globals
to +/-0.001953125 (half a texel of the 256px font page) and shrinks
each glyph's UV rect by that per edge. Mirror exactly that inset for
quads we rotate: InsetGlyphUVs walks the node's page buffers in
four-vert quads and nudges each vert's u/v toward the cell centre.
The rotation baseline now captures/restores UVs alongside positions so
the inset never compounds across restore->rotate cycles, and
SetRotation(0) puts the untouched cell edges back. Unrotated text is
unaffected.
Replace the raw `*reinterpret_cast<const T *>(base + Offsets::OFF_X)`
idiom (and its double-cast variant when base wasn't already a byte
pointer) with three typed accessors in Game.h:
Game::Read<T>(base, offset) field value read
Game::Ref<T>(base, offset) writable field lvalue
Game::Ptr<T>(base, offset) pointer to a field (arrays / out-params)
Game::Read<T>(address) / Ref<T>(address) fixed-VA globals
Pure mechanical refactor, zero behavior change: ~330 cast sites across
84 files collapse to 651 helper calls. Offsets stay in Offsets.h per the
single-source-of-truth rule — the helpers only remove the cast noise and
fold away the inner `reinterpret_cast<uint8_t*>(obj)` base casts.
Left as raw casts by design: FUN_* function-pointer casts, void*/char*
VA handles passed as arguments (caches, method registries), subobject-
anchor pointers fed to __thiscall helpers, `void *volatile` player-ptr
readiness gates (converting would strip volatile), and VA-as-array-base
captures that are subscripted later.
Text with an inline |T...|t icon stopped respecting its parent frame's
alpha: the icon faded, the glyphs stayed fully opaque. Iconless text was
unaffected.
Root cause: a fontstring's live opacity (SetTextColor alpha x the frame
chain's effective alpha, re-folded by FUN_0077fac0 on every alpha
change) lands in the node's uniform colour at node+0x2c, which the
paint consults PER FRAME for uniform text. But a segmented icon line
carries baked PER-GLYPH colours (the emitter hook clears node-flag bit
3), which the paint copy-out FUN_005c8710 uses verbatim with no later
modulation -- and FUN_005ccb40 skips the colour-change rebuild for
bit-3 nodes, so those baked alphas never refreshed. On top of that the
emitter hook force-baked alpha 0xFF: a misdiagnosis of the pfUI-bubble
"black text" bug, where the observed 0x07 was the bubble's live folded
alpha at build time (it built while faded out), not a stale default.
Fix, systemic for both bugs:
- Drop the 0xFF force -- colorState's build-time alpha (a node+0x2c
copy) is already correct.
- Mirror the node's live alpha byte into every baked glyph colour from
the flush walk, change-detected per page (a byte compare per frame
until an actual fade). Same signal and cadence as the icon fade
mirror; bit-3-clear (chat) nodes excluded -- the engine re-bakes
those on colour change itself.
Adds OFF_TEXT_PAGE_COLOR_COUNT (0x1C), OFF_TEXT_PAGE_COLORS (0x20) and
OFF_TEXT_NODE_COLOR (0x2C) to Offsets.h with the derivation chain.
* fix(texture): measure vertex offsets against the untouched layout rect
* fix(texture): honor SetTexCoordModifiesRect instead of dropping the crop
The flag is not inert in 1.12. Script_SetTexCoordModifiesRect
(0x0079C080) writes the Lua boolean to region+0x124 and
Script_GetTexCoordModifiesRect (0x0079C120) reads it back (1/nil), and
all three FUN_REGION_TEXCOORD_CROP call sites gate on that field: the
4-arg and 8-arg SetTexCoord writers (FUN_00770410 / FUN_007704C0)
crop+store only when it is set, and the layout-resolve store path
(FUN_00770670) crops when set / stores the raw anchor rect when clear.
The earlier "toggling it changes nothing" probe was a timing artifact:
the crop applies at the next SetTexCoord or layout resolve, never
retroactively.
So instead of removing the crop call, ApplyFromRect now applies it
under the engine's own gate. Default state (flag clear) behaves exactly
as this PR intends -- untouched layout rect, wedges fixed -- while a
region that DID opt in keeps its offsets measured against the same
cropped rect the engine stores, so the immediate apply and the
corner-store co-hook can never disagree about the base rect in either
flag state.
Adds OFF_REGION_TEXCOORD_MODIFIES_RECT (0x124) to Offsets.h with the
derivation, and corrects the FUN_REGION_TEXCOORD_CROP comment that
implied a partial SetTexCoord always shrinks the drawn quad.
---------
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
SetCreature(creatureID) resolves through the client creature cache to a
display ID, then loads like SetDisplayInfo (uncached entry = no-op,
documented with a RequestLoadCreatureByID recipe).
Character-based displays (extendedDisplayInfoID != 0) are now dressed by
the engine's own CharComponent compositor, mirroring
CGUnit_C::UpdateCharacterCustomization's NPC branch field for field:
baked body texture, hair/facial-hair geosets (CharHairGeosets,
CharacterFacialHairStyles), equipment geosets (sleeves, robe->trousers,
tabard), and helm/shoulder attachment models with HelmetGeosetVisData
hiding. Dress jobs run on WorldTick behind the engine's own
model-loaded gate, one compositor per Model frame, kept until the frame
loads another display or the reload teardown (PrepareForReload).
Attached helm/shoulder children additionally get the frame's light/fog
pre-render callback: the frame only registers it on its own instance,
so the children rendered pure black (verified in-game -- a light tint
colored the body but not the equipment). Weapons cannot show: NPC
weapons are server-pushed per spawned unit, not in any client table.
The manual baked-skin/hair-texture appliers are superseded by the
compositor and removed, along with the geoset debug probes
(_classicapi_ModelInstance/Peek/Poke).
Creature::Info gains a public DisplayID(creatureID) for the cache
resolve.
The fontstring analog of Texture:GetCorners — returns the first glyph's
four baked vertex positions plus the bounding box of all glyph verts (12
numbers). Because the text paint (FUN_005c8710) is translate-only, these
node-local coords track the on-screen positions directly, so a 0-vs-90
dump reads the rotation direction arithmetically.
Also documents, on RotateVerts, why that dump can NOT settle rotation
CHIRALITY on its own: node-local glyph-vert Y handedness is not
determinable from the verts alone, so the visual comparison against the
retail-verified Texture:SetRotation is the arbiter. The RotateVerts sine
is unchanged (matches Texture:SetRotation / retail CCW-for-positive).
Backport the retail Model widget method (present since 5.4.8, string
verified in the 5.4.8 and 1.15.8 binaries) that points a Model frame at
a creature by its display ID rather than a raw .mdx path.
Resolve creatureDisplayID -> CreatureDisplayInfo[+0x04] ModelID ->
CreatureModelData[+0x08] ModelPath, load it through SetModel's own worker
(vtable+0x94), then apply the display's TextureVariation columns as
replaceable-texture skins (MONSTER_1/2/3 = slots 11/12/13). The DBC
column offsets are verified two ways: parsing the extracted 1.12 DBCs and
the engine's own unit-display resolver FUN_0060afb0.
Verified in-game: displays 13/330 (human NPCs) load correctly textured
and lit; missing assets fall back to the engine's error cube.
Only the single-model form is implemented; the optional mountDisplayID
(rider-on-mount) second arg is a separate follow-up.
* fix(texture): correct vertex-offset units and the corner-store contract
The SetVertexOffset/SetRotation corner writes were wrong in four coupled
ways, found by driving pixel-ruler and wedge fixtures against a raw
corner-array readback:
- Offsets converted px->anchor with the global 1024-normalized factor
alone, so a region scaled 0.9 got every offset 1/0.9 too large (a 24px
request measured at 26.67px). Offsets are now stored in the caller's
LOCAL pixels and folded through the region's live effective scale
(+0x7C) inside WriteCorners, which also makes them track later scale
changes. Verified at two scale points (0.9 and 0.45): a 24px offset
lands at 24.00px on both.
- The anchor space is Y-DOWN (a rect reads top=0.222 < bottom=0.277),
so +offsetY -- up, the retail contract -- is numerically negative
there. It was added positive, sending every vertical offset the wrong
way.
- The draw consumes the +0xD4 array with y INVERTED about the rect's
horizontal midline, and CULLS a quad whose slot y-parity flips: quads
stored at their target positions rendered as exact y-mirrors of the
stored corners, and mirroring the values in place culled every
transformed quad, including a plain rotated square. Each corner's
mirrored data is therefore written into its VERTICAL PARTNER's slot
(BL<->TL, BR<->TR): positions land on target after the draw's flip,
and the slot parity keeps the winding the draw accepts. For an
axis-aligned rect this degenerates to exactly what the engine itself
writes -- which is why none of it was visible on plain rects or
centered squares, both mirror-invariant as point sets.
- With that store contract, the y-down-transposed rotation matrix is
the one that renders a positive angle counter-clockwise (settled with
a rotated-bar fixture; squares and near-equilateral arrows are
direction-blind).
Only regions carrying an active transform are affected: the corner-store
co-hook passes untracked regions through unchanged.
* feat(texture): GetCorners debug binding
texture:GetCorners() returns the raw drawn-quad corner array (+0xD4)
and the resolved layout rect -- 12 numbers, no unit conversion.
Diagnostic tooling: comparing stored corners against rendered pixels is
what made the corner-store contract measurable from Lua. Kept for
future transform triage; note that with the mirrored-partner store the
raw values deliberately differ from the on-screen positions.
Generalizes the region-transform module (formerly texture-only) to serve
the FontString method registry too, backporting the modern
`fontstring:SetRotation(angle [, cx, cy])` and `GetRotation()`.
Rotation is visual only, matching retail: GetStringWidth/GetStringHeight/
GetRect/SetPoint stay axis-aligned. A positive angle is CCW, same
convention as Texture:SetRotation; the optional cx,cy pivot is a superset
of retail (centre-only).
How it works: the text paint copy-out (FUN_005c8710) is translate-only,
so a rotation baked into the node-local glyph verts survives to screen.
SetRotation rotates the live verts immediately (like the texture path)
around the vert bounding-box centre, idempotently via an axis-aligned
baseline snapshot (restore -> rotate). The baseline is re-captured on
every fresh vertex bake, wired through inline-texture's DrawBuilder
co-hook (gated on g_buildEmitSeq > 0 so clean paints never double-rotate,
since inline-texture owns every text-render hook). Node page-buffer
vertex layout added to Offsets.h (cross-verified across the emitter,
FUN_005c8710, and FUN_005ce0c0).
Known limitation: inline |T icons in a rotated fontstring don't turn with
the text (separate anchored regions). Plain text rotates correctly.
Post-settle `WarmCache` fired one CMSG_ITEM_QUERY_SINGLE per item
immediately, and `OnWorldTick` escalation issued every pending item in a
single tick. A `/reload` that makes addons (pfQuest/Questie/Bagnon) look
up hundreds of items in one frame therefore emitted an unbounded packet
burst — a reported 1,325 requests in ~14 ms exhausted a consumer
router's NAT table and dropped the machine's internet connectivity.
Make `OnWorldTick` the single point that issues queries and rate-limit
it. All warmups now route through the pending queue (`WarmCache` ->
`Track`); background misses go out at most one per 50 ms (20/sec). The
timeout now counts only after a query is actually issued, so a throttled
backlog no longer fires spurious failures for items never queried.
To keep the throttle from delaying the player's own inventory on a cold
cache, add a priority tier: the carried items (equipment + backpack +
equipped bags) issue first on a separate budget (up to 6/tick, no
spacing floor). The owned set is a bounded snapshot built by a pure-C
inventory walk (no Lua stack — safe at world-tick time), refreshed at
most once per second and only while there is queued work. Peak issue
rate stays ~7 packets/frame, far below the flood.
Fixes#25
The unit-token twin of C_CreatureInfo.GetCreatureID — resolves the token
to a GUID and reads the creature-template entry vanilla packs into bits
24-47. nil for players, empty tokens, and non-creature/pet GUIDs.
Factor the entry extraction + creature/pet gate out of GetCreatureID into
a shared Guid::CreatureEntry so both callers use one source of truth for
the GUID bit layout.
It's a Unit* function taking a unit token, so it belongs with the other
Unit calls, not under Spell. Relocates the API.md section + ToC entry and
the README calls-table row.
Returns the name of the unit a caster is casting/channeling a spell at,
or nil. The cast's target rides in SMSG_SPELL_START (SpellCastTargets:
u16 mask + packed GUID when TARGET_FLAG_UNIT) — we already parse that
packet for cast timing and just stopped before the target block. Extend
the parse to capture the target GUID for the player (g_castTargetGuid)
and remote units (RemoteCast.targetGuid), gated on an active cast so a
stale target is never returned.
nil when the unit isn't casting, the spell has no unit target
(self/ground/item), or the target's name can't be resolved.
Factor the GUID->name chain (object manager -> friends -> NameCache) out
of UnitNameFromGUID into a shared Player::Info::NameFromGuid so both
callers use one source of truth.
Adds a Behaviors row for the inline `|T…|t` texture backport (icons in
FontStrings, chat, and tooltips; icon-inclusive GetStringWidth/Height).
Raid marks were already covered by the `mark1`..`mark8` Unit tokens row.
A `type="spell"` click-cast button with no `unit` attribute did nothing:
the `spell` verb bailed the moment no unit resolved. Now it reads the
`spell` attribute first, then branches — with a `unit` it casts at that
unit via C_Spell.CastAtUnit (unchanged), and with no `unit` it falls back
to the plain global CastSpellByName on the current target, like `/cast`.
Fixes#18.
Throwing a grenade and moving mid-cast fired a false UNIT_SPELLCAST_INTERRUPTED
and cut the bar short, even though the throw completed (issue #23). A cast is
interrupted by movement only if its Spell.dbc InterruptFlags carry
SPELL_INTERRUPT_FLAG_MOVEMENT (verified against the server's HandleMovement-
Opcodes and the client's own CheckCast); thrown items (e.g. spell 4068) lack it,
so the server never interrupts them on movement, but the engine still drops the
client cast-state, which our poll surfaced as an interrupt.
Fix: when the movement path clears a movement-immune cast WHILE THE PLAYER IS
MOVING (PlayerMoving mirrors the engine's 0x200f cast-drop mask), keep it tracked
instead of ending it; it then finishes as SUCCEEDED (no INTERRUPTED) or, if its
SPELL_GO never comes, ends a grace past its computed end. A real cancel/interrupt
happens with the player stationary, so it clears normally. Gated on a per-cast
latch, so normal (movement-interruptible) casts are unchanged.
Also de-dup UNIT_SPELLCAST_INTERRUPTED: the client fires Spell_C_SpellFailed AND
drops the tracked cast for the same interrupt (e.g. an Esc cancel), which fired
the event twice; PollPlayer now skips its own when SpellFailed_h already fired
one for the same spell. SuperWoW-style kicks (no Spell_C_SpellFailed) stay owned
by the poll.
New Offsets.h constants: SPELL_INTERRUPT_FLAG_MOVEMENT (InterruptFlags bit 0),
MOVEFLAG_MASK_CAST_DROP (0x200f). Wrap-safe deadline/window checks use
Time::Clock::Reached / Elapsed.
Fixes#23.
CastSpellNoToggle(name | spellID [, unit]) now takes a unit token as an
optional second argument, so the spell fires at that unit without
changing the current target:
CastSpellNoToggle("Auto Shot", "focus")
CastSpellNoToggle("Shoot", "targettarget")
The spam-safe toggle gates run first and are unchanged: if the requested
spell is already auto-repeating or its self-aura is already up, the call
stays a no-op and the unit is ignored. When the spell does cast and a
unit is supplied, it dispatches through the existing Spell::AtUnit core
(resolve token -> GUID -> dispatch) instead of the plain CastSpellByName
-- a unit-target/auto-repeat spell fires straight at the unit, a ground
spell lands at its feet, and an unknown token raises the engine's
standard "Unknown unit" error. No new offsets or hooks.
Fixes#22.
* feat(bindings): backport direct-action and override binding APIs
Add SetBindingSpell/Item/Macro/Click plus the owner-scoped
SetOverrideBinding family. Store permanent commands through vanilla
SetBinding, execute SPELL/ITEM/MACRO/CLICK in the resolved-command hook,
and layer priority overrides ahead of the native binding table.
Factor raw and saved macro dispatch into Macro::Execute so click
attributes and binding commands share the same parser fallback. Preserve
key-down/key-up pairing when overrides change during an action.
Document return values, persistence, precedence, macro-text usage, and
the deliberate 1.12 differences in README and docs/API.md.
* fix(bindings): normalize override key order and preserve command exec-context
Two fidelity gaps in the override binding layer, both verified against the
1.12 binary:
- Override key matching now normalizes modifier order to the engine's own
keypress form (ALT-CTRL-SHIFT- before the base key, per FUN_004b6630), so a
caller passing "CTRL-ALT-F" matches the "ALT-CTRL-F" the engine builds at
keypress. Prefix-stripping keeps the minus base key intact ("SHIFT--").
- Override-dispatched commands now run inside the same execution context the
engine wraps around a native keypress (save/zero/restore DAT_00ceeac0, its
nesting depth DAT_00ceeac4, and the manager +0xD8 mid-command flag, per
FUN_004b7990). Without it, an ordinary Bindings.xml command routed through
SetOverrideBinding ran without the context its handler expects. The native
binding-table path is untouched (the engine already establishes context
before the co-hooked FUN_004b7b50).
Adds the three verified offsets to Offsets.h and documents the any-order,
case-insensitive key handling in docs/API.md.
---------
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
* feat(text): inline |T...|t texture escapes in chat and display text
Backport modern inline-texture markup to the 1.12 text engine: |Tpath:h|t
renders an icon inline in FontStrings / chat, for BLP and uncompressed TGA.
- Rendering primitive: resolve a path to a resident CGxTexture via FUN_0044acf0
and draw it as a textured quad through the UI text VB path.
- Positioning: co-hook the per-line glyph emitter (FUN_005ccbe0); render plain
runs by delegating to the original per segment (threading the pen through
linkState[4], read as a float), record icon quads at the pen, and flush them
in the paint pass (FUN_005c8fe0) translated by each node origin. Handles the
FontString pipe-doubling (|T -> ||T), the bit-3 per-call batch-clear mode,
multi-icon and multi-line accumulation, and font-relative vertical centering.
- Measure/wrap: co-hook the shared tokenizer (FUN_005c2810) so an inline-texture
span is one near-zero-width token, so icons no longer force early line wrap.
- Editbox exclusion: read the focused-editbox global (DAT_00cf4dc8) and suppress
icon recording while an input field is focused, so editboxes show raw,
editable markup while cached display text keeps its icons.
Diagnostic scaffolding (stats/capture Lua helpers) is still present. Follow-ups:
exact icon width in measure (hover-box accuracy), texcoords for sprite sheets,
vertex color, and the already-built editbox case. See docs/InlineTextureEscapes.md.
* fix(text): exclude editboxes from inline textures via node flag bit 6
Editable text (node flags [+0x5c] bit 6 / 0x40 -- verified in-game: the macro
editbox is 0x4D vs chat display 0x20D and FontStrings 0x0D) now shows raw,
editable |T...|t markup instead of rendered icons, checked per node in the
tokenizer, emitter, and flush. This is the 1.12 analog of 4.3.4's per-render
texture-disable flag (tokenizer bit 0x1000).
Replaces the earlier focus-global-only exclusion, which only caught single-line
inputs that rebuild on each keystroke; bit 6 also covers multi-line editors
(the macro editor) that build once while un-focused. The keyboard-focus global
(DAT_00cf4dc8) is kept as a fallback. Removes the failed monochrome heuristic.
Diagnostic scaffolding remains; a docs update and cleanup pass follow.
* feat(text): inline-texture texcoords + fix vertical flip
- Add the width / offset / texcoord fields to the escape
(:width:offsetX:offsetY:texW:texH:left:right:top:bottom), so a sprite sheet
such as UI-RaidTargetingIcons can be cropped to a single cell.
- Fix a vertical flip: the UI device backend is OpenGL (bottom-left texture
origin), so top screen corners map to v1 and bottom corners to v0. The flip
was invisible on symmetric icons but obvious on directional ones (raid-target
markers, swords).
* docs(text): document the completed inline-texture RE map
Fold this session's findings into InlineTextureEscapes.md now that the feature
ships: the frame -> renderNode -> group -> node -> line render hierarchy;
segmented-delegation positioning (the float linkState[4] pen read, the bit-3
per-call batch-clear handling, first-line multi-line accumulation); font-relative
vertical centring; the FontString pipe-doubling (|T arrives as ||T); the shared
tokenizer measure/wrap fix; editbox exclusion via node flag bit 6 (0x40), with
the failed focus-global and monochrome attempts recorded so they aren't retried;
and the texcoord fields plus the OpenGL bottom-left vertical flip.
* chore(text): enable inline textures by default on this branch
* feat(text): inline-texture vertex colour tint (:r:g:b)
Parse the trailing r:g:b (0-255) positional fields of the inline-texture
escape and apply them as the quad's vertex colour, so |T...:r:g:b|t tints
the icon (the font stage's default MODULATE COLOROP does texture.rgb x
tint; white = untinted). The bytes pack to 0xFFrrggbb -- the same
0xAARRGGBB order the tokenizer builds for |cAARRGGBB text, verified from
FUN_005c2810's colour branch -- so the value flows straight into the
existing vertex colour slot. Threaded parse -> IconDesc -> IconRecord ->
DrawTexturedQuad. Verified in-game (red/green/blue/white diamonds).
Also record the hover-accuracy (measure-width) verdict in the docs: the
tokenizer reports an icon as ~zero width, so GetStringWidth/wrap/hit-test
undercount it. Scoped by disassembly (FUN_005c6940 width backend,
FUN_005c6b70 per-glyph advance, FUN_005cdc20/FUN_005c7260 wrap): the token
contract has no width field, there is no single cold choke, and the only
fixes are hot per-glyph hooks (against the MinHook-collision guidance) or
~6 parallel measure-loop hooks -- for a mostly-cosmetic gain. Deferred.
* docs(text): record editbox/measure findings; strip inline-texture debug scaffolding
No behavior change — functionally identical to the shipped inline-texture
feature. This captures what a long investigation established and removes the
temporary debug instrumentation added during it.
Comments (src/text/InlineTexture.cpp):
- The tokenizer's `flags & 0x40` gate is load-bearing for RENDER, not just
measure: a suppressed editbox delegates to the ORIGINAL emitter, which
re-enters the tokenizer to lay out glyphs, so bit 0x40 (EDITABLE) must make
the tokenizer stand down or the macro editor draws BLANK instead of raw.
- bit 6 (NodeEditable) and the focus global (InputFocused) are COMPLEMENTARY,
not redundant: bit 6 catches the un-focused macro editor; the focus global is
the only signal for single-line focused inputs (chat box, pfChatCopyBox),
whose nodes lack bit 6 (0x0D/0x20D, indistinguishable from display).
- Removed the tokenizer call/pipe/span counters and the ZZWIDTH TokCap capture
(debug-only), and the now-unused g_lastFocused/g_lastMono stats fields.
Docs (docs/InlineTextureEscapes.md):
- Editbox exclusion: two complementary signals, and VERIFIED against retail —
typing `|T` into a chat box or macro editor shows raw there too; only
addon/system text renders. Our editbox-raw + display-renders split matches.
- Measure gap: added the focus-time caveat (icon ~0 width unfocused; counts the
literal path text while an editbox is focused — the editbox-raw tradeoff).
- Vertex-colour tint documented in the descriptor-fields section.
* feat(chat): strip player-injected |T icon spoofs from chat + bubbles
Vanilla neutralizes player-TYPED escapes in chat (a typed |c/|H/|T shows
raw); only trusted text (addon AddMessage, shift-clicked links) renders
them. Our Text::InlineTexture backport broke that parity for |T alone: it
detects the neutralized ||T form and draws the icon anyway, so a player
could /say |Tpath|t and put a real icon in everyone's chat AND the speech
bubble over their head. This restores vanilla behavior for |T while leaving
|c/|r/|H/|h exactly as vanilla.
It can only be done at the chat source, not the text emitter: addon |T and
player |T reach the emitter as byte-identical ||T (vanilla doubles the
unrecognized |T for both), so the only place they differ is provenance --
server chat flows through the chat dispatcher FUN_0049A870, addon print does
not. That one function copies the message into a single buffer fed to BOTH
the chat frame and the say/yell bubble spawn (FUN_00608AC0), so sanitizing
on entry covers both. (ECX is the MESSAGE, not the "sender name" the old
note claimed -- verified from the call site in FUN_0049D560.)
Structure mirrors Net::PacketDispatch (one hook, fanned out), since MinHook
allows only one hook per target and this choke point already had the
GUID-capture concern on it:
- Chat::Dispatch -- single owner of the FUN_0049A870 hook; orchestrates
- Chat::IconFilter -- pure Sanitize(); no hook, no engine state
- Chat::CurrentGUID -- GUID globals + getter + DispatchScope RAII (no
longer owns a hook)
Defang detail (the subtle bit): the message arrives doubled (||T). Blanking
the PIPE would leave a lone `|` + space -- an INVALID escape the chat frame
tolerates but the bubble text setter chokes on, producing an empty bubble
that fades instantly. So we keep the `||` (a valid literal-pipe escape) and
blank the `T`; for a lone |T we blank both chars. No dangling `|`, no |T
opener survives. Verified in-game: typed |T shows raw in chat and bubble,
addon icons still render, colours and links untouched.
* feat(text): per-editbox inline-texture suppression
Inline-texture rendering was globally suppressed whenever any editbox held
focus (VAR_FOCUSED_EDITBOX != 0), so opening the chat box flipped every
rendered icon/emote in chat history to raw |T...|t.
Replace the global check with per-editbox buffer correlation: suppress inline
rendering only for text that belongs to the FOCUSED editbox, matched by
pointer range against its own text buffers. Chat history keeps rendering while
you type.
The editbox keeps its text in two buffers (both verified via Ghidra), and both
must be matched or the field desyncs (raw width but a rendered icon):
- INPUT buffer ([fe+0x32C]/[fe+0x334], selected by [fe+0x318]&8) — the
caret/width MEASURE path reads it in place (FUN_0077da80 -> FUN_00772ae0 ->
FUN_005c6940, which loops the tokenizer).
- DISPLAY buffer (*([fe+0x328]+0xF0)) — a SStrDup'd copy the editbox actually
RENDERS from (FUN_00771d80); the emitter node text and the re-entrant render
tokenizer see this one.
TextInFocusedEditbox() bounds each buffer by a tight strlen (distinct heap
allocations -> no false-match cap) and runs only on pipe-leading tokens after a
null-focus short-circuit, so it stays off the hot path. A display FontString
measured while an editbox is focused matches neither buffer, so its icons now
measure correctly -- an improvement over the old global.
Editbox struct offsets centralized in Offsets.h (OFF_EDITBOX_BUFFER_SELECT /
BUFFER / BUFFER_MASKED / TEXT_FONTSTRING).
* feat(chat): raid-target marker substitutions ({rt1}/{skull}/…)
Expand the modern raid-target marker tokens in chat into inline icons, backported
onto the inline-texture renderer. {rt1}..{rt8} plus the named aliases ({star},
{circle}/{coin}, {diamond}, {triangle}, {moon}, {square}, {cross}/{x}, {skull};
case-insensitive) each become a |T escape cropping Interface\TargetingFrame\
UI-RaidTargetingIcons to the matching 64x64 cell (mapping verified against the
client's UnitPopup.lua: col=(N-1)%4, row=(N-1)/4).
Chat::RaidMarkers::Substitute runs in Chat::Dispatch AFTER the |T anti-spoof
sanitize, so players get the fixed, safe marker icons without a path to inject an
arbitrary texture. It emits the pipe-DOUBLED ||T…||t form: the inline emitter
renders both, but this is what survives into speech bubbles — the vanilla bubble
text builder (FUN_004b1600) truncates a lone |T while preserving ||, and pfUI's
bubble reskin copies the bubble's FontString text into its own FontString, which
then renders the icon through the normal paint path. (This is the form bubbles
rendered before the anti-spoof filter defanged the ||T opener.)
InlineTexture emitter fixes for the standalone-FontString (bit-3, batch-clear)
render path that bubbles/tooltips use — chat lines are unaffected:
- Colour: clearing bit 3 switches the engine to per-glyph colour from
colorState=[node+0x2c], whose alpha byte is a stale ~0x07; force the glyph
alpha opaque (keeping RGB) so a FontString coloured via SetTextColor with plain
text doesn't render near-transparent (which showed only pfUI's dark outline =
"black text"). |c-coloured text already has full alpha, so it's a no-op for chat.
- Horizontal justify: the engine positions centre/right-justified text using the
MEASURED width, which counts inline icons as ~zero (the measure gap), so the
rendered line overflows its box. Pre-sum the line's icon widths and shift the pen
(half for centre, full for right) so text+icons justify as a unit.
- Default centerFrac 0.5 -> 0.6 (better vertical centring across chat + bubbles).
* fix(text): suppress inline textures in editboxes by content match
The per-editbox suppression missed the chat input box: a pasted `|T…|t` rendered
as an icon instead of raw markup. An in-game probe showed why — the editbox
renders its text through a TRANSIENT copy: same bytes as the input buffer, but a
distinct heap allocation with no pointer link to the editbox in either direction
(every offset/back-pointer scan of fe / its FontString / the render node came back
-1). So the prior pointer/node-identity checks (input buffer, SetText copy,
FontString +0x48, node == *(fe+0x328)) could never match the render.
Match on CONTENT instead:
- Emitter: EmitLineIsFocusedEditbox compares the render line's bytes against the
focused editbox's input buffer (the emitter is handed the whole line, so a
bounded strcmp identifies it). Chat-history lines differ, so their icons keep
rendering while an editbox is focused.
- Measure (caret/GetStringWidth): unchanged pointer path — the tokenizer reads the
input buffer in place, so TextInFocusedEditbox still matches by range.
- Re-entrant render: the suppressed emitter brackets its delegated raw layout with
a text range (g_reentryLo/Hi); the tokenizer stands down across the whole span
via TextInReentry, so `|T` isn't eaten as a zero-width token (which drew BLANK).
Drops the dead node-identity/second-and-third-buffer heuristics. Covers any focused
input (chat box, pfChatCopyBox, macro editor).
* fix(text): drop stale icon records for reused chat line FontStrings
Inline-texture icons could paint as a ghost strip at the bottom of the screen
after chat had been busy for a while. The chat frame reuses its line
FontStrings: when a line is cleared (text set empty) or reused for markup-free
text, the draw builder early-returns on it, so the emitter never runs — and so
never erases that node's old icon record. The paint flush still walked the node
and drew its now-stale record at the FontString's parked position.
Gate the flush draw on the node's LIVE text still containing `|T`: if a node has
an icon record but its current text has no markup, the record is stale — drop it
and skip drawing. Kills the bottom-of-screen ghost.
(The separate scroll-freeze — icons only refreshed on the chat's dirty repaints —
is a different, deeper issue tracked next.)
* fix(text): stop chat icons vanishing — suppress only input boxes, not editable chat display
pfUI renders the chat scrollback through EDITABLE nodes (flags 0x45: bit 6
editable, no bit 3). The inline-texture emitter's blanket NodeEditable gate
suppressed inline rendering for ALL editable nodes, so those chat icons blanked
whenever that display was live — during scroll and as messages arrived, which
read as icons "disappearing/reappearing."
Gate suppression on a true INPUT box instead: editable (bit 6) AND bit 3 (0x08).
Macro editor 0x4D and name/chat input 0xC8 all carry 0x08; the chat scrollback
0x45 does not — so real input boxes still show raw |T markup while the chat
display keeps its icons. Also drop the flush's NodeEditable draw-gate: a
suppressed input box never records, so any record reaching the flush is a real
display line.
Pointer/content match alone can't cover a focused input box because its render
form is pipe-doubled (||T) while the input buffer is single-pipe, so the compare
misses.
* feat(text): inline |T icons as fontstring-anchored engine regions
Port 4.3.4's CSimpleEmbeddedTexture model. Each icon the emitter records
becomes a pooled CSimpleTexture ANCHORED TO ITS OWNING FONTSTRING,
configured off-render on the WorldTick, and drawn by the engine's own
region pipeline. 1.12 chat lines are real CSimpleFontStrings (the
ScrollingMessageFrame display refresh FUN_00788750 drives one per visible
line), so a co-hook on CSimpleFontString::RebuildString (0x7724A0) maps
every text node to its owner and one mechanism covers chat, tooltips, and
standalone FontStrings.
This replaces the raw-GxU-quad renderer and every overlay attempt:
- engine-drawn regions keep their textures resident: no flicker, verified
across three concurrent clients
- the anchor system moves icons with their lines: scroll costs nothing
- icons mirror the owner's shown state: faded lines hide their icons
- all engine mutation happens outside the render pass: no pending-layout
corruption, survives /reload (pointers dropped in PrepareForReload)
- placement verified pixel-exact against owner rects
RE landmarks recorded in Offsets.h:
- fs+0xF8 is an HTEXTBLOCK handle; the text node is at handle+8
- Show (0x77FCB0) no-ops unless desired-shown (+0xC4) is set, and
SetParentAndLayer(show=0) clears it on reparent
- the renderer draws the corner array (+0xD4), stored from the rect by
SetTexCoord - realize (0x768060) must run between SetPoint and
SetTexCoord or the corners go stale (the historical invisible-icon and
frozen-top-left-icon bugs)
- pen coords are y-down from the owner fontstring's BOTTOM edge; anchor
offsets use Script_SetPoint's pixel scale (LinePool::PixelToInternal)
* refactor(text): inline |T icons = pixel-perfect quads + residency holders
Replace the fontstring-anchored region approach with a hybrid that
decouples position from residency, because the two live in different
coordinate spaces that no single scale could reconcile:
- POSITION: draw each icon as a raw GxU quad in the text engine's own
render space (pen + node origin), the same coordinate system and
dynamic VB the glyphs use. Pixel-exact by construction — no anchor/
layout-space conversion. This is the original quad math; the region
detour proved pen-space and anchor-space differ by the text paint's
projection, not a scalar, so region anchoring could never be exact.
- RESIDENCY: a raw quad binds an unowned texture the engine evicts under
VRAM pressure (the flicker on scroll / blank on a 2nd client). So for
each distinct texture path, Text::InlineTexturePool creates ONE managed
CSimpleTexture, draws it every frame (a ~2px, alpha 1/255 corner box),
and never touches it again — its engine-managed draw is the live
reference that keeps the texture resident for the quads to bind.
Verified in-game: icons pixel-perfect across chat / bubbles / wrapped
lines / sizes / tint, no flicker on scroll, and two concurrent clients
both render — the residency-holder premise (untested before: the earlier
"disproof" had non-drawing holders) finally validated with a holder that
actually draws. Non-zero holder alpha is load-bearing: a fully
transparent region gets culled and residency is lost.
Diagnostics (probe, perf counters, scale/bias tune knobs) stripped; the
SEH fault guard on the flush stays.
* feat(text): render inline |T icons as engine regions anchored to their fontstring
Replace the raw-quad + residency-holder hybrid as the default renderer with
the 4.3.4 CSimpleEmbeddedTexture model: one pooled CSimpleTexture per visible
icon, anchored RELATIVE TO THE OWNING FONTSTRING. Engine-drawn, so texture
residency (the chat scroll flicker / blank 2nd client) is impossible by
construction; engine-anchored, so icons track their line for free.
The pen<->anchor bridge that unblocked this: RebuildString (0x7724A0)
multiplies every text-unit quantity by fs+0x7C when creating the text node,
and the node origin is the fontstring's justification anchor point mapped by
a single measurable scale K (verified with two-position probes: origin =
rectJustifyRef x K, K ~ 1470 = physical px per anchor unit, both axes, no
drift). Placement pipeline:
- RebuildString co-hook records node -> owning fontstring (a node only
speaks for its fs while it IS fs+0xF8->block+8, killing the stale-node
clear race around rebuilds).
- The paint hook converts each icon's pen-space quad through K and queues
fs-relative placements; regions are keyed BY FONTSTRING (nodes die on
every SetText - node keying orphaned regions into an accumulating wall).
- Placements apply on WorldTick, never mid-render. SetPoint offsets are
divided by the region's effective scale (+0x7C; anchor resolution
multiplies stored offsets by the scale chain - measured 0.0319 stored ->
0.0290 resolved = x0.91), then converged exactly by a per-region rect
readback correction each tick (deterministic, self-healing; a same-call
readback read stale pooled rects and shipped 7.4px crests).
- Engine SetTexCoord (0x770410) takes the 4-float rect Y-FIRST INTERLEAVED
{v0,u0,v1,u1} (diagnosed in-game: {u,v,u,v} rendered the raid-mark moon
as the circle cell); it is absolute, and the +0x124 shrink-by-UV-span
flag stays clear so crops stretch to the rect like modern clients.
- Expired/hidden lines are swept every tick (regions parent to the chat
frame, not the line, so they cannot inherit the line's own hide).
The quad renderer and residency holders remain behind
_classicapi_InlineTexMode (0 = quads, 1 = regions [default], 2 = overlay
A/B) as the bring-up oracle, plus diagnostics (_classicapi_InlineTexProbeFS
/ RegionDump / RegionCal / HolderCfg). Calibrated residual: RegionCal
(3, -1) pen units.
* fix(text): apply the inline-icon LEAD pad at draw time
The emitter reserves w + 1.5x pad in every inline icon's advance (lead 1x,
trail 0.5x), but FlushLayout drew the icon at the raw pen - all reserved gap
landed AFTER the icon, none before. Mid-text icons looked balanced only
because their surplus trailing gap padded the following text; a string-FINAL
icon (the money-string copper coin) sat jammed against its digits, and the
locale format strings had grown per-coin offsetX hacks to compensate.
Draw at pen + w x iconPadFrac (quads and regions share the cx), matching the
emitter's reservation. The coin formats drop to zero offsets - verified
against the texture itself: the three coin arts in UI-MoneyIcons are
byte-identical in alpha (same bounds, same center of mass, measured from the
exported BLP's DXT3 data), so no coin ever needed a positional nudge.
RegionCal's baked x constant drops 3 -> 0: the +3 calibrated pre-fix was
exactly this missing lead pad (user re-calibrated (0,-1) post-fix). The 1px
y residual remains the pen->anchor map's one true constant.
* fix(text): make inline-icon state lifetime-exact; fix every scroll-landing loss
Chat icons randomly landed shown-or-hidden when lines scrolled, and stale
icons ghosted at parked positions. Root causes, each found by in-game
triangulation (FsDump/Broken/Stats diagnostics, included), all fixed:
- Placements are FS-RELATIVE, computed at PAINT time in one coherent
snapshot (icon coords + fs rect in the same flush). The old apply-time fs
rect read raced the chat relayout after SetText and parked icons off
their line, where the dedup froze them.
- The queue tail gate now matches the per-icon build gate EXACTLY (incl.
K); any not-ready input skips-and-retries instead of queueing {} - an
empty queue froze lines iconless via the dedup (early-session K window).
- K (pen->anchor scale) is derived PER NODE from its own fontstring - pen
space scales with each fs's frame-scale chain, so a global chat-derived K
mis-sized icons on differently-scaled fontstrings. Global cache kept as a
+/-10%-clamped fallback.
- Stuck-blockless fontstrings (RebuildString releases the block on a
SetText that lands on an unresolved rect and never rebuilds; the zombie
node keeps painting) are re-dirtied so the engine rebuilds - the icon
pipeline then resumes naturally.
- First-line-of-build detection comes from a draw-builder co-hook
(FUN_005CDC20) build stamp - exact - with the old text-pointer heuristic
only as fallback for emitter paths outside the builder.
- THE LIFETIME FIX: co-hook the gxu text-node free (FUN_005CD950, the
single site where every node dies and its address becomes reusable) and
erase ALL per-node state there. Anything in the maps now refers to a
live node by construction: inherited ghost records on recycled nameplate
fontstrings, orphaned owners (dropNoFs 61k -> 0), and reload leftovers
lose their mechanism of existence.
- Ghost guard rescans the FONTSTRING's text (fs+0xF0, proven trustworthy),
not the node text pointer (which lied on pfUI chat lines and made the old
scan erase LIVE records every frame - the persistent iconless LFG lines).
False matches can only false-keep, never false-erase.
- Freshness TTL: regions stay only while the paint keeps re-queueing their
fontstring (deduped touches count); parked/orphaned fs regions expire in
~1s. Do NOT build-version records against the builder counter - the
builder runs every paint but only emits when dirty, so a version check
erases good records on every clean paint (the all-icons-gone regression).
Diagnostics added (Lua): _classicapi_InlineTexFsDump (pipeline triangulation
with chain classification), _classicapi_InlineTexBroken (names the broken
lines), _classicapi_InlineTexStats gains queued/drop/nudge counters and the
SEH fault address.
Also removes the FrameTick glue-screen attempt (b164d68): hooking the UI
render root regressed in-world chat; glue-screen icon support will be
re-approached without touching the in-world render path. The
FUN_UI_RENDER_ROOT offset stays documented for that retry.
* fix(text): pointer probes must allow Large-Address-Aware heap (>2GB)
LooksReadable capped plausible pointers at 0x7FFF0000. This client is LAA
(VanillaFixes), so heap allocations above 0x80000000 are valid and routine
once the heap grows - and the flush's layout walk BREAKS on an "unreadable"
node, so a single high-heap node silently truncated the walk and every node
AFTER it in the layout list lost its icons. That was the irreducible random
per-ROW missing-chat-icon bug that survived every renderer architecture
since the guard shipped with the first inline-texture commit (aa94ea5): it
fired only when the allocator placed a node/fontstring above 2GB, varying
per session with addon load and uptime. The same check also rejected
high-heap fontstrings (dropNoFs storms, "unreadable" owner chains).
Bound raised to 0xFFFF0000 in both copies.
Diagnosed with the per-node build/emit instrumentation added here: the
smoking-gun Broken() trio was builtKnown=1 (engine built the node),
emitOutcome=8 (emitter recorded its icons), nodeSeenAge=-1 (our walk never
once saw it). _classicapi_InlineTexBroken now reports both new fields.
* feat(text): render inline |T icons on the glue (login) screen
Inline icons never appeared on the login / character-select screens: the
text-pipeline hooks are engine-generic and fire on glue (the |T spans are
consumed there), but InlineTexturePool::Maintain - the step that creates and
shows the icon regions - was driven by Tick::WorldTick, and FUN_WORLD_TICK
(0x0066FD50) is the world-simulation tick, silent on glue. Queued placements
were never applied.
Add Tick::FrameTick, hooked at FUN_UI_RENDER_ROOT (0x00764330) - the
CSimpleTop singleton's per-frame render callback, registered by both the
world-init (FUN_0048FBF0) and glue-boot (FUN_0046A7B0) paths and drained
every frame by the master scene-render pump. Subscribers run at the tail of
the callback (strata walk complete, no frame mid-draw). The pool's Maintain
moves onto FrameTick as its ONLY tick: one driver, fires wherever the UI
renders.
An earlier version of this change was blamed for an in-world chat regression
and reverted. That regression was the Large-Address-Aware pointer-bound bug
(dc61f77) rolling its allocation dice at the moment the tick moved - the
symptom persisted after the revert, which already exonerated the hook.
Glue boot (LoadGlueScriptFunctions_h) now also clears the inline-icon maps:
the world->glue teardown bulk-frees every world fontstring and text node they
reference (glue->world was already covered by FrameScript_Initialize_h, and
the node-free hook covers individual deaths in both states).
WorldTick keeps its world-only subscribers (aura eviction, cast state, raid
targets) - they read world state that is null on glue; the two ticks stay
semantically distinct: WorldTick = "world advanced", FrameTick = "a UI frame
is rendering".
* feat(text): icon-inclusive GetStringWidth + FontString:GetStringHeight backport
GetStringWidth now counts inline |T...|t icons. The old "deliberate
measure gap" analysis was right that the gxu engine has no cold choke
(parallel measure/wrap/hit-test loops, no width field in the token
contract, hot per-glyph helpers) - but the choke sits one level up:
FUN_00772890 = CSimpleFontString::GetStringWidthInternal, a cold,
MinHook-safe funnel for exactly 4 callers (Script_GetStringWidth,
GameTooltip auto-size, editbox caret positioner, effective-width
vmethod). The co-hook calls the original and re-adds the icon advances
the tokenizer hook hid, so measured width matches rendered width - and
tooltips/auto-width layout now size correctly for icon lines.
Shared-source refactor so measure and render can never disagree:
- InlineInterceptActive: the tokenizer's stand-down set factored into
ONE predicate (tokenizer passes gxu flag 0x40; the width hook passes
fs+0x120 & 0x1000, the fs-level bit FUN_0044D670 translates into it,
so editbox carets keep measuring raw markup).
- IconAdvancePen/SumIconAdvances: the emitter's reserved advance
(w + 1.5*pad + positive offsetX) factored out and shared by the
emitter loop, the justify pre-shift, and the width hook. Side fix:
the justify pre-shift previously omitted the positive-offsetX term.
Units (bit us on first flight): escape sizes are UI pixels; convert
px->anchor by the push factor K = [0x832A4C]*1024/[0x832A44] (~1468),
NEVER by fs+0x7C - that's the layout UI scale (~0.68), and dividing by
it measured a 16px icon as +44k px. The hook never writes the fs+0xFC
cache (original may serve cached; the sum is re-added per call).
Verified in-game: delta +19.5 for a 16px icon = 20.32 exact advance
minus the known 0.8 trailing-icon ink-width residual. Wrap-break,
substring measure, and hit-test stay icon-blind (documented).
FontString:GetStringHeight() (2.3.0+ binding) backported in new
src/fontstring/Metrics.cpp: 1.12 already ships the internal wrap-aware
getter FUN_007729B0 (cache fs+0x100, lines*fontH + (lines-1)*spacing,
same wrap engine the render uses, empty -> 0) - it just was never
bound. Registered on the FontString method registry; mirrors
Script_GetStringWidth's resolve + anchor->px push conversion.
Verified in-game: one-line 11.7, wrapped 46.9 (4 lines), empty 0.
* fix(text): wrap lines account for inline icon advances
Wrap decisions measured inline |T icons as ~0 (the tokenizer hook), so
icon-bearing chat lines broke as if the icons weren't there and the
emitter's real advances ran past the frame's right edge.
Choke: FUN_TEXT_WRAP_STEPPER (0x005C7260), the wrap-stepper dispatcher
that lays out one wrapped line per call. Its exactly 4 callers are
every wrap consumer - the draw builder (render breaks), the height
measure (GetStringHeight), the chars-that-fit counter (ellipsis), and
the break-array computer - so one co-hook keeps render, height,
truncation, and break arrays mutually consistent. outBreak and
outNext-text are byte counts (==len means "all fit"); outWidth is the
measured icons-at-zero line width in the caller's units.
The hook shrinks the wrapWidth argument by a minimal feasible shrink,
found by probing the stepper sandboxed (local out-params + a copy of
the p10 first-line state byte): feasible(s) iff the line's icon
advances fit in the shrink plus the spare the break left
(iconSum <= s + probeW - outWidth). s=0 feasible -> no shrink (the
common fits-anyway case); otherwise escalate by the measured deficit,
then bisect the [infeasible, feasible] bracket to a quarter-glyph
(<=8 probes). Never overflows; packs each line to its physical
capacity. Gated on the shared InlineInterceptActive predicate + the
editbox flag bit 0x40 the dispatcher already carries.
Unit trap #2 (cost one flight): each caller passes fontH/wrapWidth in
its OWN space - node text units from the builder, the small
anchor-converted space from the fs-level callers chat wraps through.
px -> caller units = fontH / FUN_TEXT_FONT_HEIGHT(flag, fontH), the
engine's own pixel realization of the fontH param (FUN_005C6940's
final scale proves the convention for every gxu caller).
Rejected en route (documented in docs/InlineTextureEscapes.md): raw-px
subtraction (shredded lines to 2-glyph fragments), whole-remaining-
text sums (8-icon line -> one word per line), icon-count fixed-point
iteration (oscillates - 55-byte zero-width escapes make the
break<->count coupling discontinuous), max/average tiebreaks.
Cross-checked against 4.3.4 (renders |T natively): Blizzard extended
the token contract instead - texture token type 7 + a shared parser
(FUN_00617ba0) called inline by all five layout loops, fontH passed in
for :0 defaults, plain width, no padding. No single-choke trick exists
at source level; the probe search is the 1.12-shaped equivalent.
Diagnostics: _classicapi_InlineTexWrap(n) dumps the last 8
icon-bearing calls (widths, per-probe sums/breaks/raw out-params,
final shrink, floor hits).
* fix(text): wrap-shrink search refinements from in-game shakeout
Three fixes to the minimal-feasible-shrink search (2450522), each from a
live regression the _classicapi_InlineTexWrap diag pinned down:
- Full bracket bisection (<=8 probes, quarter-glyph epsilon). The first
cut abandoned the [infeasible, feasible] bracket after one stale
escalation candidate, costing the Marks harness line a whole
mark+word pair per line (3+5 instead of the optimal 4+4).
- Half-glyph feasibility tolerance. The width hook and the wrap hook
derive the icon sum through different unit chains (FUN_007727B0xK vs
FUN_005C6FA0 ratio, OUTLINE extras, the trailing-glyph ink/advance
quirk); the drift can read "fits exactly" as "1px over" and shrink a
zero-slack line. Genuine icon overflow is >= a full icon, so half a
glyph separates drift from real overflow.
- Auto-width bail (all text fits at s=0 AND slack <= 1.5 glyphs). A
single-anchor, no-width fontstring (pfUI's addon-list labels) wraps
at its own text-only measure plus a ~1-glyph engine margin (measured
1.07xfontH), so icons "never fit" its budget - but the box is
synthetic, icons draw past it harmlessly, and any shrink FABRICATES a
wrap ("Twitch Emotes" split across two lines). Real frames stay
covered: overflowing lines break, fitting lines carry frame-sized
slack (Marks measures ~3.5xfontH). The narrow give-up band - text
within 1.5 glyphs of exactly filling a real frame - reverts to the
pre-hook overflow, bounded by the line's icon sum.
Diag reshaped for /dump's ~30-value print cap: 8 numeric headers plus
ONE pass-summary string (shrink candidate, icon sum, line length, raw
outBreak/outNext/outWidth per probe), so no record truncates.
* feat(texture): add Texture:SetRotation via corner-vertex rotation
Backports the modern texture:SetRotation(angle [, cx, cy]) method, which
vanilla 1.12 lacks entirely.
4.3.4's SetRotation (Script FUN_0052cb50) rotates the four texture
COORDINATES around a centre and feeds the 8-arg SetTexCoord, which clips
the corners of an off-axis square. 1.12 can't mirror that — its
CSimpleTexture::SetTexCoord takes only a min/max UV rect.
1.12 has a cleaner seam: a CSimpleTexture stores its drawn quad as four
independent corner positions at +0xD4, and the draw enqueue (FUN_00772fd0)
hands region+0xD4 straight to the vertex batch as four free vertices — no
axis-aligned rect is re-derived. So rotating those four corner positions
draws a truly rotated quad with no corner clipping.
The engine fills the corners axis-aligned from the layout rect via
FUN_REGION_STORE_CORNERS; a co-hook there re-applies rotation right after
each store, so a static rotation is written once and costs nothing per
frame while moves/resizes re-rotate automatically. Angle is radians
(positive = counter-clockwise); optional (cx, cy) is a normalized pivot
in [0,1], default centre; an angle of 0 clears.
Per-region state keys on the raw region pointer and is cleared on /reload
and world->glue (Texture::Rotation::PrepareForReload). A texture freed and
instantly re-pooled mid-session could briefly carry a stale rotation —
accepted; a destructor-evict hook is the later hardening.
New in Offsets.h (all verified against WoW.exe): OFF_SIMPLETEXTURE_CORNERS,
FUN_REGION_TEXCOORD_CROP, FUN_REGION_GET_RECT.
* feat(texture): add Texture:SetVertexOffset, generalize corner transforms
Backports Texture:SetVertexOffset / GetVertexOffset (plus GetRotation),
generalizing the SetRotation module (texture/Rotation.* -> texture/Transform.*)
into one corner-transform primitive that backs both methods.
Both write the four drawn corner POSITIONS at region+0xD4 — the vertices the
draw enqueue FUN_00772fd0 hands straight to the batch; no stock 1.12 function
exposes them. Per-region state widened from an angle to {rotation, four vertex
offsets}, applied through the same FUN_REGION_STORE_CORNERS co-hook: after the
engine writes the axis-aligned corners, rotate about the pivot, then add the
offsets. The two transforms compose (matching retail) and persist across layout
resolve / move / resize / SetTexture with no per-frame cost.
SetVertexOffset offsets are UI coordinate units (like SetPoint x/y), converted
to the engine's internal layout space with the same factor Tooltip::LinePool
uses; +y is up. vertexIndex maps to a corner via VertexSlot; the
UPPER_LEFT_VERTEX / ... constants are defined FrameXML-style in the embedded
addon's Util/Constants.lua.
Non-inverting warps (perspective, skew, trapezoid, wave) are free; a warp that
mirrors the quad past edge-on inverts its winding and the region draw culls it
(documented).
* feat(texture): add Texture:SetColorTexture
Backports the 7.0 SetColorTexture(colorR, colorG, colorB [, a]) as a second
name on the engine's own Script_Texture_SetTexture handler (0x0079BB40): its
numeric branch already clamps r/g/b/a to [0,1] and fills a solid colour via
FUN_00770360 (the string branch loads a path). So SetColorTexture is exactly
vanilla's numeric SetTexture form under the modern name — zero reimplementation,
with the clamp and opaque-alpha default coming straight from the engine.
The alias also accepts a path string (a harmless superset of the retail
contract). SetDesaturated / IsDesaturated are already native (real Desaturate.bls
pixel shader); SetDesaturation is not addable — vanilla desaturation is a fixed
effect handle with no strength constant in the setter, draw command, or effect
table.
* fix(text): correct the emitter's lazy pen read-back; drop quad render mode
The inline-icon emitter hook threaded the pen between text runs by
reading the engine's linkState[4] after each delegated run. That value
is LAZY (verified in FUN_005ccbe0): the engine places each glyph at
pen + pairAdvance(prev, cur) and its final write re-adds the LAST PAIR
as a stand-in for the last glyph's own advance. For a one-glyph run the
stand-in is 0 (no pair exists), so the run read back as zero-width and
the icon drew on top of the digit — the money-string "coin covers the
lone 0/1" bug. For "] 1" the stand-in was the space's advance, ~3px
short: the single-digit clip. "11" matched by luck (same-glyph pair).
Fix (the 4.3.4 model — FUN_0061ea10 case 7 computes the pending last-
glyph advance explicitly instead of trusting a stored pen): drawRun now
token-walks each run with the engine's own tokenizer and, when it ends
in a glyph, replaces the stand-in with the truth:
pen = linkState[4] - pairAdvance(prev, last)*scale
+ baseAdvance(last)*scale
using the engine's own advance functions (FUN_005ca240 base,
FUN_005ca2d0/FUN_005ca4b0 pair, per the node's kerning flag) and its
per-placement __ftol snap. Runs ending in a non-glyph token (trailing
|c/|r) need no correction — the trailing iteration already folded the
last glyph's base advance into the write.
Also:
- The per-icon breathing pad is now FONT-relative (fontH * padFrac),
not icon-width-relative: the gap no longer scales with icon size, so
a small icon doesn't jam its neighbour while a big one floats.
- Quad render mode is removed entirely (DrawTexturedQuad, residency
holders, _classicapi_InlineTexMode/_InlineTexHolders/_InlineTexHolderCfg).
Engine regions (the 4.3.4 CSimpleEmbeddedTexture model) are the only
render path.
* feat(currency): GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString
The retail coin-icon money string, backported as a C function
(src/currency/CoinText.cpp) the way retail ships it (behaviour from
3.3.5's Script_GetCoinTextureString FUN_00510d00): split copper into
gold/silver/copper, emit only non-zero denominations in that order,
zero shows "0<copper>". Each denomination formats through the
GOLD/SILVER/COPPER_AMOUNT_TEXTURE GlobalStrings, backported in the
embedded addon's locale layer (stock 1.12 doesn't have them), so
locales can override.
1.12 has no per-coin icon textures — the |T markup crops vanilla's
UI-MoneyIcons 4-cell sheet (gold 0:1, silver 1:2, copper 2:3; the
three coin arts are byte-identical in alpha, verified by decoding the
exported BLP's DXT3 data). height/width 0 = size to the display
font's height (the retail default, honoured by the inline-texture
renderer). No offsetX nudges and no separator spaces: correct spacing
comes from the renderer's font-relative pad plus the lazy-pen fix
(550cf5e) — per-coin offsets were compensations for that bug and must
not return.
FormattingUtil.lua comment updates only (GetMoneyString stays the
text-symbol form; the coin form now exists). docs/API.md: new
Currency section.
* docs(readme): list GetCoinTextureString in the API table
* refactor(text): purge the quad-mode leftovers
Follow-through on 550cf5e (quad render mode removed): delete everything
that existed only for the raw-GxU-quad path and no longer has a single
reference.
- Offsets.h: drop the GxU device primitives (dynamic-VB lock/data/
submit/unlock, texture bind, vertex stride/selector), the standalone
path->CGxTexture loader trio (FUN_TEXTURE_LOAD_BY_PATH,
PTR_TEXLOAD_DESC_VTBL, FUN_GX_TEXFLAGS_INIT), the colorop render-state
and direct-D3D state setters, FUN_TEXTURE_FORCE_DECODE, and
FUN_TEXTURE_GET_RENDERABLE. All were quad/residency-era scaffolding;
the VAs stay recorded in docs/InlineTextureEscapes.md and git history.
FUN_TEXT_PAINT (the flush co-hook site) and VAR_TEXTURE_BLEND_DEFAULT
(the pool's SetTexture blend arg) remain, with comments rewritten for
the region path.
- InlineTexture.cpp: drop IconRecord.z / penZ (only the quad draw read
them); rewrite the paint-flush section comments to describe what the
flush actually does now (queue fontstring-relative region placements).
* feat(addon): backport TextureUtil.lua (Blizzard_SharedXMLBase)
The non-atlas slice of the Classic Era reference, ported to Lua 5.0
(string.format instead of string-method syntax):
- SetClampedTextureRotation / ClearClampedTextureRotation — the retail
90-degree-step texcoord shuffle with width/height swap. Works
unchanged because 1.12's Texture:GetTexCoord() natively returns the
8-value corner form and SetTexCoord accepts it (both operate on the
same engine corner array, verified at Script_Texture_GetTexCoord
0x0079BDF0). One deviation, commented in-file: Clear also nils
rotationDegrees — the reference leaves 0 behind, which is truthy in
Lua and breaks the next Set's origTexCoords re-capture.
- GetTexCoordsByGrid — pure math.
- CreateTextureMarkup / CreateSimpleTextureMarkup — |T markup builders;
output renders via the inline-texture backport.
- TextureUtil = {} for namespace parity.
Omitted: everything atlas-based (CreateAtlasMarkup*, the TextureKit
family, CheckSetAtlas, GetAtlasSize, SetTextureWithAddressModeOptions —
1.12 has no atlas system), the LFG role-icon tables, and
GetTextureInfo (reads the atlas/fileID getters).
* refactor(text): remove the inline-texture bring-up diagnostics
The pool's _classicapi_InlineTexRegionDump / _InlineTexFsDump /
_InlineTexBroken were bring-up triage for the scroll-landing /
ghost-icon bug class, which the node-free lifetime hook ended
(b5df20d) — they have no remaining job. Removing them also kills their
entire support tree, none of which had another consumer:
- the five Debug* chain-state exports on Text::InlineTexture
(DebugChainState / NodeFlags / NodeSeenAge / NodeBuiltKnown /
NodeEmitOutcome),
- g_nodeSeen + g_flushSeq (flush visit stamps),
- g_nodeEmit (per-node emitter-outcome tracking — map churn on every
emitted line, purely diagnostic),
- g_nodeBuilt + g_buildCounter + NodeIcons.builtSeq (write-only: the
build-version check they were made for was deliberately REJECTED —
the builder runs every paint but only emits when dirty, so version
mismatches on clean paints erased live records). g_buildNode /
g_buildEmitSeq stay — they are the live firstLine detection.
NodeIcons collapses to a plain std::vector<IconRecord> map value, and
the pool drops its Game.h / InlineTexture.h includes (nothing left
uses them). The stale "versioned by build" struct comment is rewritten
to describe the actual invariant (node-free hook + firstLine erase).
Kept: _classicapi_InlineTexEnable / Suppress / Tune / Stats / ProbeFS /
RegionCal / Wrap — the live control surface and field telemetry.
* refactor(text): reduce the inline-texture Lua surface to the kill switch
Only _classicapi_InlineTexEnable remains — the feature toggle the SEH
latch pairs with. Removed with their entire backing state, none of
which had a non-diagnostic consumer:
- _InlineTexSuppress + g_suppressInline (the manual global override —
editbox exclusion has been per-node/per-buffer for a while) and the
now-unreferenced InputFocused() helper
- _InlineTexTune: g_vBias / g_sizeScale / g_centerFrac / g_iconPadFrac
become constexpr (calibrated during bring-up, fixed now)
- _InlineTexRegionCal: g_regionCalX/Y become constexpr (0 / -1)
- _InlineTexStats + the six flush-gate counters (the queue gate
if/else collapses to the plain condition; the nudge drops its
counter-guard and just sets the dirty bit — idempotent)
- _InlineTexWrap + the WrapDiag ring and every capture site in the
wrap-stepper hook (the minimal-feasible-shrink algorithm is
untouched — it always ran on locals)
- _InlineTexProbeFS (the pen<->anchor bridge verification probe)
- the SEH filter no longer records fault code/address (nothing read
them); the latch still disables the feature on a flush fault
- the Arg() Lua helper and <cstdio> (last users removed)
* feat(text): inline icons fade with their chat line
The one visible seam left in the region renderer: icon regions parent
to the CHAT FRAME (regions cannot parent regions), so they inherited
the frame's alpha but not the LINE's — a fading chat line's text
dimmed while its inline icons stayed opaque until the end-of-fade
hide.
RE (verified in the 1.12 binary): chat fading is engine-side in the
ScrollingMessageFrame's fader FUN_00788460 — gated on the SetFading
flag (smf+0x360; the 40-method SMF registry's SetFading 0x007939A0
writes it), it counts down each visible line's timeVisible then
fadeDuration ({rgba@+4, shown@+8, timeLeft@+0xC, fadeLeft@+0x10} in
the stride-0x10 line array at smf+0x3A4) and animates the LINE
FONTSTRING's color alpha byte through the fs SetColor (0x0077F750)
every frame, hiding the fs at zero. SetColor stores the dword at
(*(u32**)(fs+0xB8))[0] (count fs+0xB4; alpha = byte 3), and the
per-line setter FUN_00788af0 resets alpha to 0xFF on message reset —
so that byte IS the line's live fade state, whoever wrote it.
The mirror (InlineTexturePool): Maintain reads FsColorAlpha(fs) each
tick and, on change only, rewrites the shown regions' colors with the
markup tint's alpha modulated by the line's alpha (RGB preserved).
ApplyPlacement folds the live alpha in at placement time, so a line
placed mid-fade starts at the right opacity. Composition is exact by
construction: the fs's on-screen alpha is frameAlpha(+0xC8) ×
fsColorAlpha, and the regions share the same parent frame, so
mirroring the fs byte alone makes icon and text alpha identical.
Steady state costs one byte compare per icon-bearing fontstring per
tick; scroll-back/un-fade restores (the SMF resets the line color and
the mirror follows). Verified in-game: fade lockstep, reappear on
scroll.
* feat(text): line-height growth for tall inline icons
An inline icon taller than the font (a 32px TwitchEmotes emote in 15px
chat) used to overlap its neighbouring lines — the line box stayed
fontH-sized. Retail grows the line: 4.3.4's measure and wrap loops
export a max-texture-height out-param (FUN_00618b90 / FUN_00618e40
case 7) and the layout takes it.
1.12 equivalent, one cold co-hook: FUN_FONTSTRING_STRING_HEIGHT
(GetStringHeightInternal, cache fs+0x100) gains the tallest icon's
overflow past the font height (MaxIconOverflowPx — explicit |T heights
only; the :0 default IS the font height). Its three binary consumers
(verified via xrefs) split exactly right:
- FUN_00772A60, the anchor effective-height vmethod — chat line
fontstrings get only a SetWidth from the ScrollingMessageFrame
refresh, so their rect height resolves here -> the rect grows;
- FUN_00788750, the SMF display refresh — budgets vertical space and
anchors each line to the previous line's rect -> the stack spaces by
the grown height;
- FUN_0077D4D0, the multi-line editbox self-size — excluded by the
same editbox gate as the width hook (editboxes render raw markup).
Same rules as the width hook: shared InlineInterceptActive predicate,
px->anchor via the push factor (never fs+0x7C), the fs+0x100 cache is
never written (delta re-derived per call — idempotent). The backported
fontstring:GetStringHeight routes through the hooked VA and becomes
icon-inclusive automatically.
Draw side: tall icons CENTRE on the line like any other icon. The text
centres itself inside the grown rect, so the extra space appears half
above and half below — a centred icon fills exactly that, matching
retail. (A bottom-aligned first cut clipped the neighbouring line;
verified in-game against retail's centred, no-clip rendering.)
v1 limit (documented in-code): the delta is the whole text's max
overflow — exact when a message's tall icons share one wrapped line
(the emote case); tall icons on two different wrapped lines of one
message under-grow.
Verified in-game: 32px emotes between normal chat lines each take
their own vertical room, centred, no neighbour overlap.
* fix(text): full-height hyperlink hover for tall inline icons
With line-height growth (49934b0), a tall emote inside a |H...|h link
was only hoverable across its text-high middle band: the hyperlink hit
rect is registered at layout time (FUN_TEXT_LINK_RECT_ADD 0x005CD310,
the GXUFONTHYPERLINKINFO append — record {y,x,y,x} normalized + link
ptr/len pairs) with the TEXT band's y extent, fontH tall.
Consumer geometry (FUN_00788c00 -> FUN_007a3350 decompile + two in-game
experiments): the ScrollingMessageFrame rebuilds a hit-region frame per
record, anchored to the LINE FONTSTRING's top-left with the record's
band as offset/height. The band's y values are Y-UP (numeric max =
screen top — extending the max visibly grew the region into the line
above), and the band is TEXT-relative — but the grown rect's top sits
half the icon overflow above the text (the text centres in the grown
rect), pre-shifting the whole hit band up. Net: the unmodified band
already covered from the icon's top down to textBottom − overflow/2,
and the single correct fix is pushing the screen bottom DOWN by the
full overflow — decreasing the numeric MIN edge.
Co-hook on the append: when the |H...|h escape span contains a |T icon
taller than the line's font (the same MaxIconOverflowPx the
string-height hook grows the line by), decrease the band's min edge by
the overflow before the original stores it. All consumers of the
record (hover, click) inherit the expansion. Gated on the shared
InlineInterceptActive predicate so raw-rendered contexts stay literal.
Known v1 rough edge (commented in-code): a tall icon outside the link
but on the same line grows the line without this hook firing for that
link, leaving its band half-overflow high.
Verified in-game: a 32px emote link hits across its full height, no
bleed into neighbouring lines.
* docs(text): truth-pass on the inline-texture RE docs
InlineTextureEscapes.md and InlineTextureResidency.md described the
removed raw-quad renderer and a bring-up plan as if current. Bring them
in line with the shipped engine-region design.
InlineTextureEscapes.md:
- New "Current design (shipped)" section up front: region renderer, the
three fs-level measure hooks (width/height/wrap), the lazy-pen fix,
chat fade, tall-icon line growth + full-height hover, editbox
exclusion, the payload, and anti-spoof — each with its commit.
- Banner the quad "rendering primitive" section as REMOVED (bring-up
record; the address log for code deleted in 56c2670).
- Fix the DrawTexturedQuad / quad-flush references to region-queue
language; vertical centring is g_centerFrac (0.6) with the tall-icon
grown-rect note; the flip note becomes the region SetTexCoord
Y-first order.
- "Remaining/optional": diagnostics removed (only InlineTexEnable
left), measure/height/wrap DONE, animation is addon-side, the TGA
convert command is -compress none only.
- "Reality check": correct the prediction that we would reimplement the
emitter — the shipped design segment-delegates and co-hooks instead.
InlineTextureResidency.md: rewritten from a "not-yet-implemented plan"
into an accurate reference. States the region design, records the
CSimpleTexture entry points / layout the pool uses (VAs verified
against Offsets.h), and corrects the root cause honestly — the flicker
was the LAA pointer-bound truncation (dc61f77), NOT texture residency;
regions are kept for 4.3.4 parity and are resident by construction as a
bonus. Keeps the D3D "ruled out" notes as a do-not-re-investigate
record.
* fix(text): stop inline-icon Maintain from dereferencing stale fontstrings
A destroyed chat-line fontstring left a dangling key in the icon pool's
per-fontstring map. Its heap memory was reused as a string buffer, and
Maintain's weak LooksReadable() range check waved the reused pointer
through; reading the "parent" field (fs+0x9C) then yielded string bytes
(ASCII "-Rac"), which the engine's region attach (FUN_0076a750)
dereferenced at *(parent+0x1B4) -> ACCESS_VIOLATION (ERROR #132 at
0x0076A7CE).
Two compounding defects, both in Maintain:
- the stale-entry expiry was gated on shown > 0, so a scrolled-off line
(shown == 0) never expired and its dead key was re-dereferenced every
frame, waiting for its memory to be reused;
- LooksReadable is a bare VA-range test that a reused buffer passes.
Fix: verify a key is a LIVE fontstring by its object+0x00 vtable before
using it. The vtable is learned at paint time from fontstrings the engine
is actively drawing (QueuePlacements), so every fontstring class -- chat's
internal ScrollingMessageFrame lines, tooltip/CreateFontString lines,
addon fontstrings -- is recognized without hardcoding (one hardcoded value
filtered live chat icons out entirely). Maintain now iterates with erase:
a key whose memory no longer carries a learned fontstring vtable has its
regions hidden (our tex pointers stay valid, parented to the chat frame)
and the entry erased, so dead keys can neither accumulate nor be
dereferenced.
* fix(text): guard inline-icon pool hide loops against short region vector
CreateRegion can fail when the region pool is exhausted, breaking the
apply loop with fewer regions than `want`. The code still set
`np.shown = want`, so a later tick's freshness-expiry and fs-hidden hide
loops indexed np.regions[i] past regions.size() -- an out-of-bounds
std::vector access.
Clamp np.shown to min(want, regions.size()) so it never exceeds the
vector, and add the same i < regions.size() bound the eviction loop
already carries to the other two hide loops so all three self-guard.
* refactor(text): centralize inline-texture offsets and pointer probe
Enforce the single-source-of-truth offset rule on the inline-texture
files:
- Read the fontstring text pointer via Offsets::OFF_FONTSTRING_TEXT
instead of a raw `fs + 0xF0` in FlushLayout's ghost guard; the symbol
already existed and is used elsewhere in the same file.
- Add OFF_TEXT_NODE_JUSTIFY (0x54) to Offsets.h with a verification note
and use it for the two node-justify reads (DeriveK and the emitter
pre-shift) that previously hardcoded 0x54 in two places.
- Move the LAA-aware LooksReadable probe into a shared text/PtrProbe.h
(namespace Text) so InlineTexture.cpp and InlineTexturePool.cpp share
one definition instead of two copies plus a third raw-bounds copy in
LearnFontStringVtable. The 0xFFFF0000 bound now lives in exactly one
place -- this is the constant whose earlier 0x7FFF0000 form caused the
missing-chat-icon bug, so keeping the copies from drifting matters.
No behavior change; full build compiles and links.
* fix(currency): clamp GetCoinTextureString to the uint32 copper ceiling
An amount above INT_MAX gold copper (e.g. 1e15) made `gold =
(int)(amount / 10000)` overflow: the long long division was truncated to
its low 32 bits, so GetCoinTextureString(1e15) printed a garbage gold
count (1215752192). silver/copper were already bounded by the % math.
Vanilla money is a uint32 copper field, so clamp amount to 0xFFFFFFFF
copper (~429,496 gold) -- the most the game can represent -- which keeps
gold within int range for the "%d" GlobalString format. Can't widen the
type instead: the count is formatted through the retail-compatible "%d"
coin GlobalString, and passing a long long to %d is undefined.
* fix(text): SEH-guard the inline-icon Maintain tick; document region leak
#6 (SEH backstop): Maintain's liveness probe dereferences a fontstring
pointer that may have been freed (the documented IsLiveFontString
residual). The paint path already latches off on a fault via SafeFlush,
but Maintain ran bare under the render-root tick, so a fault there was an
uncaught client crash. Split the body into MaintainImpl (holds the C++
objects) and a thin Maintain() SEH wrapper that latches g_maintainDisabled
on fault -- mirroring the paint latch. Re-armed on /reload.
#7 (region leak): document why the stale-key eviction intentionally drops
np.regions without reclaiming the pooled CSimpleTextures. Any reclaim (a
free list or explicit destroy) would touch a region whose parent frame may
already be gone -- a use-after-free on the very path this liveness check
was added to stop crashing. The leak is bounded and near-zero in practice
(chat/tooltip fontstrings are reused, never evicted).
* fix(chat): stop raid-marker expansion from dropping the message tail
A token inflates ~28x ({rtN} -> ~85-byte |T markup), so a marker-dense
message could fill the 0x800 buffer and silently truncate everything after
it -- a DLL user would see a shorter chat line than a non-DLL user (a mild
content-hiding vector).
Reserve room for the literal tail before expanding a token: only iconify a
marker when the expansion plus the whole remaining input (worst case copied
1:1) still fits. Past that point later tokens stay literal {rtN}, so the
full message text is always preserved -- just with fewer icons.
* fix(text): gate eviction Hide on region liveness to avoid a UAF
The stale-key eviction Hid its regions unconditionally, assuming they
outlive the fontstring. They usually do (regions are parented to the
fontstring's parent FRAME, which persists as lines churn) -- but if the
fontstring became non-live because that parent frame was destroyed, the
frame freed the fontstring AND its child regions together. Hiding a region
pointer there wrote to freed/recycled memory: a use-after-free that could
crash.
Learn the pooled CSimpleTexture vtable in CreateRegion and gate each
eviction Hide on IsLiveRegion (the same vtable-liveness technique
IsLiveFontString already uses). A recycled region is skipped (it needs no
hiding -- it's already gone); a live one is still hidden so it can't ghost.
Factored the shared WoW-image-range check into InWowImage so the two Learn*
functions don't duplicate the constant.
* fix(aura): identify a cached aura by its caster and descriptor slot
Two casters of one spell on one target are two auras in two descriptor
slots, but `Aura::Source` held a single entry per `(target, spellId)`.
The second cast overwrote the first caster's `casterGuid` and timing, and
`OnAuraRemoved` for either copy evicted the entry backing the survivor.
Observable from Lua, with a same-class groupmate debuffing your target:
`sourceUnit` names the wrong caster, so a `PLAYER`-filtered query drops
your own debuff; and the base-duration estimate stored for the other
caster elapses while your talent-extended aura is still up, which
`PushEnriched` reports as `expirationTime = 0` — an aura on the unit with
no timer at all.
Entries are now keyed `(target, spellId, caster)`, the identity the server
uses. Recovering which instance a descriptor slot holds needs the two
halves joined: SpellGo knows the caster but no slot, `OnAuraAdded` knows
the slot but no caster, and they arrive in that order, so the application
hook seats its slot on the newest cast capture still awaiting one.
`Get` takes that slot and resolves by it first, falling back to the unit's
sole entry for the spell — every single-caster case, and the paths with no
descriptor to read a slot from. Several entries and no binding resolves to
a miss: the caller's unknown-caster defaults beat a coin flip between two
casters.
`OnAuraRemoved` and `EvictAbsent` retire the instance in the vacated slot
rather than every entry sharing its spell ID.
* fix(aura): compare unbound-entry freshness wrap-safely
FindFreshestUnbound picked the newest unbound capture with a raw
`stampMs >` on two absolute engine ticks. That compare inverts across the
2^32 ms tick wrap (~49.7 days uptime, or the rdtsc backend already past
2^31 after a warm reboot), so the OLDER capture would win and OnAuraAdded
would seat the descriptor slot onto the wrong cast — mis-attributing that
aura's caster and duration.
Pick by smallest Time::Clock::Elapsed(stamp, now) instead, the wrap-safe
signed difference the rest of the codebase uses for tick math.
* fix(aura): keep the observed caster on the aura fallback paths
The cache-fallback push paths (the descriptor-drop fallback and the
out-of-range group-member fallback) already hold each aura instance's
caster and timing — Enumerate copies them straight out of the cache entry.
But PushEnriched then re-resolved them through Get(guid, spell, slot),
which returns a miss for an unbound entry that shares its spell with
another caster's entry. So an aura Enumerate had fully attributed came
back out with no sourceUnit/sourceGUID and no expirationTime.
Hand the entry's own attribution to PushEnriched as `known` so the
fallback reports what the cache already knew instead of re-deriving it.
* refactor(aura): route Get and Evict through one FindInstance resolver
Get and Evict each hand-rolled the same FindBySlot-then-FindSole ladder —
the read-side rule for which instance a slot-bearing query names. Naming it
once keeps the two from drifting into resolving the same query to different
instances (reporting one caster while evicting another). No behavior change.
* perf(aura): bound cache scans to the active high-water prefix
Every Aura::Source lookup linear-scanned all 2048 cache slots. Get runs
per-aura per-query — hundreds of times a frame when addons poll UnitAura in a
raid — so the full scan dominated the read path.
Track a high-water mark that Claim (the sole allocator) extends and FlushAll
resets, and scan only [0, g_usedHigh). Every used entry lives below the mark,
so no lookup can miss one. Outside a raid the working set is a handful of
auras, cutting a ~2048-entry scan to ~30; in a raid it drops to the real
working set. Claim still scans the full array — as the allocator it must hand
out slots beyond the current prefix.
---------
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
The `macro` verb selected the clicked unit and left it targeted (Clique's
click-heal minus the restore). Snapshot the current selection GUID, retarget
the clicked unit, run the macro, then restore the prior target via
FUN_TARGET_BY_GUID (which validates the GUID and drops a stale one), or
ClearTarget when there was no prior target. The macro's actions dispatch
synchronously while the clicked unit is selected, so restoring afterward
doesn't affect them.
The `macro` click verb ran the macro text with no unit context, so a
plain `/cast Flash Heal` bound to a raid frame hit the current target
instead of the clicked unit -- a regression from Clique/Healbot-style
click healing. Vanilla macros have no `@unit` conditional, and unlike
the `spell` verb (which feeds a GUID straight to the cast dispatcher)
we can't inject a target into arbitrary macro text.
Emulate pfUIevan's classic click-heal: TargetUnit the clicked unit
before running the macro. Like pfUIevan, the previous target is not
restored -- the click doubles as a target swap. Only the `macro` verb
is affected; `spell`/`item` already act on the unit directly.
The engine's OS millisecond counter (FUN_OS_TICKCOUNT_MS, same epoch
GetTime() scales by 0.001) is unsigned and wraps at 2^32 ms. Nine files
each re-derived the reader; several stored the tick into a signed int,
so past 2^31 ms (~24.9 days uptime) every timestamp went negative and
`startTime + duration` landed in the past -- callers saw every cooldown,
aura, and lockout as already elapsed. The same bug shipped three times
(the cooldown by-ID getters and C_LossOfControl).
Add one source of truth -- Time::Clock (src/time/Clock.h) -- with a
uint32 NowMs() and wrap-safe Reached/Remaining/Elapsed helpers, and route
every tick reader through it. No module re-derives the reader or hand-
rolls a `now < deadline` compare (which inverts across the wrap) anymore.
Also fixes the reported cooldown/LoC getters (unsigned out-params,
wrap-safe compares) and three latent instances of the same `now < end`
inversion found while sweeping: totem/Tracker's two timer getters and
aura/Source's cache-eviction pass.
Named Time::Clock, not Time::Tick, because a Time::Tick inner name
shadows the global Tick namespace (the WorldTick hook) from inside any
Time::* file.
Discovered by fuffc in #17.
The secure "macro" action read both `macrotext` (raw text) and the
deprecated `macro` (saved-macro name) into one buffer and ran whichever
through the global RunMacro. RunMacro is name-based -- stock resolves an
index, and SuperCleveRoidMacros' RunMacro calls GetMacroIndexByName --
so a macrotext value like "/cast [hp:<90] Holy Light" resolved to no
macro and silently did nothing. Because the global existed, the native
fallback never ran.
Split the two by their real semantics: macrotext runs through the chat
parser (RunMacroTextC), so each line dispatches via SlashCmdList and
picks up addon slash hooks like SuperCleveRoidMacros' conditional /cast;
the name form keeps preferring RunMacro. Fixes click-cast macros with
conditionals.
Backport C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank) -> table,
reusing Spell::Info's DBC reader. slotIndex is 1-based across the book;
spellBank is Enum.SpellBookSpellBank (0 = Player, 1 = Pet), resolved via
Spell::Lookup::SpellbookSlotToID. Returns nil for an empty/out-of-range
slot, matching retail.
Fields map from Spell.dbc: itemType is Spell (player) or PetAction (pet)
- vanilla's book holds only real spells, so None/FutureSpell/Flyout never
occur; actionID == spellID (no override system); name/subName from the
localized Name/Rank; isPassive from Attributes; isOffSpec always false.
iconID is the icon PATH string (vanilla has no fileID), the same
deviation as C_Spell.GetSpellInfo.iconID; skillLineIndex is omitted.
Also register the two Enum tables the return contract references
(non-destructively, like the other Enum.* additions) so
info.itemType == Enum.SpellBookItemType.Spell comparisons work:
Enum.SpellBookSpellBank and Enum.SpellBookItemType.
Document in docs/API.md (SpellBook section + both Enum entries) and list
in README.
ClassicAPI_ToggleUnitMenu's initialize closure ran the same level-1
customization at every level. When the mouse crossed a nested entry
(Loot Method / Loot Threshold / Raid Target Icon) the menu builds its
level-2 submenu and re-invokes initialize; our AddFocusButton /
AddCancelButton call UIDropDownMenu_AddButton with no level, which
defaults to level 1 (not the current level), appending onto the still-
open level-1 list. Level 1 is not reset while a submenu rebuilds, so the
list grew every hover until "Too many buttons in UIDropDownMenu:
ClassicAPIUnitMenuDropDown", pushing the real entries out of reach.
Make initialize level-aware: at level >= 2 hand off to UnitPopup_ShowMenu
(it resolves the submenu from UIDROPDOWNMENU_MENU_VALUE and adds its own
Cancel); do the scratch-copy + Focus/Cancel append only at level 1 -
mirroring how Blizzard's own unit dropdowns init.
Spell_C_CastSpell (0x006E4B60) calls the cast-start writer
FUN_CAST_START_SET before Spell_C_TargetSpell raises the reticle, so a
target-selection spell (Disenchant, ground-target AoE) stamps g_cast at
the green-cursor step - seconds before the item/ground is clicked and the
cast actually begins. The later click re-enters Spell_C_CastSpell only to
hit its "already this spell" short-circuit, so the writer never re-fires
for the real start. Result: UNIT_SPELLCAST_START (and a UnitCastingInfo
cast bar) fired far too early, and a cancel fired a phantom
START/INTERRUPTED/STOP for a cast that never happened.
Drop the client stamp in OnWorldTick while the reticle is up for that
spell. On selection the server's SMSG_SPELL_START re-stamps g_cast via
the fresh-stamp path (it is 0), so START fires with the correct time and
the SENT castGUID; on cancel it stays cleared. Gated on !g_castFromServer
(never touches the server re-stamp) and on g_cast.spellID ==
VAR_PENDING_CAST_SPELL (only the spell whose reticle is actually up).
Normal casts have no targeting phase and are unaffected.
Add build/Release/ClassicAPI.map to the release step's asset list so
every vX.Y.Z release ships the linker map beside the DLL. It resolves
the `0001:offset` logical addresses in a shipped build's ERROR #132
crash dump back to function names for that exact build. Dev-build
artifacts stay DLL-only.
RunModern reimplements the frame-script runner tail, saving the
this/arg1..argN globals it clobbers so it can restore them after the
handler pcall. The old save used luaL_ref into the registry, mirroring
the engine's own FUN_00704f10 - but any push before the protected call
can longjmp (out-of-memory being the real case), which unwinds past the
C frame and skips the restore: the refs leak and the globals stay
clobbered for later handlers.
Save the old values on the Lua stack instead. An unwinding error resets
L->top past them for free, so there is nothing to leak and no clobbered
state to strand. This also drops the per-call luaL_ref/luaL_unref churn
and the per-arg re-read from globals on the OnUpdate hot path; the call
args are pushed directly from a pure-C parse of the vararg buffer.
The stack save is deeper than the registry save (~2N slots live at
once), so CheckStack up front; if it cannot grow (or there is no Lua
state) RunModern returns false before any side effect and the thunk
falls back to the original engine runner, never double-running.
Behavior unchanged: default-off passthrough, and identical
(self, [event,] arg1..argN) semantics when enabled.
Add /MAP to the shared-linker flags so the Release build produces
ClassicAPI.map, which resolves the `0001:offset` logical addresses in
WoW's ERROR #132 crash dumps back to function names. Linker-only output;
no effect on code layout.
Carnage's Ferocious Bite gate was a hardcoded list of five rank IDs. Replace
it with the server's own structural test (IsDruidFerociousBite): druid family
+ the Rip/Bite family flag + a direct-damage effect (the damage effect is what
separates Bite from Rip, its DoT sibling that shares the flag). Rank-proof and
covers any Turtle-added rank with no ID list. Verified against the client
Spell.dbc: all five FB ranks are {family 7, flag 0x800000, Effect[0]
SCHOOL_DAMAGE}; Rip shares the flag but is APPLY_AURA, and Rake carries a
different flag.
The family + flag-bit test is the client-side form of the server's
SpellEntry::IsFitToFamily, and Aura::Source::AffectedMatchesRaw read the same
two fields inline, so extract Spell::Lookup::IsFitToFamily and route both
through it. Adds SPELL_EFFECT_SCHOOL_DAMAGE (= 2, verified vs the server's
SpellDefines.h).
Turtle's Carnage refreshes the caster's Rip and Rake off Ferocious Bite,
behind a per-combo-point roll whose outcome the client only sees as the
combo point the proc returns. A src/turtle/Carnage watcher arms on a
Ferocious Bite send and confirms on the first combo-point rise, then
refreshes the cached auras. Verified against tortoise-wow
spell_druid_carnage: icons 108 (Rip) / 494 (Rake), family bits 0x800000 /
0x1000, caster scoping, and RefreshHolder (restore the aura's own applied
duration).
A cache duration edit has no engine packet behind it, so nothing re-fired
UNIT_AURA and event-driven aura bars never saw the change. The trigger mods
(ApplyMod) and the Carnage refresh now enqueue the affected unit, and the
WorldTick flush re-broadcasts UNIT_AURA via the engine's own per-token
signal (FUN_UNIT_EVENT_BROADCAST), deferred off the SMSG_SPELL_GO subscriber.
Also move the two static Turtle duration mods (Conflagrate -> Immolate,
Molten Blast -> Flame Shock) from Lua into src/turtle/DurationMods via a new
C++ entry Aura::Source::AddDurationMod, and drop the now-unused exact-spellID
RegisterAuraDurationModifier Lua export (RegisterAuraDurationModifierByTrigger
stays for Shadow Weaving).
Based on the approach from @fuffc in #15.
Closes#15
The TOC entry pointed at #unit_spellcast_-events-player, but the heading
generates #unit_spellcast_-events (no -player suffix) — broken link. Move
both the TOC entry and the content block out of the Spell section and into
the Events section where the other event docs live, and correct the anchor.
The capture was a single slot, and ConsumeCapturedCP cleared it before
testing whether the spellID even matched. Every player-cast aura spell
reaches TryComboScaledMs, so any aura landing between a finisher's
CMSG_CAST_SPELL and its own SMSG_SPELL_GO consumed the snapshot and left the
finisher with no combo points — falling back to the 0-CP base duration.
Not a rare race. Turtle's rogue Taste for Blood casts spell 52529 on the
player off the same Rupture, and its SPELL_GO arrives first, so Rupture
reported a static 12s (its 6s 0-CP base plus the talent) at every combo
count. Untalented, with no proc to clobber the slot, the same cast scaled
correctly — which is what made this look like a modifier bug rather than a
capture one.
Key the captures by spellID in a small ring and consume only the matching
entry. The comment claiming one cast is in flight at a time was reasoning
about finishers alone; the funnel sees every cast.
Add item/Record.{h,cpp} with Item::PeekRecord(itemID) -- the passive
ItemStats_C cache peek (null callback, no network query). Route the three
duplicate implementations through it:
- the per-module GetItemRecord_t typedef + fn(cache,id,&zero,nullptr,
nullptr,0) call in ~25 read-only item accessors
- Item::StatAccum::FetchRecord (deleted; 7 callers repointed)
- item/Tooltip.cpp's local PeekItemRecord (deleted)
Behavior-identical: every converted call was already a passive peek with
identical args (dedup flag is a no-op when the callback is null).
item/Data.cpp is deliberately untouched -- it owns the ACTIVE fetch
(CacheFetch passes a real callback to fire SMSG_ITEM_QUERY_SINGLE, the
load-bearing cache-race path in CLAUDE.md) and keeps its own typedef.
OBJ_TYPE_* was a partial second naming of the same object-type bits as
the complete TYPEMASK_* set (OBJ_TYPE_ITEM=2=TYPEMASK_ITEM, ... GAMEOBJECT
=0x20 both) -- a single-source-of-truth violation. Repoint all ~18
references to TYPEMASK_* (identical values, behavior-preserving) and
delete the OBJ_TYPE_* definitions, folding their derivation notes
(bitmask semantics, FUN_004F8DB0 for PLAYER, FUN_0052AA20 for GAMEOBJECT,
FUN_00529FE0 SetUnit) into the surviving TYPEMASK_* comment.
Add item/CGItem.{h,cpp} with two null-safe accessors for a CGItem's two
descriptor-like pointers:
InstanceBlock(cgItem) -> CGItem + 0x08 (item GUID @ +0, itemID @ +0x0C)
ObjectFields(cgItem) -> CGItem + 0x114 (stack/flags/charges/durability/...)
Replace the 30 hand-written `*reinterpret_cast<const uint8_t *const *>(x +
OFF_ITEM_INSTANCE_BLOCK/OFF_ITEM_DESCRIPTOR)` derefs across 24 files with
these calls. Behavior-preserving: same deref, all reads were already const,
and each site keeps its own null-check and field reads. Helpers add a
cgItem null guard (strictly safer).
Every caller of the engine object resolver (0x00468460) re-declared the
same __fastcall typedef and split the GUID into lo/hi by hand -- 30 call
sites across 28 files. Add object/Resolve.{h,cpp} with
void *Object::ByGuid(int typeMask, uint64_t guid, const char *debugName, int priority)
and route every site through it (behavior-identical: same typeMask,
debugName, priority per site; single-uint64 callers pass guid, split-lo/hi
callers reconstruct it).
Also collapse the duplicate offset constant: FUN_CLNT_OBJ_MGR_OBJECT_PTR
was a second name for the same 0x00468460 address as
FUN_OBJECT_RESOLVE_BY_GUID (a single-source-of-truth violation). Remove
it; the ABI-equivalent (u32,void*,u64,int) view it documented is folded
into the surviving constant's comment.
Add baselib/Ascii.h (header-only Ascii::ToLower / Ascii::EqualCI) and
replace the two hand-rolled case-insensitive compares (frame/Attributes'
EqI, taxi/Map's IEqual + AsciiLower).
Add DBC::ClassIdForToken / RaceIdForToken (token -> ChrClasses/ChrRaces
id, the reverse of ClassToken/RaceToken) backed by a shared
IdForStringField walk using Ascii::EqualCI, and replace player/NameCache's
two near-identical ResolveClassToken / ResolveRaceToken loops.
Add Unit::Identity::NameForGuid(guid) -- a passive NameCache peek that
returns the cached character name -- and replace the two identical local
copies in friendlist/IsFriend and friendlist/IsIgnored. Both files no
longer touch Offsets directly, so drop the now-dead include.
Add DBC::RaceToken (race client-filename token) and make AreaName skip
the parent Record lookup when resolveToParent is false, then replace the
inline ChrClasses/ChrRaces/AreaTable name+token reads across the codebase
with the shared helpers:
player/Info, creature/Info, player/LocationInfo, unit/ClassBase,
unit/RaceBase, map/Areas, taxi/Map, loot/History (drops its verbatim
ClassTokenForId), classes/Info (drops local Records/LocaleIndex).
All behavior-preserving -- the helpers wrap the same DBC::Record /
LocalizedField / StringField calls with identical id==0 / out-of-range /
empty-string -> nullptr semantics (verified against Lookup.cpp).
Left alone: NameCache's reverse token->id resolvers (no forward helper
fits) and the Record() reads of non-name fields in spell/Mod,
map/Overlays, creature/Info.
The second value's meaning varies by server core. Mangos/cmangos cores
send the query match count; Turtle (tortoise-wow) sends the whole
realm's online population whenever more than 49 players are online,
ignoring the filter (verified against tortoise-wow MiscHandler.cpp:
data.put(4, count > 49 ? count : clientcount), count = online players).
Earlier wording claimed it was always the match total, which is wrong
off Turtle -- confirmed by a Twinstar /who priest returning 15/15.
Add the modern table form of /who results. Vanilla's global
GetWhoInfo returns positional values and no class token; GetWhoInfo
here returns a table with filename (the "MAGE"/"WARRIOR" token, keyed
for RAID_CLASS_COLORS) plus fullName, fullGuildName, level, raceStr,
classStr, and area. GetNumWhoResults returns the shown + total counts.
Reads the engine's /who buffer directly (WHO_RESULT_* globals added to
Offsets.h, verified by decompiling Script_GetWhoInfo / GetNumWhoResults).
No gender field -- vanilla's /who entry has no sex.
Share the class/race/zone display-name resolvers: extract ClassName,
ClassToken, AreaName out of GetFriendInfo into dbc/Names.{h,cpp} (adding
RaceName and an AreaName resolveToParent flag -- friends resolve a
sub-area to its zone, /who uses the area's own name). GetFriendInfo and
GetWhoInfo both use them now.
Document both functions in docs/API.md and README.md.
Return the locale-independent class token ("WARRIOR", "MAGE", ...)
alongside the localized className, read from the same ChrClasses.dbc
filename column GetPlayerInfoByGUID's englishClass uses. It is the key
for RAID_CLASS_COLORS and other class tables, so addons can color a
friend's name without matching the localized class name. nil when
unknown, so className and classFilename appear together. Document in
docs/API.md.
Add a friends-list fallback to UnitNameFromGUID, between the object
manager and the persistent name cache. The friend list keeps name +
GUID for online AND offline friends (SMSG_FRIEND_LIST), so a friend's
GUID now resolves to a name even when the unit is not synced and was
never seen in chat -- the case that previously returned nil.
Only UnitNameFromGUID uses this: it returns name + realm, and the
friend entry has the name. GetPlayerInfoByGUID is left unchanged because
it also returns race and sex, which the friend entry does not carry.
Add FriendList::NameForGuid helper. Document the lookup order in
docs/API.md.
Add the online-friend count. Vanilla stores no online total, so count
friend entries whose connected byte is set -- the same flag
GetFriendInfo reports. Document in docs/API.md and README.md.
Add the two friend-note GlobalStrings vanilla 1.12 lacks, per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW), from Blizzard's Classic
GlobalStrings. Lets note-aware UI (e.g. a pfUI "Set Note" entry driven
by C_FriendList.SetFriendNotes) label itself in the client language.
Add ignore-list membership checks. Vanilla's ignore list is a flat u64
GUID array in the social singleton (social + 0x650, stride 8, max 25,
terminated by the first zero) -- verified by decompiling GetIgnoreName
(FUN_005AE570 reads social + 0x650 + index*8) and GetNumIgnores
(FUN_005AE550 stops at the first zero, caps at 25).
IsIgnoredByGuid parses the GUID string and walks the array.
IsIgnored accepts a name or GUID string (mirroring IsFriend); for a name
it resolves each ignored GUID to its cached name through the NameCache
and compares -- the same GUID->name path GetIgnoreName uses -- so it
matches any ignored player the client has synced this session.
Share the ignore layout + IgnoreGuidByIndex / IsGuidIgnored helpers in
FriendList.h alongside the friend layout. Document both functions in
docs/API.md and README.md.
UIDropDownMenu_CreateInfo does not exist in stock 1.12.1 FrameXML, so
building the Focus and Cancel buttons through it errored with "attempt
to call global UIDropDownMenu_CreateInfo (a nil value)". Build the info
tables directly ({}) -- the vanilla idiom UIDropDownMenu_AddButton
expects.
Unify ClassicAPI_ToggleUnitMenu so every menu (players and NPCs) shows
the base menu minus its trailing Cancel, then appends Focus + Cancel.
An NPC resolves to the RAID_TARGET_ICON menu, whose entries
UnitPopup_HideButtons hides entirely unless you are grouped and can
place markers -- so solo the menu was empty and never opened. The
appended Focus + Cancel now always open it, and the NPC's own name is
used as the title.
Give the Cancel button a no-op func: UIDropDownMenuButton_OnClick
early-returns when a button has no func and never hides the list, so the
funcless Cancel did nothing on click.
Add a contextual Focus / Clear Focus entry to the ClassicAPI unit
dropdown (ClassicAPI_ToggleUnitMenu), shown for real non-self units
(party / raid / target). pfUI unit frames route their right-click
through this dropdown, so it reaches every frame in use.
Built entirely inside our own dropdown: no hooksecurefunc and no
mutation of Blizzard's shared UnitPopupMenus tables, so default frames
and other addons are unaffected. Cancel stays last by showing the base
menu minus its trailing Cancel, then appending Focus and Cancel with our
own click funcs. "Clear Focus" shows when the unit is the current focus,
"Set Focus" otherwise.
Labels use localized SET_FOCUS / CLEAR_FOCUS globals, added per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW) from Blizzard's Classic
GlobalStrings.
Update the GetFriendInfo entry so notes is a live field (with the
per-character persistence note), add SetFriendNotes and
SetFriendNotesByIndex sections plus TOC and README-matrix rows. Example
values are from a verified in-game /dump.
Vanilla has no friend notes, so persist them client-side like the equipment manager. New friendlist/Notes.{h,cpp}
stores guid -> note in
WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt --
per-character (matching the friends list's own scope), GUID-keyed (stable
and realm-unique), atomic tmp+rename write.
Expose C_FriendList.SetFriendNotes(name, notes) and
SetFriendNotesByIndex(index, notes) -- empty/nil clears, fires
FRIENDLIST_UPDATE -- and fill the notes field in GetFriendInfo /
GetFriendInfoByIndex from the store.
Character isolation (the equipment-manager trap): EnsureLoaded re-resolves
the path every call and reloads on change; Load clears the map before the
file-open check, so a note-less character still wipes the previous one's
map; and an empty-globals transition drops the cache so a stale path is
never read or written.
FriendInfo's optional strings (notes, className, area) are left nil when
absent instead of "" -- SetFieldString coerces null to "", which is
truthy in Lua and would fool an `if info.notes then` check.
Verified in-game: set / get / clear by name and by index.
Add API.md sections and the README matrix row for GetFriendInfo,
GetFriendInfoByIndex, and GetNumFriends, with the FriendInfo field list
and example values captured from a live /dump (verified, not guessed).
Return the modern FriendInfo table (name, connected, level, className,
area, guid, afk, dnd, plus vanilla-truthful mobile / referAFriend /
rafLinkType defaults). Fields are read straight from the friend entry,
mirroring the engine's GetFriendInfo (FUN_005AD060): class name via
ChrClasses.dbc, zone via AreaTable.dbc resolved to the parent area, both
locale-applied. Verified in-game against a live friends list -- online,
offline, level-60 and level-1, with AFK and DND toggled.
Factor the friend-entry layout and list accessors into a shared
friendlist/FriendList.h and move IsFriend onto it, so the one struct has
one definition. Add OFF_AREATABLE_PARENT_ID (0x08) for the zone resolve.
Add API.md sections and README matrix rows for the four functions, with
example returns verified against Spell.dbc. Also correct two stale docs:
- IsRangedAutoAttackSpell now describes the AttributesEx2 autorepeat flag
(bit 0x20), matching the fixed behavior -- not the old Attributes
RANGED-bit test that wrongly matched Aimed Shot / Multi-Shot.
- ResetsMeleeSwing mirrors the server's IsMeleeAttackResetSpell: true for
cast-time spells whose casting resets the swing (Fireball, Frostbolt),
false for instant specials (Sinister Strike) and for Slam (NOT_RESET
flag). The first draft had the examples and framing backwards.
Backport the friend-list membership check. Friends live inline in the
social singleton (0x00C28168): up to 50 entries, stride 0x20, each with
a GUID at +0x08 and a name pointer at +0x04 (verified from GetNumFriends
/ GetFriendInfo and the count walker FUN_005AE490). IsFriend matches the
input GUID against +0x08 -- the GUID the server sends for online and
offline friends -- with a name-compare fallback (GUID -> NameCache name),
and also accepts a bare character name.
Renamed VAR_WHO_SYSTEM -> VAR_SOCIAL_SYSTEM: 0x00C28168 is the one social
singleton shared by the friend list, ignore list, and /who system, so it
gets one accurate name instead of a who-specific alias plus a duplicate.
The remaining flag-bit duplicates -- the same drift risk that caused the
AttributesEx2/Ex3 mix-up -- now live once in Offsets.h:
- SPELL_ATTR_PASSIVE (0x40): was already in Offsets.h yet copied in
Info.cpp and Level.cpp; drop both locals.
- SPELL_ATTR_EX_CHANNELED (0x4|0x40): identical local in Cast.cpp and
CastEvents.cpp; add to Offsets.h and repoint.
- SPELL_ATTR_EX2_AUTOREPEAT_FLAG (0x20): the same bit under two names
(Cast's _AUTOREPEAT, AutoAttack's _FLAG); unify in Offsets.h.
Single-use flag bits (TRADESPELL, RANGED, HEALTH_FUNNEL,
HIDDEN_CLIENTSIDE, ON_NEXT_SWING, NOT_RESET_AUTO_ACTIONS) and per-module
effect/target enum codes stay local -- they are not duplicated and read
clearer at the use site.
Fold the last duplicated DBC offsets into Offsets.h:
- SpellIcon.dbc path (+0x04): 4 copies across 3 files -> OFF_SPELLICON_PATH.
aura/Data and spell/Cast had local consts; spell/Info used two raw
`iconRec + 4` literals.
- totem/Tracker's OFF_MISC_VALUE (0x1A8) duplicated the existing
OFF_SPELL_RECORD_EFFECT_MISC_VALUE -- repointed. OFF_TOTEM_TOOL (0xA0) is
a Spell.dbc field, added as OFF_SPELL_RECORD_TOTEM.
Audit result: unit-descriptor offsets were already fully centralized in
Offsets.h, and item ItemStats_C fields are read via a named struct rather
than offset consts, so neither needed work. Spell.dbc and its sub-DBC
record offsets are now a single source of truth.
Extend the single-source-of-truth cleanup to the whole Spell.dbc record
layout. 40 local constexpr offset copies across 16 files (spell/, totem/,
lossofcontrol/, macro/, aura/) redefined field offsets that belong in
Offsets.h: name (0x1E0), icon (0x1D4), effect (0xF4), rank (0x204),
effect implicit targets (0x148/0x154), powerType (0x7C), manaCost (0x80),
rangeIndex (0x90), castingTimeIndex (0x48), maxLevel (0x6C), spellLevel
(0x74), manaCostPercent (0x270).
Add the ones Offsets.h lacked as OFF_SPELL_RECORD_* and repoint every
local use to the Offsets:: constant, deleting the duplicates. Purely
mechanical and behavior-preserving -- every local value already matched
the canonical (verified before repointing). SpellIcon.dbc / SpellRange.dbc
sub-record offsets (0x04/0x08) stay local; they are different structs.
SpellHiddenFromAutoLearn tested AttributesEx3 bit 0x02000000, a gate
ported from a MoP function (FUN_00911c60) whose +0x24 field is not
1.12's AttributesEx3. Verified against Spell.dbc: in 1.12 the bit is set
on only 44 of 27925 records -- a grab-bag of passive procs, odd DoT
ranks, the Night Elf priest Starshards line, and Turtle customs -- so it
is not an auto-learn flag and could only wrongly hide legitimate
learnable spells (Starshards carries it).
Drop the AttributesEx3 test and keep the verified HIDDEN_CLIENTSIDE
(Attributes bit 0x80) filter, renamed SpellHiddenFromSpellbook. The
authoritative auto-learn signal is SkillLineAbility.learnOnGetSkill
(col 9), not a Spell.dbc bit; this feature reports the broader
"trainable at level" set and applies no learnOnGetSkill filter.
The check tested Attributes bit 1 (RANGED), which every ranged ability
sets -- so Aimed Shot and Multi-Shot wrongly counted as ranged
auto-attacks. The two auto-repeat spells (Auto Shot, Shoot) are the ones
with SPELL_ATTR_EX2_AUTOREPEAT_FLAG (AttributesEx2 bit 5, 0x20). Verified
from Spell.dbc: Auto Shot (75) and Shoot (5019) carry Ex2 0x20; Aimed
Shot (19434) and Multi-Shot (2643) set only RANGED, not the flag.
isFunnel read AttributesEx bit 6, which is CHANNELED_2 -- false for
Health Funnel itself and true for unrelated channels, so it never
actually detected funnel spells. The real flag is
SPELL_ATTR_EX2_HEALTH_FUNNEL (AttributesEx2 bit 11, 0x800). Verified
from Spell.dbc: Health Funnel (755, Ex2=0x808) and Hellfire (1949,
Ex2=0x800) carry it; the channeled non-funnels Drain Life, Mind Flay,
Arcane Missiles, and Rain of Fire do not.
The prior consolidation moved the spell-mod disable read from record
+0x24 to +0x20 on the handoff's claim that all three "0x24 readers"
intend AttributesEx2. A tortoise-wow sweep plus client disassembly
disproved that for the mod gate: the client's spell-mod core
(FUN_006e6b30) reads [record + 0x24] & 0x20000000 -- AttributesEx3 bit
29, SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS, which is the real "ignore
caster modifiers" flag. AttributesEx2 bit 29 is CANT_CRIT and has
nothing to do with spell mods. So +0x20 was a regression.
Add OFF_SPELL_RECORD_ATTRIBUTES_EX3 (0x24) and the correctly named
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS to Offsets.h, and point Mod.cpp
at them. Cast.cpp's autorepeat read stays at AttributesEx2 (bit 5,
AUTOREPEAT_FLAG) -- that one was a genuine EX2 flag and the move to
+0x20 was correct.
Level.cpp's auto-learn gate was ported from a Cata function
(FUN_00911c60, outside 1.12's .text) and its 1.12 meaning is
unverified, so restore it to the original +0x24 read (no behavior
change from before the consolidation) and mark the constant unverified
pending a real 1.12-client check.
Add C_Spell.GetSpellDispelType, IsNextMeleeSpell, and ResetsMeleeSwing.
Each reads a Spell.dbc field straight from the record: dispel type
(0x10), the on-next-swing attribute bits (0x18 & 0x404), and the melee
swing-reset rule (InterruptFlags 0x54 has AUTOATTACK and AttributesEx2
0x20 lacks NOT_RESET_AUTO_ACTIONS). All offsets come from the shared
Offsets.h constants. This lets pfUI drop nampower's GetSpellRecField.
The Attributes (0x18), AttributesEx (0x1C), and AttributesEx2 (0x20)
record offsets were each copied as local constants across several spell
modules. AttributesEx2 was 0x24 in three of those copies, which is
actually AttributesEx3 -- an off-by-one-field bug. Spell.dbc stores each
record as a flat uint32 array by column, so AttributesEx2 (column 8) is
at 0x20. Verified against the 1.12.1 client, the server column order,
and nampower.
Move the record offsets to Offsets.h as the single source of truth (add
AttributesEx at 0x1C and InterruptFlags at 0x54; correct AttributesEx2
to 0x20) and remove the local copies in AutoAttack, Cast, CastEvents,
Info, and Level. This corrects the autorepeat aim-time skip, the
auto-learn hide check, and the spell-mod family gate -- all of which read
AttributesEx2 -- and removes the duplication that let one wrong offset
become three bugs.
The Aura::Source cache holds one entry per (target, spellId) for every
aura-applying SMSG_SPELL_GO observed — every buff on every unit in view, not
just the player's. At 256 entries the table was permanently full in a group or
raid, so nearly every new cast fell through Store's overflow branch, which
overwrote a blind round-robin slot with no presence or expiry check. That slot
was almost always a live buff, dropping its cached caster — the "buff kept its
sourceGUID for a few minutes then lost it" report (e.g. Blessing of Wisdom).
Size kCacheSize to the realistic worst case (2048 — a fully raid-buffed 40-man
plus debuffs, ~80 KB static) so the table stops living at capacity, and replace
the round-robin overflow with a presence-aware victim: prefer the
least-recently-updated ORPHAN (no live descriptor backing — out of range /
despawned), and only touch a still-present entry under true saturation (every
one of 2048 slots a live aura, practically unreachable). This extends the
descriptor-presence invariant the tick sweep and slot-reuse already honor to
the last unguarded eviction path, so an aura still on a resolvable unit keeps
its source for its whole life. Verified in-game: 30+ minutes in a full
Stormwind with no source loss.
The self-buff sourceGUID inference from 7caee62 ran only at display, so a
self-buff we never saw cast (e.g. login-persisted Inner Fire) showed
sourceUnit="player" yet was excluded from HELPFUL|PLAYER — IsPlayerCast
still keyed off the Aura::Source cache alone, which has no entry for it.
Centralize the resolution in EffectiveCaster (observed caster, else the
self-buff inference: a self-only-target aura is cast by the unit carrying
it) and route display plus both filter predicates (IsPlayerCast,
GroupIsPlayerCast) through it, so the two paths can't disagree. The
predicate stays unit-scoped: a self-buff on the player matches PLAYER; the
same self-buff on another unit resolves to that unit and correctly does not.
Two changes so an aura's source stops vanishing:
Pin observed casters. The WorldTick expiry sweep (and Store's slot
reuse) dropped a cache entry once its computed expirationMs elapsed —
but that time is only a base-duration estimate for non-player casters
(we lack their mod tables), so a talent-extended debuff expired in the
cache while still live on the target. The entry took the caster with it,
and the descriptor path then rendered the aura with no sourceGUID. Now
an entry whose aura the owning unit's live descriptor still lists is
never timer-reclaimed (DescriptorListsAura resolves the GUID through the
object manager); only genuinely orphaned entries — unit out of range /
stealthed / despawned — expire. The fallback path already skips expired
entries, so no phantom can resurface. PushEnriched also reports an
already-elapsed cached expiration as unknown rather than a negative
remaining time.
Infer self-buff casters. On a caster miss (no cache entry, or one with
no caster), a self-only-target aura can only have been self-cast, so the
unit carrying it is its own source. Factor the record scan out of the
C_Spell.IsSelfBuff Lua wrapper into Spell::IsSelfBuff::IsSelfBuff and
call it from PushEnriched to recover sourceUnit/sourceGUID for
pre-existing self-buffs we never saw applied. Caster is exact; timing
stays unknown.
Reuses the C_LossOfControl classifier so the two never drift. Extract
ClassifyControlLoss out of the LoC module into a shared
Spell::CrowdControl: Classify(rec) returns the hard control-loss type
string (stun/fear/silence/root/charm/confuse/pacify/possess/disarm) and
still backs C_LossOfControl; IsCrowdControl(spellID) backs the aura
filter.
Crowd control is broader than loss of control: IsCrowdControl also
counts movement slows / snares (SPELL_AURA_MOD_DECREASE_SPEED = 33,
added to Offsets.h), which are crowd control but not a loss of control —
so a Frostbolt/Hamstring slow is CROWD_CONTROL while C_LossOfControl
still ignores it. Classify (LoC) is unchanged.
Wire it as a third CcMode dimension on the Aura::Data::Match struct,
checked in MatchesAura; tokenizer honors CROWD_CONTROL / !CROWD_CONTROL.
Docs updated. Verified in-game: Psychic Scream (fear) and a Frostbolt
slow both classify as CROWD_CONTROL; DoTs and buffs do not.
Modern DISPELLABLE means "can be dispelled/purged/stolen by SOME
mechanism, regardless of whether the player can". Confirmed against the
Turtle/Tortoise server: an aura is dispellable iff its Spell.dbc Dispel
type is Magic(1), Curse(2), Disease(3), or Poison(4) — the server's
DISPEL_ALL_MASK — and vanilla applies no per-aura "undispellable"
attribute, so the dispel type is the whole story.
Data::IsDispellable(spellID) reads the Dispel field (OFF_SPELL_DISPEL_TYPE)
via the existing SpellRecord and checks 1..4 (the type ID directly, not
dispelName != nil, in case SpellDispelType.dbc names Stealth/Invisibility).
Fold the caster and dispel predicates into one Aura::Data::Match struct
that replaces the standalone CasterMode parameter across the Find*/Push*/
Append* surface, applied by a shared MatchesAura(match, isPlayerCast,
spellID). Orthogonal filter dimensions now add a struct field instead of
another parameter. Existing caster behavior is unchanged.
Tokenizer honors DISPELLABLE / !DISPELLABLE; docs updated. Verified
in-game: HARMFUL|DISPELLABLE returns Magic debuffs, HARMFUL|!DISPELLABLE
returns physical debuffs (Rend).
shell: bash on the self-hosted Windows runner resolved to the WSL
launcher at C:\Windows\System32\bash.exe (Git Bash isn't on the
NETWORK SERVICE account's PATH), so Resolve version died with 'no
installed distributions'. Default the build job's run steps to Windows
PowerShell 5.1 (always present) and rewrite Resolve version accordingly,
dropping the bash/WSL dependency from the native-Windows build.
Probe repo runners in a pick-runner job and hand the resolved label
(self-hosted if one is online, else windows-latest) down to build via
needs, so an offline self-hosted runner no longer queues the build
indefinitely.
The aura filter string was matched with raw strstr, which the modern
AuraFilters token set breaks: strstr(f, "PLAYER") also matches
RAID_PLAYER_DISPELLABLE (false PLAYER-only), and "!PLAYER" contains
"PLAYER" so it was read as PLAYER-only — the opposite of intent.
Replace it with a real tokenizer (Aura::Api::ParseFilters): tokens
separated by "|" or whitespace, each optionally negated with a leading
"!", whole-token matching. Honored tokens: HELPFUL / HARMFUL
(descriptor slot ranges) and PLAYER / !PLAYER (caster == / != the local
player). Every other AuraFilters token (RAID, CANCELABLE, DISPELLABLE,
INCLUDE_NAME_PLATE_ONLY, MAW, …) is accepted and ignored — vanilla has
no data for them.
!PLAYER makes the caster restriction ternary, so the Aura::Data layer's
`bool playerOnly` becomes `CasterMode { Any, PlayerOnly, NotPlayer }`
threaded through every Find*/Push*/Append* function (plus the
lossofcontrol call site). Existing Any/PlayerOnly behavior is preserved
exactly; NotPlayer is additive. A cache miss counts as not-player, so
!PLAYER includes auras whose cast we did not observe (matching
IsPlayerCast's miss semantics).
Also fixes: "PLAYER" alone on the by-id / by-name getters now searches
both ranges instead of wrongly restricting to helpful.
Docs: API.md gains an STE "Filter tokens" note under GetAuraDataByIndex.
pfUI-style unit frames have no OnEnter; their tooltip is produced by
Frame::Attributes driving the engine mouseover setter (FUN_00492890).
That setter builds the unit tooltip only when the GUID resolves to a
live object, so an offline / far out-of-range party or raid member
(roster GUID, no object) got no tooltip.
The unit-tooltip builder FUN_00529FE0 itself works offline — with no
object it fills name/level/class + the "Offline" line from the party/
raid roster (same path GameTooltip:SetUnit("party1") uses). So when the
hovered unit-frame's GUID has no live object, build the tooltip
ourselves from the roster.
Fire OnTooltipSetDefaultAnchor before building (via the self-contained
FUN_FRAME_INVOKE_SCRIPT), exactly as the engine setter does — that
FrameXML handler sets the tooltip owner/anchor and is what shows it.
Without it the first hover worked by luck but a re-hover after the
leave-clear had no owner and stayed invisible (only a live-unit hover,
which takes the engine's own anchor path, would recover it).
New offsets: VAR_GAMETOOLTIP_OBJECT_PTR (0x00B4B3C4, the mouseover
tooltip object) and OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER (0x444).
Modern WoW exposes GetMouseFoci() (the stack of frames under the
cursor) alongside GetMouseFocus(), with GetMouseFocus() ==
GetMouseFoci()[1]. Vanilla stores only the single topmost focus
(Script_GetMouseFocus reads *(ctx + 0x7C)); there is no stored stack.
Reimplement natively (no Lua polyfill, no hooks): walk the engine frame
list that Script_EnumerateFrames iterates (head *(ctx + 0xCC4), next
+0x308, low-bit/null sentinel), keep frames that are visible +
mouse-enabled and whose rect contains the cursor (Region getters +
cursor/effective-scale, mirroring Frame::Modern's IsMouseOver), order
top-first by strata rank then frame level (level read directly from
+0xC4), and force index 1 to the engine's stored focus so the invariant
holds by Lua table identity. Frames are pushed via the shared
UI::FrameObject::Push so == compares the same wrapper GetMouseFocus
returns.
Filters to the mouse-enabled (interactive) stack, matching 1.15.x
(verified: GetMouseFocus skips a higher non-mouse-enabled cooldown to
land on the button, and 1.15.x GetMouseFoci returns the same set).
/framestack lists more because it is a pure-geometry debug view, not the
mouse-focus set.
Docs: API.md entry (Simplified Technical English).
Push-triggered runs can be silently dropped when GitHub throttles
webhooks during an Actions incident, leaving no way to kick a build.
Add a workflow_dispatch trigger as a manual escape hatch, runnable
from the Actions UI.
A single Resolve step computes is_release/tag from the pushed ref, the
dispatched ref, or an optional `tag` input, and every downstream step
keys off it — so push behavior is unchanged. Dispatch modes: run from a
vX.Y.Z tag (release build of that commit), run from a branch with a
`tag` input (release build attached to that tag), or run from a branch
with a blank input (dev build artifact). The Release step now passes
tag_name explicitly so it also works when the ref is a branch.
Two related fixes to the embedded-addon precedence so users stop
running a stale/mismatched on-disk !!!ClassicAPI without realizing it,
while keeping the developer's disk-wins workflow intact.
Version is now a pure build-flag artifact. The embedded copy's
`## Version:` is stamped from CLASSICAPI_TAG at embed time
(cmake/embed_addon.cmake rewrites the line in-memory before hex
encoding, portable via a temp-file round-trip). The committed source
toc stays `## Version: DEV` and is never edited, so a release build is
`-DCLASSICAPI_TAG=vX.Y.Z` and nothing else. Dropped the CI sed step
that previously mutated the source toc. Moved the version-derivation
block above the embed step so CLASSICAPI_TOC_VERSION is available to it.
Dev override moved from the toc version to an explicit marker. A
`.classicapi-dev` file in the on-disk addon folder now forces the disk
copy to win regardless of version (checked via the original
FUN_FILE_READ, so it reflects a real on-disk file). Consequently `DEV`
no longer sorts as "newer than any release" in CompareVersions — it
sorts oldest — so a stray `DEV` source/clone copy on a user's disk no
longer silently shadows a newer embedded release. The normal local
loop still works without the marker (local DEV DLL + DEV disk = tie =
disk wins); the marker is only needed to prefer disk against a released
DLL. The marker is gitignored and excluded from the embed (both globs),
and the release ships only the DLL, so it can exist only in a
developer working tree.
ClassicAPI 1.8.4 added a UPDATE_MOUSEOVER_UNIT loss-fire polyfill
(matching retail): the event now also fires when the mouseover goes
unit -> nothing, with `mouseover` already cleared. Puppeteer's
EnemyTracker OnEvent reads `unit = UnitGUID("mouseover")` on that
event, which is now nil on the loss fire; it then calls
`UnitCanAttack("player", unit)`, and Puppeteer routes UnitCanAttack
through its DoubleUnitProxy (no nil guard) straight to native
UnitCanAttack -> engine raises `Usage: UnitCanAttack("unit",
"otherUnit")` on the nil arg.
Wrap PTEnemyUpdater's OnEvent from our AddOnCompat shim and skip the
handler when UPDATE_MOUSEOVER_UNIT fires with no live mouseover, so
the nil never reaches the proxy. Gains still pass through, so enemy
tracking is unaffected. Deferred via ContinueOnAddOnLoaded (Puppeteer
loads after us) and guarded on PTEnemyUpdater existing (it is only
created when SuperWoW is present).
The CBOR encoder walked string-keyed tables with lua_next (pairs()
order), which Lua does not guarantee to be stable. The pfUI export
path rebuilds its config tables on every press, so each rebuild
re-hashed the same keys into a different internal layout and the map
serialized its key/value pairs in a different byte order -> different
compressed bytes -> different Base64. Harmless (every variant decodes
to the same config) but no stable fingerprint.
EncodeTable's map branch now stashes each key, records its encoded
bytes, sorts by encoded-key bytes (shorter first, then bytewise per
RFC 7049 3.9), then re-fetches key/value in sorted order. Nested
maps sort recursively, so the whole byte stream is canonical. Decode
is order-agnostic, so the round trip stays lossless.
Verified in-game: encode/decode/re-encode of a pfUI profile now
produces a byte-identical Base64 string.
C_UnitAuras dropped sourceGUID/sourceUnit for buffs cast by other units
(e.g. a druid's Mark of the Wild / Gift of the Wild on the player).
SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
the target's descriptor. In that window an addon aura poll runs
ReconcileCache, which calls EvictAbsent. The just-captured cache entry
named an aura the descriptor did not list yet, so EvictAbsent dropped it
and lost the caster. OnAuraAdded then recreated the entry with no caster,
so sourceGUID stayed nil. Verified by trace: every "Store NEW caster=0x8"
was followed at once by "EvictAbsent (had caster=0x8)".
Give each cache entry a write timestamp (stampMs) and exempt entries
younger than kEvictGraceMs (2s) from EvictAbsent. A real removal is far
older and still gets dropped, and OnAuraRemoved evicts genuine removals
directly, so stale-aura cleanup is unaffected.
Verified in-game: a remote druid buff now reports sourceGUID; a self-cast
buff still reports sourceGUID + sourceUnit="player".
`GameTooltip:SetSpellByID` now shows the spell rank in the top-right
corner of the title line. The cross-class `SetTalentByID` fallback shows
it too. Both go through `Spell::Tooltip::ShowByID`.
The engine builder `BuildSpellTooltip` (`FUN_0052E610`) sets the title
line right-text to `Rank[locale]` when `param_5` or `param_2` is not
zero. `param_5` has no other effect in the builder (confirmed by
disassembly). `SetAction` and `SetPetAction` pass `param_5=1` for this.
Native `SetSpell` and the old `SetSpellByID` passed `0`, so they did not
show the rank. `ShowByID` now passes `param_5=1`.
The change is additive. A spell with no rank (passive or racial) shows
nothing there, the same as the engine.
With this change, the macro tooltips of SuperCleveRoidMacros match the
native action-button tooltips.
The CGPlayer sub-struct at [unit+0xE68] (guild key, PLAYER_FLAGS, quest
list, visible items) exists only on real player objects. Reads of it were
gated on UNIT_FLAG_PLAYER_CONTROLLED, but the server sets that flag on
pets, totems, guardians, companions, and MC'd creatures too (Turtle
UnitDefines.h; Pet.cpp/SpellEffects.cpp/SpellAuras.cpp setters) -- all
TYPEID_UNIT creatures with no such sub-struct. On one of them the +0xE68
pointer is garbage-but-non-null, so the null check passes and the deref
AVs (ERROR #132, pfUI issue #34: UnitIsInMyGuild on a nameplate pet/totem,
frequent in crowded dungeons/raids).
Add Unit::Flags::IsPlayerObject (m_objectType == OBJECT_TYPE_PLAYER via
[obj+0x14]) and use it as the gate at every arbitrary-unit +0xE68 reader:
- UnitIsInMyGuild (ReadGuildKey) -- the reported crash
- UnitIsAFK/UnitIsDND (TestPlayerFlag) -- same latent crash on a pet
- IsUnitOnQuest (Quest::Log::UnitHasQuest)
- GetInventoryItemID (non-local unit visible-items path)
Local-player-only readers are unaffected. IsPlayerControlled stays for
minion/ownership classification (Unit::Pet), which wants the broad flag.
The vanilla CreateMacro / EditMacro globals take an icon INDEX into the
GetMacroIconInfo list, and that list omits INV_* item icons entirely --
so item icons are unreachable through the stock API even though the
engine stores and renders them fine. C_Macro.CreateMacro / EditMacro
accept the icon as a texture string (a full Interface\Icons\<name> path
or a bare <name> basename) instead, writing the basename directly to the
macro struct; GetMacroInfo re-prefixes it as Interface\Icons\%s on read,
so any Interface\Icons\ texture round-trips.
Both go straight to the engine's own workers (FUN_004f1010 create /
FUN_004f1280 edit), which do the bounded copy, re-run the primary-spell
parser, set the persist-on-logout dirty flag, and refresh the action
bars. EditMacro resolves its index arg as a slot number or a macro name
(via the GetMacroIndexByName resolver), leaves any nil field unchanged,
and reads back the entry's local flag so the worker's unconditional
rewrite of it is a no-op.
The legacy globals stay untouched (index-only); string-icon callers opt
in through the C_Macro namespace. Verified in-game incl. an INV_* item
icon surviving a relog. Unblocks a pfUI macro-UI remodel.
On this Lua 5.0 build, unpack() ignores its explicit i/j arguments entirely
and takes its element count from getn() -- which returns the table's `.n`
field, or border-searches to the first nil when `.n` is absent. Verified in
the binary (luaB_unpack 0x007030a0 -> luaL_getn 0x006f5050) and in-game
(unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
- GenerateClosure built its forwarding table `rest` with no `.n`, so
unpack(rest, 1, restN) border-searched and silently dropped every value
past the first nil hole in the payload -- despite the comment claiming
embedded nils survive. It now stamps `all.n` and unpack(all) forwards
holes intact.
- Every other unpack(x, 1, x.n) / unpack(x, 1, count==x.n) call was a no-op:
the table already carried `.n`, so unpack(x) is identical. Simplified
across FunctionUtil, Pools, CallbackRegistry, and TableUtil's SafeUnpack,
and corrected the header comments that recommended the redundant form.
A callback firing during the engine's logout teardown can touch state
that has already been freed, faulting with ERROR #132 (ACCESS_VIOLATION).
A dedicated frame owns PLAYER_LOGOUT and tears down frameEventFrame's
event delivery (UnregisterAllEvents + clear OnEvent) so nothing dispatches
during teardown. A separate frame keeps this decoupled from the
ref-counted consumer event set, which may no longer hold PLAYER_LOGOUT.
ShaguTweaks' libpredict sender frame (ShaguTweaksPredictionSender) registers
TBC-era events alongside its vanilla ones — COMBAT_LOG_EVENT_UNFILTERED and
UNIT_SPELLCAST_START/STOP/FAILED/INTERRUPTED/SENT (libpredict.lua:342-347). In
stock 1.12.1 those never fire, so registering them was a harmless no-op and only
the vanilla SPELLCAST_* path ran. ClassicAPI now backports those events, so
libpredict's TBC branches fire too and double-process cast/heal predictions.
Unregister the six TBC events on libpredict.sender once ShaguTweaks loads,
restoring vanilla-only behavior. Guarded on the addon existing and the
libpredict table being present.
EventRegistry callbacks (RegisterOnceFrameEventAndCallback, etc.) dropped any
payload whose first arg was nil — e.g. this client's reload PLAYER_ENTERING_WORLD
(arg1=nil, arg2=1) delivered nil to every callback parameter. Root cause is a
WoW Lua 5.0 quirk: unpacking a table whose hole lands right after a present
element into a `func(first, ...)` collapses the receiving vararg count to 0, so
the payload after `owner` was silently lost.
Fix both build-then-unpack sites in CallbackRegistry to pass the leading arg
explicitly and the payload as the unpacked tail:
- TriggerEvent Function branch: func(owner, unpack(arg, 1, arg.n))
- GenerateClosure: func(bound[1], unpack(rest, 1, restN))
Verified in-game: RegisterOnceFrameEventAndCallback("PLAYER_ENTERING_WORLD", cb)
now delivers arg2=1 to the callback's 2nd parameter on reload.
The attribute methods and the modern-script-args toggle shipped on this
branch but weren't in the README index, and the toggle wasn't documented
at all.
- README: add frame:SetAttribute / SetAttributeNoHandler / ClearAttribute /
GetAttribute, the OnAttributeChanged script, and SetModernScriptArgs /
GetModernScriptArgs to the Frame row.
- docs/API.md: document SetModernScriptArgs(enable) / GetModernScriptArgs()
(opt-in modern positional handler args) with a Frame TOC entry. The
attribute methods and OnAttributeChanged were already covered.
* frame: backport Frame:SetAttribute/GetAttribute + unit-frame mouseover/click
Adds native SetAttribute / SetAttributeNoHandler / GetAttribute on the base
Frame registry: a per-frame, case-insensitive value store with the 3.3.5
prefix/name/suffix getter precedence, stored on the frame's own Lua table.
A `unit` attribute makes the frame a mouseover source. A WorldTick poll of the
engine's mouse-focus frame (GetMouseFocus's global) calls the real mouseover
setter (FUN_00492890) for its unit -- 1:1 with hovering the 3D model: model
highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the native GUID slot.
Stomp-proof (the engine's 3D-hover setter is event-driven) and independent of
the frame's scripts, so an addon setting its own OnEnter can't break it.
A `type1`/`type2`/`type` attribute installs a chained OnClick on that frame only
(PCall-guarded, Button-only) that dispatches target / assist / focus on the
unit at click time, then runs any prior handler.
No global hooks: mouseover reads the mouse-focus global; clicks use the frame's
own OnClick. Deliberately does NOT MinHook the button-click dispatcher
(FUN_00779540) -- SuperWoW's click-casting owns that prologue and a second
inline hook corrupts the trampoline (ERROR #132).
* frame: extend attribute backport (one-verb click dispatcher + OnAttributeChanged)
The `type*` click path now mirrors retail's SecureActionButton_OnClick:
resolve ONE verb per click from the modifier/button-qualified `type`
attribute and perform it, instead of a fixed `type1=target`.
- Modifier/button resolution: `[alt-/ctrl-/shift-]type[1..5]` with
prefix..name..suffix -> name..suffix -> name precedence, read from the
live key state and the click's button.
- Verbs: target (with the engine's default-interaction precedence -
cast a pending spell / drop a cursor item on the unit instead of
switching target), assist, focus, spell, stopcasting, macro, and menu.
- Own-the-click: when a verb resolves we perform it and do NOT chain the
frame's previous OnClick, so a configured `type1` no longer double-
dispatches alongside an addon's own conditional click handler.
- Self-healing wire: addons that re-SetScript OnClick (pfUI on every raid
relayout) clobber our closure; WireOnClick re-wraps a clobbered Lua
handler but skips its own C closure, so re-setting `type*` recovers
without double-chaining.
Dispatch goes straight to C++ where we own the code - no Lua round-trip
through the global table: spell -> Spell::AtUnit::CastByName (a native
unit-targeted cast; the unit's GUID is fed to the engine's dispatcher so
there's no target juggling, and ground-target spells land at the unit's
feet), focus -> Unit::Focus::Set, and the target verb's predicates ->
Spell::AtCursor::IsPlacementActive / Cursor::Info::HasItem. Only genuine
engine entries (TargetUnit/AssistUnit/SpellTargetUnit/DropItemOnUnit/
SpellStopCasting) and addon-provided functions still go through Lua
globals. Spell::AtUnit gains a C++ header sharing its CastCore with the
C_Spell.CastAtUnit Lua entry.
macro verb: takes the content from the `macrotext`/`macro` attribute and
prefers an addon-provided RunMacro (SuperCleveRoidMacros etc. - handles
named macros and extended macro text), falling back to running the text
natively when no RunMacro global is present. The native path runs each
line through the stock FrameXML ChatEdit_ParseText via a throwaway
edit-box (GetText closure + no-op __index), so it needs no addon.
menu/togglemenu verb: pops the standard unit dropdown at the cursor via
the new ClassicAPI_ToggleUnitMenu (Util/SecureUnitMenu.lua), resolving
the unit -> menu type with Blizzard's own generic resolver
(self->SELF, pet->PET, grouped player->PARTY, other player->PLAYER,
NPC->RAID_TARGET_ICON). Keeps the FrameXML dropdown logic in Lua with
the C side as a thin call.
OnAttributeChanged is now a real SetScript/GetScript/HookScript-able
frame script on every frame: SetAttribute fires it, SetAttributeNoHandler
suppresses it. Made settable by co-hooking the base-frame script-name
resolver FUN_0076a0d0 (the one every frame type delegates through) and
handing back an external per-frame {handler, context} cell for that name -
frames are immortal in 1.12, so the cell never goes stale. One hook does
it (vs the tooltip OnTooltipSet* work's five) because we already own the
firing side. 1.12 convention: the handler reads this = frame, arg1 = name,
arg2 = value. Recursion-guarded so a handler may itself SetAttribute.
* frame: add Frame:ClearAttribute (retail 11.2.0)
`cleared = frame:ClearAttribute(name)` removes an attribute and returns
whether it was set. Registered on the base Frame method registry next to
SetAttribute/GetAttribute, so every frame has it.
Routes through DoSet's nil-value path (removes the stored key, drops a
`unit` mouseover binding) but does NOT fire OnAttributeChanged — verified
against a live retail 11.2 client: SetAttribute fires the handler,
ClearAttribute fires nothing, and the return is true for a set attribute /
false when there was nothing to clear.
* frame: store attributes in the Lua registry, hidden from Lua
Attributes were kept as a subtable on the frame's own Lua table under a
control-char key ("\1ClassicAPIAttributes"). That key, while collision-
safe, is still enumerable via pairs(frame) and readable/writable from Lua.
Move the store to registry[frame] in the Lua registry (LUA_REGISTRYINDEX),
which has no _G path — so the attribute map is invisible to pairs(frame)
and can't be read or tampered with from script, matching how retail keeps
attributes C-side. Only PushSub changed (create/fetch now key the registry
by the frame table); GetAttribute/DoSet/CopyAttr sit on top unchanged.
1.12 frames are immortal, so the strong registry key never leaks. The dead
kAttrKey constant is removed.
Verified in-game: SetAttribute/GetAttribute still round-trip, and a
pairs(frame) scan no longer finds any ClassicAPI key.
* frame: add the `item` click verb (SecureActionButton item action)
type*="item" uses the modifier-qualified `item` attribute:
- a "bag slot" string (e.g. "0 1") -> UseContainerItem(bag, slot);
- otherwise an item name / itemID / link -> C_Item.UseItemByName(item, unit),
passing the unit as the use target (self-use items ignore it, targeted
ones like scrolls/bandages apply to it);
- the deprecated `bag` + `slot` attributes are honored when `item` is unset.
Both bag paths go through lua_call (new CallTableFunc2Str / CallGlobalNum2)
rather than a direct C++ call: the item-use bag walk does SetTop(L, 0), so
it needs its own Lua stack frame — a direct call would wipe the dispatcher's
stack. (spell stays direct because Spell::AtUnit is stack-free; the helper
comment records the distinction.) Retail's "bag slot" string is the
preferred form; the deprecated per-attribute form is supported too.
* docs: format the attribute click verbs as a table
The verbs were one long paragraph of nested parentheticals - hard to scan.
Replace it with a Verb | Extra attributes | Action table so each verb's
inputs and behavior are legible at a glance. Content-equivalent; no API change.
* frame: clear target on the `target` verb when unit is "none"
Retail's SecureActionButton target action treats a "none" unit as "clear
target". Match it: the `target` verb calls ClearTarget() when the resolved
`unit` is "none" (case-insensitive), before the normal cursor/spell
precedence. Mouseover already handles "none" implicitly (it resolves to
GUID 0 = no unit). Docs' target-verb row updated.
* frame: pass modern positional args to script handlers
Vanilla always invokes frame-script handlers with zero Lua args; the
handler reads this/arg1/event as globals. Modern addon ports write
SetScript("OnMouseWheel", function(self, delta) ...) and get nothing.
Co-hook both script runners -- FUN_FRAME_INVOKE_SCRIPT (no-arg scripts)
and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) -- and reimplement
their pcall tail: keep the engine's exact this/argN global save-set-
restore and message-handler errfunc, but additionally push self +
arg1..argN as real positional arguments and pcall with nargs = 1+N.
Purely additive: a zero-param vanilla handler ignores the extras and
reads globals; a modern (self, delta) handler binds them.
OnEvent gets (self, event, arg1..argN), detected structurally by
matching the invoked handler ref against the frame OnEvent slot
(frame+0x0C) -- exact, since each SetScript makes a distinct ref.
Runtime switch SetModernScriptArgs()/GetModernScriptArgs(), default on;
when off both detours are a straight passthrough to the original runner,
which matters because FUN_FRAME_RUN_SCRIPT_ARGS is the hottest Lua path
in the engine (every OnUpdate frame). Verified in-game across
OnMouseWheel/OnClick/OnValueChanged/OnUpdate/OnEnter/OnLeave/OnEvent.
* net: consolidate spell SMSG hooks onto one dispatch funnel
The spell subsystem's per-opcode SMSG handlers (SpellGo, SpellStart,
SpellDelayed, ChannelStart/Update, SpellFailure, SpellFailedOther,
SpellCooldown) were each co-hooked directly -- and nampower (hadesmem)
plus SuperWoW detour the same prologues. Two inline hookers on one
prologue is the documented MinHook collision risk, and a whole-protocol
bisect fingered exactly this cluster as the crash-suspect surface.
Add Net::PacketDispatch: a receive-side mirror of Net::SendObserver that
co-hooks the single incoming-SMSG dispatch funnel FUN_NET_MESSAGE_DISPATCH
(0x00537AA0) -- a chokepoint nampower does NOT hook (it detours the leaf
handlers) -- and fans each packet out to opcode subscribers with the
cursor at the body and centralized save/restore.
Migrate all eight handlers to subscribers: aura/Source (SpellGo, which
re-decodes the packed guids + spellId the leaf handler used to hand it
pre-decoded), spell/Cast (Start/Delayed/Channel*/Failure*), and
lossofcontrol (Cooldown). The parse bodies are unchanged; only the hook
site moved. Subscribers run before the engine's leaf handler instead of
after a co-hook's original -- verified order-independent for all eight
(self-contained cache/state updates; SpellGo's aura-application handoff
rides a separate SMSG_UPDATE_OBJECT).
Cuts the ClassicAPI-nampower shared spell prologues from 10 to 3 (the
OnAura* method hooks, which have no packet chokepoint to relocate to).
One hook on an uncontended function replaces eight on contended ones.
Verified in-game: cast bars, channels (incl. Mind Control cast-then-
channel), pushback, remote-cast interrupt eviction, and C_LossOfControl
school lockouts all intact.
* ScriptArgs is back to opt-in
* frame: fix ScriptArgs comments to match opt-in default
* frame/net: dedupe packet observers, reuse Lua/token helpers, fix attr edge cases
Post-review cleanup of the attribute + SMSG-consolidation work.
- net: extract the shared packet-observer plumbing (PacketObserver.h) — the
intrusive subscriber list, Subscribe(), and the read-opcode / reset-cursor /
fan-out / restore loop — so Net::PacketDispatch (SMSG, u16) and
Net::SendObserver (CMSG, u32) each reduce to a one-line hook thunk over
FanOutByOpcode<OpcodeT>. Drops the dead per-namespace Callback aliases
rather than keeping them as compat shims.
- Game::Lua: promote PushGlobalFunction / CallGlobal / CallGlobalString into
the shared Lua helpers (a pattern several modules open-code); Frame
attributes use them instead of private copies.
- frame/Attributes: resolve unit tokens through Unit::Identity::GuidForToken
(engine resolver + player fast-path) instead of a duplicate raw-cast wrapper.
- frame/Attributes: wire the chained OnClick for every "type" action attribute
the click resolver reads (IsTypeAttr: modifier prefixes + type1..type5), not
just type/type1/type2 — a middle-click or modifier-only binding now installs.
- frame/Attributes: FireAttributeChanged snapshots and restores the caller's
arg1/arg2 around the fire, so calling SetAttribute from inside a running
script handler no longer clobbers its globals.
Remove Mixin/CreateFromMixins from the AddOns helper list and streamline the surrounding text. Drop outdated paragraphs describing engine-dependent addon features and the note about the triple-`!!!` folder prefix/load-order. This is a documentation cleanup only; no code or behavior changes.
"mark1".."mark8" (star..skull) now work as unit tokens against the whole
Script_Unit* surface, and fire per-token unit events (UNIT_HEALTH, ...)
with arg1 == "markN" -- the same TokenObserver path focus/nameplateN use.
Resolution: a markN branch in the FUN_TOKEN_TO_GUID hook
(unit/TokenExtensions.cpp) reads the engine's 8-slot marker table
(VAR_RAID_TARGET_GUIDS = 0x00B71368, mark{1..8} = table[N-1]), so every
stock Unit* gains the token for free and "mark1target" composes via the
existing suffix walker.
Events: unit/RaidTarget.cpp runs a WorldTick watcher that diffs the marker
table and attaches/detaches one descriptor observer per marked unit,
deferring registration until the unit is in the object table (a unit can
be marked while out of range and enter later) -- the same range handling
Focus does, x8. One distinct callback per slot fires its fixed "markN"
token. Idle (no markers set) costs 8 compares and no object lookups; a
marker on a non-unit is never observed.
Verified the array shape against the binary (reverse lookup FUN_004bb190
walks 8 uint64 slots) and against nampower's CGRaidInfo_m_raidtargets.
Docs added under Unit tokens (markN); README token table updated.
The bundled library provides FrameXML-compat fixes (e.g. the tooltip
mouseover guard) that the DLL and other addons rely on; letting a user
uncheck it at character-select would silently break them.
FinalizeEmbeddedEntry now sets entry+0x29 (filter-out) to hide it from
the AddOns list -- the flat-display builder FUN_0051da70 copies only
+0x29==0 entries into GetNumAddOns/GetAddOnInfo -- and entry+0x2b
(DefaultState) enabled. A co-hook on the per-character enable resolver
FUN_ADDON_ENABLE_RESOLVE (0x0051e470) force-returns "enabled" for
!!!ClassicAPI, defeating any stale WTF disable-override that hiding
alone would otherwise leave stuck off.
Hiding only affects the index-based display array; the addon hash table
keeps the entry, so ## Dependencies: !!!ClassicAPI, IsAddOnLoaded, and
GetAddOnInfo-by-name all still resolve. README updated to match.
The DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover loss (retail
parity). The stock FrameXML GameTooltip OnEvent recolors the name
unconditionally via GameTooltip_UnitColor("mouseover"), which returns
white when no mouseover exists -- so the loss fire (and the transient
clear when clicking a unit) flashed NPC and PvP-flagged-player names
white during the tooltip fade.
Wrap the stock OnEvent in Util/GameTooltip.lua: swallow the event when
UnitExists("mouseover") is false, otherwise delegate to the original
untouched. The name keeps its color through the loss window and
recolors normally when a mouseover resolves again.
* spell: backport UNIT_SPELLCAST_* events for the local player
Adds the TBC+ cast/channel events (START, STOP, DELAYED, SUCCEEDED,
INTERRUPTED, FAILED, SENT, CHANNEL_START, CHANNEL_UPDATE, CHANNEL_STOP)
for the player, so cast-bar / rotation addons written against the modern
signature work on 1.12. Each carries (unit, castGUID, spellID, spellName,
rank) -- SENT inserts `target` at arg2 -- with a synthesized modern-shape
castGUID (Cast-3-0-0-0-<spellID>-<castUID>) shared across all of a cast's
events so START pairs with STOP. arg1 is always "player" (player-only phase).
The events are derived, not independently tracked: Spell::Cast already
hooks every cast/channel path for UnitCastingInfo/UnitChannelInfo, so
Spell::CastEvents reuses that state.
- START/STOP/DELAYED/CHANNEL_START/STOP: polled from Cast's OnWorldTick
against the previous snapshot.
- SUCCEEDED: fired from the SMSG_SPELL_GO hook (covers instants too).
Deferred for channels so it lands after CHANNEL_START (modern order).
- INTERRUPTED/FAILED: split by result code in a Spell_C_SpellFailed
co-hook -- a started cast that aborts (kick/move/LoS) fires INTERRUPTED
(repeated per retry, reusing the cast guid); a pre-cast rejection fires
FAILED.
- SENT: minted from the CMSG_CAST_SPELL send, threading its castGUID
forward so all of a cast's events share one guid.
- CHANNEL_UPDATE: from a new MSG_CHANNEL_UPDATE co-hook, which also
re-anchors g_channel.endMs to the server's remaining time so channel
pushback (which shortens vanilla channels) shows in UnitChannelInfo.
Every fire is gated on Event::Custom::HasListeners (new), so the system
costs one pointer-compare per transition when no addon registers.
Supporting changes:
- Net::SendObserver: shared co-hook on the NetClient send funnel with an
AutoSubscribe list, so CastEvents (SENT) and ComboDuration (combo-point
capture) both watch outgoing packets without owning the hook. Extracted
from ComboDuration's private hook.
- Cast.cpp: player channel now self-expires at its computed endMs in the
poll (was cleared only by the ~1s-lagged +0x228 field, so CHANNEL_STOP
trailed the real end by ~1s); wrap-safe delta compare so a long-uptime
ms tick past 2^31 can't misfire it.
Player-only; remote-unit fan-out, FAILED_QUIET, and empowered casts are
not implemented.
* spell: extend UNIT_SPELLCAST_* to non-player units (phase 2)
Fires the six observable cast/channel events -- START, STOP,
CHANNEL_START, CHANNEL_STOP, SUCCEEDED, INTERRUPTED -- for other units,
not just the player. Each is fanned out once per unit token currently
mapping to the caster (target / focus / nameplateN / party / raid / pet /
mouseover), so a target frame, a party frame, and a nameplate cast bar
all get their own event with a shared castGUID -- matching retail.
Token fan-out reuses the engine's own GUID->token reverse map
(FUN_00515c50, nampower's unfilled GetNamesFromGUID) for native tokens,
plus focus/nameplate which the engine can't see, via
Unit::Identity::TokensForGUID. SuperWoW's raw-GUID token ("0x...") is
filtered out so only standard tokens fan out.
Remote events are derived from Spell::Cast's existing packet hooks:
- START / CHANNEL_START from HandleSpellStart (SMSG_SPELL_START)
- SUCCEEDED from the SPELL_GO hook (Aura::Source)
- STOP / CHANNEL_STOP at the computed end, or on abort
- INTERRUPTED (casts only) from HandleCastAborted (ClearCastingSpell /
failure packets)
A per-caster state machine (PollRemote, from OnWorldTick) fires them in
modern order; a channel's SPELL_GO cast->channel ClearCastingSpell is
filtered so it doesn't self-interrupt at its own start.
Not fired for remotes (data is caster-only / client-local): SENT,
DELAYED, FAILED, CHANNEL_UPDATE.
Also in this change:
- castGUID follows type rule from https://warcraft.wiki.gg/wiki/GUID#Cast : real casts are Type 3 with a
time-based castUID (low 23 bits = UNIX second, high bits = per-second
counter, matching retail's structure), threaded across a cast's events
(player and remote converge on the same value); FAILED is Type 2 with
a flat incrementing integer.
- Channels never fire INTERRUPTED (player or remote) -- retail emits
only CHANNEL_STOP whether a channel completes or is cut short.
arg1 is the caster's unit token ("player" or another token); the
spellName / rank tail extensions are unchanged.
* spell: return the castGUID as UnitCastingInfo's castID
UnitCastingInfo / CastingInfo now return the cast's castGUID as castID
(return 7) instead of nil -- the same string the UNIT_SPELLCAST_* events
carry, so an addon can correlate the polled info with the events. It's
pulled from Spell::CastEvents (the single source of the castUID) via a new
CurrentCastGuid accessor, and works for the player and remote units. The
timing stays sourced from Spell::Cast -- only the castGUID crosses over,
so there's no duplication of cast state.
UnitChannelInfo is left retail-exact (no castID; channels don't carry
one), even though our channel events do include the shared castGUID.
Also corrects the spell-cast-GUID spec attribution in comments/docs to
warcraft.wiki.gg/wiki/GUID#Cast (it was wrongly credited to Wowhead).
* spell: add UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR events
Backports the ground-target reticle events for the player:
RETICLE_TARGET when an AoE placement reticle comes up (Blizzard, Flare,
Rain of Fire, ...), RETICLE_CLEAR when it's cancelled. Args are
(unit, "", spellID, spellName, rank); arg1 is always "player".
Driven by polling the engine's targeting-flag global (0x00CECAC0 -- the
predicate behind SpellIsTargeting) from OnWorldTick: 0->nonzero fires
RETICLE_TARGET with the pending spellID (0x00CEAC58, written by
Spell_C_CastSpell); nonzero->0 fires RETICLE_CLEAR. CLEAR is suppressed
on placement (a CMSG_CAST_SPELL for the reticle spell went out, seen via
the shared send observer), so it fires only on a genuine cancel --
matching retail, which emits no CLEAR when the spell is placed/cast.
The castGUID slot (arg2) is "" rather than retail's nil: there's no cast
to identify yet, and the engine's event dispatcher can't emit a nil
mid-argument-list (a NULL %s corrupts the args after it -- the nil trick
only survives as the last arg). unit (arg1) and spellID (arg3) are exact.
* spell: fire UNIT_SPELLCAST_FAILED_QUIET for the quiet result whitelist
Vanilla's Spell_C_SpellFailed dropped SPELL_FAILED_DONT_REPORT silently.
Modern surfaces suppressed (no-error-text) failures as
UNIT_SPELLCAST_FAILED_QUIET rather than FAILED.
Verified against the 3.3.5 client's unit-spellcast dispatch (FUN_007fecc0),
which selects the QUIET event for a fixed 3-code whitelist, mapped to 1.12's
SpellCastResult enum (FUN_006e23e0):
CHARMED (0x14) . DONT_REPORT (0x17) . SPELL_IN_PROGRESS (0x61)
SPELL_IN_PROGRESS (casting while already casting / a nampower spell-queue
rejection) is the common case that previously mis-fired as a loud FAILED.
* unit: accept raw GUID literals as unit tokens
Fold a "0x<hex>" branch into the existing FUN_TOKEN_TO_GUID hook so every
Unit* function (UnitName, UnitHealth, UnitClass, UnitExists, ...) accepts a
64-bit GUID literal in place of a token. The parsed GUID goes straight to
the engine's own object-manager lookup (the path "player" takes), so an
unloaded/out-of-range GUID resolves to nil like any absent unit, no error.
Reuses the existing suffix walker, so "0x...target" composes.
Gated on SuperWoW's absence (GetModuleHandleA("SuperWoWhook.dll"), cached):
SuperWoW already provides GUID input via its own resolver hook, so when it
is loaded we defer to avoid two layers parsing the same string. Behaves
identically either way. Input direction only.
C_Loot.LootAllCorpses([max]) loots every nearby lootable corpse in
sequence -- coin (CMSG_LOOT_MONEY) plus every item slot (CMSG_LOOT_ITEM)
from each window before releasing and advancing. Optional max caps the
corpse count.
Shares Loot::Scan's existing corpse-walk state machine and its single
FUN_LOOT_CONTROLLER hook -- MinHook allows one hook per target, so this
can't be a separate module. ScanNearbyLoot scrapes each window and
releases; LootAllCorpses does the same walk but actually loots it first.
The walk stays silent (LOOT_OPENED/LOOT_CLOSED suppressed, so LootFrame
never flickers) while the item-received / money chat + inventory events
fire normally.
Packet-based and target-clean, matching the rest of the C_Loot suite:
no right-click, no target change, no auto-walk toward out-of-range
corpses. Money goes through FUN_005ea8c0 (CMSG_LOOT_MONEY) with the SAME
player pointer passed to FUN_CMSG_LOOT_UNIT -- whose send wrote this
window's target GUID to the player's +0x1D28 guard slot, so the money
send's internal guard passes. Item sends bypass the client BoP/unique
confirm dialog (the server still enforces real loot permissions).
Reimplemented in spirit from WeirdUtils's LootAllCorpses (public domain)
on our own packet loot primitives + object-scan helpers, rather than its
right-click-autoloot approach. Wires the existing AOELOOT keybinding to
it (was ScanNearbyLoot).
f91ffd3 gated the drain on a one-way PLAYER_ENTERING_WORLD latch
(Event::EnteringWorld::HasEnteredWorld), added on the belief that no
pollable in-world flag existed. cd674aa found the real one:
VAR_IN_WORLD (0x00B4B424), 1 while in world and 0 during any loading
screen.
The one-way latch stays true forever after the first login, so if the
object burst re-populates bag descriptors during a LATER zone/instance
loading screen it would fire BAG_UPDATE_DELAYED mid-load again -- the
very thing the gate was meant to prevent, recurring on every zone.
VAR_IN_WORLD clears on every transition, so polling it re-suppresses
during later loads and emits one DELAYED once actually in-world
(matching retail's one-per-load).
Switch the gate to VAR_IN_WORLD and remove the now-dead HasEnteredWorld
latch/accessor and its header; EnteringWorld keeps only its PEW-payload
role.
Turtle's server re-sends SMSG_BINDPOINTUPDATE on every map/zone
transition with the unchanged bind location, behind a loading screen.
The valid-flag gate only suppresses the initial post-login sync, so
every map change fired HEARTHSTONE_BOUND.
A player can't bind mid-load, so gate on the engine's "world is live"
flag instead: VAR_IN_WORLD (0x00B4B424), set to 1 by the enter-world
init FUN_004908C0 and back to 0 by the world-teardown FUN_00490A80 that
runs at the start of every transition (verified via the enter/leave
pair in Ghidra). The resync arrives with the flag 0 (suppressed); a
real innkeeper bind always arrives with it 1 (fires) -- including a
rebind at the same inn, unlike a location diff would.
Add VAR_IN_WORLD to Offsets.h (the real pollable in-world boolean;
0x00B4E37C, once wrongly labeled this, is only instance grace-period
state). Update the bind-handler offset comment and docs/API.md.
The in-world gate added in 5d785ea polled 0x00B4E37C, believed to be an
"in world" flag. It isn't: verified in the binary it's instance
grace-period state, written from a packet handler (FUN_0049e6c0) and
read only by the "recently left an instance" 90-second window check
(FUN_00495c90). It never reliably goes non-zero from being in the world,
so OnWorldTick's early-return held g_pending forever and
BAG_UPDATE_DELAYED stopped firing entirely. There is no clean pollable
in-world boolean at these offsets (0x00B4E378 is the map ID; map 0 is
valid).
Gate on the correct signal instead: PLAYER_ENTERING_WORLD, already owned
by Event::EnteringWorld. Expose a one-way HasEnteredWorld() latch flipped
the first time PEW fires (loading screen done, world live) and stays true
across /reload; UpdateDelayed holds g_pending until then, then emits one
DELAYED covering the settled login inventory. Correct the mislabeled
0x00B4E37C note in Offsets.h so the trap isn't repeated.
PLAYER_EQUIPMENT_CHANGED fired an extra time per /reload. The event's
descriptor-field observers are registered in ObserverSetup_h, a co-hook
of the engine's per-enter-world inventory-observer setup. /reload
re-runs that setup but does NOT destroy the CGPlayer object, so the
prior session's observer nodes are still attached; the engine registrar
always appends (no dedup by callback), stacking a second observer per
equipment field each reload. Logout/login recreates the player object,
old nodes die with it, so the count resets to one -- matching the
observed "doubles only on /reload" symptom.
Unregister each equipment-field observer before re-registering it. On
/reload this drops the stale node (same object/guid/callback key) so we
keep exactly one per slot; on logout/login the object is already gone so
the unregister no-ops. Mirrors the objects-survive-/reload guard
Unit::TokenObserver uses for nameplates.
The per-frame world tick (FUN_0066FD50) runs behind the initial-login
loading screen, and the engine populates every bag slot during the
initial object burst -- setting g_pending while still loading. That made
OnWorldTick fire BAG_UPDATE_DELAYED repeatedly mid-load, so addons
rescanned bags for inventory that wasn't a real change yet.
Gate the drain on the engine's in-world flag (new VAR_IN_WORLD =
0x00B4E37C): hold g_pending -- without clearing -- until the flag is set.
The first in-world frame then emits a single DELAYED covering the settled
login inventory (matching retail's one BAG_UPDATE_DELAYED at login), and
a pending update suppressed during a later zone transition fires on
return. The three bag hooks are unchanged (still just set the flag).
AppendNonBagOwned read each bank-bag container's slot count straight
from the invMgr's first dword with no upper bound. A container resolved
while only partially initialized (plausible right at login) can yield a
garbage value there, driving AppendGuidArray into a multi-billion-
iteration loop -- a multi-second freeze. Clamp to kMaxContainerSlots
(64), far above any real vanilla/Turtle bag, so it's invisible to real
inventories and only trips on a bogus count.
Backports retail 4.x's frame-cached GetTime() behavior under a distinct
name, leaving vanilla GetTime() live/per-call. 1.12's Script_GetTime
recomputes the OS tick on every call, so same-frame calls can differ;
GetTimeCached() samples once per frame (via the shared Tick::WorldTick
hook) and returns that until the next frame, on the same epoch as
GetTime() so the two are directly comparable. Falls back to a live
sample before the first world tick so it never returns 0.
Deliberately NOT a perf feature and documented as such: the 1.12 tick
source is cheap (GetTickCount shared-page read or rdtsc) and both
functions pay the same dominant Lua->C dispatch, so caching saves
nothing measurable. The only benefit is frame-stable semantics; the
real hot-loop fix is caching GetTime() in a Lua local, which no C
function can do for the caller. New global chosen over modifying
GetTime() to avoid changing a core vanilla function's behavior.
Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.
Co-hooks the engine's mouseover set/clear chokepoint (FUN_00492890) --
the ONLY writer of the mouseover GUID globals, so every gain/loss/change
flows through it. Resolves whether the mouseover is a unit before and
after the original and fires the event on the unit->non-unit transition.
Gating on unit TYPE (not just "GUID present -> absent") is load-bearing:
the same GUID slot also holds gameobjects and items, which never fire a
gain, so a naive present->absent test spuriously fired when moving off a
herb node or chest (reported in testing). The unit|player object-mask
resolve fixes that; the !hasUnit half prevents a double-fire on any
->unit transition the engine already covered inline.
Change-gated target (per-frame would spam the gain event while
hovering), so it's a cool hook. Documented in API.md (loss-fire fix
subsection + TOC) and Events.md notes.
Re-reads the current level's live table in place. The nav stack holds
the real table reference (never a copy), so refresh reflects mutations
made since the view was built -- keys added/removed, values changed --
without leaving the current view. Scroll position is preserved (Back and
drill reset to top; refresh deliberately doesn't) and Update re-clamps
the offset if the entry count shrank. No-op if the held reference is no
longer a table.
Adds TableInspector_Refresh() and a Refresh button in the header between
Back and the breadcrumb (breadcrumb shifted right and narrowed to fit).
Add the two globals to the State section of docs/API.md (with TOC
entries) and the README State row. Notes the 1/nil return contract
(retail + SuperWoW 2.2 parity) and that the state is a live WMO
geometry query, not zone/area based.
Backport the modern `IsIndoors()` / `IsOutdoors()` no-arg globals to 1.12.
Both return 1 when the condition holds, nil otherwise (the historical
retail contract, also what SuperWoW 2.2 uses), so `if IsOutdoors()` and
`IsOutdoors() == 1` both work. Recomputed live from the engine's WMO
geometry query, so they flip as the player crosses an interior threshold
rather than tracking zone/area; nil pre-world.
The signal is a live WMO query, not a cached flag. Vanilla ships no Lua
binding for it, and the static anchors all dead-ended (the WMO runtime
flag bits differ from 3.3.5; ZONE_CHANGED_INDOORS is never fired with a
literal event ID; WMOAreaTable is only the area-naming path). SuperWoW
2.2 exposes `IsIndoors`, so decoding its handler gave the exact recipe:
resolve the local player object, then call FUN_00612cc0(unit) -- a thunk
that reads the unit world/collision context at +0xE0 and tail-jumps to
the WMO query FUN_006706f0 (nonzero = outdoors, fully null-safe). Same
shape as the 3.3.5 client's IsIndoors (unit+0xB8), so cross-confirmed.
Added Offsets::FUN_UNIT_IS_OUTDOORS with the full trail; the two
functions live in Unit::State next to IsMounted / IsSwimming and share
Unit::Identity::PlayerObject().
C_Item.GetItemNameByID and C_Item.GetItemInfo resolved an item link down
to just the itemID and dropped the random suffix (3rd `item:` field), so
"item:12022:0:769:0" returned "Iridium Chain" instead of "Iridium Chain of
the Owl" (and GetItemInfo's rebuilt link lost the 769). The global
GetItemInfo applies it; now these match.
Item::Arg::Resolve parses the suffix alongside the itemID. New
Item::Link::NameFromIDSuffix builds the suffix-decorated name from
(itemID, suffixID) without a live CGItem, via the engine's inner name
builder (FUN_005d8b00 = the formatter behind FUN_ITEM_BUILD_INSTANCE_NAME,
which reads the localized "of the X" suffix from ItemRandomProperties).
Item::Link::BasicFromIDSuffix preserves the suffix in the rebuilt link
(payload + bracketed name); BasicFromItemID is now a suffix=0 wrapper.
GetItemNameByID and GetItemInfo route through these; numeric/suffixless
input still yields the base name. Numeric item stats are unaffected (the
suffix only decorates the name/link). Enchant/unique fields are normalized
to 0 in the rebuilt link (suffix is what affects the name).
Verified in-game: item:12022:0:769:0 -> "Iridium Chain of the Owl" (name +
link); bare 12022 / item:12022:0:0:0 -> "Iridium Chain".
C_UnitAuras expirationTime for a debuff on another unit is derived from
SMSG_SPELL_GO at cast time; the vanilla server never retransmits a LATER
server-side duration change on a non-self unit (verified in the server
source: SetAuraDuration/RefreshHolder, and UpdateAuraDuration is self-
scoped -- on a mob no packet is built at all). So a refresh/consume goes
stale in our cache. We can't hear the change, but we DO see the triggering
cast, so mirror the server's edit off that.
Aura::Source gains a rule engine applied in SpellGo_h (before the aura
gate -- triggers apply no aura themselves): a trigger matched by exact
spellID OR by SpellFamilyName + school index, an affected aura matched by
family + family-flag overlap (+ optional icon, rank-proof like the server
scripts), and an op (refresh/reduce/set/remove). Proc-applied affected
auras enter the cache unattributed (casterGuid 0); the rule accepts those
and attributes them to the trigger caster (also gives them a sourceUnit).
Lua: C_UnitAuras.RegisterAuraDurationModifier (exact-ID trigger) and
RegisterAuraDurationModifierByTrigger (family+school trigger), so the
server-specific ruleset lives in the addon, not the DLL.
Default Turtle ruleset (AuraDurationModifiers.lua, gated on
TURTLE_WOW_VERSION; IDs/flags DBC-verified):
Conflagrate -> reduce Immolate 3s
Molten Blast -> refresh Flame Shock
Shadow Weaving (5/5 talent 15334) -> priest shadow-school casts refresh
Shadow Vulnerability 15258 (covers the max-stack refresh blind spot;
Turtle reworked it, so stock DBC proc masks don't apply -- match by
school). Best-effort: DoT-tick procs emit no cast packet.
Carnage's roll-gated Rip/Rake refresh is deliberately excluded (the client
can't know the server roll).
Verified in-game (debug trace): Conflagrate/Molten Blast, and Mind Blast /
SW:P / Mind Flay all refresh 15258 with advancing expiration + sourceUnit.
Previously CastAtUnit only handled ground-target spells (placement at the
unit's feet); a non-ground spell fired with no implicit target (fell to
current selection). Now it resolves the unit once to its object, reads
position AND GUID, and dispatches the cast WITH that GUID -- the engine's
FUN_SPELL_CAST_DISPATCH substitutes it exactly where it would the current
selection (verified: passing 0 substitutes VAR_CURRENT_SELECTION_GUID), so
a unit-target/normal spell fires straight at that unit regardless of
current target.
Branch on placement state after dispatch: no placement -> cast went out
(unit-target); ground placement -> commit at the unit's feet (existing);
other placement -> unit not accepted as target, cancel + false.
AtCursor gains an optional targetGuid on DispatchSpellCast[ByName] (0 =
prior behavior, so CastAtCursor is unchanged) and IsPlacementActive().
Verified in-game: ground spells still land at the unit's feet; Frostbolt/
Renew/PW:Fortitude cast on the named unit while another unit stays targeted.
IsFirstLoadThisSession is glue-only -> GlueXML-calls section only.
IsOnGlueScreen is registered on both states -> main in-game table AND the
GlueXML-calls section.
IsFirstLoadThisSession() - glue-only; true only during the first glue
screen since process launch, false after a world->glue logout (login <->
character-select stays within the first session). Backed by a
process-static glue-boot counter incremented in the GlueModuleAutoRegister
callback (fires once per glue boot).
IsOnGlueScreen() - true on a GlueXML screen, false in the world.
Registered on both Lua states; since glue and in-game states are mutually
exclusive the answer is a constant per state (true from glue, false
in-game), so it's callable from either environment with no runtime flag.
Verified in-game: first login true/true, post-world-logout false; in-game
C_Glue.IsOnGlueScreen() false.
Own alpha times every ancestor's alpha -- the product WoW composites at
render time. Vanilla has only GetAlpha (a frame's own alpha); this walks
self -> parent -> ... via the engine's own GetAlpha/GetParent, so it
reflects the engine exactly, including 8-bit alpha quantization
(SetAlpha(0.5) -> 127/255). Frame-scoped, matching GetAlpha's registry.
Verified in-game: matches an equivalent Lua GetAlpha/GetParent walk, and
a two-level 0.5/0.5 chain returns (127/255)^2 = 0.248043 exactly.
Backport the modern UnitPosition -> posY, posX, posZ, instanceID. Reads
any visible unit's world position through the shared Unit::Position helper
(CGObject::GetPosition vtable slot 5 -- same path UnitDistanceSquared
uses), so no group/taint restriction like retail: works for target,
mouseover, nameplates, in-range party/raid. nil when the unit has no
known position.
Retail return order (posY=world Y/west, posX=world X/north, posZ=height);
instanceID is the current Map.dbc id. Verified in-game: squared delta of
two UnitPosition reads matches UnitDistanceSquared exactly.
Note: SuperWoW also defines a global UnitPosition with a different shape;
last registrant wins if both load. Documented in docs/API.md.
Two more Region-base methods backported from 3.3.5, both on the Region
registry so they resolve on every region type.
GetRect() -> left, bottom, width, height, composed from the engine's own
GetLeft/GetBottom/GetWidth/GetHeight (all UI-scale conversion is engine
code). Returns nothing for an unpositioned region, matching retail.
IsDragging() -> true while the region is the frame currently being
moved/sized. Reads the engine's single global drag target
(*(VAR_UI_CONTEXT_PTR) + 0xCFC); StopMovingOrSizing (0x00776990) gates its
own clear on the identical `context+0xCFC == self` predicate. Non-frame
regions never match the stored frame, so they report false.
Verified in-game: GetRect on UIParent/Minimap; IsDragging toggles with a
live frame drag.
Add a MouseIsOver(region, top, bottom, left, right) global that forwards
to the native Region:IsMouseOver method (mirrors modern FrameXML's
forwarding; nil offsets default to 0 in the C method).
Drop DebugTools' hand-rolled DebugTools_IsMouseOver helper in favor of
the native method. This also fixes _CollectFramesUnderMouse's showHidden
path: the old helper baked a visibility check into the geometric test, so
a hidden-but-hovered frame could never surface; the native method is
purely geometric and visibility is already gated separately at the call
site.
Native Region method (all region types) for cursor-in-rect testing with
optional per-edge offsets. Mirrors the engine's own math: delegates to
the Region-registry rect getters (GetLeft/Right/Top/Bottom) and
GetCursorPosition, dividing the cursor by the region's effective scale
read from obj+0x7C -- the same field GetLeft/GetEffectiveScale read
internally, so it works on textures/fontstrings where GetEffectiveScale
(Frame-only) would typecheck-fail. Returns false for an unpositioned
region. Offset semantics match modern FrameXML's MouseIsOver forwarding.
A bag update in progress transiently drops items from the pure-C++ bag
read -- sometimes the entire backpack (all 16 slots) for a frame or two.
The old logic rebuilt the seen-set from each scan, so a momentarily-
missing item fell out of the set and was re-flagged "new" the instant it
reappeared (e.g. dragging/swapping an item into the backpack lit it up).
Replace the rebuilt-each-scan set with a persistent seen-set whose entries
carry a consecutive-absent-scan counter (SeenReconcile): present items
reset it, absent ones age by one, and only items absent kMaxMissStreak
scans running are evicted. Counting scans rather than wall-clock means a
pause with no bag activity never ages an owned item, and any reappearance
resets the streak -- so transient drops are absorbed while the set stays
bounded to ~currently-owned items (genuinely-removed items age out).
Verified in-game: backpack repeatedly vanishing from the scan, seen-set
holds steady, zero false flags.
The server resends out-of-range party/raid stats as deltas only, so a
member seen in range and then moved away again leaves an empty party-
stats aura array (it's cleared when they become visible and never re-
sent), and C_UnitAuras showed nothing until a full roster resync (relog).
Their auras still sit in the Aura::Source cache from when we observed them
in range, so the four group enumerate paths now fall back to the cache
when the group array is empty -- gated by GroupArrayHasVisibleAura, the
same "container non-empty => authoritative" rule the descriptor path uses
(DescriptorHasVisibleAura). A populated array is trusted as-is (no
phantoms); only the empty-array gap consults the cache, deduped and
visibility/playerOnly-filtered.
Returns the spell's three per-effect SpellMechanic ids (Spell.dbc
EffectMechanic[3], +0x13C) as a 1-based array, or nil for an invalid spell.
Complements GetSpellMechanicByID, which reads only the spell-level Mechanic
field (+0x14): vanilla stores some mechanics on an effect instead -- e.g.
Rake's bleed is effect-level (spell-level 0, EffectMechanic[2]=15), so
GetSpellMechanicByID misses it while this returns {0,15,0}.
Add OFF_SPELL_RECORD_EFFECT_MECHANIC offset, API.md section + TOC entry, and
README index entry.
Permanent auras (paladin auras, passives, racials) carry an infinite
SpellDuration (base -1); the server stores their fixed cast-time in the
player buff-expiration table, so it reads as already-elapsed and drifts
into the past. We were reporting that stale timestamp, giving a permanent
aura like Devotion Aura a negative time-left. Mirror the engine's own
GetPlayerBuffTimeLeft (Script at 0x004e4930 -> FUN_004e4450), which reads
the same table and returns 0 once now >= expiration. Signed compare
absorbs the GetTickCount wrap, matching the engine.
An out-of-range party/raid member has no CGUnit and never yields
SMSG_SPELL_GO, so the caster/duration cache never learned their auras'
timing and expirationTime stayed 0. But SMSG_PARTY_MEMBER_STATS carries
their aura spell IDs and the server sends it on change
(SendUpdateToOutOfRangeGroupMembers flushes the moment an aura add/remove
dirties the group mask), so a spell ID that newly appears in the group
array did so ~one tick + latency after the real application.
Aura::Source::ObserveGroupAuras (called from the group enumerate paths in
Aura::Data) keeps a per-member snapshot of the last-seen spell-ID set and,
on a genuine absent->present transition, stamps now + base duration into
the same cache PushEnriched already reads. Covers party and raid alike
(AuraArray reads the raid slot too; snapshot table sized for a 40-man).
Base duration only (no remote caster mods) and no caster recorded
(sourceUnit/GUID stay nil). Auras already present at first sight have
unknown age and are not stamped; snapshots reset on map change and after
30s unpolled. The Store guard keeps any real SpellGo timing, so this
never downgrades better data.
Verbatim backport of retail's Blizzard_SharedXML vector mixins
(CreateVector2D/3D/4D + Vector*DMixin + the free Vector*_ helpers).
Pure math on CreateFromMixins and Lua 5.0 math.*, so no adaptation was
needed. Registered the three files in the TOC (we list .lua directly
rather than via retail's VectorLib.xml include).
Backport of retail's Rectangle helper (sides, stretch/move/adjust,
contains/intersects, center, width/height accessors), built on
CreateFromMixins. Registered in the TOC.
Backport of retail's table.count (added 11.2.5): returns
(numTableNodes, numArrayNodes, maxArrayIndex) -- total entries, entries
with an integer key in [1..numTableNodes], and the largest positive
integral key (0 if none). Pure lua_next walk, no Lua-internal layout
dependency; keys read via ToNumber only (ToString on a numeric key
would corrupt traversal). All five cases verified to match retail,
including negative-key handling ({[-3]=x} -> 1, 0, 0).
Modern WoW implements these FrameXML table primitives as engine C
functions (TableUtil); do the same so they exist whenever the DLL is
injected -- even if the !!!ClassicAPI addon is disabled. Behaviour
matches the Lua form exactly: shallow key/value copy from each table
arg, non-table args skipped, returns the object / new table. Registered
on both the in-game and glue states.
Slimmed the addon Mixin.lua to just CreateAndInitFromMixin (calls a Lua
:Init, so it stays Lua), mirroring retail's Mixin.lua. The taint-based
SecureMixin family is omitted -- vanilla 1.12 has no execution taint.
New C_ColorUtil native module (src/color/Util.cpp) with the modern color
helpers, all pure math/string: ConvertRGBToHSV, ConvertHSVToRGB,
ConvertHSVToHSL, ConvertHSLToHSV, ConvertHSLToRGB, GenerateTextColorCode,
WrapTextInColor, WrapTextInColorCode. Conventions verified in-game against
a retail-family client: hue in degrees [0,360), -1 achromatic sentinel,
bare AARRGGBB from GenerateTextColorCode, |c..|r wrapping.
Ported the addon Color.lua / ColorUtil.lua to match retail now that the
natives exist: ColorMixin:GetHSL/GenerateHexColor/WrapText* delegate to
C_ColorUtil (GetHSL now returns degrees), SetRGBA defaults alpha to 1,
byte getters use Round; added COLOR_FORMAT_* constants and
CreateColorFromBestRGBHexString. Kept 1.12-Lua-5.0-safe syntax and the
ClassicAPI-only CAPI_ApplyStandardColorGlobals / ITEM_QUALITY_COLORS bits.
New global returning whether the player can equip an off-hand weapon.
The server tracks this as Player::m_canDualWield, flipped on only by
SPELL_EFFECT_DUAL_WIELD (effect 40); exactly one client Spell.dbc row
carries that effect -- 674 "Dual Wield" -- so CanDualWield() is exactly
IsPlayerSpell(674). Factored the known-spell bitmap read into a shared
PlayerKnowsSpell() helper used by both.
New Win32-backed global that copies text to the Windows clipboard and
returns the byte length copied. Optional removeMarkup strips WoW UI
escape sequences (color codes, hyperlinks keeping visible text,
textures, |n, || -> |) so the clipboard gets plain text. UTF-8 in,
CF_UNICODETEXT out. Registered on both the in-game and glue states.
Docs: folded the lone Screen section and this into a single System
section rather than adding a one-function Clipboard section.
A player DoT with a duration talent (e.g. Improved Shadow Word: Pain,
2/2 -> 24s vs 18s base) intermittently showed the base duration. The
displayed value is the Aura::Source cache entry's durationMs, which is
computed two ways: SpellGo knows caster == player so it applies the
player's duration mods (skipMod 0 -> 24s), while the OnAuraAdded /
OnAuraStacksChanged application hooks have no caster and take the
unmodified base (skipMod 1 -> 18s).
Normally SpellGo owns the entry and the application hooks can't clobber
it, but the base value wins whenever SpellGo can't land its talented
value on THIS target's entry: an empty SMSG_SPELL_GO hit list files it
under the caster instead (numTargets == 0), or a slot reshuffle
evicts + refills the entry after SpellGo ran. Both are packet-order
dependent, hence the flicker.
Fix: SpellGo (which knows caster == player) records recent player aura
casts by spellId; the application hooks recompute with skipMod 0 and
attribute the caster when the spellId matches a recent player cast.
Robust against both the attribution miss and the refill race, and
leaves other units' auras on the base path (we lack their mod tables).
Generalize the item-only Tooltip::SetItemEvent into Tooltip::SetEvents,
backing all four object tooltip scripts through one shared fire path
(stack save/restore + recursion guard + Suppressor). One resolver
co-hook recognizes all four OnTooltipSet* names (the per-tooltip cell
now holds a slot per kind), plus one builder co-hook per kind firing its
script after the original build:
- Spell -> FUN_0052E610 (SetSpell/SetSpellByID/SetTalent/SetShapeshift;
7 stack args, RET 0x1c)
- Unit -> FUN_00529fe0 (SetUnit + mouseover; 1 arg, returns an int the
caller tests -> co-hook forwards it)
- GameObject -> FUN_0052aa20 (GO hover populator; 1 arg)
Each hook's declared stack-arg count matches its callee RET N (verified
from disassembly) so the caller's stack isn't corrupted.
Auras deliberately do NOT fire OnTooltipSetSpell: SetUnitBuff/Debuff/
PlayerBuff funnel through a separate aura-tooltip builder (FUN_0052f880)
that bypasses the spell builder -- matching retail, where unit auras are
a distinct tooltip data type.
Verified in-game: all three new scripts fire (spell hover, unit
mouseover, gameobject mouseover), item still fires, no crash.
Mirror the classID extension just added to GetTalentIDByIndex. classID
is the 4th arg (GetTalentSpellID(tab, idx[, rank[, classID]])); pass rank
as nil to keep its default. A numeric classID resolves the talent
against the Talent.dbc / TalentTab.dbc flat arrays via the shared
ResolveTalentRecordForClass, then reads SpellRank[rank-1] off the record.
One semantic difference from the same-class path: there's no player
currentRank for a class you aren't, so a cross-class query with no
explicit rank defaults to rank 1 (the canonical spellID) rather than
delegating to the engine's GetTalentInfo.
Verified in-game: own-class parity at rank 1 (14524 == 14524), Mage (8)
tab1 talent1 -> Arcane Subtlety (11210 r1, 12592 r2, r9 nil),
default-rank cross-class == rank 1, and bad tab/index/class all nil.
Previously it only read the local player's talent tree (the engine's
runtime per-tab arrays at VAR_TALENT_TAB_INFO_ARRAY, built only for the
player's class). Addons that want another class's talent IDs -- build
tooling, spec previews -- had no way to get them.
Add an optional 3rd arg: nil/omitted keeps the runtime path (player's
class, unchanged); a numeric classID resolves against the Talent.dbc /
TalentTab.dbc flat arrays instead, so any class works.
Ordering matches the player-class path exactly because it replicates the
engine's tree builder FUN_004f2c00: tabs taken from TalentTab.dbc in row
order filtered by classMask (+0x30; mask 0 = all, race not a factor --
FUN_004f2e50 is a plain bitmask test), then talents from Talent.dbc in
row order within each TabID. The runtime arrays are built from that same
walk, so GetTalentIDByIndex(t,i) == GetTalentIDByIndex(t,i,<player class>).
New Offsets: the flat (row-order) DBC array globals the builder uses
(VAR_TALENTTAB_DBC_FLAT_* / VAR_TALENT_DBC_FLAT_*), distinct from the
sparse by-ID index SetTalentByID uses.
Verified in-game: own-class parity (166 == 166), Mage (8) tabs 1/2/3
first talents = 37/1/18, and out-of-range tab/index/class all return nil
with no fault.
C_UnitAuras.* returned nothing for a party/raid member with no live
CGUnit (different map / far out of range): every path bailed on the
null unit, and the only fallback was the Aura::Source SMSG_SPELL_GO
cache, which needs the object to exist.
The server keeps transmitting each group member's current aura spell
IDs via SMSG_PARTY_MEMBER_STATS (GROUP_UPDATE_FLAG_AURAS / _NEGATIVE)
and the client stashes them in the group-member roster structs -- the
same source the built-in UnitBuff/UnitDebuff read out of range. Surface
them.
- Group::MemberStats::AuraArray(guid) mirrors the engine's own
out-of-range fallback in Script_UnitBuff/Script_UnitDebuff exactly
(party stats block +0x1a gated on the online flag, else raid slot
+0x64; both 48x u16).
- Factor aura-table enrichment (caster/expiration/duration) into a
single descriptor-independent PushEnriched core; Push and
PushFromCache now delegate to it.
- New group-array enumerate branch (PushNthGroupAura /
PushGroupAuraBySpellID / ...BySpellName / AppendGroupAuras), applying
the engine's per-slot visibility predicate and merging any
caster/timing the SpellGo cache observed.
- Api.cpp routes unit==null to the group array for every C_UnitAuras
entry point. Safe: the token resolver never raises (returns 0), and
the GUID lookup only runs after ResolveUnit already accepted the
token.
Spell IDs only (no stacks; IDs are u16-truncated) -- same limitations
as UnitBuff. Verified in-game: GetUnitAuras("raid1") cross-map returns
Power Word: Fortitude (10938), matching UnitBuff.
UnitClassBase / UnitRaceBase resolved via FUN_RESOLVE_UNIT_TOKEN and returned
(nil,nil) for a groupmate on another map -- but the engine's own UnitClass /
UnitRace fall back to the cached player-info record there (verified in-game:
UnitClass("raid1")="Priest", UnitRace("raid1")="Human" while our descriptor
path yielded nil). Give ours the same fallback: on a descriptor miss, read
class (+0x140) / race (+0x138) from the NameCache record by GUID. Verified
fixed: UnitClassBase("raid1")=("PRIEST",5), UnitRaceBase("raid1")=("Human",1).
That engine-NameCache peek existed in four places (the two above plus
loot/History and player/Info). Collapsed them into one primitive,
Unit::Identity::PlayerInfoRecord(guid) -> record* (null callback = peek only),
and pointed all four at it. FUN_PLAYER_INFO_LOOKUP is now called in exactly
one spot.
Extract the roster fallback (added for UnitPower) into a shared
Group::MemberStats reader and give it the full picture. Lookup(guid) tries
the party stats block (FUN_GROUP_MEMBER_STATS_LOOKUP) then the raid slot
(FUN_GROUP_MEMBER_SLOT_LOOKUP) -- the object-independent group roster fed by
SMSG_PARTY_MEMBER_STATS -- and returns health, maxHealth, power, maxPower,
powerType, level, areaId, online (both paths) plus dead, subgroup, rank (raid
path). Raid-struct layout mapped from Script_GetRaidRosterInfo (0x004bb560):
subgroup@+8, rank@+0xc, flags@+0x18 (bit0 online, bit2 dead), level@+0x22,
areaId@+0x24, plus the health/power fields already used.
UnitPower/Max/Missing now go through it (no behavior change -- verified target
still reads UnitPower == UnitMana == 2896 on a different map), and
UnitHealthMissing gains the same fallback so it no longer returns 0 for an
out-of-range groupmate. UnitPowerType already had the engine's own roster
fallback (0x00517940) and needs nothing.
The extra fields (level/area/online/dead/subgroup/rank) are populated but not
yet consumed -- groundwork for fixing UnitLevel / UnitIsConnected / UnitIsDead
and a full GetRaidRosterInfo for out-of-range members.
UnitPower / UnitPowerMax / UnitPowerMissing read the CGUnit descriptor
directly, so they returned 0 for a party/raid member on a different map
(no live object). The engine's UnitMana/UnitManaMax don't -- they fall back
to the cached member stats from SMSG_PARTY_MEMBER_STATS.
Mirror that fallback: when ResolveArgs finds no live object, resolve
token -> GUID and read the member's primary power from the party stats block
(FUN_GROUP_MEMBER_STATS_LOOKUP: type@+9, cur@+0xe, max@+0x10) then the raid
slot (FUN_GROUP_MEMBER_SLOT_LOOKUP: type@+0x58, cur@+0x60, max@+0x62), all
u16 -- exactly Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
Verified in-game: a different-map target now reports UnitPower == UnitMana
(2896) instead of 0.
Limitation (inherent): the roster caches only the member's primary power, so
an explicit non-primary powerType returns 0 out of range. Reached only after
ResolveArgs fails on an already-valid token, so TokenToGUID can't raise.
3.3.5 does not drop focus the instant a unit leaves the object table -- its
object-teardown purge (FUN_00524350) is gated by FUN_00512a30, which is
"party-member (FUN_0052d310) OR raid-member (FUN_00573200)". So a groupmate
whose CGUnit is destroyed by going out of range/LoS keeps focus and reattaches
when they return; only non-group units clear on despawn. (Verified in 3.3.5
via Ghidra: focus GUID at DAT_00bd07d0/d4, sole writer FUN_0051ff20, purge
gate decoded to the party/raid roster checks.)
OnWorldTick now mirrors that gate: on an ObjectByGUID miss it keeps focus when
IsGroupMemberGuid(g_focusGUID) -- reading the object-independent roster GUIDs
(VAR_PARTY_GUIDS 4-slot array + VAR_RAID_MEMBER_PTRS 40-slot member-ptr array,
GUID at *member+0) -- otherwise clears as before. A new g_focusObserved flag
tracks whether the "focus" unit-event observers are attached to a live object;
they die with the object on despawn, so OnWorldTick re-registers them when a
groupmate returns to range, and Set() registers only against a resolvable
object (deferring to OnWorldTick when focusing an out-of-range member).
Corrects the earlier note claiming vanilla/modern drop focus on mere range
loss -- that was wrong; the drop is object-teardown gated by group membership.
While a groupmate is out of range UnitExists("focus") is still false (no client
object to read), but the focus survives and snaps back live on their return.
Vanilla 1.12 has no spell-haste API. UnitSpellHaste reads UNIT_MOD_CAST_SPEED
(the cast-time multiplier at descriptor +0x22c that the server folds into
SpellEntry::GetCastTime) and converts it to the modern haste percentage:
haste% = (1 / modCastSpeed - 1) * 100
so 1.0 -> 0, 0.5 (half cast time) -> 100, and a slow like Curse of Tongues
(>1.0) -> negative. This is the Blizzard-shaped surface over the same field
nampower exposes raw as GetUnitField(unit, "modCastSpeed"), letting addons
drop that dependency (raw multiplier recoverable as 1/(1 + haste/100)).
Formula verified byte-exact against 4.3.4's Script_UnitSpellHaste
(FUN_004555b0): `(C1 / mult) * C2 - C2` with C1=1.0, C2=100.0 and a
div-by-zero guard -- the constants are forced by "unhasted must read 0" and
"return a percentage". The 4.3.4 field lives at desc+0x10c; +0x22c is the
verified 1.12 offset. Our div-guard uses `> 0` vs retail's `!= 0` (identical
for the always-positive cast-speed field, marginally more defensive).
New src/unit/SpellHaste.cpp + OFF_UNIT_MOD_CAST_SPEED; docs + README updated.
Completes the retail signature UnitPower(unit [, powerType [, unmodified]])
(and UnitPowerMax). A truthy third arg skips the per-type display divisor and
returns the raw internal value, via the same ToBoolean(L, 3) path already used
by UnitPowerMissing.
Verified in-game on a warrior (rage, divisor 10):
UnitPower(1)=96 UnitPower(1,true)=961 (floor(961/10)=96)
UnitPowerMax(1)=100 UnitPowerMax(1,true)=1000
UnitPowerMissing(1)=4 UnitPowerMissing(1,true)=39
The missing=4 (not 3) confirms the display-value subtraction: 100-96, not
(1000-961)/10 floored to 3.
Docs updated (signature, TOC anchor, unmodified note).
The power analogue of UnitHealthMissing: returns UnitPowerMax - UnitPower for
the given type in one native call. Reuses the Power module's ResolveArgs /
PowerDivisor, so it inherits the primary-power fallback (powerType omitted)
and per-type display divisor. The unmodified third arg (truthy) skips the
divisor for the raw internal deficit, matching retail's UnitPower.
Subtracts the two display values (each integer-divided) rather than dividing
the raw difference, so it equals UnitPowerMax(...) - UnitPower(...) exactly --
the rage case would otherwise be off by one (100/5 -> 95, not (1000-55)/10 =
94). Returns 0 for invalid units and for types the unit has no pool for
(e.g. energy on a mana user). Verified in-game: mana 6685-5929 = 756.
Docs + README updated.
Returns the health deficit -- the equivalent of
UnitHealthMax(unit) - UnitHealth(unit) -- in one native call, so healing
addons don't call both engine getters and subtract in Lua every frame.
One token resolve + two descriptor field reads (HEALTH +0x40, MAXHEALTH
+0x58); no display divisor (health is stored at face value), clamped at 0.
Tracks the engine's own UnitHealth/UnitHealthMax exactly in both forms:
real values (player 3600-1 = 3599) and vanilla's percentage form for
non-grouped units (target 100-85 = 15). Returns 0 for a valid-but-absent
unit, matching UnitHealth's convention.
FUN_006e3340 (the spell tooltip's cast-time helper, which our zero-latency
CastStartSet_h stamp reuses) omits the flat +500ms "aim time" the server adds
to every ranged, non-autorepeat spell -- SpellEntry::GetCastTime does
`castTime += 500` for SPELL_ATTR_RANGED. So the client prediction ran ~500ms
short of the server on Volley / Aimed Shot / Multi-Shot (Volley's real cast is
3000ms base + 500 = 3500ms), and the confirming SMSG_SPELL_START then snapped
the bar out ~1 RTT later. This was the dominant cause of the Volley overhang,
present with or without Quick Shots.
CastTimeMs now adds the +500 for SPELL_ATTR_RANGED spells (excluding Auto Shot
/ Shoot via the AttributesEx2 autorepeat bit, matching the server), so the
prediction is right from frame 1. Only applied when the spell already has a
cast time; instant ranged abilities the server gives a 500ms aim time still
surface through the packet path. The HandleSpellStart reconcile stays as the
backstop for any residual client/server divergence.
The client-side FUN_CAST_START_SET hook stamps g_cast with a zero-latency
prediction from FUN_006e3340, which folds in the caster's cast-speed /
ranged-haste multiplier (descriptor +0x22c). That is correct for spells the
server also hastes (Aimed Shot / Multi-Shot), but wrong for ones it does
not. Volley (1510 / 14294 / 14295) is the case: it is NOT channeled in the
client DBC (AttrEx has no 0x4/0x40) so it takes the regular-cast path, and
its Attributes & 0x30 == 0 so FUN_006e3340 applies the +0x22c multiplier --
yet Volley gains no benefit from Quick Shots server-side. The predicted bar
therefore ended ~before the real cast finished ("still casting after the bar
ends").
SMSG_SPELL_START carries the server's authoritative cast time, but for a
cast the client already stamped we were deduping the packet away entirely to
avoid restarting the bar. Instead, keep startMs (no visual restart) and snap
endMs to the server's castTime (preserving any accumulated pushback). Aimed
Shot / Multi-Shot are unaffected -- the server hastes them too, so client and
server agree and the reconcile is a no-op.
Closes https://github.com/brues-code/pfUI/issues/28
Both modules held per-character state in process globals that the DLL
never cleared on a logout -> character-select -> login (the DLL is not
reloaded across that transition), the same bug class as the equipment-set
leak.
Focus: g_focusGUID / g_focusTargetGUID carried a dead GUID from the prior
character into the next. The WorldTick despawn probe self-healed it within
a tick, but fired a spurious PLAYER_FOCUS_CHANGED and could not cover a
GUID collision. Clear it from a GlueModuleAutoRegister, which fires on the
world->glue return a logout triggers but NOT on /reload -- so focus still
survives /reload (retail behavior) and drops only on logout.
Who: a /who whose SMSG_WHO response never crossed a logout left g_inFlight
stuck (IsWhoQueryPending wrongly true for the next character) and the
VAR_WHO_TO_UI_FLAG override forced on (routing the next character's /who to
the WhoList). Reset in-flight state and restore the flag on every in-game
Lua (re)build via ModuleAutoRegister, which fires per login and per /reload
before any addon runs -- previously only ResetIfStuck's lazy 10s-on-next-
send backstop covered this.
EnsureLoaded latched on a process-global g_loaded flag that was never
reset. The injected DLL survives a logout -> character-select -> login
(no process restart), so opening the Equipment Manager on character A
cached A's file path and sets permanently; character B then read, and
saved into, A's data. Only a full game restart cleared the globals, and
never opening the manager on A left B correct -- matching the report.
Re-resolve the per-character path on every EnsureLoaded call and reload
when it changes, so the cache self-heals across any character/account
switch. Also clear the per-session ignored-slot state on switch. Every
mutation Persist()s immediately, so dropping the old in-memory sets
never loses unsaved work.
Fixes https://github.com/brues-code/pfUI/issues/29
Switch nameplateN indexing from a compacting vector (append on ADDED,
erase + shift-down on REMOVED) to retail-exact slot assignment: a plate
keeps its slot for its whole lifetime, a removal frees the slot, and the
next new plate reuses the lowest free one. Survivors are never renumbered
when another plate is removed - matching modern WoW, where a middle plate
vanishing leaves the others' tokens unchanged and its slot goes vacant
until reused.
g_orderedGUIDs -> g_slots (sparse; free slots hold GUID 0, trailing
empties trimmed). GetGUIDByIndex returns 0 for a free or out-of-range
slot; new GetSlotCount exposes the high-water mark. The reverse token walk
in Unit::Identity now scans 1..GetSlotCount() and skips gaps instead of
stopping at the first 0. The per-plate observer lifecycle is unchanged
(keyed on GUID: register on ADD, unregister on REMOVE).
Verified in-game: 3 plates in slots 1/2/3, removing the middle one leaves
slots 1 and 3 occupied and 2 vacant - no renumber.
Extend the focus observer to the focus unit's current target, so
UNIT_HEALTH / UNIT_AURA / ... fire with arg1 == "focustarget" like a
native token. Unlike focus (a sticky GUID), the focustarget GUID is
volatile - it changes whenever the focus re-targets, and vanilla fires no
event for a unit changing its target (UNIT_FIELD_TARGET, field 10, has no
event-table slot). So the WorldTick watcher re-reads the focus's target
each tick and re-points a second TokenObserver at it only when that
identity changes; the focustarget events themselves still fire
event-driven through the observer (only the re-pointing is tick-gated,
<=1 tick lag). Set() drops the stale focustarget observer immediately on
a focus change/clear.
Verified in-game: focustarget events fire and re-point when the focus
switches targets.
Register/Unregister funnel through FUN_00464890, which dereferences the
client/player-rooted observer registry at [VAR_LOCAL_PLAYER_PTR] (+0x24).
That root is null out of world (login, loading screens, logout teardown),
where the helper null-derefs -> ERROR #132 (ACCESS_VIOLATION reading 0x24).
The nameplate teardown on world-exit fires NAME_PLATE_UNIT_REMOVED for
every plate, each calling Unregister while the root was already null ->
crash. Player::Equipment never hit this because it only registers from the
enter-world hook; focus/nameplate register from Lua / WorldTick, which can
run mid-transition.
Gate both Register and Unregister on RegistryReady() ([VAR_LOCAL_PLAYER_PTR]
!= null). Out of world there are no live objects or observer nodes to touch
anyway - nodes die with their objects on teardown, and in-world callers
re-register / clean up on the next tick.
Make the synthetic focus / nameplateN unit tokens first-class sources of
the standard per-token unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA,
UNIT_LEVEL, ...): arg1 == "focus" / "nameplateN" now fires just like
"party1", so focus and nameplate frames can update event-driven instead
of polling.
Vanilla fires these events only for units it "watches"
(target/party/raid/pet/mouseover) - FUN_0060c520 recomputes a unit's
token-category mask and, on 0->nonzero, FUN_0051bbb0 registers bank-UNIT
descriptor-field observers whose callback broadcasts the field's event to
the guid's tokens (field index == event id; health +0x40 = event 16 =
UNIT_HEALTH). Focus and nameplates are not watch categories, so they
never fired.
New Unit::TokenObserver replicates that watch registration (FUN_0051bbb0's
field set + per-index sizes) with a caller-supplied callback, via the
engine's own observer registrar - the same API Player::Equipment uses. No
hooks: zero MinHook trampolines, hence no collision risk (the reason this
beats co-hooking the broadcast). The registrar appends a fresh node per
(guid, field), so our observers coexist with the engine's - a unit that is
target AND focus AND a nameplate fires all three tokens. Node lifetime is
engine-managed (nodes die with the object; unregister no-ops post-despawn).
- Focus: register/unregister in Unit::Focus::Set.
- Nameplates: register on NAME_PLATE_UNIT_ADDED, unregister on _REMOVED
and (all) in PrepareForReload, since objects survive /reload; the
callback resolves the changed guid to its current plate index at fire
time.
Offsets cross-confirmed against nampower's offsets.hpp (SendUnitSignal
0x515e50, StringParamFormat "%s" 0x82e280, SignalEventParam 0x703f50,
UNIT_HEALTH 16); the GUID->token reverse map nampower left as a TODO is
FUN_00515c50.
SetEquipmentSet built its lines by calling back through self:SetText /
self:AddLine / self:Show - a PushValue/GetTable/Insert/Call dance per
line. Replace that with the engine's own per-tooltip clear plus raw
add-line (with a small r,g,b to packed-color helper) - the same native
path SetTotem and SetHyperlinkCompareItem use. Localization is unchanged:
PushLocalizedFormatInt for the count lines, Lua string.format for the
ITEM_MISSING %s template.
Shows itself via Script_Show at the end, matching retail (the inner
builder tail-calls the show) and PaperDollFrame.lua, which sets the
anchor then calls SetEquipmentSet with no trailing :Show().
Backport of the TBC (2.4.0) tooltip method. Fills the tooltip with the
slot's active totem as two native lines - name (NORMAL_FONT_COLOR) and
time remaining (white) - mirroring retail's Script_GameTooltip_SetTotem
(verified against the 3.3.5 binary; the two line colors are read verbatim
from its color data). Built with the engine's own per-tooltip clear plus
raw add-line - no Lua method dispatch - and shows itself via Script_Show,
matching retail's TotemFrame.xml OnEnter which calls SetTotem with no
trailing :Show().
The time line mirrors SecondsToTimeAbbrev (raw seconds under a minute,
minutes rounded up above) and localizes through the FrameXML GlobalStrings
SPELL_TIME_REMAINING_SEC/_MIN with English fallbacks; slot data is the
existing GetTotemInfo tracker.
Re-introduces the event-table grow valve (removed in 538beda as
mis-timed) at the correct point. Event::Custom::Grow runs from
RetryClaims -- the frame:RegisterEvent hook -- on the first in-game
registration, which is AFTER RebuildEventTable (and SuperWoW/nampower's
clamp-to-700 that co-hooks it) but BEFORE any chain is populated. So
every chain is still an empty self-sentinel and the buffer can be moved
safely. A one-shot latch (reset per reload) runs it at most once per
build, and a hard "abort if any chain is live" scan backs the timing.
Same realloc logic the old EnsureCapacity used -- SMemAlloc a new
buffer, copy name pointers, re-init empty-sentinel anchors, swap the
{cap,count,base} globals, SMemFree the old -- bypassing SetEventCount's
destructive =700 clamp. Consumers read base fresh; the engine's reload
teardown frees our buffer + names as its own.
Dormant in practice (~200 low gaps vs ~46 reservations). Validated via a
new _classicapi_ForceEventGrow() diagnostic that forces a grow on the
next /reload: table grew 700->716, all events (ours, engine,
SuperWoW/nampower, VMT) survived with names intact, dispatch worked on
the grown table, and a following /reload cleanly freed the buffer.
Append counterpart to SetSpellByID -- adds a spell's tooltip (name, cast
time, cost, range, description) to the current tooltip WITHOUT clearing
existing lines, so callers can compose tooltips like
AddLine(header) + AddSpellByID(id).
BuildSpellTooltip's last arg gates the clear: SetSpellByID passes 0
(clears via FUN_00530050 first); AddSpellByID passes 1 (skips the clear
and appends). That append path is the engine's talent "next rank"
preview, so it emits a "Next rank:" header instead of the spell name and
doesn't stash the displayed spellID -- we overwrite that header line (at
the pre-build numLines index) with the real name from Spell.dbc and let
GetSpell keep reflecting the base tooltip. Verified in-game.
EnsureCapacity was meant to grow the event table when the engine's ~200
low NULL gaps ran short of our ~46 reservations, by reallocating the
entry buffer via Storm and swapping the base/count globals. But it was
called from LoadScriptFunctions_h, which runs BEFORE RebuildEventTable
inside FUN_0048FBF0 -- so it always operated on the previous cycle's
still-populated table, tripped its own "abort if any chain is live"
guard, and never actually grew anything. Mis-timed dead code doing risky
buffer surgery on the shared event table.
Claim-NULL into the engine's low gaps is the sole mechanism now; it
always suffices, and TryClaim logs once if it somehow doesn't. Drop the
function, its Storm alloc/free helpers, and the now-inaccurate offset
comments.
Reworks custom-event slot reservation to be coordination-free and
durable without depending on DLL load/hook order.
- Claim NULL-name slots walking LOW->high instead of from the tail.
SuperWoW/nampower own the high range (fixed IDs 549..655) and
destructively clamp the table to 700, and VanillaMinimapTracking also
walks the tail, so the top is contested; the engine's low gaps (~200,
incl. a ~64-slot hole at 42..106) are stable and uncontested. Verified
in-game: our events now land at 1..64, VMT alone at 699 -- clean
separation, independent of the clamp.
- Add Event::Custom::EnsureCapacity: on-demand grow run once from
LoadScriptFunctions_h (after module registration so all reservations
incl. lazy TTS are counted, before any frame registers). If NULL slots
< reserved, it reallocates the entry buffer via Storm and swaps the
{cap,count,base} globals directly, bypassing SetEventCount's clamp --
so it's load/hook-order independent. Defensive: reads fresh, verifies
all chains empty before moving the buffer (aborts otherwise), frees
only the old buffer (names are shared), publishes base before count.
Dormant in the common case (low gaps dwarf our count).
- Drops the SetEventCount co-hook (Capacity.cpp) entirely -- nothing new
on a SuperWoW/nampower-contended function. Keeps the one-shot
table-full log as the backstop.
Adds Storm SMemAlloc/SMemFree + VAR_EVENT_TABLE_CAP offsets.
Exposes the client's computed flight route to a taxi-map slot as an array
of TaxiNodes.dbc IDs in flight order { currentNodeID, ..., destNodeID } --
the exact chain TakeTaxiNode sends the server.
Mirrors the TakeTaxiNode worker (FUN_004dc8d0): a single direct hop when a
TaxiPath edge exists (via the FUN_004dbad0 adjacency matrix), otherwise the
client's precomputed multi-hop route array (slot +0x14, length +0x18). This
lets addons identify every hop of a per-character route without matching
flight-map coordinates back to nodes.
Offsets added: per-slot route array/count/flag, the current-node record
pointer, and the node adjacency matrix. Documented in docs/API.md + README.
Expose the taxi routing graph and per-path geometry that vanilla keeps in
TaxiPath.dbc / TaxiPathNode.dbc but never surfaces to Lua (and addons can't
read DBCs at all).
- GetTaxiPaths() -> array of { pathID, fromNodeID, toNodeID, cost } (every
directed flight-path edge; cost is the routing weight in copper).
- GetTaxiPathWaypoints(pathID) -> array of { x, y, z, mapID } world
coordinates ordered by node index; sum the 3D chords for path length
(/ 32 yd/s ~= single-hop flight seconds).
Lets addons build the flight graph, run travel-time / routing math, or drive
a map overlay without baking the geometry in. Pure DBC readers, no flight
master needed. Offsets for TaxiPathNode.dbc + TaxiPath cost added to
Offsets.h.
Returns the frames currently subscribed to an event as multiple return
values (retail's vararg signature). Reads the engine's own subscriber
chain off the event-table entry (entry+0xC head; node next @+0x04,
frame @+0x08, low-bit/null end sentinel) — the exact list
frame:RegisterEvent appends to, so it stays in sync with real
registrations, including AutoReserve custom events. Each frame is
pushed via the shared UI::FrameObject wrapper so frame methods work.
A counting pass drives lua_checkstack up front against heavily
subscribed events.
The detail ScrollFrame had mouse wheel enabled unconditionally, so even
when the content fit (bar hidden, 0 range) it still consumed wheel events
— clamping to a no-op but swallowing the scroll instead of letting it pass
through. Enable the wheel from SetDetailBarShown alongside the bar, so it's
on only when there's something to scroll.
The trade-skill-link viewer's reagent pane couldn't scroll to the last
row for some recipes (issue #10). Root cause, found by comparing against
Blizzard's Blizzard_TradeSkillUI: resizing a ScrollFrame's child does NOT
recompute its scroll range in 1.12 — you must call UpdateScrollChildRect()
(TradeSkillFrame_SetSelection does exactly this). We resized the child but
never refreshed the range, so the bar's travel was capped at a stale max.
- Call detailScroll:UpdateScrollChildRect() after (re)sizing the child in
RenderDetail and ClearDetail.
- Size the child from actual laid-out geometry (bottom of the last reagent
button relative to the child top + name-plate overhang) instead of a
hand-tallied constant that undercounted.
- Drive scrollbar visibility from the computed overflow (SetDetailBarShown)
rather than the engine's range-changed event: scrollBarHideable only
auto-hides on a range CHANGE, so content that fits from the start (e.g.
Turtle's roomy pane) never hid, and GetVerticalScrollRange isn't reliably
readable the same tick as UpdateScrollChildRect.
Net removes the old detailNeedScroll bool-tracking, per-need mouse-wheel
toggling, and the OnShow re-assert workaround — all of which were
compensating for the missing range recompute.
Fixes#10
Addons that re-include ..\..\FrameXML\Fonts.xml in their TOC (notably
SpecialTalentUI, LoadOnDemand — pulled in when the talent frame is first
opened) re-run its inline script, which resets NORMAL_FONT_COLOR /
HIGHLIGHT_FONT_COLOR / GRAY_FONT_COLOR to plain {r,g,b} tables and strips
the ColorMixin methods our UI relies on. That crashed the trade-skill-link
viewer's NORMAL_FONT_COLOR:GetRGB() (issue #10).
Factor the standard color-global setup into CAPI_ApplyStandardColorGlobals
(idempotent — only (re)creates a tag that's missing :GetRGB) and re-run it
after SpecialTalentUI loads via EventUtil.ContinueOnAddOnLoaded, next to the
existing pfUI compat in AddOnCompat.lua.
Also simplify the viewer frame anchor to SetPoint("CENTER").
Adds the post-vanilla TargetScript selection functions 1.12.1 lacks:
TargetDirectionEnemy(facing[,cone]) / TargetDirectionFriend
TargetNearest([reverse])
TargetNearestEnemyPlayer([reverse]) / TargetNearestFriendPlayer
GetPlayerFacing()
All five targeting functions reuse the engine's own tab-target core: the
visible-unit enumeration, the per-mode validity predicate FUN_00493e40
(reaction/attackability/alive, modes 1=enemy/2=friend), and the selection
commit. The only new logic is a player-GUID filter for the *Player variants
and the cone geometry for the direction variants. Cycle functions keep a
frozen per-filter snapshot advanced within a ~1s window (no ping-pong).
Target-setting routes through FUN_TARGET_BY_GUID (0x00489A40), which
rejects a GUID that no longer resolves to a live unit rather than
committing it — a stale snapshot GUID would otherwise leave "target"
pointing at freed unit data and crash the next handler that reads it.
GetPlayerFacing reads the CGUnit movement-block orientation at +0x9C4
(located by probing the player object across a known 180-degree turn); it's
the facing source the direction-target functions need.
Not backported (no vanilla equivalent): TargetPriorityHighlightStart/End,
IsTargetLoose, TargetToggle.
New offsets: FUN_TARGET_CANDIDATE_VALID (0x00493E40),
FUN_SET_SELECTION_BY_GUID (0x00493540), VAR_CURRENT_SELECTION_GUID_LO/HI,
OFF_UNIT_FACING (0x9C4).
Returns the active chat-bubble Frame objects, matching modern WoW's
7.2.5 signature. Vanilla's CGChatBubbleFrames are full CSimpleFrames
the engine creates in C++ without CreateFrame (same as default
nameplates), kept on an intrusive Storm TSList at 0x00B6F038 (next
link +0x318, low-bit sentinel). Mirror the engine's own walk
(FUN_004b1060) and push each as its canonical engine wrapper, so the
returned frames are real and method-capable and their text FontString
(+0x31c) surfaces via :GetRegions().
includeForbidden is accepted and ignored (vanilla has no forbidden
frames).
Factor the ScriptRegister->rawgeti wrapper push out of nameplate/Info
into a shared UI::FrameObject::Push; PushNamePlateFrame now forwards to
it (no behavior change).
Returns the slot's active totem total duration in seconds (0 when none) —
the totem's full lifetime from cast, same value as GetTotemInfo's 4th
return, companion to GetTotemTimeLeft. Reads the tracker's durationMs.
Verified in-game (constant 45 while time-left counts down).
Target the player's totem in a slot (1-4). Looks up the totem creature's
live GUID (object-manager scan for the player-owned creature of the
slot's tracked entry) and sets the target via FUN_00489a40 — the
set-target-by-GUID path Script_TargetUnit uses after its token→GUID
resolve. No-op when the slot is empty or the totem isn't visible. Added
Offsets::FUN_TARGET_BY_GUID.
Returns seconds remaining before the slot's totem auto-destroys (0 when
no totem is active), computed from the existing Totem::Tracker's
startMs + durationMs. Slots 1-4 = Fire/Earth/Water/Air. Verified in-game
counting down.
Backport the TBC PLAYER_TOTEM_UPDATE(totemSlot) event (absent from
vanilla's table, so reserved as a custom event) on top of the existing
Totem::Tracker. Every slot state change marks a dirty bit — the cast
path (SpellGo), duration expiry, and the WorldTick death-scan removal —
and OnTick flushes the dirty slots with Fire(id, "%d", slot). Flushing
from the tick keeps Lua handlers out of mid-packet context. Drops fire
next frame; removals within the ~250ms scan interval. Verified in-game.
Vanilla 1.12 tracks nothing about totems client-side (the totem bar,
PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
Totem::Tracker tracks the four element slots itself — data-driven:
- Slot from the summon spell's Spell.dbc effect (87-90 =
SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air; slot = effect - 86).
- Creature entry from the effect's EffectMiscValue (for death detection).
- Duration from SpellDuration.dbc via the engine duration helper.
- haveTotem tool item from the spell's Totem[0] field (0xA0) — the totem
the spell requires (a tool, not a consumed reagent) — resolved from the
player's spellbook on SPELLS_CHANGED, not hardcoded.
Casts are observed through Aura::Source's existing SMSG_SPELL_GO hook (a
one-line notify before its aura gate, since a summon applies no aura) —
no new hook, respects one-hook-per-target. Tool items resolve on
SPELLS_CHANGED (a no-arg event via the shared Event::SignalHook), when
the spellbook is populated. A slot clears when its duration elapses OR
when the totem creature disappears early (killed / Totemic Recall /
destroyed), detected by a throttled WorldTick object-manager scan for the
player-owned creature of that entry (seen-then-gone latch; CreatedBy ==
player so another shaman's same-entry totem can't mask yours).
GetTotemInfo returns haveTotem, totemName, startTime, duration, icon,
modRate=1, spellID. Turtle-custom totems are tracked automatically.
Also exposes Item::Count::InInventory (equipped + bags) as a shared
helper for the tool-presence check. Verified in-game: Earthbind lands in
slot 2, haveTotem reflects the Earth Totem tool, and Totemic Recall
clears the slot early.
Returns the GUID string of a unit's owner — the summoner of a
pet/guardian/totem or the charmer of a charmed unit — or nil for
players, world creatures, and unresolved units. Reuses Unit::Pet's
existing OwnerGuid helper (CharmedBy, else CreatedBy in m_objectFields),
the same field the pet predicates share. Verified in-game:
UnitOwnerGUID('target') == UnitGUID('targetowner').
Returns xPos, yPos, distance for the nearest visible game object of a
given GO-template ID, or nothing when none is in range — the game-object
counterpart of ClosestUnitPosition.
Extract the shared visible-object-manager scan into
Object::ClosestByEntry (object/ClosestByEntry.{h,cpp}): enumerate visible
objects, match GUID type + template entry (bits 24-47), return the
closest one's world position + center distance. Both ClosestUnitPosition
(TYPEMASK_UNIT / Creature) and ClosestGameObjectPosition
(TYPEMASK_GAMEOBJECT / GameObject) are now thin wrappers over it;
ClosestUnitPosition behavior is unchanged.
Same retail-vs-vanilla caveat as the unit version: returns the nearest
CURRENTLY-VISIBLE object rather than reading retail's static starting-
zone database (which vanilla lacks). Documented in docs/API.md.
Returns xPos, yPos, distance for the nearest creature of a given NPC
entry ID, or nothing when none is in range. Walks the visible-object
manager (Loot::Nearby pattern), matches creatures whose GUID encodes
the entry (bits 24-47, per guid/CreatureInfo), and returns the closest
one's world position + center distance in yards.
Semantic note: retail's ClosestUnitPosition reads a static client spawn
database (starting-zone mobs only); vanilla 1.12 has none, so this
returns the nearest CURRENTLY-VISIBLE creature of that entry (anywhere)
— more general than retail, but can't point at un-synced spawns.
Documented in docs/API.md.
Poor=0 through Artifact=6, matching the numeric quality GetItemInfo /
C_Item.GetItemQuality report. Heirloom (7) and WoWToken (8) are omitted
— no such items exist on 1.12. Registered in src/item/TypeInfo.cpp
alongside Enum.ItemClass / Enum.InventoryType. Docs + README updated.
- GetItemClassInfo(classID) -> localized ItemClass.dbc name, registered
as both a global and C_Item.GetItemClassInfo. Obsolete class slots
return the client's literal "...(OBSOLETE)" string.
- GetItemSubClassInfo(classID, subClassID) exposed as a global (the
C_Item form already existed); returns name + the armor-material bool.
- Enum.ItemClass: 0..15 with modern key names on the obsolete slots
(Gem/ItemEnhancement/CurrencyTokenObsolete/PermanentObsolete), plus
post-vanilla 16..19 for parity, matching the Enum.InventoryType
precedent in the same module.
All added to src/item/TypeInfo.cpp alongside the existing subclass /
inventory-type surface. Docs + README updated.
Two pieces of hardcoded Turtle-specific data were embedded in stock
modules; relocate them to src/turtle/ behind extension points so the
stock code carries only generic mechanism.
- aura/ComboDuration: add an AutoDanglingResolver extension point; the
stock module keeps the dangling-row detection + scaling formula, and
Turtle's reworked-Rip base/max values move to
src/turtle/ComboDuration.cpp. Not gated on Turtle::Detected() — the
dangling-row condition stays the gate (gate on the bug, not the
realm), preserving prior behavior exactly.
- spell/BonusDamage: add a Spell::HealingAura resolver extension point;
the stock GetSpellBonusHealing decoder keeps auras 135 (flat) and 175
(xSpirit), and Turtle's Ironclad aura 199 (xArmor) moves to
src/turtle/HealingAura.cpp, gated on Turtle::Detected() (an aura index
is a generic slot that could collide on another server).
Behavior is unchanged on Turtle; both extractions are pure relocation.
Turtle's item data leaves m_bagFamily at 0 on the bags/quivers
themselves — the four profession bags (Soul/Herb/Enchanting/
Engineering, Container class) and quivers/ammo pouches (Quiver
class) all read 0, so C_Item.GetItemFamily and
C_Container.GetContainerNumFreeSlots reported 0 for them even
though the tooltip clearly shows "Soul Bag" etc. Recover the family
from the item's class + subclass instead (keyed on class because
subclass numbers repeat: Container sub 2 = Herb Bag, Quiver sub 2 =
Quiver). Loot items keep carrying the field directly.
Factored the derivation into a shared Item::BagFamily helper
(BitmaskForRecord) used by both GetItemFamily and the free-slot
functions, so a bag and the items it holds always agree on the
family bit. Side effect: CalculateTotalNumberOfFreeBagSlots now
correctly excludes specialty bags from the general free-slot total.
Non-stock data lives in its own module: src/turtle/ with a
TURTLE_WOW_VERSION-based Turtle::Detected() gate (no realm sniffing)
and the custom Meat/Fish/Leather/Mining families (ItemBagFamily.dbc
rows 10-13) plugged into Item::BagFamily via an AutoResolver
extension point, so the stock table stays pure vanilla.
Compare center distance against `40 + playerReach + targetReach`
instead of a flat 40, reading each unit's bounding radius from
`[m_objectFields + 0x1F0]` — the same per-unit size factor the
engine's spell-range helper (FUN_006e3480) and the interact/loot
range checks add. A 40-yard heal reaches a target whose center is
slightly past 40 yards but whose body is within reach; the old flat
cap reported those out-of-range. UnitDistanceSquared stays raw
center-to-center, matching retail.
Backport the 8.0.1 PEW payload onto vanilla's arg-less PLAYER_ENTERING_WORLD.
PEW is a no-arg event, so it broadcasts through FUN_FIRE_EVENT_NO_ARGS
(__fastcall(int), the no-arg dispatcher) — NOT the variadic FUN_FIRE_EVENT.
So we intercept that dispatcher and, for the PEW event id, suppress the
no-arg fire and re-broadcast through the variadic dispatcher with the two
booleans (true->1 / false->nil, the %d/%s+nullptr trick). Both fire paths
walk the same frame chain, so PEW-registered frames get the args once.
Flags need no PEW-specific engine hook — they fall out of signals we already
produce: GlueModuleAutoRegister (glue-screen boot) latches "next enter-world
is a fresh login"; ModuleAutoRegister (in-game Lua re-init) marks
login-or-reload; a zone/instance transition rebuilds neither, so both false.
New shared Event::SignalHook: FUN_FIRE_EVENT_NO_ARGS was already hooked by
loot/Scan (silent-scan suppression), and MinHook allows one hook per target,
so — like Tick::WorldTick for the per-frame tick — this module now owns the
single hook and fans out to interceptors. loot/Scan refactored from owning
the hook to being a subscriber (behavior identical). Event::EnteringWorld is
a second subscriber.
Verified in-game: fresh login, /reload, and instance transition each report
the correct (isInitialLogin, isReloadingUi).
lua_xmove moves values via api_incr_top, which asserts room on the
destination rather than growing it. AuxResume xmoved an unbounded count both
ways without a guard: nres (a coroutine body can yield/return arbitrarily
many values) onto L, and nargs (a caller can resume(co, unpack(bigTable)))
onto co. Moving more than the ~20-slot C-call guarantee corrupted the stack
-- the same latent bug strsplit had, now fixable thanks to the bound
Game::Lua::CheckStack.
Guard both xmoves; on overflow (>2048) push an error and return the error
path (false,msg for resume; re-raise for wrap). Verified in-game: a coroutine
yielding 100 values via unpack now returns all 100 cleanly (previously stack
corruption), and normal small returns still work.
Close the remaining 5.0->5.1 string/math stdlib gaps:
- string.reverse(s) / strrev(s) — byte-wise reverse; 5.1 added it, 5.0's
strlib lacks it. Registered on the string table and as the strrev global.
- math.modf(x) -> (integral, fractional) — integral truncated toward zero,
fractional keeps x's sign (via C modf). Genuinely new in 5.1 (unlike fmod,
which 1.12 has as math.mod).
- math.huge — positive-infinity constant (HUGE_VAL) set directly on the math
table (it's a value, not a function).
All on both Lua states. Verified in-game: reverse, modf on +/- inputs, and
math.huge as a true infinity sentinel.
Round out the WoW string globals alongside strsplit. These are FrameXML Lua
in 3.3.5 (no C original — no name strings in the binary), so they're
implemented from standard semantics rather than disassembled:
- strjoin(delimiter, ...) — inverse of strsplit; concatenate varargs with the
delimiter. nil/non-coercible pieces become "" so delimiters stay aligned.
- strtrim(str [, chars]) — trim a literal character set (default whitespace)
from both ends via a two-ended scan; no allocation, embedded NULs preserved.
- strreplace(str, find, replace) -> (result, count) — plain-text (non-pattern)
substring replace; empty find returns str unchanged. Not a stock WoW global
(that build's compat script added it); documented as a ClassicAPI
convenience complementing gsub.
Registered as globals on both Lua states. Verified in-game: join, empty
delimiter, trim (default + custom set), plain replace with count, and a
strsplit->strjoin round-trip.
Backport the WoW global strsplit(sep, str [, pieces]) to 1.12, ported from
3.3.5's strsplit (FUN_00816a60): split str on any character in sep, returning
the pieces as multiple values; pieces>0 caps the count with the unsplit
remainder as the final piece (0/omitted = unlimited, 1 = whole string).
Consecutive/trailing delimiters yield empty pieces, matching retail.
Two deliberate changes from the 3.3.5 source:
- Skip its lua_settop(L, 0). Popping the args un-roots the source string, so a
GC step during a pushlstring could free the buffer we're still scanning.
Leaving the args on the stack keeps the source GC-rooted; Lua returns the
top N (the pieces) regardless of the args below.
- Bind lua_checkstack (0x006F2F30, __fastcall(L, size) -> int, found via its
cmp ...,0x800 LUA_MAXCSTACK test) as Game::Lua::CheckStack and guard every
push with it, erroring "strsplit(): Stack overflow" like 3.3.5 on genuine
exhaustion (strsplit pushes an unbounded number of results). Reusable for
any future multi-return C function.
Registered on both Lua states. Verified in-game across basic split, pieces
cap, multi-char separator set, empty middle piece, and empty-separator cases.
AutoReserve silently drops any reservation past MAX_RESERVED, and the
codebase crossed 32 file-scope reservations, so a shifting subset of custom
events never claimed a table slot -> Lookup returned -1 and Fire no-op'd.
Adding the three AUCTION_MULTISELL_* events moved the overflow onto
ITEM_DATA_LOAD_RESULT / GET_ITEM_INFO_RECEIVED, which stopped firing.
Raise the cap to 64 (well above the ~45 total incl. the lazy TTS events) and
add a comment warning to keep it above the AutoReserve count. Verified via
_classicapi_DumpAllEvents: all custom events now claim slots, item events
included.
Returns the 1-based log index of the collapsible header (zone / category)
a questID sits under, or nil if the quest isn't in the log. The log is laid
out header-then-its-quests, so it locates the quest's entry and walks back to
the nearest preceding header row (+8 non-NULL). Pairs with GetQuestLogTitle
to fetch the category name.
Verified in-game: quest 60142 -> header index 1 -> GetQuestLogTitle "Dun
Morogh" with isHeader true.
The inverse of GetQuestIDForLogIndex: returns the 1-based quest-log index of
a questID, or nil if the quest isn't in the log. Walks VAR_QUEST_LOG_ENTRIES,
skips header rows via the +8 header-pointer gate, and returns the first
matching entry's index. The index spans the full entry list (headers
included), so it feeds straight into GetQuestLogTitle / the other index-based
quest-log accessors.
Verified in-game: index->ID->index and ID->index->ID both round-trip.
Implement C_Item.GetItemInfo(itemInfo) in C, returning the full modern
tuple instead of the Lua alias to the vanilla global (which only returned
1.12's short set). Sourced entirely from the cached ItemStats_C record plus
the class/subclass/inventory-type DBC name lookups item/Info.cpp already
does for GetItemInfoInstant — no new duplication.
Returns 18 values: itemName, itemLink, itemQuality, itemLevel, itemMinLevel,
itemType, itemSubType, itemStackCount, itemEquipLoc, itemTexture, sellPrice,
classID, subclassID, bindType, expansionID (0), setID (nil if none),
isCraftingReagent (false; no such flag in 1.12), itemDescription. itemTexture
is the icon path (no fileID system in 1.12). On a cache miss it warms the
cache and returns nil, landing on the GET_ITEM_INFO_RECEIVED retry — same
async contract as the stock global.
Drops the `C_Item.GetItemInfo = GetItemInfo` shim from ItemUtil.lua so the
native implementation isn't clobbered at addon load.
Verified in-game against Rancher's Trousers (nil setID) and Blackened Defias
Boots (setID 161) — all 18 fields correct.
The new-item diff only tracked bag contents, so an item moving INTO a bag
from anywhere out of scope looked identical to a fresh loot. Unequipping a
piece of gear (equipment -> bag) flagged it new; same for pulling an item
out of the bank.
Widen the "already owned" baseline to the player's whole inventory — bags,
worn equipment (paperdoll + equipped bags), and the bank (main + bank bags)
— while still only flagging bag items. An item whose GUID was already owned
somewhere is a move, not an acquisition, so it isn't flagged. Bank GUIDs are
read straight from the invMgr array (the gate-bypass C_Item.GetItemCount
already uses; the array is server-populated at login), so no bank window is
needed. Cap raised 256 -> 512 to hold bags + equipment + a full bank.
Post numStacks auctions of `quantity` each from one source stack in a
single call (retail multi-sell style). Vanilla's CMSG_AUCTION_SELL_ITEM
(opcode 0x256, verified from Script_StartAuction 0x004CE770) has no count
field like WotLK's, so the server posts the whole item object a GUID points
at; posting a partial quantity requires the client to split it into a real
bag item first. Both the split and the post are server-authoritative and
async, so this runs a WorldTick state machine:
Idle -> (source > quantity) split `quantity` into a reused free general
bag slot via Item::Swap::MoveCount (CMSG_SPLIT_ITEM)
-> SplitWait until the stack materializes
-> point the sell-slot globals at it and call the engine's own
Script_StartAuction (reusing all its per-stack validation)
-> PostWait until the server pulls it from the slot -> next stack.
The final exact stack posts the source object directly, no split.
At most one reused free slot is needed (each post frees it for the next
split); a whole-stack post needs none. Per-step ~10s timeout aborts
gracefully. Progress via retail event names AUCTION_MULTISELL_START /
_UPDATE(done,total) / _FAILURE. Immediate rejections return (false, reason).
New offsets: FUN_SCRIPT_START_AUCTION, VAR_AUCTION_AUCTIONEER_GUID_LO/HI,
VAR_AUCTION_DURATION_TABLE ({120,480,1440} minutes = 2h/8h/24h).
v1 limits: single source stack (no cross-bag aggregation), general-purpose
bags only for the temp split, one job at a time.
Backport math.fmod to 1.12's Lua 5.0. Pure 5.0->5.1 rename: 5.0 ships the
identical C function as math.mod (mathlib entry, FUN 0x007FAFD0 —
checknumber(1/2) -> fmod -> pushnumber); 5.1 renamed the Lua-facing entry
to math.fmod. Registered as a direct alias of that function pointer on both
the in-game and glue states (BaseLib::MathLib), no wrapper. math.mod stays
available, so code written for either name works.
Backport the Lua 5.1 pattern helpers to 1.12's Lua 5.0 (BaseLib::StringLib),
both reusing pattern machinery 5.0 already ships:
- string.match(s, pattern [, init]) calls the engine's str_find
(0x007FC3B0) and reshapes the result — captures, or the whole match when
the pattern has none, or nil. match and find are the same operation in
lstrlib.c; find returns start/end, match returns the captures.
- string.gmatch is 5.0's string.gfind verbatim (renamed in 5.1), registered
as a direct alias of the engine gfind (0x007FCFA0) — no wrapper.
Registered on both the in-game and glue Lua states. Only the
string.foo(s, ...) form works, not ("x"):foo(...): WoW's VM strips
type-metatables for non-table/userdata values (both lua_setmetatable and
luaT_gettmbyobj hard-return no-metamethod for strings), so string method
sugar can't be enabled client-side without hooking a hot VM core function.
Documented as a known limitation.
Drops the Util/String.lua polyfill (was just string.gmatch = string.gfind),
now redundant, and its .toc entry.
Backport Lua 5.1's select() to 1.12's Lua 5.0 (BaseLib::Select). Two
forms: select('#', ...) returns the vararg count; select(n, ...) returns
the args from position n on (negative n counts from the end). Ported from
luaB_select (lbaselib.c) — the numeric form pushes nothing and just
returns top-n, handing back the slice already on the stack. Registered on
both the in-game and glue Lua states (both are 5.0 and equally missing it).
Docs: gather the scattered Lua base-library restorations (select,
table.wipe, coroutine.*) into a single "Lua" section in API.md, and
collapse the separate Coroutine/Table rows in the README table into one
Lua row. No prose lost — the coroutine and table.wipe entries moved
verbatim under the new section (coroutine.* as level-4 subsections).
Backport the modern structured-table accessor. Vanilla's global
GetContainerItemInfo (Script_GetContainerItemInfo, 0x004F9670) returns
only a flat texture/itemCount/locked/quality/readable tuple; assemble the
full ContainerItemInfo table from the same descriptor + item-cache sources
plus the fields 1.12 never surfaced (hyperlink, hasNoValue, itemID,
isBound, itemName).
Live per-instance fields (stackCount, isLocked, isBound, itemID, hyperlink)
are always present; cache-derived fields (iconFileID, quality, hasLoot,
hasNoValue, itemName) are omitted/nil until the item's static data lands.
Reuses Item::StatAccum::FetchRecord and Item::Link for the cache peek and
link/name build (no new duplication); gathers all data into locals before
building the table so the engine link/name builders never interleave with
the half-built table on the Lua stack.
readable mirrors the engine branch at 0x004F97BD (static page text OR the
per-instance readable-text id at descriptor +0x9C, added as
OFF_DESCRIPTOR_READABLE_TEXT_ID). hasLoot is a best-effort static LOOTABLE
approximation (vanilla can't track post-loot emptiness client-side).
GetSpellBonusDamage(school) reads the player's per-school spell damage
directly from the CGPlayer sub-struct at [player+0xE68]+0xFD4 (POS) / +0xFF0
(NEG), net = POS-NEG. That field isn't in the broadcast descriptor (+0x110
reads zero — the vanilla server never sends it there), but the client keeps a
fully-computed copy at +0xE68 with gear/enchants/buffs/talents/sets baked in.
Same source nampower's GetSpellPower reads; verified 613 generic / 647 Fire on
a geared mage. 1-based school, matching 3.3.5's Script_GetSpellBonusDamage.
GetSpellBonusHealing() has no such field (vanilla lacks a healing-done field
entirely), so it's derived to mirror the server's SpellBaseHealingBonusDone:
- flat healing: SPELL_AURA_MOD_HEALING_DONE (135) off gear equip-effects,
item enchants, random suffixes, and active buffs
- Spirit conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT (175) x
total Spirit / 100 (Priest Spiritual Guidance)
- Armor conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_ARMOR_PERCENT (199) x
armor / 100 (Turtle Ironclad)
Percents are read straight from Spell.dbc, so it's generic across any
aura-175/199 talent/buff including Turtle customs, and gated on SPELL_ATTR_
PASSIVE for the known-spell-bitmap walk. Spirit/Armor come from UNIT_FIELD_
STAT4 (+0x250) / UNIT_FIELD_RESISTANCES[0] (+0x254).
Item flat healing routes through the shared Item::StatAccum engine rather than
re-implementing the item spell/suffix/enchant walk: extract a reusable
ApplyEnchant() (from ApplyRandomSuffix) plus a Value() reader, and read
ITEM_MOD_SPELL_HEALING_DONE_SHORT.
The healing sum is expensive (StatAccum over 19 items + buff table + the 27k
known-spell bitmap), so it's cached and recomputed only when a player stat
input changes. New Player::StatSignal (a pull-based epoch) is bumped from the
aura hooks (player-guarded), the equipment observer, and the spell learn/
unlearn writers (0x4B25B0 / 0x4B2C50) -- so buffs, gear, and talent respecs
all invalidate instantly. A coarse world-tick recompute remains only as a
backstop against a missed signal.
Wire a Windows VERSIONINFO resource to the existing CLASSICAPI_TAG so the
DLL's file/product version (Explorer -> Properties -> Details, and
GetFileVersionInfo) matches the CLASSIC_API_VERSION Lua global. Both derive
from the vMAJOR.MINOR.PATCH tag; an untagged dev build gets the 9999.99.99
"newer than everything" sentinel.
Enables the RC language, keeps the tag's major/minor/patch components that
were previously discarded after the integer math, and configure_file's a
version.rc into the build. The resource lands in .rsrc, untouched by the
.rdata/.pdata section merges.
The PlayerLocation-taking C_PlayerInfo accessors. Resolve the location to a
unit token - unit locations use their token, GUID locations via
Unit::Identity::TokenFromGUID (while the GUID has a live token) - then:
- GetName / GetSex / IsConnected tail-call the engine's Script_UnitName /
Script_UnitSex / Script_UnitIsConnected (the GetFactionInfoByID
forward-the-return pattern), reusing all vanilla unit logic. GetSex must go
through the engine because the raw UNIT_FIELD_BYTES_0 gender byte is 0/1
while the API reports 2/3. IsConnected defaults to the local player when
called with no location.
- GetRace / GetClass read UNIT_FIELD_BYTES_0 for the numeric ids vanilla's
UnitRace / UnitClass don't return (login-time mirror globals for "player",
else the descriptor bytes); GetClass resolves className / classFile from
ChrClasses.dbc like C_CreatureInfo.GetClassInfo. classID/raceID round-trip
with C_CreatureInfo.
Only the unit and guid location kinds resolve on 1.12; the chat/who/BG/
community/voice kinds return nil. New file - player/Info.cpp is the separate
NameCache/GetPlayerInfoByGUID module. Adds the three Script_Unit* offsets
from raw_globals.txt.
Backport the modern PlayerLocation parameter object (Blizzard_ObjectAPI) - a
lightweight descriptor wrapping one way to identify a player (GUID, unit
token, chat line, /who index, battlefield-score index, ...). Pure table
bookkeeping like the existing ItemLocation mixin, so ported addon code can
construct and inspect it unchanged.
The GUID / unit / chatLine / whoIndex / battlefieldScore kinds map to real
1.12 concepts; community / voice / Battle.net are kept as harmless data
holders so the mixin matches retail exactly. The only two methods that reach
into systems 1.12 lacks - IsValid (Club) and IsBattleNetGUID (account info) -
are guarded so they degrade gracefully instead of erroring on the missing
namespaces.
Classic Era addition (present in the 1.15.x client, absent in 5.4.8) — a
plain occupancy check for a bag slot. Occupancy sibling of
GetContainerItemID: resolves (bagIndex, slotIndex) the same way and returns
whether the CGItem is non-null. Presence only, so it reports true for a
just-looted item even before its static data caches. Returns false for
empty slots and out-of-range indices.
Sum of free slots across the player's general-purpose bags (backpack +
equipped bags 0..4 with BagFamily 0), matching FrameXML's implementation.
Reuses the module's existing ResolveBagInfo (slot count + family) and
CountFreeSlotsInBag, so it's just their sum with the family == 0 filter.
Same sparse-BagFamily caveat as GetContainerNumFreeSlots: vanilla server
data leaves most bags' family field at 0, so specialty bags retail would
exclude get counted here - consistent with the per-bag values it sums.
Nine modules each rolled their own ResolvePlayer / PlayerInvMgr helper on the
throwing FUN_RESOLVE_UNIT_TOKEN("player"). Delete them all and route through
the canonical non-throwing Unit::Identity::PlayerObject(), plus a new
Unit::Identity::PlayerInventoryManager() (PlayerObject + inventory-manager
offset, mirroring the existing PlayerDescriptor accessor).
Verified equivalent by disassembly: FUN_RESOLVE_UNIT_TOKEN is TokenToGUID ->
FUN_OBJECT_RESOLVE_BY_GUID, the same object-manager lookup PlayerObject()
performs, differing only in a type-mask the player object passes either way.
Callers therefore get the identical CGPlayer (and inventory manager) but
non-throwing - null pre-world instead of a raised Lua error. That also makes
item/Location's equipment resolver safe to call outside a Lua callback (e.g.
the C_NewItems WorldTick bag scan), where the token resolver could have
raised.
Migrated: item/Count, item/AverageLevel, item/Swap, item/Location,
player/CanUseItem, spell/Cancel, equipmentset/Data, equipmentset/Locations,
merchant/Frame. debug/Probe keeps its own (deliberately standalone).
Backport the modern C_NewItems system - IsNewItem / RemoveNewItem /
ClearAll - flagging items freshly added to bags as "new", cleared once
acknowledged. Vanilla has none of the machinery (no DBC, no server data),
so it is pure client-side bookkeeping, done entirely in C++ like retail.
Newness is keyed on each item's instance GUID (read off the CGItem), so a
flag survives the item being moved between slots - impossible for a Lua
addon, which can only track by (bag, slot). Bags 0..4 only; equipment and
bank are out of scope, matching retail.
The feed is event-driven, not polled: it subscribes to Bag::UpdateDelayed
(which co-hooks the engine's three BAG_UPDATE fire sites), so a bag rescan
runs only on frames where a bag actually changed. A one-time baseline taken
~1.5s after login (re-armed on character switch) keeps items owned at login
from being flagged. BAG_NEW_ITEMS_UPDATED fires when the set changes.
Consolidations along the way:
- New Bag::UpdateDelayed::AutoSubscribe exposes the authoritative
"a bag changed this frame" signal as a C++ subscription.
- New Item::Location::ContainerInventory factors the CGContainer -> its
inventory (vtable +0x10) dispatch hand-rolled in item/Count,
item/AverageLevel, and equipmentset/Locations; all now share it.
- New Item::Location::EquippedBagInventory (equipped bag -> inventory) plus
the FUN_GET_EQUIPPED_BAG_GUID / OFF_CONTAINER_GET_INVENTORY offsets.
- NewItems resolves the player via the shared Unit::Identity::PlayerObject.
Raw CreatureType id (CreatureType.dbc row: 1=Beast … 11=Totem), the
counterpart to the family additions:
UnitCreatureTypeID(unit) -> id, nil if none
C_CreatureInfo.GetCreatureTypeInfo(id) -> { id, name }
C_CreatureInfo.GetCreatureTypeIDs() -> ascending id array (1..11)
UnitCreatureTypeID calls the engine's own type resolver at 0x00605570
(Script_UnitCreatureType's inner helper) directly — it handles all three
engine paths (display-override, creature-cache +0x18, player-race
ChrRaces+0x24), so it matches UnitCreatureType for every unit kind
(players resolve to Humanoid). The C_ lookups read CreatureType.dbc
(records 0x00C0DE2C, count 0x00C0DE30, name +0x04).
Also documented that 0x00605570 is really the creature-type resolver
(new canonical alias FUN_UNIT_CREATURE_TYPE); the existing
FUN_UNIT_PET_MINION_CLASS name is a historical label — Pet.cpp's
"!= 1" test works because it is effectively "is this a Beast (type 1)":
hunter pets are Beasts, warlock minions are Demons (3). Verified
in-game: target -> 7 Humanoid, IDs[8] -> 8 Critter, round-trips.
The by-raceID form of vanilla's UnitFactionGroup, returning
{ name (localized), groupTag (english "Alliance"/"Horde") } for a race
with no unit required. Mirrors the player branch of Script_UnitFactionGroup
(0x00516630) exactly: ChrRaces[race]+0x08 -> FactionTemplate id ->
FactionTemplate[id]+0x0C group mask -> scan FactionGroup.dbc for the row
whose bit the mask sets and whose localized name is non-empty (the
non-empty test is how the engine skips the always-set "Player" bit and
the nameless "Monster" row to land on Alliance/Horde). nil for
non-numeric / non-positive / unknown race or a race with no named group.
Every field offset ground-truthed against ChrRaces/FactionTemplate/
FactionGroup .dbc files. FactionGroup is stored contiguously (base +
i*0x30, direct field reads), so it's iterated by hand rather than via
DBC::Record. Its records-base/count globals (0x00C0DD5C / 0x00C0DD60)
are taken from the disassembly, which is authoritative over DBCs.md's
standard-shape tabulation for this table (0x00C0DD64 / 0x00C0DD68) --
verified in-game: race 1 -> Alliance, race 2 -> Horde.
Retail backports over CreatureFamily.dbc (records 0x00C0DE7C, count
0x00C0DE80 — the globals Script_UnitCreatureFamily already indexes), on
the existing C_CreatureInfo table:
GetCreatureFamilyInfo(id) -> { id, name, iconFile }
GetCreatureFamilyIDs() -> ascending array of populated family ids
Localized name at record +0x20 (via LocalizedField), icon at +0x44 (via
StringField). GetCreatureFamilyIDs skips the sparse unused rows
(10/13/14/22/...); each element round-trips with GetCreatureFamilyInfo.
Faithful deviation: retail's iconFile is a numeric fileID, but vanilla's
DBC stores the icon as a texture-path string, so iconFile is a string
(usable directly with SetTexture) and "" for families with no icon
(warlock pets). Verified in-game: 27 -> Wind Serpent + icon path, 15 ->
Felhunter + "", IDs array sparse, unused id 10 -> nil.
Extension exposing the numeric CreatureFamily id (CreatureFamily.dbc
row) for a unit, the value addons want for stable checks — stock
UnitCreatureFamily only returns the locale-dependent display name.
It's the same id the engine's Script_UnitCreatureFamily (0x0051A310)
resolves internally: its family helper FUN_006055e0 returns
[unit+0xB30] ? [row+0x1C] : 0, then maps it through CreatureFamily.dbc
for the name. We stop at the id. Resolves the token via the same
FUN_RESOLVE_UNIT_TOKEN, returns nil for family 0 (players, non-beast
NPCs) and unsynced/unresolvable units — matching UnitCreatureFamily.
Shares UnitSubName's +0xB30 creature-cache read path. Verified in-game:
target (cat) -> 2, targetpet -> 2.
MODIFIER_STATE_CHANGED never fired for a modifier released while WoW was
in the background: WM_KEY* messages are only delivered to the focused
window, so the WH_GETMESSAGE hook never saw the release and the cached
bit stayed stuck down (also leaving IsLeft/RightShiftKeyDown etc. wrong).
Add a WH_CALLWNDPROC thread hook to catch WM_ACTIVATEAPP (sent, not
posted, so invisible to WH_GETMESSAGE). On app activation, resync the
cached 6-bit mask from GetAsyncKeyState (global physical key state,
unaffected by focus), firing MODIFIER_STATE_CHANGED for any bit that
drifted while WoW was away. Both the keyboard path and the resync now
funnel through a shared SetBit helper so the "only transitions fire"
invariant holds regardless of source.
Caveat (documented): the engine's native IsShiftKeyDown/IsControlKeyDown/
IsAltKeyDown bottom out at synchronous GetKeyState and share the blind
spot; they are not reconciled here — only ClassicAPI's L/R functions and
the event reflect the corrected state.
Retail backports over ChrRaces.dbc / ChrClasses.dbc, on the existing
C_CreatureInfo table. By-id lookups (no unit token needed, unlike
UnitRace/UnitClass) returning the localized name plus the non-localized
file token that round-trips with the unit APIs:
GetRaceInfo(raceID) -> { raceName, clientFileString, raceID }
GetClassInfo(classID) -> { className, classFile, classID }
Reuses the already-verified DBC offsets (OFF_CHRRACES_* / OFF_CHRCLASSES_*),
StringField for the token and LocalizedField for the name. nil for
non-numeric / non-positive / unknown ids. Verified in-game: race 4 ->
Night Elf/NightElf, class 1 -> Warrior/WARRIOR, unknown id -> nil.
Taxi areaID resolution now tries the node's LOCATION part first
("Orgrimmar" in "Orgrimmar, Durotar") -- accepted only when it's a
mapped area whose WorldMapArea rect contains the node -- then the zone
suffix, then the geometric resolver. Capitals thus pin on their own city
map (Orgrimmar 1637, Undercity 1497) instead of the surrounding region,
while normal nodes (location is an unmapped subzone) still resolve to the
outdoor zone. Map::Area::PercentInZone is now containment-checked so a
candidate zone is taken only if it actually holds the point.
Add C_Map.GetMapAreaIDs() -> { [mapName] = areaID } from WorldMapArea:
the missing bridge (vanilla has no GetCurrentMapAreaID) for resolving a
browsed city/instance map -- which GetMapZones() doesn't enumerate -- to
its zone id. Keys on the WorldMapArea name (matches GetMapInfo()'s dir
for nearly all maps; a few misspelled Blizzard folders like "Ogrimmar"
differ, documented).
Verified in-game: Orgrimmar/Undercity flight masters pin on their city
maps; GetMapAreaIDs()["Orgrimmar"] = 1637.
Zone assignment (Map::Area::ZonePercent, shared by GetTaxiNodesForMap
and GetAreaTriggerInfo) chose among overlapping WorldMapArea bounding
boxes by smallest rect, mis-assigning border points. Now resolve by the
zone whose DRAWN LANDMASS the point sits on -- WorldMapOverlay hit rects,
the runtime analog of an ADT area-grid lookup -- tie-broken by how deep
inside an overlay the point is (a node deep in its own zone's overlay
beats one clipping a neighbor's edge). Falls back to most-interior
bounding box when on no overlay. Fixes Ratchet, Un'Goro, Nijel's Point.
For taxi specifically, resolve areaID from the node's own name suffix
("..., Western Plaguelands" -> AreaTable, exact then word-prefix) --
authoritative, and right at borders geometry can't separate (Chillwind
Camp -> WPL, not the Alterac box it sits in). New Map::Area::PercentInZone
projects the position into the named zone; geometric resolver remains the
fallback and the area-trigger path.
Add a `reachable` field: a flight path leads to the node (in-degree > 0).
false marks departure-only nodes reachable only by other means -- druid
Teleport (Nighthaven, Moonglade) or a boat dock (Stormwind Harbor) -- so
a consumer can hide them from a "where can I fly" map without a hardcoded
druid guess. BuildTaxiGraph tracks endpoint + destination sets in one pass.
Verified in-game: Nijel's->Desolace(405), Marshal's->Un'Goro(490),
Ratchet->Barrens(17), Chillwind->WPL(28).
Previously no-arg meant the currently-viewed continent; make it return
every flight master on every continent instead -- a one-call flight DB.
A numeric mapID still filters to that continent. Each entry already
carries its own mapID, and the mount + TaxiPath-endpoint filters apply
uniformly. Verified: no-arg 97, GetTaxiNodesForMap(0) 48.
GetTaxiNodesForMap(mapID) reads TaxiNodes.dbc: real flight masters on a
continent, faction-tagged, discovered or not -- the static DB pfQuest's
pfDB.meta.flight wants. Non-flight rows are filtered out: a node must
have a flight mount (horde @+0x38 / alliance @+0x3C -- drops transports/
boats/zeppelins) AND be a TaxiPath.dbc endpoint (drops Northshire Abbey,
spurious duplicate hub rows, standalone markers with no connecting
path), plus a guard for Blizzard's lone "Generic, World target"
placeholder. Returns retail nodeID/name/faction/position (continent
0-1) + ext mapID/x/y/z and derived areaID/mapX/mapY (zone-relative).
GetAllTaxiNodes([uiMapID]) wraps the open flight master's live node
list (VAR_TAXI_NODE_ARRAY stride 0x20: slot+0 = TaxiNodes record ptr,
+0x04/+0x08 = flight-map x/y; count VAR_TAXI_NODE_COUNT; type via
FUN_004dc4e0): slotIndex (TakeTaxiNode), name, nodeID, state
(Enum.FlightPathState). Empty when no taxi map is open.
Registers Enum.FlightPathFaction / Enum.FlightPathState (retail values).
Extracts the zone-percent projection into shared Map::Area::ZonePercent
+ adds ContinentPercent; refactors AreaTriggers onto ZonePercent.
Verified: EK 48 real masters (Northshire/EPL/transports gone), Stormwind
Alliance / Undercity Horde, continent position matches projection; live
24 nodes with Stormwind state=Current.
GetExploredMapTextures(areaID) backports the retail (9.0-era) call:
only overlays the local player has discovered. GetUnexploredMapTextures
is the ClassicAPI-extension complement (the reveal-over-fog pieces); its
inverse, C_Map.GetMapOverlays, still returns all overlays.
Refactor the overlay walk into a shared Map::Overlays::PushZoneOverlays
with a Filter {All, Explored, Unexplored}; GetMapOverlays is now the
All wrapper. "Explored" is computed per-areaID from the player's
explored-areas bitfield ([player+0xE68]+0xE6C, indexed by AreaTable
AreaBit +0x0C, gated by +0x28) -- mirrors the engine's discovered-
overlay rebuild FUN_004a67a0 -- so the areaID arg works for any zone,
not just the current view. Also add a fileDataIDs field (ordered tile
paths, retail's field name) to every overlay entry.
New offsets: OFF_AREATABLE_AREA_BIT, OFF_AREATABLE_EXPLORE_GATE,
OFF_PLAYER_EXPLORED_BITS. Verified: Elwynn explored(10) + unexplored(2)
== all(12).
Surface AreaTable.dbc's localized name column. GetAreaInfo(areaID) is
the retail backport -- localized name for one zone/subzone id, nil for
bad input / no row. GetAreas() is an extension: the full { [areaID] =
name } map (empty-name rows omitted, round-trips with GetAreaInfo), the
piece name databases need for name->id / reverse search. Same "surface
the hidden DBC" pattern as GetAreaTriggers / GetMapOverlays.
AreaTable.dbc is a client DBC (always loaded, synchronous, localized),
so no WDB cold-cache concerns. Uses existing VAR_AREATABLE_* /
OFF_AREATABLE_NAMES wiring; no new offsets. Verified: (12) Elwynn
Forest, (9) Northshire Valley, GetAreas()[12] Elwynn Forest.
GetSkillLineRank(skillLineID) returns the local player's curRank,
maxRank, modifier for a SkillLine.dbc id, or nil if unlearned -- keyed
by id directly instead of forcing addons to map id -> localized name and
string-match GetSkillLineInfo's by-name, by-position walk. Resolves the
id to the player's skill slot via FUN_SKILL_LINE_TO_SLOT, then reads the
CGPlayer +0xE68 skill table (the storage Player::CanUseItem's
require-skill gate already uses). GetSkillLineName(skillLineID) is the
id -> localized name half (pure SkillLine.dbc read, learned or not).
Both live in the existing Spell::SkillLine module; no new offsets.
Verified: Enchanting(333) 171/225/15, Tailoring(197) 261/300/0,
name(182)=Herbalism, unlearned line -> nil.
Each overlay now carries hitRectTop/Left/Bottom/Right (WorldMapOverlay.dbc
+0x34..+0x40) -- the tight clickable landmass bounds in world-map canvas
px (~1002x668), the subzone-center source map addons scrape into their
zone tables. Verified: areaID 12 overlay 8 (NorthshireValley, areaID 9)
-> hitRect 164/400/388/617.
Also stop dropping texture-less rows: a row with an areaID + hit rect but
no texture now appears with textureName == "", zero size, and an empty
tiles table (ipairs-safe), instead of being skipped. No-op on stock/Octo
data (every overlay has a texture) but matches the schema and the "every
overlay of a zone" contract.
Each overlay table now carries areaID (primary AreaTable id revealed,
first nonzero) and areaIDs (the full nonzero list -- WorldMapOverlay.dbc
areaID[4]; 52 of 707 overlays span 2-3 subzones). Lets consumers map an
overlay to its subzone(s) / exploration state. Verified: overlay 1 of
areaID 85 -> areaID 157.
Backport the retail C_Map.GetMapWorldSize (9.0): return a zone's world
size in yards as width, height, read from the WorldMapArea.dbc loc rect
(width = locLeft - locRight east-west, height = locTop - locBottom
north-south). Takes an AreaTable areaID, or defaults to the world map's
current view like GetMapOverlays. Verified: (85) -> 4518.75, 3012.5.
Extract the two WorldMapArea row resolvers (areaID -> row, current view
-> row) out of map/Overlays.cpp into a shared Map::Area helper; both
GetMapOverlays and GetMapWorldSize key off the same rows, so the two
ways of naming a zone now live in one place.
Expose AreaTrigger.dbc (517 static trigger volumes: subzone-entry /
exploration / teleport), which the client loads but never surfaces to
Lua -- the reason map addons ship hand-scraped trigger tables. Same
"read the hidden DBC" pattern as C_Map.GetMapOverlays.
Each trigger returns both the authoritative raw geometry (mapID, world
x/y/z, radius, isBox + box length/width/height/yaw) and a derived
consumable form (areaID + mapX/mapY as a 0..100 zone-relative percent)
via the WorldMapArea rect, picking the smallest zone whose rect
contains the point. GetAreaTriggerInfo(id) returns one trigger;
GetAreaTriggers([mapID]) enumerates all, optionally filtered to a map.
Adds the AreaTrigger.dbc record offsets and the WorldMapArea mapID /
loc-rect offsets to Offsets.h.
Add the API.md entry (input shapes, equipment-then-bags search, no-op
conditions, direct-cursor-primitive note) plus its TOC link, and the
README Item-row index entry.
Replace the auto-slot path's Script_PickupContainerItem stack-fake
(SetTop + PushNumber x2 + call) with the shared
Item::Cursor::PickupBagItem, which drives the engine's cursor primitive
directly -- the same one C_Item.PickupItem uses. The ClearCursor at the
top still runs first, so PickupBagItem's empty-cursor guard is satisfied;
bail if the pickup didn't take (locked item / busy cursor) rather than
auto-equipping nothing. Drops the now-unused ScriptFn_t typedef.
FUN_SCRIPT_PICKUP_CONTAINER_ITEM now has no callers -- the Script-pickup
stack-fake is gone from the codebase. Explicit-dstSlot path (atomic
FUN_INVENTORY_SWAP) is unchanged. Verified in-game (auto-slot equip by
name and by id).
C_Item.PickupItem(itemInfo) picks the matching item up onto the cursor.
itemInfo is an itemID / "item:NNN" / link / name (same input family as
UseItemByName / EquipItemByName): resolved via Item::Arg::Resolve and
found by Item::Location::FindByArg across equipment (1..19, checked first)
and bags (0..4) using the shared MatchesArg predicate (itemID, or the
decorated name for a name arg). An equipped match is lifted off the
paperdoll; a bag match off the container. Verified in-game (bag + equipped,
by id and by name).
Drives the engine's cursor-pickup primitives DIRECTLY rather than the Lua
Script_Pickup* state-machine wrappers (no faking the Lua stack), via a new
shared Item::Cursor:
- PickupEquipmentSlot -> FUN_004C7300 (self-contained; own guards)
- PickupBagItem -> FUN_00494B60, replicating the two guards the
Script wrapper applies (empty cursor + unlocked item) and the
post-pickup source lock. The empty-cursor check reuses a new shared
Cursor::Info::HasItem() (the same cursor-GUID globals GetCursorInfo
reads), rather than duplicating it.
Supporting consolidation in Item::Location:
- ResolveBagSlotDetail exposes the container object + linear slot the
cursor primitive needs; ResolveBagSlot now delegates to it.
- FindByArg (equipment-then-bags, MatchesArg) reuses FindByArgInBags for
the bag half; FindByItemID is now its itemID-only specialization.
C_EquipmentSet.UseEquipmentSet was considered but already uses the atomic
FUN_INVENTORY_SWAP (no cursor). EquipItemByName's auto-slot path still uses
the Script wrapper; migrating it to Item::Cursor::PickupBagItem is a
follow-up.
Returns true if an item is a consumable: item class Consumable (0), or
inventory type INVTYPE_AMMO (24) / INVTYPE_THROWN (25). Registered as both
the namespaced C_Item form and the bare global, like IsUsableItem.
Definition established by decoding 3.3.5's Script_IsConsumableItem
(0x005173E0) then verifying the surviving contract on a modern client:
3.3.5 walked the item's on-use spells and returned true for any with a
real effect (which would include on-use trinkets), but that heuristic did
NOT survive -- an on-use trinket with a numeric Use: effect (Talisman of
Ephemeral Power) returns false on modern. It's a plain class/ammo check.
Verified in-game on our client, matching modern exactly: potion / bandage
/ Faintly Glowing Skull (all class 0) and Rough Arrow (Ammo) -> true;
Hearthstone, Talisman, plain armor -> false.
itemID / "item:NNN" / link input (names -> false, no vanilla name->ID
map); uncached -> false with no async load, same contract as
C_Item.IsEquippableItem. Documented in API.md (+ TOC) and the README
Item-row index.
C_Item.IsItemInRange(item, targetUnit) resolves the item to its on-use
spell (Item::Spell::OnUseSpellIDForItemID) and runs the same geometric
range test as C_Spell.IsSpellInRange: true / false in-or-out of range,
nil when the check doesn't apply (no on-use spell, a rangeless one, or an
unresolvable item/unit). Matches retail, which also returns nil for items
with no range restriction. item accepts itemID / "item:NNN" / link (names
aren't resolvable in vanilla -> nil). Range-only, ignoring LoS/faction,
like IsSpellInRange.
The range predicate SpellIDHasRange was about to become its fourth copy,
so extract the shared core into Spell::Range (SpellIDHasRange +
PlayerVsUnit, returning 1/0/-1). C_Spell.IsSpellInRange and
C_Item.IsItemInRange are now thin Lua wrappers over PlayerVsUnit, and
C_Spell.SpellHasRange / SpellHasRange call the shared SpellIDHasRange --
range logic single-sourced.
Follow-up to the random-suffix work (aa0279a, e8a9003, 2a218a0). API.md
still described the old base-name behavior in five places; update them:
- GameTooltip:GetItem() example: name is now the decorated name that
agrees with the returned link ("Superior Cloak of the Eagle").
- C_Item.GetItemName: split the two forms -- location returns the
decorated name (live instance, via the engine name builder), ByID
returns the base name (an itemID has no instance/suffix).
- C_Item.IsEquippedItem / EquipItemByName / UseItemByName: name matching
is against the decorated name (a suffixed item matches its full name,
not the base), noting the one shared predicate behind all three.
Left the C_Item.GetItemData return-table `name` row as base m_name[0] on
purpose: that table is a static itemID-keyed record dump (same contract
as GetItemInfoInstant, which doesn't decorate either).
IsEquippedItem(name) and the bag find-by-name (FindByArgInBags, behind
UseItemByName/EquipItemByName/UseAtCursor) compared the caller's string
against the base ItemStats m_name[0], so a random-suffix item ("Foo of
the Owl") only matched when queried by its base name ("Foo"). The modern
client does the opposite -- verified in-game on both the modern client
and ours:
IsEquippedItem("Iridium Chain") -> false
IsEquippedItem("Iridium Chain of the Owl") -> true
IsEquippedItem("iridium chain of the owl") -> true (case-insensitive)
IsEquippedItem(12022) -> true (itemID)
Match the decorated display name instead, via the engine name builder
(Item::Link::NameFromCGItem), which falls back to the base name for
unsuffixed items so plain items still match.
The match predicate was duplicated inline in both call sites -- which is
why this took two edits. Consolidate it into a single
Item::Location::MatchesArg(cgItem, resolvedArg): itemID-compare when
arg.itemID > 0, else case-insensitive decorated-name compare. Both
IsEquippedItem and FindByArgInBags now delegate to it, so the full chain
is single-sourced (Item::Arg parses input -> MatchesArg decides the match
-> NameFromCGItem / ID::FromCGItem supply the comparands). Also drop a
now-unused <string.h> from Equipment.cpp and fix the stale m_name[0] doc
on FindByArgInBags.
GameTooltip:GetItem() resolved the CGItem and built the suffixed link via
Item::Link::FromCGItem, but returned the base ItemStats name beside it --
so name and link disagreed ("Iridium Chain" vs "[Iridium Chain of the
Owl]"). The old comment claimed the engine didn't expose the dressed name
as a separate string; it does, via the same builder the link uses.
Extract that builder into a shared Item::Link::NameFromCGItem helper
(next to FromCGItem, wrapping FUN_ITEM_BUILD_INSTANCE_NAME) and use it in
both GetItem and C_Item.GetItemName so the returned name matches the link
and modern behavior. Verified in-game: GetItem() now returns "Iridium
Chain of the Owl" alongside its matching link.
Left the base-name reads that are correct as-is: itemID-only paths (mail,
cursor-by-id, GetItemInfo, equipment-set missing items) have no instance,
and the by-name *matching* in IsEquippedItem / bag lookups compares
against a caller string (a separate semantics question).
The location form of C_Item.GetItemName points at a real item instance,
so its name should carry random-suffix decoration ("... of the Owl") the
way modern WoW does -- but we were reading m_name[0] off the itemID-keyed
ItemStats_C cache, which only holds the base name.
Resolve the location to its CGItem and build the name via the engine's
own per-instance name builder (FUN_005D8BC0): it reads the suffix ID off
the descriptor (+0x98) and formats base + ItemRandomProperties localized
suffix through the engine's ITEM_SUFFIX_TEMPLATE. This is the same
builder FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the output
now matches GetItemLink's bracketed name and modern C_Item.GetItemName
exactly (verified in-game: "Iridium Chain of the Owl"). Falls back to the
base ItemStats name (and warms the cache) when the instance build yields
nothing.
GetItemNameByID is unchanged -- an itemID has no instance, so base name
only is correct.
FUN_TOKEN_TO_GUID (0x00515970) is a pure parser: it returns 0 for any
input it doesn't recognize and never raises -- verified from the
disassembly (plain stack frame, no assert/SEH prologue; every
unknown-input path is xor eax,eax; ret). Both call sites had mirrored
the engine's token grammar by hand to pre-screen input, on the mistaken
belief this function throws (it was conflated with its sibling
FUN_RESOLVE_UNIT_TOKEN 0x00515940, which does throw).
- item/Use.cpp UseItemByName: delete LooksLikeToken / MatchBase /
SkipTargetSuffixes / HasPrefix; just call FUN_TOKEN_TO_GUID and let it
return 0 for non-tokens.
- unit/Flags.cpp UnitIsInMyGuild: delete LooksLikeKnownToken / HasPrefix
/ AllDigits. Resolve the input to a GUID via FUN_TOKEN_TO_GUID, then
resolve GUID -> object via the non-throwing FUN_OBJECT_RESOLVE_BY_GUID
(unit|player mask) for the fast guild-key compare, and GUID -> name via
the NameCache for the roster fallback. This avoids the throwing
resolver entirely, and -- because the token->GUID step goes through
our TokenExtensions hook -- now supports focus / nameplateN, which the
old grammar mirror silently treated as literal names. Matches what
3.3.5's Script_UnitIsInMyGuild does (resolve to a GUID first).
No grammar to keep in sync with the engine anymore. Behavior otherwise
unchanged (self, guildmate/other-guild target, NPC, OOR raid member,
literal name, empty slot all verified case-by-case).
UseItemByName resolved the unit token (arg 2) AFTER FindByArgInBags,
which stomps the Lua stack (clears it and pushes bag-slot numbers for
PackBagSlot). So ResolveUnitGuid read a leftover slot number instead of
the token: IsString failed, the GUID came back 0, and the engine fell
back to its default target. That default is the player's current target
for a targeted-use item, so "target" appeared to work while "focus",
"party1", "mouseover", etc. silently did nothing useful.
Resolve the target GUID before the bag walk (as the sibling UseAtUnit
already does). ResolveUnitGuid only reads slot 2 and resolves a C
string -- no stack stomp -- and leaves arg.name (slot 1) intact for
FindByArgInBags; the GUID is a plain value by the time the stack is
blown away.
ClassicAPI extension (retail has no such global): true if the player
has clear LoS to unit, false if world geometry blocks it, nil when the
check can't apply (absent/unresolvable token, or non-unit object).
Traces a ray between the two units through the client's world-collision
core CWorld::Intersect (0x672170, verified in-binary: __fastcall(start,
end, unused, hitOut, tOut, flags), tail-calls the real intersect
FUN_0069BFF0). Flags 0x100111 = terrain + WMO (0x100171 also tests M2
doodads but crashes in some dungeons; matches vanilla server LoS which
ignores doodads). Endpoints are raised by each unit's collision-box
height (CMovement+0xB4) so foot-to-foot rays don't false-block on
terrain, with a two-pass level/look-up trace for height mismatches and
a 150yd guard against a long-segment crash.
Mirrors UnitXP_SP3's proven implementation (same VA, flags, guard, and
two-pass logic) but native, so it works with or without that addon --
we call the intersect rather than hooking it, and our flag isn't one
UnitXP_SP3's detour acts on. Gated on object type (unit/player) so it
never reads CMovement off a GameObject.
Adds FUN_CWORLD_INTERSECT, OFF_CMOVEMENT_COLLISION_BOX_HEIGHT,
OFF_CGOBJECT_TYPE_ID, OBJECT_TYPE_UNIT/PLAYER (reuses the existing
OFF_UNIT_MOVEMENT_INFO_PTR = 0x118), plus API.md and README entries.
Returns true/false for in/out of range, nil when the check does not
apply (rangeless spell, or unresolvable spell/unit). spellIdentifier
accepts a spellID, link, or spellbook name; targetUnit is a unit token.
Backed by the engine's own geometric range core FUN_006E47B0 -- the
same test the action-button range coloring uses (Script_IsActionInRange
to FUN_004E56F0 to FUN_006E4440 to FUN_006E47B0). It derives the
spell min/max range via FUN_006E3480 (which folds in the target's
bounding radius AND applies the range SpellMod op 5, so range-extending
talents/items like Hawk Eye and Nature's Reach count) then compares
center-to-center distance, so the boundary matches the client exactly,
melee and min-range spells included.
We call the pure core rather than FUN_006E4440: that wrapper also gates
on target type and folds range-not-applicable into the same truthy
return as in-range, so it cannot yield a clean true/false/nil. The core
is range-only, matching retail (ignores line of sight; does not reject
wrong-faction targets, documented). Positions are guarded via
Unit::Position::Read before the engine call, which dereferences the
GetPosition result without a null check.
Adds Offsets::FUN_SPELL_RANGE_CHECK (0x006E47B0) with derivation notes,
plus API.md and README entries.
table.wipe nil'd every key via lua_next + rawset(nil) but left the
Lua 5.0 length counter untouched. In 5.0 a table's table.insert/getn
length lives out of band from its keys (a t.n field, else a weak
sizes[t] entry in the registry's LUA_SIZES table), so it survived the
clear. After a wipe, getn stayed at its old value and subsequent
table.insert appended past the wiped slots -- t[1] stayed nil. The
3.3.5 (Lua 5.1) twipe this was ported from has no getn legacy, which
is why the ported logic looked complete.
Fix: call luaL_setn(L, 1, 0) after the clear loop. Bound the engine's
luaL_setn (FUN_006f4ea0, verified against 5.0.2; located via
table.insert FUN_007fb6b0) as Game::Lua::SetN.
Repro before: table.insert(t,1); table.insert(t,2); table.wipe(t);
table.insert(t,3) -> getn 3, t[1] nil, t[3]=3.
After: getn 2, t[1]=3, t[2]=4. Verified in-game.
Previously g_lastButton persisted as the last click forever. Modern
WoW returns the button only while a mouse handler is running and nil
otherwise (verified in 3.3.5: GetMouseButtonClicked pushes a
frame-manager field [mgr+0x1234] that is set before a mouse handler
dispatches and cleared after, so it is nil when null).
We cannot bracket the handler the same way: WoW dispatches the
frame-script mouse handlers deferred from the WH_GETMESSAGE pump, so
eviction is time-based via the existing per-frame WorldTick. The
button is still captured on each WM button message; EvictTick clears
it after a couple of quiet ticks with nothing held. A held button
keeps it (OnDragStart still reads it) and it survives the whole frame
its click lands in, so multiple addons hooking one OnClick all see it,
avoiding the multi-reader hole a consume-on-read latch would have.
Verified in-game: value matches the GLOBAL_MOUSE_DOWN button arg inside
a handler, and reads nil once clicks settle.
Returns the button name of the most recent mouse-button message
("LeftButton"/.../"Button5"), or nil before any click. Rides the
existing WH_GETMESSAGE stream in GlobalMouse that already backs
GLOBAL_MOUSE_DOWN/UP and IsMouseButtonDown, so no engine hook is
needed: g_lastButton is captured on every press/release, and the
Script fn pushes it (null tail-jumps to pushnil for the "no click
yet" case).
The hook runs at message-dequeue, synchronously before the engine
dispatches the click to the Lua handler, so the value reflects the
button of the handler currently running (and is readable from nested
helpers where arg1 isn't in scope). Not cleared between events -- no
"handler ended" signal in the 1.12 stream -- so outside a mouse
handler it returns the last-clicked button rather than nil; documented.
Move the persistent name cache from the per-account
WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt to a shared
WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt, so alts on
different accounts of the same server pool into one file instead of each
rebuilding their own.
Keyed by the realmlist address (unambiguous server identity — can't collide
across unrelated private servers, the same reason CredStore scopes on it)
plus the realm name. Since this path isn't under Account\, WoW doesn't
create it: EnsureParentDirs makes the tree before saving. On first load with
no shared file yet, a pre-existing per-account cache is migrated in (load
old file, mark dirty so the next save lands in the shared location); the old
file is left untouched.
The PersistentNameCacheEnabled / NameCacheScanEnabled toggles stay
per-account in WTF\Account\<acct>\ClassicAPI.txt — each account opts in
independently; only the cache contents are shared.
Backport retail's UnitDistanceSquared(unit) -> (distanceSquared,
checkedPosition): the squared world distance from the player to the unit,
plus a flag for whether both positions were readable. Squared because
almost all distance logic is a threshold compare or nearest-unit sort,
neither of which needs the sqrt — which is why retail never shipped a plain
UnitDistance.
Added to the existing Unit::Range module alongside UnitInRange (both now
share one PlayerToUnitDistSq compute off the CGObject::GetPosition vtable
virtual). Center-to-center, self-contained (reads the position in C — no
SuperWoW dependency, and leaves SuperWoW's own UnitPosition untouched).
Verified in-game to match SuperWoW UnitPosition math to float precision.
On a position miss returns (0, false); no self-quirk, so
UnitDistanceSquared("player") is a legitimate (0, true).
Expose the target-level gate for ranked beneficial auras — e.g. Divine
Spirit Rank 1 (14752) can only be applied to a target level >= 20.
- C_Spell.GetSpellLevelInfo(spellID) -> spellLevel, baseLevel, maxLevel
(raw Spell.dbc level fields; nil for an unknown spell).
- GetSpellRequiredTargetLevel(spellID) (global, plus C_Spell mirror) ->
the minimum castable target level (0 = no restriction; nil if unknown).
The computed helper mirrors the server's SpellMgr::SelectAuraRankForLevel:
returns spellLevel - 10 only when the spell is a ranked positive aura
(spellLevel > 10, not passive, has a Rank string, and has an APPLY_AURA
effect on a friendly/party/raid target or an APPLY_AREA_AURA_PARTY effect),
else 0. All fields read directly from the client's Spell.dbc.
The explicit MinTargetLevel / MaxTargetLevel fields the server also checks
are server-only columns (absent from the 1.12 client DBC, whose tail is
SpellFamilyName / SpellFamilyFlags / MaxAffectedTargets), so the spellLevel
rule is the only client-readable target-level mechanism.
C_UnitAuras' descriptor-drop fallback could surface a "phantom" aura that
was actually gone — enumerated with a spellId but SetUnitAura rendering an
empty tooltip. Two cases, both fixed:
- Owner cancels a buff (e.g. a flask) while dead: the server keeps
death-persistent auras through death without changing any field, so death
itself fires no OnAuraRemoved. Any OnAuraRemoved we do receive for one is
genuine (cancel/dispel/expiry), so always evict the cache entry — including
death-persistent auras on dead units. Fixes the phantom for the caster and
nearby observers (both receive the removal packet).
- A buff cancelled while its owner is out of an observer's range sends no
packet, so the stale entry lingers. Reconcile on return: when a unit's
descriptor holds >=1 raw aura it's in view and fully synced, so it's
authoritative — evict any cached entry whose spell isn't in the array
(Aura::Data::ReconcileCache -> Aura::Source::EvictAbsent, run at the top of
the fallback path). Reads raw slots so a flag-hidden aura still counts as
present; skips an empty array (can't tell out-of-range from buffless).
The only residual is a buff cancelled while the owner is still out of range,
which vanilla can't report until they return.
Block/dodge/parry chance and crit/hit are native vanilla percentages, so
their compare-tooltip delta read as a bare number ("-5 Block"). Attach the
percent via the locale-aware PERCENTAGE_STRING global so it renders
"-5% Block" without touching any _SHORT label (correct in every locale).
IsPercentStat marks the percent keys; FormatPercent substitutes the value
into PERCENTAGE_STRING's first conversion slot by hand (never feeds the
locale format to snprintf) with a plain "%" fallback. Flat stats (Defense,
Attack Power, Block Value) stay bare; dropped the now-redundant trailing
" %" from the English fallback labels.
Player-minion classification for a unit, all owner-based:
- UnitIsMinion(unit): owned by a player (pet/guardian/totem). Primary
check is the owner GUID, since summoned guardians ("X's Minion") carry
an owner but not UNIT_FLAG_PLAYER_CONTROLLED; the flag is a fallback.
- UnitIsPet(unit): a player's controllable pet specifically -- owned by a
player AND the engine's creature-family pet/minion classifier
FUN_00605570 == 1, the same signal driving the UNITNAME_TITLE_PET vs
_MINION tooltip label. The GUID can't be used (pets and guardians share
the 0xF14 prefix) nor can PLAYER_CONTROLLED (a Minion can have it).
ClassicAPI extension.
- UnitIsOtherPlayersPet(unit): owner is a player other than you; matches
5.4.8's owner-based Script_UnitIsOtherPlayersPet.
Owner GUID read from CHARMEDBY (+0x10, verified via Script_UnitIsCharmed)
and CREATEDBY (+0x20, verified via the unit title builder FUN_0052FD30).
Verified in-game: Salvatory's Pet is a pet; Jimmyf's/Methl's Minion is a
minion but not a pet.
The proficiency/level/class/skill/spell/rep gate that answers "can this
player equip or use this item" -- the tooltip's red-requirement logic,
distinct from IsEquippableItem (fits a slot) and IsUsableItem (on-use
castable). Mirrors the requirement checks FUN_0052B650 inlines:
1. proficiency - VAR_PROFICIENCY_TABLE[m_class] & (1<<m_subClass)
2. RequiredLevel
3. AllowableClass / AllowableRace
4. RequiredSkill - effective rank via FUN_SKILL_LINE_TO_SLOT
5. RequiredSpell - spell-knowledge bitmap (crafting specializations
like Armorsmith, proficiency spells)
6. RequiredHonorRank / RequiredCityRank - PvP honor rank + medal mask
7. RequiredReputation - current standing vs the reaction-min band
Verified in-game: mail boots false (proficiency), Riding mount false
without skill 150, Plans: Lionheart Helm false until Armorsmith learned.
Residual gap: a ranked RequiredSpell known only at a different rank.
Adds OFF_CGPLAYER_HONOR_RANK / OFF_CGPLAYER_PVP_MEDALS.
Compare-tooltip stat deltas now resolve their display label through
_G[key] (the WoW GlobalString), falling back to the built-in English
only when undefined. LabelFor tries _G[key], then _G[key.."_SHORT"],
then the English fallback: primary stats / resistances / CURRENTLY_EQUIPPED
resolve directly, while the rating/regen tokens GetItemStats keys on
(ITEM_MOD_CRIT_MELEE_RATING, ...) — whose own global is a "%s"
description — map to their compact, translatable _SHORT sibling for
display. StatAccum keys are left untouched (the GetItemStats contract,
confirmed against a TBC dump: ratings key on the non-_SHORT token).
The format guard now rejects only real printf conversions (%d/%s/%1$s),
letting a literal "%" unit through.
enUS.lua gains the ITEM_MOD_*_SHORT, ITEM_DELTA_DESCRIPTION and format
keys (native RESISTANCE*/CURRENTLY_EQUIPPED left to the client). The
non-US locales (deDE/esES/frFR/ruRU/zhCN/zhTW) are auto-filled from
Blizzard Classic (1.15) per-locale GlobalStrings; only the custom
BINDING_NAME_AOELOOT stays commented as an enUS fallback.
Fixes#8
Item analog of IsUsableSpell, returning (usable, noMana) for an item's
on-use ability -- not equippability. Gates on the item having an ON_USE
spell, its use requirements (RequiredLevel, AllowableClass/Race), then
the on-use spell's castability via the engine helper FUN_SPELL_IS_USABLE
(0x006E3D60), which sets noMana on power shortfall.
Shares the on-use spell-slot walk with C_Item.GetItemSpell: promoted
Item::Spell::FindOnUseSpellIDInRecord to the header so both use it
instead of re-deriving the trigger scan.
Bring C_LootHistory closer to the MoP contract and make a UI keying on it
robust across the ring saturating.
- GetItem now returns rollID, itemLink, numPlayers, isDone, winnerIdx,
timestamp (was itemLink-first, with the winner as a name string). rollID
is a stable monotonic ID assigned at creation, so addons can key expanded
/ highlighted rows on it instead of the volatile ring index. winnerIdx is
the winner 1-based player index for GetPlayerInfo, matching MoP; the winner
is inserted into the roster if their own roll packet never arrived, so it
always resolves. timestamp is a ClassicAPI extension (GetTime()-comparable
seconds).
- LOOT_HISTORY_FULL_UPDATE is now actually dispatched (was reserved-only):
on a new item appearing (roll open) and on ring eviction, fired before the
granular ROLL_CHANGED / ROLL_COMPLETE.
- Add C_LootHistory.Clear() (ClassicAPI extension) to wipe history and fire
FULL_UPDATE.
- Factor roll/winner roster insertion into a shared FindOrAddPlayer helper.
Docs updated for the new signature, Clear, and the FULL_UPDATE semantics.
Our DLL adds the modern HookScript widget method; older pfUI forks branch
on `if button.HookScript` and take a modern path they weren't written for
on pfActionBar buttons, breaking them. While the actionbar module builds,
wrap CreateFrame to shadow HookScript with a falsy instance field (nil
doesn't work — it's a metatable method, so the lookup falls through) so
those forks use their vanilla SetScript path. The maintained "brues" fork
is detected via the TOC X-Website tag and left alone; the wrapper is
removed once the actionbar module finishes loading.
Make profession link button position responsive to title text changes and justification. The button now repositions whenever the title's text or visibility changes, using the title's string width and justification mode to calculate the correct anchor point. Also add support for AdvancedTradeSkillWindow and AdvancedTradeSkillWindow2 addons with appropriate link callbacks.
Closes#7
The ClassTrainer detail template's decorative track pieces
($parentTop/$parentBottom) stayed visible when the slider was hidden,
leaving an empty scrollbar groove on recipes that fit. Toggle them with
the slider in UpdateDetailScrollbar (no-ops on Turtle's plain template).
Extend each reagent button's hit rect rightward across the name plate
(capped short of the next column so hovers don't bleed across), so the
item tooltip shows anywhere on the element instead of only over the icon.
Rework the trade-skill link viewer's detail pane to match the native
1.12 TradeSkillFrame:
- Detail is now a real ScrollFrame (ClassTrainer templates on stock,
plain on Turtle) whose scroll child holds the product header + reagent
grid, so the whole pane pixel-scrolls and clips partial reagent rows
instead of the old fixed 2-row faux-scroll that clipped many-reagent
recipes. Slider is managed explicitly (shown only when content
overflows; re-asserted a frame after show so the engine's layout pass
can't leave a dead scrollbar on Turtle's roomy pane).
- Reagent buttons built on demand (any count) inside the scroll child.
- Add the recipe/detail separator (stock only) using the top-quarter rod
of UI-ClassTrainer-HorizontalBar, stretched to width.
- Stock list/detail scrolls use the ClassTrainer scroll templates for the
decorative UI-ClassTrainer-ScrollBar track; Turtle keeps plain ones.
FUN_ADDON_GET_BY_INDEX returns a name pointer, not the AddOnEntry
record, so reading the OptionalDeps offsets (+0x3C/+0x40) directly off
it landed in addon-name string memory and dereferenced garbage as an
array base -> ACCESS_VIOLATION (pfUI's addon list iterates by index and
hit it immediately). Route both numeric and string args through the
by-name hash resolver FUN_ADDON_RESOLVE_REQ_DEPS to reach the real
record, exactly as stock Script_GetAddOnDependencies does for both.
Backport the optional-deps getter vanilla never exposed (it ships
GetAddOnDependencies for the required array only). Resolves the arg
the same way stock Script_GetAddOnDependencies does -- numeric via
FUN_ADDON_GET_BY_INDEX, string via the by-name hash lookup 0x0051E350
(returns record+0x48; subtract to recover the base) -- then walks the
OptionalDeps descriptor at record+0x38 (count +0x3C, data +0x40),
verified against the TOC parser FUN_0051C9B0. Arg/error behavior
matches stock: out-of-range index or a non-string/number arg errors;
an unknown name returns nothing.
Backport of the MoP C_LootHistory namespace. Vanilla 1.12 has no
loot-history store, but it receives the live group-loot roll traffic,
which the stock UI only turns into chat. This reconstructs the history
client-side (like Aura::Source): four co-hooks on the loot-roll packet
handlers accumulate a ring (128) of rolled items with per-player
Need/Greed/Pass results and the winner, exposed through the namespace.
Handlers, from the opcode dispatch FUN_005E6010:
- START_ROLL 0x2A1 FUN_0061B310 (__fastcall(packet), RET 0)
- ALL_PASSED 0x29E FUN_0061B640 (msg=EDX, RET 0)
- ROLL_WON 0x29F FUN_0061B9E0 (ctx=ECX, msg=EDX, +stack, RET 4)
- ROLL 0x2A2 FUN_0061C0B0 (msg=ECX, ctx=EDX, +stack, RET 4)
The per-handler ABI (message register, stack arg, RET) is load-bearing:
matching it exactly is what keeps the co-hooks from corrupting ESP.
Names and class come from the engine NameCache (FUN_0055F080, null-
callback read of a loaded entry), captured at roll time — so they
resolve even for a roller who has left the group by the time their roll
arrives. The item's random suffix is only in SMSG_LOOT_START_ROLL (the
result packets send 0), so it's read off the store-head node right after
that handler runs, giving a correct item:id:0:suffix:unique link.
Surface: C_LootHistory.GetNumItems / GetItem / GetPlayerInfo (name,
class token, rollType, roll, isWinner, isMe) + LOOT_HISTORY_ROLL_CHANGED
/ _ROLL_COMPLETE events (_FULL_UPDATE reserved). The MoP master-loot
functions (SetExpiration / GiveMasterLoot / CanMasterLoot) are omitted —
they need server support 1.12 lacks. Documented in docs/API.md + README.
Leaving a battleground (incl. the /afk teleport out while dead) bulk-
clears the player's aura descriptor WITHOUT firing per-aura
OnAuraRemoved, so Aura::Source entries for auras that are now gone
lingered and Aura::Data's descriptor-empty fallback kept surfacing them.
The tell from the report: a real buff (filling a descriptor slot)
temporarily masks the phantoms, removing it brings them back.
Can't evict on descriptor-empty — that's the exact state the fallback
must survive (rogue stealth, party-range drops). The clean distinguisher
is a map transition: a BG/instance/continent change moves the Map.dbc
ID, while stealth/range don't. OnWorldTick now polls VAR_CURRENT_MAP_ID
and flushes the whole cache on any change (no new hook). Every other
unit from the old map has despawned, and the player's real auras re-sync
from the descriptor / PLAYER_BUFF tables on entering the new world, so a
full flush loses nothing legitimate.
Reported via pfUI issue #20.
Vanilla has RegisterEvent / UnregisterEvent as frame methods but not the
query IsEventRegistered (added in a later expansion). There's no engine
Script_* to delegate to, so we replicate the membership check already
embedded in Frame::RegisterEvent (FUN_00702140): find the event by name
in the live event table (SStrCmpI), then walk that entry's subscriber
chain (head@+0xC, next@+4, frame@+8, low-bit/null sentinel) for this
frame. self is resolved via Game::Lua::ResolveObject, yielding the same
CFrameScriptObject* the chain stores.
Registered on the Frame method registry (VAR_FRAME_METHOD_REGISTRY,
alongside RegisterEvent) so it resolves on any frame. Pure read, no new
hooks. Returns a boolean, matching the 3.3.5-era contract; verified
in-game (true for a registered event, false otherwise). Documented in
docs/API.md and README.md.
The link viewer mirrored Turtle's HD 768x512 two-column TradeSkillFrame,
whose TW-TradeSkill art doesn't exist in the base 1.12.1 client — an
earlier stretch fallback smeared the vanilla art's internal borders.
Split every layout value that differs by client into an LO table gated on
TURTLE_WOW_VERSION. On stock clients the viewer now mirrors the native
Blizzard_TradeSkillUI geometry: 384x512, single column (list on top,
detail below), built from the vanilla ClassTrainer + UI-TradeSkill
quadrant art at native size, 8 visible rows, and the Exit button placed
exactly where the base TradeSkillCancelButton sits. Turtle keeps its
768-wide two-column layout unchanged.
Backport of the WotLK "link this profession to chat" button. A
petition-icon button sits right of the window title; clicking inserts
the window's |Htrade:...|h link (C_TradeSkillUI.GetTradeSkillListLink /
GetCraftListLink) into the chat edit box, opening it if hidden. Other
players click the link to open the recipe viewer.
Both windows are LoadOnDemand addons on this client, so creation is
deferred via EventUtil.ContinueOnAddOnLoaded (fires immediately if
already loaded). Uses the native GossipFrame\PetitionGossipIcon since
the 3.3.5 UI-TradeSkill-LinkButton art doesn't exist in 1.12. Adds the
LINK_TRADESKILL_TOOLTIP locale string for the hover text.
C_Spell.UnitCastingInfo/UnitChannelInfo returned negative startTimeMs
and endTimeMs for users whose engine ms tick has crossed 2^31. The tick
(FUN_OS_TICKCOUNT_MS) has two backends: GetTickCount, or a scaled rdtsc.
The rdtsc value survives warm reboots (only a full power-off resets the
CPU TSC), so it can sit well past 2^31 even right after a "restart" --
which is why rebooting didn't clear the negatives. We stored the tick in
a signed int and pushed it straight to a double, so the high bit read as
the sign.
Script_GetTime (0x00515EA0) computes (double)(tick & 0xFFFFFFFF) * 0.001,
i.e. it masks the tick to the low 32 bits UNSIGNED. New PushMs helper
reinterprets our stored tick as uint32 before the double conversion,
reproducing GetTime()'s exact value so startTimeMs/endTimeMs stay
consistent with GetTime()*1000 across the whole range (both wrap together
at the ~49.7-day 2^32 boundary). Carrying full 64-bit precision would be
wrong -- it would diverge from GetTime() past 2^32 and break addons'
(now-start)/(end-start) math.
Routes all four timestamp pushes (cast + channel, local and remote)
through PushMs. The aura expirationTime path already handled the tick as
unsigned, so no change there.
The old IsStealthed read descriptor +0x17C & 0x02, which is actually
UNIT_FIELD_AURALEVELS (per-slot caster-level bytes) - it only worked by
coincidence on the original test char and read false at level 60
(0x3C & 0x02 == 0), so Prowl/Stealth reported not-stealthed.
_classicapi_DiffAll on a Prowl toggle from a Cat-Form baseline showed
the real signal: UNIT_FIELD_BYTES_1 (+0x210) byte 3 flips 0x00->0x02
(the sneak/anim-tier flag the client render path reads to draw a
stealthed unit semi-transparent); everything else that changed was
aura-array slot metadata. IsStealthed now reads that broadcast bit
directly - verified in-game for Rogue Stealth, Druid Prowl, and
Shadowmeld, with no mount false-positive.
Also refactored the shared buff-slot scan into FindPlayerBuffWithEffect
(Dismount still uses it for the mount aura) and dropped the bogus
OFF_PLAYER_FIELD_VIS_BYTES / PLAYER_VIS_BIT_STEALTH constants.
A custom patch-*.mpq that upscales the WorldMap overlay art broke the tile
resolution: the resolver read the actual BLP dimensions but did its grid and
texcoord math against a hardcoded 256px cell, so HD tiles rendered sheared
and half-clipped.
- Grid resolution detects the full-cell file size S (256*scale) and uses it
as the partial-tile threshold instead of a literal 256, so HD edge tiles
aren't mistaken for full ones.
- Texcoords now use content/nextPOT(content) — the engine's own WorldMapFrame
fraction, which is scale-invariant (an HD file is POT*scale, same fraction)
and needs no file-size guessing. Fixes overlays whose stock file was a 128
cell (content <=128): the patch doubled them to 256, which the earlier
S>=256 floor read as un-upscaled and halved their texcoord (the clipped
dark patches).
Draw sizes/positions stay in logical map pixels; stock data is unchanged
(its files already are next-POT). File reads now only drive grid resolution
and the rare DBC-rounded sliver case.
Each recipe row appends " [N]" (min over reagents of floor(onHand/required),
via GetItemCount) like Blizzard's list. The frame registers
BAG_UPDATE_DELAYED and, while shown, re-runs Refresh (row counts) and
re-renders the selected recipe's detail pane (reagent have/need) so the
numbers track the reader's bags live.
- SetHitRectInsets(2,82,2,73) so the mouse/drag region matches the visible
parchment (decoded from the corner BLPs' alpha: opaque art spans frame
x[2..686], y[2..439]) instead of the full 768x512 texture rect.
- Bottom quadrants use the CraftFrame's TW-TradeSkill-BotLeft2/BotRight2
(the flat button-bar bottom) and add an Exit button positioned like
CraftCancelButton.
- Quad helper uses the bare SetPoint(point) one-arg form (enabled by the
Script_SetPoint co-hook in src/frame/Modern.cpp).
The detail pane and subclass dropdown now resolve crafted items/reagents
through Item:CreateFromItemID():ContinueOnItemLoad() — each uncached item
re-renders only its own pane/dropdown when its data arrives, guarded so a
load completing after a newer selection can't clobber it. Replaces the
global GET_ITEM_INFO_RECEIVED watcher that re-derived everything on a 0.3s
debounce whenever any item loaded anywhere.
The 'All' subclass view no longer reapplies the filter (and resets scroll)
as items trickle in; have/need counts still use GetItemCount and the
subclass name still uses the synchronous GetItemInfoInstant.
Clicking a recipe row now shows a Blizzard-styled detail pane: the crafted
item (or enchant) with its icon on the Class-Trainer detail-header bar, the
spell description for enchants, and the reagent grid (icon + name plate +
have/need counts) matching the CraftFrame layout. The first recipe is
auto-selected on open; selecting a row tints the row highlight to the
recipe's difficulty colour and whitens its text.
Rows are now real ClassTrainerSkillButtonTemplate buttons (the template the
CraftFrame/TradeSkillFrame use) so name rendering and spacing match exactly.
Reagent + product data (createdItem, numMade, reagents) is folded into the
existing C_TradeSkillUI.GetTradeSkillListRecipes decoder rather than adding a
new function.
The real GetTradeSkillReagentInfo family is indexed by the player's own open
window, so it can't describe a linked recipe from someone else's profession;
the reagents are read straight from Spell.dbc instead.
Stuck auras (pfUI issue #20 part 2): a no-duration debuff (Ghost/Spirit
in battlegrounds) stayed visible after the unit resurrected. It can only
come from the Aura::Source cache fallback — the descriptor path re-reads
the live aura array each call.
The aura is cached with expirationMs == 0 (infinite), so OnWorldTick
never expires it, and its real removal came via a bulk object drop
(death/leave-visibility, then resurrect) rather than a per-aura removal,
so OnAuraRemoved never fired to evict it. The infinite entry lingered and
the fallback resurrected it on the now-alive unit.
The fallback only exists for rogue stealth and party range fluctuation,
which both clear the unit's entire aura array at once. Gate both fallback
entry points on DescriptorHasVisibleAura: if the descriptor exposes any
live aura it's authoritative, so a cache-only aura is genuinely gone.
Stealth/range recovery is unaffected — those produce a wholesale-empty
aura array, so the fallback still fires there exactly as before.
Add visibility check to `FallbackEligible` to prevent hidden/internal auras (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE) from leaking through as phantom buffs. The descriptor path already drops these via `IsSlotPopulated`; the cache fallback must apply the same engine predicate to avoid surfacing invisible auras.
Synthesize the modern C_LossOfControl surface for the local player, which
vanilla lacks entirely (a Mists-era aggregation layer):
- SCHOOL_INTERRUPT: co-hook the SMSG_SPELL_COOLDOWN handler (FUN_006e9460,
same __stdcall(opCode*, CDataStore*) shape/registrar as Spell::Cast's
failure/channel hooks). Turtle's Player::ProhibitSpellSchool sends the
lockout as ONE cooldown packet listing every spell of the interrupted
school at one uniform duration -- a shape nothing else emits (verified
against the server source) -- so a player-GUID batch of >=2 same-school
spells with equal duration authoritatively yields the locked school
(1<<Spell.dbc School) + duration. The interrupter isn't in the packet, so
spellID=0 / displayText="Interrupted".
- CC types (STUN/FEAR/ROOT/CONFUSE/CHARM/POSSESS/SILENCE/PACIFY/
PACIFYSILENCE/DISARM): scan the player's debuffs, classify each by its
EffectApplyAuraName (values authoritative from the server's SPELL_AURA
table). Timing is best-effort from the Aura::Source cache (nil when the
applying cast wasn't observed -- the fields are nullable).
- LOSS_OF_CONTROL_ADDED / LOSS_OF_CONTROL_UPDATE: a WorldTick poll-and-diff
of the active set fires ADDED (with the 1-based eventIndex) per new effect
and one coalesced UPDATE on any change. Chosen over event-driven aura hooks
because effect EXPIRY has no engine packet (a tick is needed regardless),
and the WorldTick tail keeps Lua handlers out of the mid-packet contexts
the state updates in. Scan is bounded (16 debuff slots) and idle-cheap.
Placeholders with no vanilla equivalent: priority=0, displayType=2,
auraInstanceID=nil; iconTexture is the icon path (no FileDataIDs in vanilla).
Verified in-game: Polymorph -> CONFUSE, correct name/icon/duration, nil once
worn off; a counterspelled heal -> SCHOOL_INTERRUPT (lockoutSchool + 10s) +
SILENCE (4s), with two ADDED at cast and UPDATE as each expires.
Both resolve by name through the engine's own template lookup
(FUN_006ee6f0) -- the same path inherits= uses -- so they're
case-insensitive and cover Blizzard FrameXML plus addon templates.
GetTemplateInfo returns { type, width, height, keyValues, inherits } or
nil if the template doesn't exist. type is the element tag; width/height
are the RAW values from a <Size> element declared directly on the
template (0 if none / inherited) -- read straight from the attributes
rather than via the engine's Size parser (FUN_006f1eb0), which applies
UI-scale conversion we don't want here. inherits is the inherits=
attribute or nil. keyValues is always empty (vanilla's XML schema has no
<KeyValues> element) and sourceLocation is omitted (a 10.2 feature;
vanilla records no per-node file/line) -- both parity-safe.
DoesTemplateExist returns a bool.
Verified in-game: DoesTemplateExist true/false as expected; GetTemplate-
Info nil for a missing name; StatFrameTemplate -> 104x13; FriendsFrame-
TabTemplate -> inherits "CharacterFrameTabButtonTemplate".
Backport the modern C_XMLUtil.GetTemplates() -- enumerate every
registered virtual XML template as { name, type }.
The engine keeps virtual templates (the <Frame virtual="true"> etc. that
inherits= targets) in a Storm hash table. The XML parser (FUN_006ede10)
registers each via FUN_006ee500 and the inherits resolver reads the same
table via FUN_006ee6f0, so it's the authoritative template store. The
new module walks it: each node carries the template name (+0x14) and its
parsed definition node (+0x18), whose element tag string (+0x8) is the
frame type ("Frame" / "Button" / ...).
Covers both Blizzard FrameXML and addon templates; rebuilt on /reload.
Fonts are a separate registry and correctly excluded (matching retail).
Returns an empty table before the first template registers (mask ==
0xFFFFFFFF). Pure read, no hooks. Verified in-game: 212 templates,
entry 1 = { name="AtlasLootDefaultFrameButtonTemplate", type="Button" }.
UnitChannelInfo showed a stale "Ritual of Summoning" countdown after
helping a warlock summon: g_channel only cleared at its computed endMs
or when a timed cast superseded it, so a ritual that ends early (when the
summon fills) lingered, and a following instant cast -- which supersedes
neither cast timing nor the channel -- couldn't dislodge it.
OnWorldTick now polls the player's UNIT_FIELD_CHANNEL_SPELL (+0x228) and
clears g_channel when it drops to 0, gated by a confirm latch so the
field's ~1-tick start lag can't wipe a freshly stamped channel. Both
channel-stamp sites route through a new StampChannel helper that re-arms
the latch. Also fixes early-ended channels generally (interrupt / cancel
/ LoS break), which previously self-expired only at endMs.
Cleanup: factor the player-object/descriptor read into shared
Unit::Identity::PlayerObject() / PlayerDescriptor(), now used by
State.cpp, Cast.cpp's poll, and ComboDuration. PlayerObject() uses the
object-manager GUID resolve (FUN_OBJECT_RESOLVE_BY_GUID) -- the fastest
player-object path, non-throwing (null pre-world) -- replacing the
duplicated resolves (State.cpp's token resolver, ComboDuration's
hand-rolled GUID plumbing).
Every overlay of a zone regardless of exploration, replacing the
hand-measured tables map-reveal addons ship (pfUI pfMapOverlayData).
The engine's GetNumMapOverlays/GetMapOverlayInfo only expose
DISCOVERED overlays -- the exploration gate is a server-fed ID list at
0x00B6E630 -- so unexplored areas were previously undrawable without
per-server data. Reads WorldMapOverlay.dbc directly instead, scoped by
the zone's WorldMapArea row (never by folder contents).
No argument = the world map's currently viewed zone (same
continent/zone resolution GetMapInfo performs, globals 0x0084506C /
0x00845070 / 0x00B6E668); numeric argument = AreaTable areaID, the
same identity C_Map.GetBestMapForUnit returns.
Tile grids are resolved in the DLL rather than left to consumers,
because Octo's shipped data breaks the canonical ceil(w/256) layout
three ways (mirroring the InkLab map generator's empirically-derived
disambiguation):
- sliver columns the DBC rect rounds away (Icepoint's Kaneq'nuun has a
real 8px third column; DBC w=512 implies 2 -- naive layout shears
the overlay into misplaced strips, the one visibly broken overlay);
- foreign tiles appended to an overlay's number sequence (Kaneqnuun
ships 12 tiles, the true grid is 3x2 = 6);
- same-rect higher-resolution re-exports (Stonetalon's WindshearCrag:
4 real tiles for a 1-tile rect, kept and scaled).
The true grid comes from the actual BLP header dimensions (width @
+0x0C, height @ +0x10, BLP1 and BLP2 alike) read through the engine's
own VFS reader, since only a row's last tile may be narrower than 256.
A sweep of all 707 overlays found 108 art/DBC mismatches; Kaneq'nuun
is the only shear-class one, matching the single visible artifact.
Resolution is cached per overlay per session.
Each overlay returns the DBC fields plus a ready-to-draw `tiles`
array (file / width / height / texCoordX / texCoordY / offsetX /
offsetY) so consumers need no grid math at all. WorldMapArea.dbc and
WorldMapOverlay.dbc schemas verified against fresh extractions and
the engine's own Script_GetMapOverlayInfo (0x004A8A00).
ClassicAPI advertises WOW_PROJECT_ID = WOW_PROJECT_CLASSIC, which
steers modern addon ports onto their Classic Era code paths -- where
they also assume Era's C-level frame behaviors. Supply the missing
ones (surfaced by AI_VoiceOver's Era path, but generic to every port):
- region:SetPoint("RIGHT") -- the one-arg form. Vanilla's parser
accepts every other modern shape but raises the usage error when
arg 3 is fully omitted (LUA_TNONE fails its type validation); a
co-hook on Script_SetPoint (0x007A2540, the ONE implementation in
the Region base table) normalizes it to (point, 0, 0) -- vanilla's
parent-relative form, exactly the modern semantic.
- region:SetSize / region:GetSize -- registered on the REGION base
method registry (ctx 0x00CF54B4), so they resolve on frames,
buttons, textures, fontstrings -- including engine-created textures
(GetNormalTexture():SetSize(...)).
- frame:SetShown -- Show/Hide are per-branch script functions with
their own object typechecks, so it's registered separately on
Frame / Texture / FontString with matching delegates.
- frame:SetResizeBounds -- modern rename of SetMinResize/SetMaxResize.
- frame:HookScript -- absent in vanilla entirely. Chains the old
handler (invoked vanilla-style, reading the this/event/argN
globals), then invokes the hook handler with modern positional args
((frame, event, arg1..arg9) for OnEvent, (frame, arg1..arg9)
otherwise) via a C closure over (handler, old, isOnEvent).
All methods delegate to the engine's own Script_* implementations by
reshaping the Lua stack -- no re-derived setters, so UI-scale
conversion, object resolution, and type checking stay engine code.
Method-registry contexts beyond GameTooltip's (Region / Frame /
Texture / FontString) are now in Offsets.h per the
docs/BlizzardScriptAPI.md catalogue.
Verified in-game: GameTooltip:GetSize() returns real dimensions,
CreateFrame + SetSize(50,50) reads back 50, and AI_VoiceOver's Era
path runs through its entire frame construction (SetPoint one-arg,
SetSize, SetShown, SetResizeBounds, 14 HookScript sites) where it
previously errored on the first call.
Rupture at 4 combo points reported duration=6 (the SpellDuration.dbc
base) instead of the real 14s: finisher durations are computed
server-side at cast time and 1.12 never transmits them for debuffs the
player puts on other units. Verified with a whole-protocol sniff --
SMSG_UPDATE_AURA_DURATION (0x137) is self-scoped (slot+duration for the
player's own aura array), 0x2DD has no client handler and never
arrives, and nothing else carries the value. (pfUI issue #17.)
Mirror the server's computation instead (new Aura::ComboDuration):
- Snapshot combo points ([player+0xE68]+0x1029, the field behind
Script_GetComboPoints) the instant CMSG_CAST_SPELL leaves, via a
co-hook on the NetClient send funnel (FUN_NET_SEND 0x005379A0). The
server consumes CP before SMSG_SPELL_GO returns, so reading at
SpellGo time is too late; the send funnel also covers
nampower-queued casts, which bypass any Spell_C_CastSpell co-hook
via trampoline re-entry.
- Compute base + (max - base) * CP / 5 from the spell's own
SpellDuration.dbc row ((v)mangos CalculateSpellDuration), then apply
the player's duration SpellMods, matching server order. DBC-driven
and spell-agnostic: finishers are exactly the rows with base != max
(Rupture 6/16s, Kidney Shot 1/6s, r1 0/5s, Slice and Dice 6/21s;
Expose Armor 30/30 self-disables). Consumed by Aura::Source's
SpellGo hook; 0 falls through to the existing base path.
- Turtle's reworked Rip (8s + 2s per CP) has NO client data: all six
ranks' DurationIndex points at SpellDuration row 87, which Turtle
added server-side only -- the client table jumps 86 -> 105. Built-in
values cover it, gated on the dangling-row condition itself (the
only dangling cross-reference in the entire client Spell.dbc, per a
full DurationIndex/CastingTimeIndex/RangeIndex sweep) rather than on
realm detection: stock clients resolve Rip's index normally, and if
Turtle ships the missing row the DBC takes over automatically.
- C_UnitAuras.RegisterComboDuration(spellID, baseSec, maxSec) as an
escape hatch for other servers with the same class of client-data
gap; a registered override beats DBC and built-ins.
Verified in-game: Rupture at 4 CP now reads duration=14.
Turtle's core doesn't broadcast SMSG_SPELL_FAILURE or
SMSG_SPELL_FAILED_OTHER on interrupt (verified by logging both
handlers through live tests), so the SPELL_FAILED_OTHER co-hook never
fired and interrupted enemy casts (Kick / Earth Shock / Counterspell)
lingered as ghost bars. What actually stops the cast visual on Turtle
is SuperWoW calling CGUnit_C::ClearCastingSpell (0x0060D040) directly.
Co-hook that choke point instead: every unit-stopped-casting path
funnels through it (failure packets, SPELL_GO completion, movement,
death, animation events, local cancel), gated on the unit's live
current-cast field at +0xC8C, so eviction is independent of which
signal the server sends. Both failure-packet handlers stay co-hooked
as backstops for servers that do broadcast them. Verified in-game:
all three interrupts evict; non-interrupting damage doesn't
false-evict.
Also confirmed via a whole-protocol sniff on the NetClient message
dispatch (FUN_NET_MESSAGE_DISPATCH = 0x00537AA0, documented for
future use): SMSG_SPELL_DELAYED goes only to the affected caster --
observers receive no packet of any kind for pushback, so remote bars
can't stretch. Documented as an inherent 1.12 protocol limitation.
Fires (no payload) when any equipped item's durability changes -
combat damage, death, repairs - via a co-hook on the engine's
inventory-alerts recompute (FUN_004C7EE0, the routine behind vanilla's
UPDATE_INVENTORY_ALERTS). The engine calls it whenever an owned item's
fields change (the item post-update handler FUN_005D9A90 routes every
item values-update through it) plus equip/enter-world paths, so it is
the engine's own durability-may-have-changed signal. Post-original we
diff a per-slot {itemGuid, durability} snapshot and fire once per
recompute with a real change - a death dinging all armor produces one
event, not nineteen.
The snapshot seeds silently on the first recompute of the process and
is deliberately NOT re-seeded per enter-world: death/release/graveyard
teleports re-run the setup, and a per-setup reset swallowed the
death's own durability loss (verified in-game; repairs fired, deaths
didn't until this was fixed).
Dead end, kept documented at OFF_DESC_ITEM_DURABILITY: bank-1 (ITEM
field) descriptor observers register without error but are never
invoked - item values-updates demonstrably arrive and player-bank
observers fire in the same session; registration arithmetic fully
verified (bank base FUN_00465690(1)=0x18, bank walk item 0->1->2).
Prime suspect is the registrar's internal wrapper lookup
(FUN_00464890, the observer registry, distinct from the object
manager) silently no-op'ing for item objects.
Also in Player::Equipment: resolve the player via the non-throwing
GUID resolver (FUN_00468460, what the engine's own bag observer uses)
instead of FUN_RESOLVE_UNIT_TOKEN, which raises a Lua error when the
token doesn't resolve - unsafe inside SMSG_UPDATE_OBJECT processing
where the player can be mid-create.
The quest's objective count — the length GetQuestDetails(questID)
.requirements would have — read straight from the cache block with no
Lua tables or strings materialized. nil when the quest isn't cached
(same contract as GetQuestDetails).
Exists because materializing full detail tables in a login-path loop
is expensive at warm cache: GetQuestDetails copies description /
objectives / completion text into Lua strings (~5 KB per quest).
TurtleGuide's guide-load OIDX validation did exactly that for every
tagged step — fine on the first (cold-cache) session, but once the
quest cache was warm from questcache.wdb every subsequent login
exhausted vanilla's fixed Lua memory pool and crashed the client in
lmemPool.cpp:53. With this accessor the same validation costs a few
dozen byte reads regardless of cache temperature.
The earlier investigation tried to hook a firer of
UNIT_INVENTORY_CHANGED; none of the three known 0xBA fire sites
trigger on equip because equipment descriptor writes are silent — the
engine registers descriptor-field observers (the __AUCMirrorHandler__
system: registrar 0x00467E70, dispatcher 0x00465570 with per-node
mirror + memcmp change detection) for the bag/backpack/bank/keyring
GUID ranges but never for equipment 0x4A8..0x538.
So register our own: co-hook the engine's inventory observer setup
(FUN_004F8CC0, once per enter-world) and, post-original, register one
observer per equipment GUID field through the engine's registrar —
same bank/size/GUID shape as the engine's bag observers, engine-
managed lifetime, no polling, no hot-function hook. The dispatcher
invokes us only on a real change.
Callback ABI verified from the dispatcher call site at 0x004655DC and
the engine callback FUN_004F8DB0's RET 0x10:
__fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
oldValue*, userArg) -> ret 0x10
equipmentSlot is the 1-based Lua inventory slot (1 head .. 19 tabard);
hasCurrent (1/nil) is read from the player invMgr GUID array — the
same post-change truth the engine's bag observer reads.
Vanilla has two disjoint questgiver paths. Gossip NPCs send
SMSG_GOSSIP_MESSAGE into the gossip arrays this namespace reads; but
quest-only NPCs send SMSG_QUESTGIVER_QUEST_LIST (the QUEST_GREETING
event / QuestFrameGreetingPanel), whose data lands in entirely
separate storage C_GossipInfo never read — every getter returned
empty for those NPCs. GetText was also ungated: the gossip text
buffer persists after close, so it served the previous gossip NPC's
greeting.
- Gate all gossip reads on the live gossip NPC GUID (0x00BC3F58,
cleared by the close worker at 0x004E1FA0 before GOSSIP_CLOSED
fires). Stale text/options/quests are no longer visible, and
selectors can't send a CMSG from stale slots.
- Serve the quest-greeting session when one is live instead: the
questgiver GUID (0x00BE0810) must match the greeting-fill GUID
(0x00C4D740) so leftovers can't leak when a quest session starts
from a gossip-menu click. GetText returns the greeting text;
Get{Available,Active}Quests / GetNum* read the greeting arrays
(32 x 0x4C entries {questID, level, title}) with isComplete
recovered from the packet handler's scratch status array
(status 4 = turn-in-ready); Select{Available,Active}Quest route to
the engine's greeting selectors (0x005012A0 / 0x00501320).
- Neither session live: empty tables, 0 counts, empty text.
GetOptions/GetNumOptions stay gossip-only — greetings have no
options. Retail keeps the two APIs disjoint; merging is deliberate so
modern-addon ports work on greeting-path NPCs (common on 1.12
servers).
Introduce QUEST_REMOVED (fires with questID) and document it in API.md and README. Rename Quest::Accepted → Quest::LogEvents and update the quest-log rebuild hook to compute pre/post deltas for both QUEST_ACCEPTED (questLogIndex, questID) and QUEST_REMOVED (questID). Add suppression rules to avoid firing on login/resync bulk changes. Update Offsets.h commentary to reflect the new LogEvents module and its responsibilities.
Mind Control is a 3 s cast on a spell flagged CHANNELED with a 60 s
duration. HandleSpellStart branched on the channel attribute alone, so
at cast start it stamped g_channel with the 60 s duration and killed
the client hook's correct 3 s cast stamp — the bar showed a premature
60 s countdown (pfUI #9). Same story remotely: an observed MC stored a
60 s channel entry instead of the 3 s cast.
- Treat a channeled spell with castTime > 0 as its CAST phase in
HandleSpellStart (player and remote paths); instant channels keep
the immediate stamp.
- Co-hook the MSG_CHANNEL_START handler (0x006E7550, layout from
nampower's SpellChannelStartHandlerHook: spellId u32, duration u32).
The server sends it to the caster the moment the channel begins —
after MC's cast completes — with the server-authoritative duration.
Stamp g_channel there, before the original fires
SPELLCAST_CHANNEL_START, so pollers see fresh data. For instant
channels it re-stamps what the SpellStart path just wrote.
Verified: MC shows 3 s cast then 60 s channel; Mind Flay-style instant
channels unaffected.
tinycbor's grow-and-retry contract: on buffer overflow the encoder
switches to counting mode and encoding must CONTINUE for
cbor_encoder_get_extra_bytes_needed() to be accurate — a nested
container's tally only reaches the parent via close_container. The old
code returned false on the first CborErrorOutOfMemory, so a mid-
container overflow could report extra == 0 and be misread as a genuine
"value not representable" failure (returning nil), or undercount and
churn through multiple resize passes.
Add EncOk() (NoError or OutOfMemory = keep going) around every encode
call; the retry loop now distinguishes real failures (return nil) from
overflow (single complete-tally resize, retry).
Remote casts cached from SMSG_SPELL_START were expired only by their
computed end time — an interrupted remote cast lingered as a ghost bar
for every UnitCastingInfo consumer (pfUI #11). The interrupt signal
does exist at the packet layer: the server broadcasts
SMSG_SPELL_FAILED_OTHER to observers whenever a started cast aborts
(interrupt, death, movement, fizzle).
Co-hook its handler (0x006E8E40, same __stdcall shape as
FUN_SPELL_DELAYED; address + layout mirrored from nampower's
SpellFailedOtherHandlerHook — body is plain casterGuid(u64),
spellId(u32)) and:
- evict the matching g_remoteCasts entry (guid + spellID), so remote
cast/channel bars drop the same tick the cast dies;
- clear the player's own g_cast on a guid match — covers the
server-stamped chained recast that the WorldTick VAR==0 poll can't
clear (self-receipt is server-dependent; the endMs self-expiry
backstop remains if it never fires).
Update the stale 'no per-unit interrupt signal in 1.12' notes in
Cast.cpp and docs/API.md.
Re-add the C_VoiceChat / C_TTSSettings SAPI module removed in 6f543ec,
gated so it coexists with the standalone VanillaTTS DLL: when
VanillaTTS.dll is loaded in the process, the module stands down
entirely — no Lua functions, no ttsVoice/ttsSpeed/ttsVolume cvars, no
VOICE_CHAT_TTS_* event reservations, no SAPI init. Without the gate,
each DLL would claim its own event-table slot for the same event names
(listeners bind to one slot while the other DLL fires the other) and
the cvar registrar would replace the first DLL's clamp callbacks.
The check (GetModuleHandleA at RegisterLuaFunctions time) is
load-order-independent — VanillaFixes loads every DLL at process
attach, long before FUN_LOAD_SCRIPT_FUNCTIONS fires. The event
AutoReserves move from file scope into the gated registration as
function-local statics so a stand-down leaves no reservations behind.
Restore the ole32 link, the README VoiceChat/API rows + event table,
and the docs/API.md VoiceChat section (with a coexistence note).
Drop legacy third shopping tooltip handling from GameTooltip_ShowCompareItem. Unpack only two shoppingTooltips, remove unused item3/shoppingTooltip3 variables and commented code, and simplify anchor/owner logic for the two-compare layout. GAP/backdrop spacing calculation is preserved.
GameTooltip:GetItem() returned the previous item after SetUnitAura /
SetEquipmentSet (and any tooltip that shows no item). The engine's per-tooltip
clear FUN_00530050 zeroes the unit / gameobject / itemID / spell identity fields
but leaves the item GUID (+0x380/+0x384) intact -- the field CGItem tooltips
(SetBagItem/SetInventoryItem) set and that Item::TooltipItem::CurrentID resolves
when the itemID field is 0. So a stale GUID lingered across a switch to non-item
content and GetItem/HasItem/IsEquippedItem resolved the old item.
Co-hook FUN_00530050 and zero +0x380/+0x384 after the original. All 14
Set*/builder paths route through this clear (verified via xrefs; the aura
builder FUN_0052f880 and the item builder FUN_0052b650 both call it), and the
item builder calls it before writing the GUID -- so every fresh tooltip resets
the item GUID and only a real item build re-sets it. The hook runs no Lua, just
zeroes 8 bytes; the CurrentID unit/GO/spell gating is now belt-and-suspenders.
Verified in-game: bag/equipped items still resolve; SetUnitAura / SetEquipmentSet
no longer leak the previous item.
The initial fire used FUN_00702690, whose exec-context mutation (DAT_00ceeac0)
is only valid from the engine's top-level frame-script dispatch; firing it from
inside the item builder (mid-Script_Set*, and in the wild under a stack of addon
Set* wrappers) raised through the outer wrappers. Two fixes make the fire safe
for normal handlers:
- Call the inner invoker FUN_00704d50(handler, tooltip) directly. It binds the
global `this` = tooltip and runs the handler under its own protected pcall
(0 args), self-contained and context-independent.
- Snapshot lua_gettop before the fire and lua_settop back after, so the still-
running outer C code sees exactly the stack it left. Without this the invoker
left the stack shifted and the outer code / other DLLs' hooks indexed a stale
slot -> ACCESS_VIOLATION.
Verified in-game: SetScript / HookScript / GetScript all work, `this` is the
tooltip (a1 is nil -- vanilla convention, not modern `self`), and no-op /
print / AddLine handlers fire cleanly on bag / equipped / merchant / auction.
Docs: correct the handler example to use `this` (not a `self` arg), and add the
caveat that re-entrant tooltip rebuilds from the handler (ShowCompareItem, Set*
on another tooltip) can crash in a hook-heavy client and must be deferred
(C_Timer.After(0, ...)) -- the event fires mid-build.
Backports the modern OnTooltipSetItem tooltip script to 1.12 as a real
frame script, settable via the standard SetScript / GetScript / HookScript
and firing whenever a tooltip's item is set (with the tooltip as self). Lets
addons annotate item tooltips by hooking one script instead of wrapping every
Set* method -- the destructive wrap-and-rebuild pattern (AtlasLoot's
AddSourceLine) that clobbers the tooltip's item linkage.
Fully native, two Game::HookAutoRegister co-hooks in Tooltip::SetItemEvent:
- Resolver (FUN_005295d0, the CGGameTooltip vtable script-name -> slot
vmethod): for "OnTooltipSetItem" (a name the engine doesn't know) return a
pointer to a C-side per-tooltip 8-byte {handler, context} cell. The tooltip
object (alloc size 0x460) has no free slot, and the resolver's only contract
is "return where the handler lives", so external storage behaves exactly
like the engine's own slots -- proven by OnTooltipCleared (+0x44c).
- Item builder (FUN_0052B650, the shared funnel for SetBagItem /
SetInventoryItem / SetHyperlink / SetMerchantItem / SetAuctionItem / ...):
after the build, fire the handler via FUN_00702690 (the no-arg runner the
clear uses for OnTooltipCleared). Recursion-guarded, and suppressed via
Tooltip::SetItemEvent::Suppressor during Tooltip::Compare's internal
equipped-item build so a handler doesn't run mid line-shift.
Item only for now; OnTooltipSetSpell / OnTooltipSetUnit follow the same
pattern with their own builders.
Introduce Item::TooltipItem::CurrentID -- the single "what item is this
tooltip showing" resolver. It trusts the link-path itemID (+0x398), and
otherwise resolves the CGItem GUID (+0x380) but only when no unit /
gameobject / spell id is set, since the clear does not zero the item GUID
and it goes stale after the tooltip moves on.
- GameTooltip:GetItem / HasItem now route through CurrentID: fixes GetItem
returning nil for CGItem tooltips (bag / equipped / merchant / loot) and
HasItem false-positiving on a unit tooltip after an item hover.
- Add GameTooltip:IsEquippedItem() (3.3.5 backport, registry index 63),
resolving the displayed item via CurrentID + the equipment-slot walk.
- SetHyperlinkCompareItem: the comparisonTooltip (4th arg) fallback uses
CurrentID; stat deltas now show by default and hide only for an explicit
false / 0 (was: show only for a truthy shift).
- GetItem round-trips the random-suffix id in the link for auction/compare
items (no CGItem instance) via the compare descriptor.
- FrameXML GameTooltip_ShowCompareItem falls back to self.compareLink, so a
caller can hand it an explicit link when the tooltip's item state was
rebuilt out from under it (e.g. an AH addon appending source lines).
- Offsets: compare-descriptor + FontString offsets; drop the duplicate
tooltip item-id/guid offsets in favor of the canonical OFF_TOOLTIP_*.
Backports the 3.3.5+ shopping-tooltip method, fully native (no lua_pcall):
fills a tooltip with the item currently equipped in the given item's slot
— a grey "Currently Equipped" header, the equipped item's tooltip, then a
green/red per-stat delta breakdown gated on shiftButton.
- Extract the stat engine (base record + on-equip-spell auras + random
suffix) out of item/Stats.cpp into the shared Item::StatAccum, reused by
C_Item.GetItemStats/GetItemStatDelta and the compare deltas.
- Build via the engine's own item-tooltip builder; prepend the grey header
by shifting the line FontStrings, replicating the show/hide protocol,
keying on the visible flag (no stale text) and resetting cell color.
- Delta colors sourced from ColorData.h via a new constexpr ByTag helper.
- offset selects the slot for two-slot items (rings/trinkets/1H weapons);
shiftButton gates the deltas (true/1 vs false/0/nil); a comparisonTooltip
4th arg lets it run link-free, 1:1 with the retail call.
- FrameXML GameTooltip_ShowCompareItem consumer with a dynamic,
backdrop-derived anchor gap.
Vanilla GameTooltip:AddLine silently drops any line past the 30
TextLeftN/TextRightN FontStrings the FrameXML template declares, so
stat-heavy tooltips (pfUI eqcompare, AtlasLoot) lose their tail.
Tooltip::LinePool co-hooks the tooltip's XML-OnLoad region scan
(FUN_00529650, CGameTooltip vtable slot 9) via HookAutoRegister. After
the engine sets up the template's 30 lines it creates TextLeft/Right
31..60 in C++ (alloc from the CSimpleFontString pool -> ctor(parent) ->
name -> font copied from the previous line -> anchor) and appends each
pair to the tooltip's three line arrays, bumping numLinesAllocated. Runs
once per GameTooltip-family frame at creation and per /reload, so it
auto-covers GameTooltip, ShoppingTooltip1/2, ItemRefTooltip, AtlasLoot,
WorldMapTooltip, etc. No addon Lua and no per-line AddLine hook.
SetPoint stores offsets in internal coordinates, not pixels, so the
anchor offsets are scaled the way Script_SetPoint does
(pixel * [0x00832A44] / ([0x00832A4C] * 1024)) — without that a -2px gap
rendered ~3950px off.
Adds the tooltip line-pool, FontString-creation, and UI-scale offsets to
Offsets.h; documents the behavior in the README and marks TODO #97 done.
Cross-checked against Turtle's server source (patch_1172): ItemModType
is unchanged vanilla (0-7) and ApplyItemEquipSpell applies only
ON_EQUIP, confirming special stats are all equip-spell auras.
- Value now uses the engine's CalculateSimpleValue = EffectBasePoints +
EffectBaseDice (new OFF_SPELL_RECORD_EFFECT_BASE_DICE = 0x10C) instead
of a hardcoded +1; equal for the fixed-die stat auras but exact.
- Decode the additional stat auras Turtle puts on custom gear: dodge %
(49), parry % (47), block chance % (51), spell crit % (57/71), and
flat health (34) / mana (35) folded into the existing keys. Adds shield
block value from the record's m_block. New keys ITEM_MOD_DODGE_RATING,
ITEM_MOD_PARRY_RATING, ITEM_MOD_BLOCK_RATING, ITEM_MOD_CRIT_SPELL_RATING,
ITEM_MOD_BLOCK_VALUE (percent stats keep vanilla-native values).
Documents the expanded key set in docs/API.md.
Weapon DPS (avg damage across the slots / swing time) is emitted under
ITEM_MOD_DAMAGE_PER_SECOND_SHORT, matching 3.3.5's GetItemStats.
Vanilla stores the "special" gear stats (crit, attack power, spell
power/healing, hit, mp5, defense) as on-equip spells rather than stat
slots, so decode each item's SpellTrigger == ON_EQUIP spells - and the
random-suffix equip enchants - through the SPELL_AURA_* effects and fold
them into the modern GetItemStats keys. Percent stats (crit/hit/defense)
are reported as the raw vanilla percentage under the *_RATING key, since
vanilla has no (level-dependent) rating conversion; e.g. Krol Blade's
+1% crit is CRIT_MELEE_RATING = 1, not the 13 rating TBC/Era would show.
Generic attack power subsumes its paired ranged-AP aura; rarer auras
with no clean key (percent AP, per-weapon-line skill, procs) are skipped.
Documents the full key set and its sources in docs/API.md.
Read an item's stats out of the cached ItemStats_C record and return
them keyed by the FrameXML global-string names WoW's GetItemStats uses
(ITEM_MOD_*_SHORT, RESISTANCE0..6_NAME). GetItemStatDelta returns the
per-stat difference (item2 - item1).
Random suffixes are resolved from the link's third
item:id:enchant:SUFFIX:unique field through ItemRandomProperties.dbc ->
SpellItemEnchantment.dbc: type-3 (equip-spell) enchants defer to the
spell's SPELL_AURA_MOD_STAT / MOD_RESISTANCE effect (value =
EffectBasePoints + 1), type-4 add a direct resistance. Reported set
stays consistent with the base record - base attributes, armor, and the
six resistance schools - so a plain item and its suffixed variant report
the same categories.
Adds OFF_SPELL_RECORD_EFFECT_BASE_POINTS, VAR_ITEMRANDOMPROP_RECORDS/
COUNT, and OFF_ITEMRANDOMPROP_ENCHANT to Offsets.h.
Returns whether an item with the given "0x..." GUID is currently carried
by the player. Parses the GUID, then scans the 19 equipment slots plus
bags 0..4, comparing each item's instance GUID (+0x08 -> +0x00, the same
read GetItemGUID uses). Reuses the existing ResolveEquipmentSlot /
ResolveBag / GetBagSlotCount iteration.
Scope matches retail: equipment slots count (verified in-game —
IsItemGUIDInInventory(GetItemGUID({equipmentSlotIndex=1})) is true), bags
count, bank does not (GetItemCount(itemID, true) covers the bank). Returns
false for a malformed / zero GUID.
Factored the CGItem GUID read into a shared ReadItemGuid helper (also used
by GetItemGUID). Documented in docs/API.md and the README Item row.
Registers Enum.InventoryType (equip-type enum) as a hardcoded mirror of
Blizzard's 0..34 spec, via the same RegisterIntegerEnum path as
Enum.PowerType / Enum.AddOnSecurityStatus. Values 0..28 are the ones
vanilla items report; 29..34 (profession / equippable-spell types) are
post-vanilla, included so backport code referencing those keys resolves.
The field names (IndexHeadType, Index2HweaponType, …) aren't present in
engine data and don't derive cleanly from the INVTYPE key strings, so a
static mirror is the correct approach — it's a fixed API contract, not
engine-driven. For the live set of slot keys the engine actually has, use
C_Item.GetItemInventorySlotKey.
Documented in docs/API.md and the README enum row.
Three item-taxonomy lookups backported to the C_Item namespace, reusing
the ItemSubClass.dbc scan and INVTYPE key table already in the tree.
- GetItemSubClassInfo(classID, subClassID) -> subClassName,
subClassUsesInvType. Name is the localized ItemSubClass.dbc verbose
form (short-name fallback). subClassUsesInvType comes from the flags
field at record +0x10, bit 0x200 — set on exactly the armor material
types (Misc/Cloth/Leather/Mail/Plate), clear on weapons/shields/etc.
Bit derived by dumping ItemSubClass.dbc and verified against the 1.15
client (Cloth true, One-Handed Swords false, Shields false).
- GetItemInventorySlotKey(inventorySlot) -> the equipLoc key
("INVTYPE_HEAD"), straight from the engine's INVTYPE table.
- GetItemInventorySlotInfo(inventorySlot) -> the localized slot name
("Head"), resolved via the FrameXML global of that key so it's
localized on non-English clients.
All three return nil for out-of-range / missing input. Documented in
docs/API.md and the README Item row.
C_VoiceChat / C_TTSSettings (the SAPI text-to-speech surface) is moving to
its own injected DLL (C:\Git\VanillaTTS) so it can grow a cross-platform
software-synthesis backend (Flite under Wine) without bloating ClassicAPI.
- Delete src/voicechat/Tts.cpp.
- Drop the ole32 link from CMakeLists (it was only for SAPI/COM).
- Strip the VoiceChat section + TOC from docs/API.md and the VoiceChat
row + VOICE_CHAT_TTS_* events from the README.
CVar::Factory stays — it's general-purpose infra now, not TTS-specific
(reworded its header comment to drop the TTS example).
The repo was internally inconsistent: the LICENSE file is GPL v3 but every
src/ header carried the LGPL v3 notice and the README said "LGPL v3 or
later". LGPL was also the wrong fit — this is an injected mod plus a
bundled addon, not a library other code links against, so LGPL's linking
exception buys nothing.
It's also required: src/voicechat/Tts.cpp is ported from awesome_wotlk's
VoiceChat.cpp, which is GPL v3, and a derivative of GPL code cannot be
relicensed under more permissive terms. Dependencies stay compatible —
MinHook (BSD-2) and the nampower-mirrored packet shapes (BSD) are
GPL-compatible, and SAPI is covered by the system-library exception.
Swept "GNU Lesser General Public License" -> "GNU General Public License"
across all 257 src/ headers and changed the README license line to match
the GPL v3 LICENSE file. Comment-only; no build impact.
Backports modern WoW's TTS Lua surface, ported from awesome_wotlk's
3.3.5 VoiceChat.cpp. The SAPI core is client-version-agnostic; only the
three integration seams are ClassicAPI-native:
- namespaces via Game::Lua::RegisterTableFunction
- the five VOICE_CHAT_TTS_* events via Event::Custom (AutoReserve + Fire)
- ttsVoice / ttsSpeed / ttsVolume settings via CVar::Factory, persisted
to Config.wtf and clamped through change callbacks
C_VoiceChat: GetTtsVoices, GetRemoteTtsVoices (stub == local; vanilla has
no voice chat), SpeakText(voiceID, text[, dest, rate, volume]),
StopSpeakingText. C_TTSSettings: Get/SetSpeechRate, Get/SetSpeechVolume,
GetSpeechVoiceID, GetVoiceOptionName, SetVoiceOption[ByName],
SetDefaultSettings, RefreshVoices.
SAPI is created lazily on first use under an STA; SetNotifyCallbackFunction
marshals playback events back to the main thread's message pump (WoW pumps
every frame), so OnSpeechNotify fires PLAYBACK_STARTED/FINISHED on the main
thread — verified in-game. Voice enumeration uses CLSID_SpObjectTokenCategory
directly so the build needs no sphelper.h; GUIDs are instantiated via
<initguid.h>, so only ole32 is linked (no sapi.lib). Degrades gracefully
without SAPI (e.g. Wine): CoCreateInstance fails, SpeakText fires
PLAYBACK_FAILED and returns, voice list comes back empty, no crash.
Vanilla 1.12 exposes CVar reading and Lua-side RegisterCVar(name, default)
but no way to register a cvar with a change-callback or react to value
changes from C. CVar::Factory wraps the engine internals so modules can
publish persisted, validated settings.
- FUN_REGISTER_CVAR (0x0063DB90), __fastcall(name, edx, flags, default,
callback, categoryId, hiddenBool, userData). Dedups by name (idempotent
across /reload) and forces the archive flag bit on, so registered cvars
persist to Config.wtf.
- FUN_SET_CVAR_VALUE (0x0063DF50), __thiscall(cvar, value, a3..a6) — NOT
__fastcall (that routes value through EDX and leaves the callee popping
4 bytes too many off the stack, corrupting ESP). Fires the change
callback.
- Change callback ABI verified at both the set path (0x0063DF7D) and the
re-register path (0x0063DC51): __fastcall(cvar, prevValue, newValue,
userData) returning char — 0 rejects the change, nonzero accepts. Clamp
by setting the clamped value from inside the callback and returning 0.
Offsets + struct fields (value +0x20, flags +0x1C, callback +0xBC,
userData +0xC0) documented with their call-site derivations.
UnitClassBase("player") and UnitRaceBase("player") returned nil until
~PLAYER_ENTERING_WORLD because they always read the class/race byte from
the in-world unit descriptor ([unit+0x110]+0x79/+0x78), which doesn't
exist until the player object spawns.
Vanilla's Script_UnitClass (0x00518350) avoids this: for the literal
"player" token it reads a login-time global (FUN_005abde0 ->
DAT_00c27e81) instead of the descriptor. That global mirrors the local
player's UNIT_FIELD_BYTES_0 race/class/sex and is populated at
character-enter, so UnitClass("player") resolves at addon-load.
Mirror that fast path: the "player" token now reads the login-time byte
globals (race 0x00C27E80, class 0x00C27E81, sex 0x00C27E82); all other
tokens keep the descriptor path, so remote-unit behavior is unchanged.
The "player" test is factored into Unit::Identity::IsPlayerToken, shared
by both modules (and GuidForToken). It routes through the engine's own
SStrCmpI (FUN_SSTR_CMP_I) to match Script_UnitClass's comparator exactly,
rather than the CRT _stricmp.
Vanilla doesn't populate the local player object (and its +0xC0 GUID) until
SMSG_UPDATE_OBJECT in the main loop, after FrameScript_Initialize and the
addon load pass — so UnitGUID("player") / PlayerGuid() returned nil/0 at
addon file-load on first login.
Co-hook the glue enter-world worker (FUN_0046B500, behind Script_EnterWorld)
to snapshot the selected character's GUID at the moment of entry: read from
the selected-character struct (VAR_GLUE_SELECTED_CHAR; GUID at offset 0,
verified against Script_GetCharacterInfo), gated on the "entering world"
state code so the rename/locked bail paths don't capture. PlayerGuid()
returns the live engine value when present and falls back to the captured
GUID otherwise — the same value, just available from addon-load time.
Captured fresh per entry, so a character switch can't serve a stale GUID.
This makes the GUID *value* available early (keying SavedVariables,
comparisons, the aura/cast caster checks); the player *object* still doesn't
exist until +0xC0 populates.
Also simplify GetPlayerGuid() to a passthrough now that UnitGUID("player")
is reliably available early and cheap — the memo saved only a trivial
C-call + snprintf and the GUID is session-stable.
Add FUN_GLUE_ENTER_WORLD / VAR_GLUE_SELECTED_CHAR offsets.
The 1.12 engine drops a unit's UNIT_FIELD_AURA slots when it loses sync with
the unit (rogue stealth, party range fluctuation) even though the auras are
still active server-side, so C_UnitAuras.GetAuraDataByIndex /
GetBuffDataByIndex returned nil for auras that are still up.
Fall back to the Aura::Source cache (caster/timing captured from
SMSG_SPELL_GO at cast time, which survives descriptor clears) when the
descriptor has no slot:
- Aura::Source: entries gain a helpful/harmful kind, recorded from the
descriptor slot at application time (OnAuraAdded/OnAuraStacksChanged);
SpellGo-only entries stay unknown and are excluded. New Enumerate() returns
non-expired entries of a given kind for a unit.
- Aura::Data: a shared BuildTable feeds both the descriptor Push and a new
cache-fed path; PushNthCacheFallback / AppendCacheFallbacks append cache
entries after the descriptor ones, deduped against populated slots.
- Aura::Api: the index path and GetUnitAuras consult the fallback.
To keep the fallback from resurrecting auras that are genuinely gone
(replaced by a higher rank, dispelled, fell off early), co-hook OnAuraRemoved
(FUN_00612320) and evict the cache entry. A rogue stealthing does NOT route
through OnAuraRemoved (object-visibility drop, not per-aura removal), so
eviction and stealth-recovery coexist: real removals evict, visibility drops
don't.
Add FUN_ON_AURA_REMOVED offset.
Fixes#5
The 1.12 engine short-circuits a chained same-spell recast: Spell_C_CastSpell
bails at `spellID == VAR_CURRENT_CAST_SPELL` before setting the global or
firing SPELLCAST_START, so the client never runs its cast path for the 2nd of
a back-to-back same-spell cast (common with nampower spell queueing). And the
player channel field (+0x228) is server-pushed, landing ~1 tick after the
channel starts, so polling it shows the bar ~1s late.
Rework the player UnitCastingInfo / UnitChannelInfo tracking into a hybrid:
- Normal casts: co-hook FUN_CAST_START_SET, stamp g_cast from the spellID
arg before the original (zero latency); cleared by the VAR==0 WorldTick
poll.
- Chained same-spell recasts: stamp g_cast from the SMSG_SPELL_START co-hook
(server castTime), deduped against a fresh client stamp, exempt from the
VAR clear, self-expiring on endMs.
- Channels: stamp g_channel from the same packet (engine-computed duration)
instead of the lagging +0x228 poll; self-expire on endMs.
- Cast and channel are mutually exclusive for the player.
- Co-hook SMSG_SPELL_DELAYED for cast pushback (extends endMs + delayMs).
Add FUN_SPELL_DELAYED and FUN_CAST_START_SET offsets.
Vanilla stores no cast record for other units, so UnitCastingInfo(other)
returned nil and remote channels had no timing. Co-hook the SMSG_SPELL_START
handler (0x006E7640, gated on opcode 0x131) -- the only packet carrying
another unit's cast with a server-authoritative cast time -- and cache it
per caster GUID (cast vs. channel split by AttributesEx channel bits).
- UnitCastingInfo(unit): full timing for any unit within its cast window;
enemy/target/focus/nameplate cast bars now work, not just the player.
- UnitChannelInfo(unit): remote channels get real start/end times when the
channel's begin was observed, validated against the live +0x228 field so a
stale entry never sticks; falls back to the old no-times path otherwise.
- Player path unchanged (self-tracked); the cache skips caster==PlayerGuid.
Factor the CDataStore packet reader out of Aura::Source into a shared
Net::PacketReader header (vtable-correct) used by both packet hooks.
Best-effort: only casts begun while the unit was in range are seen, and an
interrupted remote cast lingers until its computed end (1.12 sends no
per-unit interrupt packet to hook).
SpellGo only sees directly-cast auras, so proc-applied debuffs (Shadow
Weaving etc.) had no expiration. Co-hook two more engine functions that
fire for every aura application:
- CGUnit_C::OnAuraAdded (0x006123F0) - any aura newly occupying a slot,
procs included; stamps now + base duration (no caster).
- CGUnit_C::OnAuraStacksChanged (0x00612450) - re-stamps on a stack-count
change so a climbing stacking debuff (Shadow Weaving 1->5) refreshes.
Both pass fromCast=false to Store, which skips entries SpellGo already owns,
so directly-cast auras keep their talented timing + caster regardless of
hook order. Consolidate the object-GUID read into Unit::Identity::
GuidForObject so the lookup and the new hooks key the cache identically.
Known limitation (documented): a refresh at MAX stacks (5->5) changes no
client-visible aura field, so the server sends no UpdateField and no
callback fires - there's no packet to hook. The countdown runs out and
expirationTime falls back to 0 (unknown) while the aura still shows.
duration came from a base Spell.dbc/SpellDuration.dbc lookup while
expirationTime came from the Aura::Source cache computed with the caster's
duration modifiers. For a talented DoT (e.g. 2/2 Improved Shadow Word: Pain,
18s -> 24s) the two disagreed, so remaining (expirationTime - GetTime())
exceeded duration -- impossible for a single aura.
Cache the applied durationMs alongside expirationMs in Aura::Source and have
Push prefer it for the duration field, keeping the two consistent
(remaining <= duration). Falls back to the base DBC value on a cache miss.
With the Aura::Source caster cache in place, the PLAYER filter token is now
derivable: restrict results to auras whose cached caster GUID matches the
local player (Unit::Identity::PlayerGuid). Wired into every getter that
takes a filter — GetAuraDataByIndex, GetUnitAuraBySpellID,
GetAuraDataBySpellName, and GetUnitAuras — via a playerOnly flag threaded
into the slot finders and the new Data::IsPlayerCast predicate.
Auras whose cast we didn't observe (predate login / no SMSG_SPELL_GO) count
as not-player-cast and are excluded, matching the best-effort caster model.
Also fix GetUnitAuras range selection so a bare "PLAYER" (no HELPFUL/HARMFUL)
returns both ranges restricted to player casts, instead of defaulting to
helpful-only.
RAID / CANCELABLE / nameplate filter tokens remain accepted-but-no-op.
Populate AuraData sourceUnit/sourceGUID and non-player expirationTime by
co-hooking SMSG_SPELL_GO (FUN_SPELL_GO) and caching (targetGuid, spellId)
-> caster + expiration. The vanilla unit aura array stores only spell IDs;
the SpellGo packet is the only place the client sees an aura's caster and a
server-authoritative duration. We parse the engine packet ourselves, so
this is self-sufficient (no nampower dependency) -- when nampower is loaded
we simply co-hook the same site.
sourceGUID is a ClassicAPI extension (raw "0x..." string), set whenever a
caster is known including when sourceUnit is nil; stable across the session,
unlike the volatile nameplate token, and usable as a unit token under
SuperWoW.
Also consolidate the local-player-GUID read into Unit::Identity::PlayerGuid()
(was duplicated inline in four files) and expose Unit::Identity GuidForToken
/ TokenFromGUID for reuse.
Lead with downloading the prebuilt ClassicAPI.dll from
/releases/latest (build-from-source is the alternative), and note the
bundled !!!ClassicAPI addon ships inside the DLL so there's no separate
addon install.
GameObject analog of C_CreatureInfo, reading the gameobject cache
(gameobjectcache.wdb, SMSG_GAMEOBJECT_QUERY_RESPONSE):
- GetGameObjectInfoByID -> { gameObjectID, name, type, displayID } | nil
(synchronous peek)
- RequestLoadGameObjectByID -> bool, fires GAMEOBJECT_DATA_LOAD_RESULT
when an uncached GO arrives.
The GO cache is a sibling class of the creature cache: same hash-map
shape but a different vtable and a larger entry (loaded flag at
entry+0x98 vs +0x50), so it has its own _GetRecord (FUN_00558560) and
response parser (FUN_005588E0). Generalized Cache::QueryLoad to take a
per-cache _GetRecord via Register and to hook BOTH parsers (creature +
gameobject), dispatching completion by cache instance. Renamed the
creature offsets FUN_CACHE_* -> FUN_CREATURE_* now that "generic" was a
misnomer.
GO field offsets: name@+0x08 verified (FUN_0062dc10 reads [block+0x08]),
type@+0x00 / displayID@+0x04 from the wire/WDB order. Verified in-game:
Mesa Elevator 47297 type 11 displayID 360, Windrunner 176250 type 15
displayID 7087, and RequestLoadGameObjectByID populated an uncached GO
("Lighting Impact Large", type 5). Creature path re-verified after the
refactor (GetCreatureInfoByID + the load event still work).
RequestLoadCreatureByID asynchronously fetches an uncached creature:
issues SMSG_CREATURE_QUERY and fires CREATURE_DATA_LOAD_RESULT(id,
success) when the response lands (or on timeout); fires success
synchronously on a cache hit. Mirrors C_Item.RequestLoadItemData.
New Cache::QueryLoad dispatcher generalizes Item::Data's
pending/timeout/event machinery so multiple query caches share one
response-handler hook. MinHook allows only one hook per target, and
the creature/gameobject (and other non-item/quest) caches all route
their SMSG_*_QUERY_RESPONSE through the generic parser FUN_00556E20 —
so we hook it once and dispatch by cache instance to the right pending
set + completion event. Item/quest use their own parsers, so no
collision. The gameobject cache will register on this same dispatcher.
Verified in-game: RequestLoadCreatureByID(10184) on an uncached Onyxia
returned true, fired CREATURE_DATA_LOAD_RESULT, and populated the cache
(type 2 Dragonkin, rank 3 world boss, displayID 8570).
GetCreatureID was catalogued under NameCache; with the new Creature
section it belongs there next to GetCreatureInfoByID. Moved the README
row entry and the API.md TOC + section body, fixed the now-stale
"above" cross-ref to C_PlayerInfo.GUIDIs*, and linked the two
C_CreatureInfo calls (GetCreatureInfoByID(GetCreatureID(guid))).
Reads the client-side creature cache (creaturecache.wdb, fed by
SMSG_CREATURE_QUERY_RESPONSE) for an NPC by ID and returns
{ creatureID, name, subName, type, family, rank, displayID } — or nil
if not cached. A synchronous peek (no network query): answers for any
cached creatureID, unlike UnitCreatureType/UnitClassification which
need a live unit token.
Cache machinery is the same generic query-cache class as Item/Quest:
instance 0x00C0E354, generic _GetRecord FUN_00556AA0 (callback=0 peeks).
Field offsets verified against the binary — rank read by the engine's
classification helper FUN_00605620 as [block+0x20], anchoring the
wire/WDB field order (Name[4], SubName, typeFlags, type, family, rank,
…) — and cross-checked in-game against real creaturecache.wdb rows
(Misthoof Stag 61332 type 1; Nordrassil Nymph 61582 type 7 rank 1;
Greathorn Hunter 61337 family 26). All match.
RequestLoadCreatureByID (async fetch for uncached creatures) + the
gameobject cache follow.
Companion to ExportInterfaceFiles: dumps every .dbc table the client
loads to DBFilesClient\ next to the exe. Reuses the same MPQ
enumeration + file-read pipeline (factored into a shared WriteFiles),
and unions two sources for a complete set:
1. MPQ (listfile) under DBFilesClient\ — catches archive files with
no loader (e.g. wowerror_strings.dbc).
2. A .text scan for the DBC path-getter pattern
(mov eax, &"DBFilesClient\X.dbc"; ret) — the authoritative list of
what the build loads. The master DBC init (FUN_0053f8b0) is
straight-line with no iterable name table, so the per-DBC getters
are the registry; scanning for them is the technique that built
docs/DBCs.md. Bounded reads, can't fault.
Listfile alone yielded 137; the union yields 155 (+18 cosmetic
anim/sound/lookup DBCs the listfile omits, 0 lost). Results deduped
case-insensitively. Verified in-game: old vs new extract diff is
exactly the 18 expected additions.
UnitCastingInfo / CastingInfo / UnitChannelInfo / ChannelInfo were
registered as globals (mirroring modern WoW), but that clobbers addons
that ship their own vanilla cast-tracking via the
`_G.UnitCastingInfo or <fallback>` idiom — notably ShaguTweaks'
libcast.lua, which scrapes the combat log for remote/enemy casts the
1.12 engine never exposes. With our global present those addons adopt
our player-only version and drop their (superior, for enemies)
fallback, killing enemy cast bars. We can't match the combat-log
scraper in the DLL (the engine carries no remote regular-cast data),
so cede the global names and register under C_Spell instead.
Tradeoff: addons calling the global UnitCastingInfo/UnitChannelInfo
must use the C_Spell.-prefixed names now; enemy-cast consumers got nil
from the global anyway.
Resolves an item-enchantment ID (the enchantID from
C_Item.GetWeaponEnchantInfo, and item permanent-enchant IDs) into its
SpellItemEnchantment.dbc record:
{ enchantID, name, effects = {{type,amount,arg},...}, spellID }
- name: localized display name ("Crusader", "Brilliant Mana Oil").
- effects: each active slot's ITEM_ENCHANTMENT_TYPE / amount / arg.
- spellID: convenience for spell-type enchants (1/3/7) — the proc/
equip/use spellID, e.g. Crusader -> 20007 "Holy Strength", to chain
into C_Spell.GetSpellDescription.
Record layout verified by parsing the on-disk DBC against known
records (Crusader 1900: type 1, arg 20007; Sharpened+3 id 13: type 2,
amount 3; Reinforced Armor+8 id 15: type 4, amount 8): Type[3]@+0x04,
Amount[3]@+0x10, EffectArg[3]@+0x28, Name[8]@+0x34. The engine's
by-ID readers only touch name/flags/condition, so the effect columns
couldn't be pinned from disassembly — the extracted dbc/ dump was the
oracle. Verified in-game across all three example enchants.
Placed in C_Item (not C_Spell): the id originates from
C_Item.GetWeaponEnchantInfo and the concept is an item enchantment.
Source-item is not derivable (no back-reference, no client reverse
index).
The console registrar (FUN_0063F9E0) dedups by name, and we pass the
same string literal so its pointer-equality fast path makes a repeat
call a hard no-op. Verified in-game: after a logout→login cycle
(which re-fires the glue hook), ExportInterfaceFiles stays listed once
in `help debug` and still runs. The static latch was unnecessary.
Console commands are process-global and persist for the session, so a
single registration at glue boot (the earliest hook, fires at launch)
covers both the login console and in-world — confirmed in-game. Drop
the redundant in-game ModuleAutoRegister; it only ever hit the latch.
The AddOns\ skip dropped Blizzard's own MPQ-baked UI addons
(Blizzard_AuctionUI, Blizzard_TalentUI, …) — part of the stock UI
source. Loose on-disk addons are never in an archive listfile, so
nothing surfaces them (verified: both branches of FUN_00401470 walk
MPQ listfiles / an in-memory registered-file list, not the loose disk
dir); the skip was both wrong and unnecessary. Matches 4.3.4
(extension filter only, no path exclusion).
Backport of the 4.3.4 developer-console command: dumps Blizzard's UI
files out of the mounted MPQs to disk (`ExportInterfaceFiles code`
writes .lua/.xml/.toc/.xsd to BlizzardInterfaceCode\; `art` writes
.blp/.tga to BlizzardInterfaceArt\), preserving the Interface\ subtree
and skipping the user's own AddOns\.
- New src/interface/Export.cpp: enumerates each archive's (listfile)
via the MPQ walker FUN_00401470, reads each entry through
FUN_FILE_READ, and writes it out with Win32.
- New generic Game::Console facility (RegisterCommand / Write / Category
enum) wrapping the engine's console-command registrar and output
paths, so future commands register in one line.
- Offsets.h: FUN_MPQ_ENUM_FILES, FUN_CONSOLE_COMMAND_REGISTER,
FUN_CONSOLE_WRITE; corrected FUN_FILE_READ to __stdcall and documented
its archive-handle arg0.
- Docs: README "Console commands" section + docs/API.md "Console" entry.
Holding both mouse buttons to run forward never fired
PLAYER_STARTED_MOVING. Ghidra trace of the input button handler
(FUN_00514840) shows that gesture drives the forward move directly at
the movement layer (FUN_005103e0) and explicitly clears the autorun
bit on the both-button press — so it sets none of the
INPUT_FLAGS_MOVING_ANY bits the mover-detection reads. (The Offsets.h
comment claiming the both-button combo sets autorun was wrong;
corrected.)
Detect it from the bit combo instead: both INPUT_FLAG_MOUSELOOK (RMB)
and INPUT_FLAG_FREE_LOOK (LMB) held means the engine is mouse-steering
the character forward. ORed into `moving`. Composes cleanly with the
TURNING/LOOKING latches (unchanged).
Stock 1.12.1 already defines DAYS_ABBR/HOURS_ABBR/MINUTES_ABBR/
SECONDS_ABBR (bare labels: "Day"/"Hr"/"Min"/"Sec") and the four
*_ONELETTER_ABBR. Our enUS.lua loads after FrameXML, so redefining
them with the modern "%d |4Day:Days;" format silently overwrote the
native values and broke any native/other-addon code reading the bare
labels.
Remove those redefinitions from enUS.lua (keep only the strings stock
genuinely lacks), and drop SecondsToTime / SecondsToTimeAbbrev /
FormatUnit from TimeUtil — they were the only consumers of the
modern-format _ABBR strings. SecondsToClock stays (uses the
stock-missing HOURS_MINUTES_SECONDS / MINUTES_SECONDS). Remaining
enUS entries sorted, all still have live consumers.
`GetPhysicalScreenSize()` → (widthPixels, heightPixels). Modern WoW
added this native in 7.0; FrameXML's PixelUtil builds its entire
pixel↔UI-unit conversion on it (768/physicalHeight). 1.12 lacks it and
has no pre-parsed pixel-dimension global — GetScreenWidth/Height are
UI-space (UIParent aspect at [UIParent+0x7c]), and the engine only
holds the current mode as the gxResolution CVar string, sscanf'd on
demand by SetScreenResolution / display-init. So the native reads the
gxResolution CVar directly (FUN_FIND_CVAR, value at +0x20) and parses
"WIDTHxHEIGHT" — the same source pfUI's GetPerfectPixel uses. Verified
in-game: 2560x1080.
7.0 was the only binary exposing the function, but retail .text is
encrypted on disk so static decompilation yields garbage; derived the
implementation from the 1.12 binary instead.
Also backports FrameXML/PixelUtil.lua (SetPoint/SetSize/SetWidth/
SetHeight/SetStatusBarValue/Get*). Its math deps (Round/Lerp/
ClampedPercentageBetween) already live in MathUtil; it consumes the
native GetPhysicalScreenSize (no Lua polyfill).
`C_Spell.CastAtUnit(spellID, unit)` and `C_Item.UseAtUnit(itemInfo,
unit)` are the unit-position analogs of the existing `…AtCursor` pair:
they commit the engine's in-flight ground-target placement at the
resolved unit's world position instead of the cursor raycast.
ClassicAPI's `/cast [@unit]` / `[@unit]` for AoE reticle spells and
on-use items. Verified in-game against "player", "target", and
"nameplate1".
Implementation reuses the cursor path's placement primitives:
- Extracted `Spell::AtCursor::DispatchSpellCast` + `CommitAtCoords`
(explicit-coords commit, sharing the same placement-ready gate as
the cursor `Resolve`).
- New header-only `Unit::Position` (vtable slot-5 `GetPosition` read);
migrated `UnitInRange` onto it to drop the duplicate copy.
The unit is resolved before the cast/use is dispatched, so an absent
unit fails cleanly without arming placement mode or consuming the
item. Unknown token strings raise the engine's standard "Unknown unit"
error, matching `UnitHealth("garbage")`.
Source the strings FormattingUtil/TimeUtil consume (LARGE_NUMBER_SEPERATOR,
PERCENTAGE_STRING, GOLD/SILVER/COPPER_AMOUNT_SYMBOL, the time
abbreviations + D_* + *_ONELETTER_ABBR, clock formats) from Classic Era
enUS GlobalStrings and define them in locales/enUS.lua, which loads
before the Util files. `or`-guarded so any value the 1.12 client already
defines is preserved (the time abbrevs use |4 plural tokens, which 1.12
supports — our existing ITEMS_* strings already rely on |4).
The Util files no longer define these (single source of truth in the
locale layer); they retain only the non-locale numeric constants
(COPPER_PER_*, SECONDS_PER_*) and the formatting logic. FormatUnit still
inserts the count whether the unit string carries a %d or is a bare label.
1.12's native unit-abbreviation globals (HOURS_ABBR, MINUTES_ABBR, …)
are bare labels ("Hr", "Min") with no %d placeholder, unlike modern's
"%d Hr". So string.format(HOURS_ABBR, n) returned just "Hr" — the count
was lost (SecondsToTime(3661) -> "Hr Min" instead of "1 Hr 1 Min").
Add a FormatUnit helper that string.formats when the unit string has a
%d and otherwise prepends "<count> ", so it's correct whether the global
is modern ("%d Hr") or a bare 1.12 label ("Hr"). Verified expectation:
SecondsToTime(3661) -> "1 Hr 1 Min".
Proactive Tier-1 backports of the most widely-assumed FrameXML utility
globals, ported to Lua 5.0 (no `#`/`%`/string-method syntax; `%` ->
math.mod). Feasible subsets only:
- FrameUtil: RegisterFrameForEvents / UnregisterFrameForEvents,
GetRootParent, SetParentMaintainRenderLayering, DoesAncestryInclude(Any).
Omits the fade/flash helpers (native in 1.12) and the scale/layout/
secure/RegisterUnitEvent helpers (APIs absent here).
- FormattingUtil: FormatLargeNumber, FormatPercentage, FormatFraction,
SplitTextIntoLines, SplitTextIntoHeaderAndNonHeader, GetMoneyString.
GetMoneyString uses text symbols (g/s/c) since coin texture markup
doesn't render in 1.12; currency/cost helpers omitted.
- TimeUtil: SecondsToTime, SecondsToClock, SecondsToTimeAbbrev,
ConvertSecondsToUnits, SecondsToMinutes/MinutesToSeconds + unit
constants. Omits the modern SecondsFormatterMixin.
FrameXML format-string globals (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING,
DAYS_ABBR, GOLD_AMOUNT_SYMBOL, …) are defined with `or` fallbacks so a
native/localized value wins when present and we degrade sanely otherwise.
Loaded after MathUtil so FormatPercentage's Round() is available.
Reverts the relocate-to-ColorUtil approach (b2db474): PLAYER_FACTION_COLORS
and BAG_ITEM_QUALITY_COLORS belong in Constants.lua, matching Blizzard's
own layout (its FrameXMLBase/Constants.lua references the named *_COLOR
objects directly).
Blizzard gets away with it via load order: the color objects are
materialized before its Constants loads. We now do the same — move
Mixin.lua + Color.lua above Constants.lua in the TOC, so Color.lua's
C_UIColor loop creates COMMON_GRAY_COLOR / PLAYER_FACTION_COLOR_* before
Constants.lua references them. Safe because Color.lua only depends on
Mixin.lua (+ the native ITEM_QUALITY_COLORS and the DLL's C_UIColor),
nothing else in the load chain; Constants.lua doesn't depend on the
utils that now follow it.
PLAYER_FACTION_COLORS was built in Constants.lua (loads early), but it
references the PLAYER_FACTION_COLOR_HORDE/ALLIANCE color objects that
Color.lua only materializes from C_UIColor at a later load step. So the
table captured nils -> ended up {}, and GetFactionColor always returned
nil. (Can't just reorder the TOC: Color.lua depends on ITEM_QUALITY_COLORS
from Constants.lua, so it's circular.)
Move the PLAYER_FACTION_COLORS construction to ColorUtil.lua, which loads
after Color.lua (and is where its only consumer, GetFactionColor, lives),
so the color objects exist by then. PLAYER_FACTION_GROUP stays in
Constants.lua (no such dependency).
GetFactionColor needs the FrameXML PLAYER_FACTION_GROUP global (faction
tag -> 0/1 index) to pair with our PLAYER_FACTION_COLORS, but we only
shipped the latter, so the lookup always returned nil. Add
PLAYER_FACTION_GROUP next to it, matching Blizzard SharedConstants
({[0]="Horde",[1]="Alliance",Horde=0,Alliance=1}); `or`-guarded so a
native definition wins if present.
Fills the gaps found diffing against Blizzard's reference (same class of
missing-global that broke tContains), porting to Lua 5.0:
Color.lua:
- WrapTextInColor(text, color)
- ColorMixin:IsRGBEqualTo (IsEqualTo now routes through it, per ref)
- ColorMixin:GetHSL
- ColorMixin:GenerateHexColorNoAlpha
ColorUtil.lua:
- CreateColorFromRGBHexString / CreateColorFromRGBAHexString
- IsRGBAEqualToColor
- RGBToColorCode / RGBTableToColorCode
- GetFactionColor (guarded: PLAYER_FACTION_GROUP/COLORS may be absent)
Color.lua and ColorUtil.lua now match the reference's function set. The
deliberately-skipped Secure* variants in Mixin/Pools (modern secure-
template machinery with no 1.12 equivalent) are left out.
tDeleteItem (added in 11191e8) used the 5.1 length operator `#tbl`, which
doesn't exist in 1.12's Lua 5.0. Swap both occurrences to tGetN(tbl)
(table.getn) to match the rest of the file.
Channel end time read SpellDuration.dbc base directly; route it through
the engine's own duration helper FUN_006ea000 (base + level scaling +
duration SpellMod op 1, unit arg 0 = local player) — mirroring how the
cast time is sourced, so the channel bar matches the engine and any
per-level / talent duration is honored. Stock vanilla channels are
fixed-duration, so no change there (verified: Blizzard still 8s).
Completes the SpellMod op map derived this session: duration 1, range 5,
radius 6, cast time 10, cost 14.
GetSpellPowerCost had a duplicate power-type -> token map. Promote the
existing UnitPowerType mapping out of Power.cpp's anonymous namespace
into a shared unit/Power.h (Unit::Power::PowerTypeToken) and have both
consumers use it. Out-of-range PowerType now reports "UNKNOWN" in
GetSpellPowerCost (was ""), matching UnitPowerType.
The mana check read the base ManaCost (+0x80) directly, so a spell with
a cost-reduction talent was wrongly reported as unusable (noMana) when
the player's power sat between the reduced and base cost. Route it
through the engine's effective-cost helper FUN_006e31b0 (op-14 SpellMod +
descriptor power-cost mods + ManaCostPercent), with a base-cost fallback
if the engine can't resolve a cost.
Example: Blizzard base 1400, 3/3 Frost Channeling -> effective 1190; with
~1200 power the spell is now correctly usable instead of noMana.
Returns a SpellPowerCostInfo[] (one entry in vanilla — spells have a
single power cost), or nil for an unknown / no-cost spell. The `cost` is
the player-effective value via the engine's own cost helper FUN_006e31b0
(base + level + ManaCostPercent-of-resource + descriptor power-cost mods
+ the cost SpellMod, op 0xE) — so it matches what casting actually
deducts, talents included.
Verified in-game: Blizzard (10187) on a Mage with 3/3 Frost Channeling
returns cost 1190 = 1400 base x 0.85 (15% reduction).
Keeps C_Spell.GetSpellInfo's `cost` field as the base ManaCost (intrinsic
spell data); GetSpellPowerCost is the player-modified counterpart — same
base/modified split as GetSpellRadius. Calling the full engine helper
beats replicating op 14 via Spell::Mod, since the helper also folds in
the descriptor power-cost modifiers.
Fields: type (Enum.PowerType), name (power token), cost, minCost (==cost,
no optional cost in vanilla), costPercent, costPerSec 0, requiredAuraID 0,
hasRequiredAura false.
Docs: API.md section + README Spell row.
Cast/channel info for the local player with full timing, plus channel
spellID/name/texture for any unit. Vanilla stores no cast times anywhere
readable (confirmed via the cross-binary technique: 3.3.5 reads them from
per-unit CGUnit fields that don't exist in 1.12), so the player's
cast/channel start/end are self-tracked on WorldTick:
- cast: VAR_CURRENT_CAST_SPELL 0->nonzero transition stamps startMs = now
(FUN_OS_TICKCOUNT_MS) and endMs = now + effective cast time. The cast
time comes from the engine's own helper FUN_006e3340 (base + level
scaling + cast-time SpellMod op 10 + the caster's haste/cast-speed
multiplier), the same one the engine cast-start uses -- so end time
matches the cast bar, e.g. a talented Frostbolt reads 2.5s not 3.0s.
Instant casts (cast time 0) and channels are filtered out.
- channel: player's broadcast UNIT_FIELD_CHANNEL_SPELL (+0x228),
endMs = now + SpellDuration base ms.
FUN_OS_TICKCOUNT_MS shares GetTime()'s epoch (x0.001), so startMs/endMs
feed addon progress math directly. Other units: channel info from the
broadcast +0x228 field (no times, since per-unit start isn't tracked);
regular casts return nil (no vanilla data -- remote casts are transient
spell-visual objects, not a queryable record).
isTradeskill is real: the spell's SPELL_ATTR_TRADESPELL flag (Attributes
bit 0x20). Remaining placeholders: castID/castBarID nil, notInterruptible
false, delayTimeMs 0.
Verified in-game: Blizzard channel 8s, Conjure Food cast 3s, talented
Frostbolt 2.5s, correct icons/spellIDs, GetTime()*1000 between start/end;
isTradeskill true for Rough Blasting Powder (Engineering).
Docs: API.md sections + README Spell row; TODO #68 marked done.
GetSpellRadius now returns (baseRadius, modifiedRadius). modifiedRadius
applies the local player's talent/item radius modifiers; equal to base
when no modifier matches.
The modifier logic is extracted into a reusable Spell::Mod helper
(src/spell/Mod.{h,cpp}) since the same engine SpellMod mechanism drives
cast time, mana cost, duration, range, cooldown, etc. — only the op
differs. Spell::Mod::Apply(spellRecord, op, base) replicates
FUN_006e6b30/FUN_006e6bf0: gate on the spell's SpellFamilyName matching
the player's class family (ChrClasses.dbc[class]+0x3c), sum the player's
flat/percent tables (0x00CEAD60 / 0x00CECB30, stride 0x74) over the
spell's 64-bit SpellFamilyFlags, then (base + flat) * (100 + pct) * 0.01.
Inherently local-player-only — the client tracks spell mods for the
player alone.
Offsets: VAR_SPELLMOD_FLAT/PCT_TABLE, SPELLMOD_SLOT_STRIDE/COUNT,
SPELLMOD_OP_RADIUS, OFF_SPELL_RECORD_FAMILY_NAME/FLAGS/ATTRIBUTES_EX2,
SPELL_ATTR_EX2_NO_SPELL_MODS, OFF_CHRCLASSES_SPELL_FAMILY.
Verified in-game: Frost Nova (10230) -> (10, 10) without Arctic Reach,
(10, 12) with rank 2 (+20%). op 6 = radius confirmed via FUN_006e6350;
percent constant 0.01 confirmed from .rdata.
C_Spell.GetSpellRadius(spellID) plus the vanilla-positional
GetSpellRadius(slot, bookType). Returns the spell's AOE radius in yards,
or nil for a non-AOE spell.
Radius is per-effect: Spell.dbc EffectRadiusIndex[3] at record +0x160
(verified via FUN_006e6350, which reads spellRec[+0x160]/[+0x164] for
effects 0/1), each resolving into SpellRadius.dbc (records 0x00C0D7B0,
count 0x00C0D7B4; base radius f32 at record +0x04). Returns the largest
base radius across the three effects. Base radius only — no caster-level
scaling, matching modern GetSpellRadius.
Dual signature mirrors SpellHasRange/GetSpellInfo: namespaced form takes
a spell identifier, the bare global takes a (slot, bookType) spellbook
position (bookType "pet" -> pet book, else player book).
Docs: API.md section + README Spell row; TODO #38 marked done.
Add the API.md section (return contract, always-English rationale,
common mechanic IDs + the Sap/Gouge=14 vs nonexistent 30 caveat) and
the README Spell-row entry.
Reads the spell-level Mechanic field straight out of Spell.dbc
(record +0x14) and resolves the name from SpellMechanic.dbc. Covers
every spell the client knows (not just the player's spellbook), with no
caching or network round-trip since the DBCs are resident from boot.
Replaces hand-maintained spellID -> mechanic tables addons keep because
vanilla exposes no Lua reader for the field (e.g. SuperCleveRoidMacros'
CCSpellMechanics, used for [cc:stun] conditionals).
Returns: nil for an invalid spellID; (0, nil) for a known spell with no
mechanic; (mechanicID, name) otherwise. The name is always the enUS
column (locale-independent) since it's a stable token consumers
string-match against; nil if the mechanic has no record / no English
name (numeric ID still returned).
Mechanic offset (+0x14) verified two ways: schema anchor off the
verified Attributes@+0x18, and FUN_006e9ca0 (mechanic-immunity check)
reading spellRec[+0x14] as the spell's mechanic. SpellMechanic.dbc name
at record +0x04 confirmed in-game: 118 -> (17,"polymorphed"),
339 -> (7,"rooted"), 133 -> (0,nil). The .dbc has 27 rows; only the
enUS name column is populated.
commit 52064f025188267ab44dae41af93261ae1bcfc03
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date: Thu Jun 18 23:33:09 2026 -0500
Item cache: fast-fail server rejections; report pending overflow
Two follow-ups to the WarmCache startup-latch fix.
Failure fast-path: the escalation path used a no-op callback, so a
server "no such item" reply (high-bit itemID — the FUN_0055BDB0 failure
branch does NOT set the entry loaded flag) went undetected and the item
waited out the full kMaxWaitTicks (~20s) before reporting failure.
Replace it with ItemLoadCallback_Escalated, which marks the pending
entry failed when the engine reports !success. A shared TryCompletePending
helper resolves each entry either way (loaded -> success, failed ->
failure) and is now used by both the response-handler sweep and the
tick, so rejections fire in the same response. Genuinely lost queries
(server silent) still fall back to the timeout.
Overflow: Track now returns false when the pending set is full instead
of silently dropping, and the explicit RequestLoadItemData/ByID Lua
functions propagate that as their boolean return. Bump kMaxPending
256 -> 512 since the unsettled window now also holds implicit warmups.
commit 797a73de44e6a4ceb0ac434944a137dcd47c18f3
Author: Brues <5278969+brues-code@users.noreply.github.com>
Date: Thu Jun 18 23:24:04 2026 -0500
Item cache: gate WarmCache behind a startup-settled latch
The previous fix (ad13440) deferred only the explicit
RequestLoadItemData path out of the engine startup window. The implicit
WarmCache path — the GetItemInfo hook plus every GetItem* getter cache-
miss warmup — still eagerly called CacheFetch(callback) with no gate, so
it orphaned cache entries during the exact PLAYER_ENTERING_WORLD /
addon-load window the original bug was about. pfQuest/Questie scan item
data there, so those items stayed permanently nil for the session.
WarmCache now gates on a one-way g_settled latch:
- Pre-settle (startup): defer through the same pending set as
RequestLoadItemData, tagged implicit so the response-handler sweep
fires GET_ITEM_INFO_RECEIVED (not ITEM_DATA_LOAD_RESULT).
- Post-settle: eager CacheFetch as before, for low-latency mid-session
lookups.
The latch flips true the moment the engine response handler fires (proof
dispatch is live) or after kSettleTicks world ticks, whichever first.
Per-process, not re-armed on relog (cold-WDB race is initial-login only).
Unify both paths through Track(itemID, implicit); sweep and timeout fire
the correct event by tag. Escalation now gates on g_settled instead of a
fixed per-item tick count, draining the startup backlog as soon as it is
safe. Bump kMaxPending 64 -> 256 to absorb the larger startup burst.
The original implementation only consulted the SMSG_NAME_QUERY_RESPONSE-
fed player NameCache, which only populates for GUIDs the engine has
explicitly queried — so currently-synced players (just-targeted, in
view, etc.) typically miss until the engine sends a name query.
Switch to vanilla's own `Script_UnitName` path: resolve the GUID via
`FUN_CLNT_OBJ_MGR_OBJECT_PTR` with TYPEMASK_OBJECT, then call the
engine's polymorphic name getter `FUN_OBJECT_GET_NAME`. That routes
through the player NameCache AND the creature cache, so both player
and NPC GUIDs return real names for any currently-synced unit. Gate
on the "UNKNOWNOBJECT" / "Unknown Being" sentinels so non-unit
objects fall through. Persistent NameCache fallback kept for ex-units
that have left the engine's sync window.
Modern WoW returns the subtitle as UnitName's second return; vanilla
1.12's UnitName returns only one value, leaving no direct route to
the subtitle. UnitSubName reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip scrape required.
Returns nil for players, unsynced NPCs, and NPCs with no subtitle.
Modern backport that reuses GetPlayerInfoByGUID's lookup chain
(engine NameCache → persistent on-disk fallback) but pushes only
(name, realm) — narrower return shape for callers that only need
the name. realm is always "" in vanilla (single-realm, engine
doesn't populate per-player realm).
CMakeLists rejects CLASSICAPI_TAG values that aren't empty or
vMAJOR.MINOR.PATCH. Branch pushes were passing `github.ref_name`
(= the branch name, e.g. "master") and failing the regex.
Was tag-only. Now builds on every push to any branch, uploading the
DLL as a workflow artifact (ClassicAPI-<sha>, ~90-day retention) so
pre-release DLLs are downloadable from the run page without cutting
a tag. Tag pushes still build + stamp the embedded TOC version +
publish the GitHub release.
Renamed release.yml -> build.yml to match the broader role.
The engine stores expiration timestamps as GetTickCount-derived uint32
milliseconds. We were reading as signed int, then bailing on `<= 0` to
catch the "no timing" sentinel of 0. Once system uptime crossed 2^31 ms
(~24.86 days) the high bit flipped, signed reads went negative, and
every active buff started reporting expirationTime = 0 while
GetTime() kept working through a different code path.
Switch to uint32_t with `== 0` sentinel check. The unsigned read flows
correctly through GetTickCount's 2^32-ms (~49.7-day) wrap because
addon math (`expirationTime - GetTime()`) operates on values that wrap
together — same as the engine's own `expirationTable[slot] - currentMs`
subtraction.
Vanilla's CMSG_SPLIT_ITEM rejects splits where count == srcStack
(EQUIP_ERR_COULDNT_SPLIT_ITEMS — would leave source empty). The
drag-and-drop UI's "drop whole stack on matching stack" goes through
CMSG_SWAP_ITEM, which the server merges for same-item destinations.
In MoveCount, read ITEM_FIELD_STACK_COUNT off the resolved source
CGItem and route count == srcStack to Item::Swap::Containers. Also
return false locally for count > srcStack (server would reject anyway,
fail fast). count < srcStack keeps the existing SPLIT path.
3.3.5+ fires UNIT_FACTION("player") on rep-state changes that vanilla
silently drops. Hook each path's inner setter / SMSG handler and fire
the event post-original via the engine's printf-style dispatcher:
- FactionToggleAtWar — fire if rep slot's flag byte changed
- SetFactionInactive / SetFactionActive — same shape
- SMSG_SET_FACTION_STANDINGS — fire if any of 64 flag bytes changed
(catches AT_WAR side-effect flips on standing threshold crossings)
- SMSG_SET_FACTION_ATWAR — unconditional after byte write
- SMSG_INITIALIZE_FACTIONS — fire after initial sync at login
Hoist the event-table strcmp walk into Event::Custom::LookupByName
(sibling to Lookup, which only consults our reservation cache). Replaces
duplicated walks in event/Util.cpp::IsKnownEventName and the polyfill.
Re-registers the standard five entries (create / resume / yield /
status / wrap) on the global `coroutine` table. The C-level
machinery (lua_resume, lua_yield, lua_newthread, threads as tag 8)
was always linked into the engine; only the script-side library
was stripped from the registration tables at build time.
Two WoW-specific quirks discovered along the way and worked
around:
- `lua_resume` returns 1 for BOTH yield and caught runtime error.
Disambiguated by checking `co->ci > co->base_ci` — a yielded
coroutine still has its body frame on the call stack; an
errored one has been unwound to base_ci.
- The byte at L+0x31 is `allowhook` (saved/restored around
protected calls), not `L->status` as it initially looked.
Status reading is replaced by the ci comparison above.
Single-call kitchen-sink reader for the cached `ItemStats_C` record.
Returns a table with ~50 fields covering everything the cache holds:
identity (name, description, icon, class/subclass/equipLoc strings),
quality + bind type + decoded flag bits (isConjured, isOpenable,
isLootable, isWrapper), stacking, economy, requirements, equipment
stats (sparse map keyed by statType), resistances, weapon damage
arrays, spell slots, and the misc fields (lockID, itemSet, pageText,
startQuest, area/map, etc.).
Returns nil for uncached / invalid input — passive reader, no cache
warming. Caller wanting to ensure load should `RequestLoadItemData`
and listen for `ITEM_DATA_LOAD_RESULT`.
Implements via existing offsets where available; adds the remaining
`ItemStats_C` fields to `Offsets.h` based on VanillaHelpers's full
struct layout, anchored against the offsets we'd already verified
(`m_stackable@+0x60`, `m_lockID@+0x1AC`, `m_bagFamily@+0x1D0`, etc.).
Bursts of `WarmCache` calls for the same uncached itemID (e.g. Bagnon
re-enumerating a slot while the SMSG_ITEM_QUERY_SINGLE_RESPONSE is in
flight) were each appending a fresh `Implicit` callback to the
entry's pending list. When the response landed the engine walked the
list and fired our callback N times, surfacing as duplicate
`GET_ITEM_INFO_RECEIVED` events for the same itemID.
`_GetRecord`'s 6th arg (`param_5` in the decompilation, previously
documented as the unused `requestIfMissing` bool) controls whether
the engine dedups appends. With `0` it always appends; with non-zero
it walks the existing list and skips the append on a matching
`(callback, userData)` node. We always have the same callback ptr and
encode the itemID in userData, so flipping the bit collapses repeat
warmups for the same item to a single registration.
`Item:CreateFromItemID(X):ContinueOnItemLoad(cb)` was leaving items
permanently uncached when pfQuest called it at addon-load /
PLAYER_ENTERING_WORLD against an empty WDB. Three intertwined bugs:
1. `GetItemInfoInstant` returned no values for uncached items, so
`ItemMixin:GetItemID()` returned nil and `AddCallback(nil, cb)`
crashed with "table index is nil".
2. `DoesItemExistByID` returned false for uncached items, which made
`IsItemEmpty()` return true and erroneously gated `ContinueOnItemLoad`
on cache state — modern semantic is "is this a valid ID", independent
of cache.
3. Our `RequestLoadItemData` path eagerly called `CacheFetch` with our
own callback at addon-load. That created a cache entry before the
engine's natural inventory prefetch had a chance to run; the engine
then saw the entry already existed and skipped its own SMSG, leaving
the entry permanently pending. `GetItemInfo` retries didn't help —
subsequent `_GetRecord` calls just appended callbacks to the dead
list, no new query was sent.
Fixes:
- `GetItemInfoInstant` always pushes the itemID (echoed from input) and
the remaining 6 fields when cached / nils when not. No cache touch.
- `DoesItemExistByID` returns `itemID > 0`. No cache touch.
- `RequestLoadItemData` tracks itemIDs in a pending set without touching
the cache. We hook `FUN_ITEMSTATS_CACHE_RESPONSE` (the engine's
`SMSG_ITEM_QUERY_SINGLE_RESPONSE` handler at `0x0055BDB0`) and sweep
pending after the engine fills entries — observing the natural
prefetch instead of racing it. `WorldTick` only handles escalation
(after ~60 ticks, kick a query for items the engine doesn't prefetch
itself, e.g. quest rewards) and the hard timeout.
Also fix the truthiness bug in modern (id, success) events:
`Event::Custom::Fire("%d%d", id, success)` was pushing `success` as a
Lua number — both 0 and 1 are truthy, so `if success then` fired the
success branch on failed loads. New `Event::Custom::FireIdSuccess`
pushes `1` for success and `nil` for failure (via the engine's
`lua_pushstring(NULL) → lua_pushnil` tail-jump). `ITEM_DATA_LOAD_RESULT`,
`GET_ITEM_INFO_RECEIVED`, and `QUEST_DATA_LOAD_RESULT` now all use this.
Hoists the macro-parser hook + long-line guard + per-line walk out
of Spell::CastNoToggle into a new Spell::MacroPrimarySpell module
that dispatches to (prefix, extractor) pairs registered by feature
modules. The body walk now visits each line once and checks every
registered prefix at each position; extractors only parse args
after a matched prefix.
Registers a pattern for C_Spell.CastAtCursor(<spellID>) so macros
like `/run C_Spell.CastAtCursor(10)` get tagged with the spellID
the same way `/cast Blizzard` does, driving action-bar cooldown
overlays and auto-repeat highlight in pfUI etc.
Vanilla 1.12.1 requires a manual click on the AoE reticle to land a
ground-target spell. This pair of functions fires the cast (or
on-use item spell) and immediately commits placement at the
cursor's current world position, matching modern `[@cursor]`
semantics:
C_Spell.CastAtCursor(spellID)
C_Item.UseAtCursor(itemInfo)
Chains the engine's own primitives — name-resolver to spellbook
slot, cast dispatcher, then a screen→world raycast via the existing
mouseover machinery and the placement-commit function. Cancels
cleanly when the spell isn't ground-target or the cursor is over
UI / off-screen.
Also extracts the bag-search pattern (used by `UseAtCursor` and
existing by-name action APIs) into `Item::Location::FindByArgInBags`,
alongside the existing `FindByItemID` / `FindByGUID` family.
Vanilla 1.12.1 hashes FrameXML.toc + Bindings.xml + each addon's
contents and compares against signature blobs (Interface\FrameXML.sig
+ per-addon stored hash). ClassicAPI's file-read hook diverges those
in memory — Bindings.xml splice and the embedded !!!ClassicAPI addon
— so the engine fires FUN_00401560(10) and terminates with "Your
game interface files are corrupt".
Hook FUN_00401560 and drop calls with code 10. Other codes (login
failures, etc.) pass through unmodified. Stock vanilla binaries
(WoW.exe without a runtime patcher) now load ClassicAPI without
needing TLS-bootloader-style modifications.
Splice into the engine's FrameXML Bindings.xml at load time via a
file-read hook so the new bindings receive display indices from the
same running counter as native bindings — landing visually inside the
TARGETING block instead of orphaned at the bottom (where addon-side
Bindings.xml entries always render, regardless of header="TARGETING").
The two functions have different signatures in modern (Classic Era
1.15.x) WoW, verified against BlizzardInterfaceCode's generated
ItemDocumentation.lua:
GetItemUniqueness(itemLocation) → (limitCategory, limitMax)
GetItemUniquenessByID(itemID) →
(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)
We were returning a 3-tuple in the wrong order for both. For a
"Unique (10)" item the byID variant now correctly returns
(true, nil, 10, nil) instead of (10, nil, 0).
Mail helms were showing up for cloth classes — the function only
checked invType→slot shape, not whether the player could actually
equip the item.
Adds a proficiency gate using the engine's per-itemClass mask table
at [0x00C4D4A0], populated by the SMSG_SET_PROFICIENCY handler at
0x005E7B70. `table[itemClass] & (1 << subclass)` is the same lookup
4.3.4's Script_GetInventoryItemsForSlot does (via FUN_00553AD0 ->
DAT_00DD4D38), and the same source the engine's own tooltip red-text
coloring and cursor-drop equip check read from. Covers both armor
and weapon proficiencies in one mask lookup.
Adds the modern (2.0.1+) query function backed by the same
WH_GETMESSAGE hook stream that drives GLOBAL_MOUSE_DOWN/UP — state
flips at the exact same point the events fire. Accepts the 1..5 ID
form, the "LeftButton".."Button5" name form, or no arg for "any
button held".
Engine InvMgr state lags behind our atomic-swap packets (server has
to ACK before CGItem moves register locally), so mid-loop FindGUID
lookups returned stale locations and could undo prior swaps.
Mirrors 4.3.4's `Script_UseEquipmentSet` at 0x009A15D0, which walks
all 19 set slots up front to build an action list before any
execution. Combined with two-phase sequencing:
1. paperdoll→paperdoll only — keeps bag state untouched, supports
chained rotations via a virtual-paperdoll walk
2. bag→paperdoll — atomic swap displaces current occupants into
the source bag slot, never strands a future set item
3. unequip slots the set wants empty into pre-snapshotted free
bag slots
Eliminates the "trinket A in 14, B in bags" failure mode where an
earlier bag-to-paperdoll swap displaced an item later needed by the
set, then stale FindGUID returned the pre-swap paperdoll location.
2026-05-30 22:23:32 -05:00
992 changed files with 175306 additions and 11294 deletions
"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.",
A small DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW) that adds a
collection of Lua API calls Blizzard never exposed in 1.12 but which make
addon authoring noticeably less painful — primarily for backporting addons
written against later API versions (3.3.5+) where these calls do exist.
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
backports a large part of the modern WoW API — 550+ functions and 50+ events,
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
versions (3.3.5+ or Classic Era) run with little or no change.
The DLL hooks the FrameScript engine after WoW boots and registers its
extensions through the same in-engine mechanisms WoW uses for its own Lua
functions. No companion addon is required.
It hooks the engine after WoW boots and registers everything the same way WoW
registers its own Lua functions. Nothing else to install — the DLL is
self-contained.
## What's added
## Highlights
Full per-function reference: **[docs/API.md](docs/API.md)**.
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)**.
### Calls
| Feature | Effect |
|---------|--------|
| Lua 5.1 code | Addon code written for Lua 5.1 runs on the 1.12 Lua 5.0 VM. It covers the [5.1 syntax](docs/API.md#lua-51-syntax) (`#`, `%`, `...` expressions, `0x` hex, `[=[ ]=]` long brackets), [method calls on strings](docs/API.md#string-methods-supper-sformat) (`msg:match("^!(%w+)")`), [up to 60 upvalues](docs/API.md#upvalue-limit) for each function, and [new `for` loop variables](docs/API.md#for-loop-variables-per-pass) on each pass. Each addon file also gets its `(name, table)` through `...`. |
| [Modern script handlers](docs/API.md#setmodernscriptargsenable--getmodernscriptargs) | Frame scripts get their values as arguments, for example `OnClick(self, button, down)` and `OnEvent(self, event, ...)`. [XML script bodies](docs/API.md#xml-script-bodies-see-self-and-their-arguments) such as `<OnLoad>self:Init()</OnLoad>` see `self` and their arguments by name. The `this` / `arg1` globals stay set. `SetModernScriptArgs(false)` restores the vanilla behavior. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| [Soft 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. |
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
Everything ClassicAPI adds, in full. Each section is collapsed — click a
heading to expand it. For signatures and return values, see the per-function
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`.
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.
@@ -69,27 +155,124 @@ 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) |
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
| State | `IsLoggedIn` |
### Macros
</details>
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.
<details>
<summary><b>Macros</b> — forms a macro body accepts</summary>
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. |
### Events
</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.
<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`. |
| | `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. |
Registered on the engine's developer console (the `~` console available
when launching with `-console`), not as Lua functions. See the
[Console section in the Lua reference](docs/API.md#console) for details.
| Command | What it does |
|---------|--------------|
| `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>
<details>
<summary><b>Events</b> — new events fired to addons</summary>
| Event | Payload |
|-------|---------|
| `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)) |
| `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 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.
See the [Bindings API reference](docs/API.md#bindings) for signatures,
precedence, execution behavior, macro-text usage, and 1.12 compatibility
notes.
**Predefined focus bindings.** Injected into the engine's **Targeting
Functions** group at FrameXML Bindings.xml load time, so they appear in the
keybind UI alongside native targeting bindings instead of orphaned at the
bottom.
| Binding | Action |
|---------|--------|
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
for the implementation note.
</details>
## Installation
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
`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. The engine
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
`ADDON_LOADED`, supports `SavedVariables`, the works.
the library is always available when the DLL is loaded. It fires
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
`IsAddOnLoaded("!!!ClassicAPI")`, `GetAddOnInfo("!!!ClassicAPI")`, and
`## Dependencies: !!!ClassicAPI` all work as usual.
If you do drop the folder into your `Interface/AddOns/` directory,
**that copy wins** — the DLL's embedded version is only registered
when the engine's normal scan doesn't already have an entry under
that name. Useful for editing the Lua locally without rebuilding the
DLL. The dispatch is transparent: addons consuming `Mixin`,
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
**It is intentionally always-on and cannot be disabled.** The library
provides FrameXML-compat fixes that other addons and the DLL's own
features rely on, so leaving it toggleable would let a user (or a
one-off character-select uncheck) silently break them. The DLL
therefore hides it from the character-select AddOns list and
force-enables it every login — so it never appears as a checkbox and
can't be switched off. Being hidden means it's omitted from the
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
by-name lookup and dependency reference still resolves normally.
## Bundled addon: DebugTools
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.
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).
## Debug tools
Slash commands provided:
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.
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. |
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. |
| `0x006F2F80` | `lua_xmove` | fastcall | `(from, to, n)` — decr `from->top` by `n*0x10`, copy each 16-byte TValue, incr `to->top`. Used by `coroutine.*` to shuttle args/returns between threads | ★ |
| `0x006F3030` | `lua_newthread` | fastcall | GC check then calls inner `luaE_newthread` at `0x006F6B10`; pushes the new state onto L with tag 8 (LUA_TTHREAD). Returns `lua_State *` in eax — Ghidra reports `void` (decompiler artifact) | ★ |
| `0x006F4940` | `lua_error` | cdecl | `(L, msg)` — args on stack; reaches `luaD_throw` via wrappers | ✓ |
| `0x006F4940` | `lua_error` | **cdecl** varargs | `(L, fmt, ...)` — printf-style; engine prepends `luaL_where` then `lua_concat`. Single-string callers OK (cdecl tolerates extra-arg-less calls); user-controlled strings should use `Error(L, "%s", msg)` to avoid format-string interpretation | ✓ |
| `0x006F6620` | `lua_resume` | fastcall | `(L_co, nargs) → int`. Zero internal callers (coroutine lib was stripped from registration tables); linker kept the symbol. Stores nargs in a local, calls `luaD_rawrunprotected(L, &resume_inner, &nargs)` at `0x006F5DAC`; inner static helper at `0x006F67B0` | ★ |
| `0x006F6860` | `lua_yield` | fastcall | `(L, nresults) → int (-1)`. Sets `0x10` flag on CallInfo state field rather than directly writing `L->status` like later Lua versions. Errors with "attempt to yield across metamethod/C-call boundary" or "cannot yield a C function" when called from the wrong context | ★ |
| `0x006F6B10` | *internal*`luaE_newthread` | fastcall | Inner allocator called by `lua_newthread` at `0x006F3030`. Allocates the new state, sets tag 8 via the push helper at `0x006F7B20`, inits via `0x006F6C40`, copies global-state fields from the parent. Returns `lua_State *` | ★ |
### Misc / GC / version
@@ -206,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
| `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 |
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.