Compare commits

...
122 Commits
Author SHA1 Message Date
Brues 3009c6d8d2 fix(addon): splice the AddOn Options menu button on first show
Addons that load after us insert their own game menu buttons by taking
Key Bindings' anchor too. ShaguTweaks anchors "Advanced Options" under
Interface Options, which put it on top of our button. Waiting for the
menu's first show lets every addon place its buttons first, so ours goes
right above Key Bindings wherever it ended up.
2026-10-22 23:05:22 -05:00
Brues b83bb5b52b feat(frame): draw the Cooldown sweep natively, as 4.3.4's CGCooldown
A Cooldown was vanilla's indicator Model with CooldownFrameTemplate loaded
into it, so SetReverse and SetDrawEdge stored flags that changed nothing,
and every cooldown paid a Model's per-frame cost.

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

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

Verified in-game: sweep, flash and hide; reverse with the edge line;
stock action-button cooldowns unchanged; repeated /reload.
2026-10-22 22:15:17 -05:00
SkeewirtandBrues f30efb29b1 feat(gossip): add GetNumGossipAvailableQuests and GetNumGossipActiveQuests (#95)
* feat(gossip): add GetNumGossipAvailableQuests and GetNumGossipActiveQuests

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

Not yet verified in-game.

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Scale and Rotation still keep their values without drawing.

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

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

* feat(animation): scale and turn font strings

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

* feat(animation): add Path and ControlPoint

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

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

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

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

---------

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

Register string.match's function as strmatch too.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Chat::Dispatch's argument labels are corrected too: the pair it called the
target GUID is the speaker the speech bubble is placed on.
2026-10-18 22:47:22 -05:00
Brues 90b4691130 fix(debugtools): show all 19 event arguments in the event trace
The event trace passed only arg1..arg9 from its XML OnEvent and kept nine
argument columns, so it never showed arg10 that 1.12 already sends with
every CHAT_MSG_*, nor anything after it. The engine sets at most
arg1..arg19 for an event, so the trace now passes and stores all 19, with
trailing nils marking the end as before.
2026-10-18 22:47:22 -05:00
312 changed files with 31031 additions and 8214 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
# is_release=true when a vX.Y.Z tag is in play (pushed tag,
# dispatched-from-tag, or a `tag` input); the embedded addon's
# `## Version:` and the compiled CLASSIC_API_VERSION are both
# stamped from it at embed/compile time (see cmake/embed_addon.cmake)
# stamped from it at embed/compile time (see cmake/embed_resources.cmake)
# — no source edit.
- name: Resolve version
id: ver
+6
View File
@@ -7,6 +7,9 @@
locales\locales.xml
FrameXML\InterfaceOptions.xml
FrameXML\InterfaceOptions.lua
SecureTemplates.xml
Compat.lua
Util\Print.lua
@@ -46,6 +49,7 @@ Util\Vector4D.lua
Util\UiMapPoint.lua
Util\UIParent.lua
Util\ActionButton.lua
Util\Cooldown.lua
Util\WorldMap.lua
Util\AuraDurationModifiers.lua
Util\AuraUtil.lua
@@ -55,6 +59,8 @@ Util\MacroOptions.lua
Util\SecureStateDriver.lua
Util\MacroIconSelection.lua
Options\Panel.lua
APIDocumentation\BaseAPIMixin.lua
APIDocumentation\FieldsAPIMixin.lua
APIDocumentation\FunctionsAPIMixin.lua
@@ -0,0 +1,373 @@
-- Backport of the Interface Options panel's addon side (InterfaceOptionsFrame,
-- InterfaceOptions_AddCategory, InterfaceOptionsFrame_OpenToCategory) to
-- vanilla 1.12 / Lua 5.0.
--
-- Later clients list addon option panels in InterfaceOptionsFrame. Addons add
-- theirs with InterfaceOptions_AddCategory(panel) - Ace3's
-- AceConfigDialog:AddToBlizOptions does it for every Ace3 addon - and open them
-- with InterfaceOptionsFrame_OpenToCategory. Frame and list names match later
-- clients because addons reach for them directly (Zygor Guides Viewer 2.0
-- re-tints the frame's first region; LibBetterBlizzOptions-1.0 resizes the
-- lists through their `buttons` / `buttonHeight` / `update` fields).
--
-- Implementation differences from the modern source:
-- - Addon panels only. Vanilla's own options stay in UIOptionsFrame and
-- friends; the Categories list exists (empty, hidden) for code that
-- expects it.
-- - Panel protocol as on later clients: panel.name (required), panel.parent
-- (name of the category to nest under), and optional okay / cancel /
-- default / refresh, each called with the panel. Categories with children
-- start collapsed. Okay and Cancel call okay / cancel on every panel;
-- Defaults calls default on the one shown; Escape is Cancel.
-- - The game-menu entry is an extra AddOn Options button: the client's own
-- options keep their buttons, so the addon list gets one of its own.
-- Not "AddOns" - on later clients that button opens the addon list.
-- - Lua 5.0: no `...` expression or select().
if InterfaceOptionsFrame then return end -- a real implementation wins
INTERFACEOPTIONS_ADDONCATEGORIES = INTERFACEOPTIONS_ADDONCATEGORIES or {}
local categories = INTERFACEOPTIONS_ADDONCATEGORIES
local BUTTON_HEIGHT = 18
local NUM_BUTTONS = 24
local function report(err)
local handler = geterrorhandler and geterrorhandler()
if handler then handler(err) end
end
local LIST_BACKDROP = {
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 16,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
}
-- ---------------------------------------------------------------------------
-- Frame
-- ---------------------------------------------------------------------------
local frame = CreateFrame("Frame", "InterfaceOptionsFrame", UIParent)
frame:Hide()
frame:SetWidth(648)
frame:SetHeight(568)
frame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
frame:SetFrameStrata("DIALOG")
frame:SetToplevel(true)
frame:EnableMouse(true)
frame:SetMovable(true)
frame:SetClampedToScreen(true)
-- The background is the frame's first region: addons re-tint it through
-- InterfaceOptionsFrame:GetRegions().
local background = frame:CreateTexture(nil, "BACKGROUND")
background:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Background")
background:SetPoint("TOPLEFT", frame, "TOPLEFT", 11, -12)
background:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -12, 11)
frame:SetBackdrop({
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
edgeSize = 32,
insets = { left = 11, right = 12, top = 12, bottom = 11 },
})
local header = frame:CreateTexture(nil, "ARTWORK")
header:SetTexture("Interface\\DialogFrame\\UI-DialogBox-Header")
header:SetWidth(360)
header:SetHeight(64)
header:SetPoint("TOP", frame, "TOP", 0, 12)
local title = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
title:SetPoint("TOP", header, "TOP", 0, -14)
title:SetText(ADDON_OPTIONS)
frame:SetScript("OnMouseDown", function()
if arg1 == "LeftButton" then this:StartMoving() end
end)
frame:SetScript("OnMouseUp", function() this:StopMovingOrSizing() end)
-- A native center panel, as on later clients: ShowUIPanel swaps it with the
-- game menu, CloseAllWindows closes it, and closing returns to lastFrame.
UIPanelWindows["InterfaceOptionsFrame"] = { area = "center", pushable = 0, whileDead = 1 }
-- ---------------------------------------------------------------------------
-- Category lists and the panel container
-- ---------------------------------------------------------------------------
local function CreateList(name)
local list = CreateFrame("Frame", name, frame)
list:SetWidth(185)
list:SetPoint("TOPLEFT", frame, "TOPLEFT", 22, -40)
list:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 22, 50)
list:SetBackdrop(LIST_BACKDROP)
list:SetBackdropColor(0, 0, 0, 0.5)
list:SetBackdropBorderColor(0.6, 0.6, 0.6)
list.buttonHeight = BUTTON_HEIGHT
list.buttons = {}
for i = 1, NUM_BUTTONS do
local button = CreateFrame("Button", name .. "Button" .. i, list, "InterfaceOptionsListButtonTemplate")
if i == 1 then
button:SetPoint("TOPLEFT", list, "TOPLEFT", 5, -8)
else
button:SetPoint("TOPLEFT", list.buttons[i - 1], "BOTTOMLEFT", 0, 0)
end
button:Hide()
list.buttons[i] = button
end
return list
end
-- Vanilla keeps its own options elsewhere; this list stays empty.
local gameList = CreateList("InterfaceOptionsFrameCategories")
gameList.update = function() end
gameList:Hide()
local addOnList = CreateList("InterfaceOptionsFrameAddOns")
local scroll = CreateFrame("ScrollFrame", "InterfaceOptionsFrameAddOnsList", addOnList, "FauxScrollFrameTemplate")
scroll:SetPoint("TOPLEFT", addOnList, "TOPLEFT", 0, -8)
scroll:SetPoint("BOTTOMRIGHT", addOnList, "BOTTOMRIGHT", -30, 8)
local container = CreateFrame("Frame", "InterfaceOptionsFramePanelContainer", frame)
container:SetPoint("TOPLEFT", addOnList, "TOPRIGHT", 16, 0)
container:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -22, 50)
container:SetBackdrop(LIST_BACKDROP)
container:SetBackdropColor(0, 0, 0, 0.5)
container:SetBackdropBorderColor(0.6, 0.6, 0.6)
-- ---------------------------------------------------------------------------
-- Categories
-- ---------------------------------------------------------------------------
local function FindCategory(name)
for _, panel in ipairs(categories) do
if panel.name == name then return panel end
end
end
local function HasChildren(panel)
for _, p in ipairs(categories) do
if p.parent == panel.name then return true end
end
end
-- Top-level categories in order, each followed by its children when expanded.
-- A child whose parent is not registered is listed at the top level.
local function VisibleCategories()
local out = {}
for _, panel in ipairs(categories) do
if not panel.parent or not FindCategory(panel.parent) then
table.insert(out, panel)
if not panel.collapsed then
for _, child in ipairs(categories) do
if child.parent == panel.name then table.insert(out, child) end
end
end
end
end
return out
end
function InterfaceAddOnsList_Update()
local entries = VisibleCategories()
local offset = FauxScrollFrame_GetOffset(scroll) or 0
local numButtons = table.getn(addOnList.buttons)
for i = 1, numButtons do
local button = addOnList.buttons[i]
local panel = entries[i + offset]
local text = getglobal(button:GetName() .. "Text")
local toggle = getglobal(button:GetName() .. "Toggle")
if panel then
button.element = panel
text:SetText(panel.name)
local isChild = panel.parent and FindCategory(panel.parent)
text:SetFontObject(isChild and GameFontHighlightSmall or GameFontNormal)
text:ClearAllPoints()
text:SetPoint("LEFT", button, "LEFT", isChild and 20 or 8, 0)
if toggle then
if HasChildren(panel) then
local tex = panel.collapsed and "Plus" or "Minus"
toggle:SetNormalTexture("Interface\\Buttons\\UI-" .. tex .. "Button-UP")
toggle:SetPushedTexture("Interface\\Buttons\\UI-" .. tex .. "Button-DOWN")
toggle:Show()
else
toggle:Hide()
end
end
if panel == container.displayedPanel then button:LockHighlight() else button:UnlockHighlight() end
button:Show()
else
button.element = nil
button:UnlockHighlight()
button:Hide()
end
end
FauxScrollFrame_Update(scroll, table.getn(entries), numButtons, BUTTON_HEIGHT)
end
addOnList.update = InterfaceAddOnsList_Update
scroll:SetScript("OnVerticalScroll", function()
FauxScrollFrame_OnVerticalScroll(BUTTON_HEIGHT, InterfaceAddOnsList_Update)
end)
-- The shown panel lives in container.displayedPanel, as on later clients:
-- addons read it there.
local function DisplayPanel(panel)
local shown = container.displayedPanel
if shown and shown ~= panel then shown:Hide() end
container.displayedPanel = panel
panel:SetParent(container)
panel:ClearAllPoints()
panel:SetPoint("TOPLEFT", container, "TOPLEFT", 4, -4)
panel:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", -4, 4)
panel:Show()
if type(panel.refresh) == "function" then
local ok, err = pcall(panel.refresh, panel)
if not ok then report(err) end
end
InterfaceAddOnsList_Update()
end
function InterfaceOptionsListButton_OnClick(button)
if button and button.element then DisplayPanel(button.element) end
end
function InterfaceOptionsListButton_ToggleSubCategories(button)
local panel = button and button.element
if not panel then return end
panel.collapsed = not panel.collapsed
InterfaceAddOnsList_Update()
end
function InterfaceOptions_AddCategory(panel, addOn, position)
if type(panel) ~= "table" or type(panel.name) ~= "string" then
error("Usage: InterfaceOptions_AddCategory(panel [, addOn, position])", 2)
end
panel:Hide()
if panel.parent then
local parent = FindCategory(panel.parent)
if parent and parent.collapsed == nil then parent.collapsed = true end
end
if position and position >= 1 and position <= table.getn(categories) + 1 then
table.insert(categories, position, panel)
else
table.insert(categories, panel)
end
if panel.collapsed == nil and HasChildren(panel) then panel.collapsed = true end
if frame:IsShown() then InterfaceAddOnsList_Update() end
end
function InterfaceOptionsFrame_OpenToCategory(panel)
if type(panel) == "string" then panel = FindCategory(panel) end
if type(panel) ~= "table" then return end
local p = panel
while p and p.parent do
local parent = FindCategory(p.parent)
if parent then parent.collapsed = false end
p = parent
end
ShowUIPanel(frame)
DisplayPanel(panel)
end
function InterfaceOptionsFrame_Show()
if frame:IsShown() then HideUIPanel(frame) else ShowUIPanel(frame) end
end
frame:SetScript("OnShow", function()
if not container.displayedPanel and categories[1] then
DisplayPanel(categories[1])
else
InterfaceAddOnsList_Update()
end
end)
frame:SetScript("OnHide", function()
if this.lastFrame then
ShowUIPanel(this.lastFrame)
this.lastFrame = nil
end
end)
-- ---------------------------------------------------------------------------
-- Okay / Cancel / Defaults
-- ---------------------------------------------------------------------------
local function CallAll(method)
for _, panel in ipairs(categories) do
local fn = panel[method]
if type(fn) == "function" then
local ok, err = pcall(fn, panel)
if not ok then report(err) end
end
end
end
local function PanelButton(name, label, point, x)
local b = CreateFrame("Button", name, frame, "UIPanelButtonTemplate")
b:SetWidth(96)
b:SetHeight(22)
b:SetPoint(point, frame, point, x, 16)
b:SetText(label)
return b
end
PanelButton("InterfaceOptionsFrameCancel", CANCEL, "BOTTOMRIGHT", -16):SetScript("OnClick", function()
CallAll("cancel")
HideUIPanel(frame)
end)
PanelButton("InterfaceOptionsFrameOkay", OKAY, "BOTTOMRIGHT", -114):SetScript("OnClick", function()
CallAll("okay")
HideUIPanel(frame)
end)
PanelButton("InterfaceOptionsFrameDefaults", DEFAULTS, "BOTTOMLEFT", 22):SetScript("OnClick", function()
local panel = container.displayedPanel
if panel and type(panel.default) == "function" then
local ok, err = pcall(panel.default, panel)
if not ok then report(err) end
end
end)
-- Escape is Cancel, as on later clients: their ToggleGameMenu clicks
-- InterfaceOptionsFrameCancel right after dismissing a static popup.
-- Without this, Escape reaches CloseAllWindows, which closes the window
-- without calling any panel's cancel.
local ToggleGameMenu_Orig = ToggleGameMenu
function ToggleGameMenu(clicked)
if not clicked and frame:IsVisible() then
if not StaticPopup_EscapePressed() then InterfaceOptionsFrameCancel:Click() end
return
end
ToggleGameMenu_Orig(clicked)
end
-- ---------------------------------------------------------------------------
-- Game menu entry
-- ---------------------------------------------------------------------------
-- Goes right above Key Bindings. Stock anchors Key Bindings under Interface
-- Options and Turtle under its single Options button, so the new button takes
-- over whatever anchor Key Bindings has and Key Bindings moves under it.
--
-- The splice waits for the menu's first show. Other addons insert their own
-- buttons the same way -- ShaguTweaks anchors "Advanced Options" under
-- Interface Options and moves Key Bindings under it -- and they load after
-- us. Splicing at load time would claim the slot first, and the later addon
-- would anchor its button on the same spot as ours. By the first show every
-- addon has placed its buttons, so ours goes into the chain as it stands.
local menuButton = CreateFrame("Button", "GameMenuButtonAddOnOptions", GameMenuFrame, "GameMenuButtonTemplate")
menuButton:SetText(ADDON_OPTIONS)
local spliced
GameMenuFrame:HookScript("OnShow", function()
if spliced then return end
spliced = true
local point, relativeTo, relativePoint, x, y = GameMenuButtonKeybindings:GetPoint()
menuButton:SetPoint(point, relativeTo, relativePoint, x, y)
GameMenuButtonKeybindings:ClearAllPoints()
GameMenuButtonKeybindings:SetPoint(point, menuButton, relativePoint, x, y)
GameMenuFrame:SetHeight(GameMenuFrame:GetHeight() + menuButton:GetHeight() - y)
end)
menuButton:SetScript("OnClick", function()
PlaySound("igMainMenuOption")
ShowUIPanel(frame)
frame.lastFrame = GameMenuFrame
end)
@@ -0,0 +1,63 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/ ..\FrameXML\UI.xsd">
<!-- One row of the Interface Options category list (see InterfaceOptions.lua).
Named as on later clients: addons and libraries create extra rows from it
by name (LibBetterBlizzOptions-1.0). Handlers use vanilla's this/arg1. -->
<Button name="InterfaceOptionsListButtonTemplate" virtual="true">
<Size>
<AbsDimension x="175" y="18"/>
</Size>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentText" inherits="GameFontNormal" justifyH="LEFT">
<Size>
<AbsDimension x="150" y="18"/>
</Size>
<Anchors>
<Anchor point="LEFT">
<Offset>
<AbsDimension x="8" y="0"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentToggle" hidden="true">
<Size>
<AbsDimension x="14" y="14"/>
</Size>
<Anchors>
<Anchor point="RIGHT">
<Offset>
<AbsDimension x="-6" y="0"/>
</Offset>
</Anchor>
</Anchors>
<Scripts>
<OnClick>InterfaceOptionsListButton_ToggleSubCategories(this:GetParent())</OnClick>
</Scripts>
<NormalTexture file="Interface\Buttons\UI-PlusButton-UP"/>
<PushedTexture file="Interface\Buttons\UI-PlusButton-DOWN"/>
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD"/>
</Button>
</Frames>
<Scripts>
<OnClick>InterfaceOptionsListButton_OnClick(this)</OnClick>
</Scripts>
<HighlightTexture file="Interface\QuestFrame\UI-QuestTitleHighlight" alphaMode="ADD">
<Anchors>
<Anchor point="TOPLEFT">
<Offset>
<AbsDimension x="0" y="1"/>
</Offset>
</Anchor>
<Anchor point="BOTTOMRIGHT">
<Offset>
<AbsDimension x="0" y="1"/>
</Offset>
</Anchor>
</Anchors>
</HighlightTexture>
</Button>
</Ui>
+337
View File
@@ -0,0 +1,337 @@
-- ClassicAPI's own entry in the AddOn Options window
-- (FrameXML\InterfaceOptions.lua): a ClassicAPI page with Controls, Combat
-- and Text To Speech pages under it.
--
-- Where retail's Settings shows a setting, the option copies its label,
-- tooltip and value mapping (Blizzard_SettingsDefinitions_Frame: Controls,
-- Combat, Accessibility; the TTS options use the Text To Speech window's
-- labels). Retail has no labels for the player cache, the cast settings or
-- friendly soft targeting, so those strings are ClassicAPI's own.
--
-- Changes apply at once. Cancel (and Escape) restores what each page held
-- when it was first shown since the last Okay or Cancel; Defaults restores
-- the shown page's defaults.
--
-- Lua 5.0: no `...` expression or select().
local L = {
ABOUT = "Settings for the features that ClassicAPI adds to the game.",
PLAYER_CACHE = "Player Cache",
REMEMBER_PLAYERS = "Remember players",
REMEMBER_PLAYERS_TOOLTIP = "Saves the name, class, race and sex of the players you meet, so addons can show them in later sessions. All your characters on this realm share the data.",
SCAN_PLAYERS = "Scan nearby players",
SCAN_PLAYERS_TOOLTIP = "Every 10 seconds, adds all the players near you to the player cache. Needs Remember players.",
FRIEND_TARGETING = "Enable Friendly Action Targeting",
FRIEND_TARGETING_TOOLTIP = "Picks the nearest friendly unit where you are looking. When you have no target, your helpful spells go to it.",
AUTO_STAND = "Auto Stand",
AUTO_STAND_TOOLTIP = "Stand up when you cast a spell while you sit.",
AUTO_DISMOUNT = "Auto Dismount",
AUTO_DISMOUNT_TOOLTIP = "Dismount when you cast a spell while you are mounted.",
AUTO_UNSHIFT = "Auto Cancel Form",
AUTO_UNSHIFT_TOOLTIP = "Leave your form when you cast a spell that you cannot cast in it.",
}
-- Enum.SoftTargetEnableFlags: retail's checkboxes switch between Any (on)
-- and Gamepad (off).
local SOFT_TARGET_GAMEPAD, SOFT_TARGET_ANY = 1, 3
-- ---------------------------------------------------------------------------
-- Settings: get / set / reset over cvars or the player cache
-- ---------------------------------------------------------------------------
local function ResetCVars(cvars)
for _, cvar in ipairs(cvars) do
SetCVar(cvar, GetCVarDefault(cvar))
end
end
local function CVarSetting(cvar)
return {
get = function() return GetCVar(cvar) == "1" end,
set = function(value) SetCVar(cvar, value and "1" or "0") end,
reset = function() ResetCVars({ cvar }) end,
}
end
local function NumberCVarSetting(cvar)
return {
get = function() return tonumber(GetCVar(cvar)) end,
set = function(value) SetCVar(cvar, tostring(value)) end,
reset = function() ResetCVars({ cvar }) end,
}
end
-- Retail's "Enable Interact Key" / "Enable Action Targeting" proxies.
local function SoftTargetSetting(cvar)
return {
get = function() return tonumber(GetCVar(cvar)) == SOFT_TARGET_ANY end,
set = function(value) SetCVar(cvar, tostring(value and SOFT_TARGET_ANY or SOFT_TARGET_GAMEPAD)) end,
reset = function() ResetCVars({ cvar }) end,
}
end
-- Retail's "Interact Key Icons" proxy: 1 = NPCs only, 2 = all, 3 = none.
local ICON_CVARS = { "SoftTargetIconEnemy", "SoftTargetIconInteract", "SoftTargetIconGameObject", "SoftTargetLowPriorityIcons" }
local ICON_PRESETS = {
[1] = { "0", "1", "0", "0" },
[2] = { "1", "1", "1", "1" },
[3] = { "0", "0", "0", "0" },
}
local interactIconsSetting = {
get = function()
local on, off = true, true
for _, cvar in ipairs(ICON_CVARS) do
if GetCVar(cvar) == "1" then off = false else on = false end
end
if on then return 2 elseif off then return 3 else return 1 end
end,
set = function(value)
for i, cvar in ipairs(ICON_CVARS) do
SetCVar(cvar, ICON_PRESETS[value][i])
end
end,
reset = function() ResetCVars(ICON_CVARS) end,
}
local function PlayerCacheSetting(isEnabled, setEnabled)
return {
get = function() return isEnabled() and true or false end,
set = function(value) setEnabled(value) end,
reset = function() setEnabled(false) end,
}
end
local rememberSetting = PlayerCacheSetting(C_PlayerCache.IsEnabled, C_PlayerCache.SetEnabled)
local scanSetting = PlayerCacheSetting(C_PlayerCache.IsScanEnabled, C_PlayerCache.SetScanEnabled)
-- ---------------------------------------------------------------------------
-- Pages
-- ---------------------------------------------------------------------------
local numControls = 0
local function NextName()
numControls = numControls + 1
return "ClassicAPIOptionsControl" .. numControls
end
local function RefreshControls(page)
page.updating = true
for _, control in ipairs(page.controls) do
control:Update()
end
page.updating = nil
end
local function NewPage(name, parent, title, subtitle)
local page = CreateFrame("Frame")
page.name = name
page.parent = parent
page.controls = {}
local heading = page:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
heading:SetPoint("TOPLEFT", page, "TOPLEFT", 16, -16)
heading:SetText(title)
page.y = -40
if subtitle then
local text = page:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
text:SetPoint("TOPLEFT", heading, "BOTTOMLEFT", 0, -8)
text:SetPoint("RIGHT", page, "RIGHT", -32, 0)
text:SetJustifyH("LEFT")
text:SetText(subtitle)
page.y = -64
end
page.refresh = function()
if not page.saved then
page.saved = {}
for i, control in ipairs(page.controls) do
page.saved[i] = control.setting.get()
end
end
RefreshControls(page)
end
page.okay = function()
page.saved = nil
end
page.cancel = function()
if not page.saved then return end
for i, control in ipairs(page.controls) do
if control.setting.get() ~= page.saved[i] then
control.setting.set(page.saved[i])
end
end
page.saved = nil
end
page.default = function()
for _, control in ipairs(page.controls) do
control.setting.reset()
end
RefreshControls(page)
end
InterfaceOptions_AddCategory(page)
return page
end
local function AddControl(page, control, setting, height)
control.setting = setting
table.insert(page.controls, control)
page.y = page.y - height
end
local function AddHeader(page, text)
local header = page:CreateFontString(nil, "ARTWORK", "GameFontNormal")
header:SetPoint("TOPLEFT", page, "TOPLEFT", 16, page.y - 8)
header:SetText(text)
page.y = page.y - 28
end
-- `enabledIf`, when given, greys the checkbox out while it returns false.
local function AddCheckbox(page, label, tooltip, setting, enabledIf)
local check = CreateFrame("CheckButton", NextName(), page, "OptionsCheckButtonTemplate")
check:SetPoint("TOPLEFT", page, "TOPLEFT", 16, page.y)
local text = getglobal(check:GetName() .. "Text")
text:SetText(label)
check.tooltipText = tooltip
check:SetScript("OnClick", function()
this.setting.set(this:GetChecked() and true or false)
RefreshControls(page)
end)
check.Update = function(self)
self:SetChecked(self.setting.get())
if not enabledIf or enabledIf() then
self:Enable()
text:SetTextColor(NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b)
else
self:Disable()
text:SetTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
end
end
AddControl(page, check, setting, 30)
return check
end
-- `options()` returns the entries as { value = ..., text = ... }.
local function AddDropDown(page, label, tooltip, setting, options)
local title = page:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
title:SetPoint("TOPLEFT", page, "TOPLEFT", 20, page.y - 6)
title:SetText(label)
local dropdown = CreateFrame("Frame", NextName(), page, "UIDropDownMenuTemplate")
dropdown:SetPoint("TOPLEFT", page, "TOPLEFT", 0, page.y - 20)
UIDropDownMenu_SetWidth(180, dropdown)
dropdown:EnableMouse(true)
dropdown:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
GameTooltip:SetText(tooltip, nil, nil, nil, nil, 1)
end)
dropdown:SetScript("OnLeave", function() GameTooltip:Hide() end)
local function Initialize()
local selected = setting.get()
for _, option in ipairs(options()) do
local info = {}
info.text = option.text
info.value = option.value
info.checked = option.value == selected and 1 or nil
info.func = function()
UIDropDownMenu_SetSelectedValue(dropdown, this.value)
setting.set(this.value)
end
UIDropDownMenu_AddButton(info)
end
end
dropdown.Update = function(self)
UIDropDownMenu_Initialize(self, Initialize)
UIDropDownMenu_SetSelectedValue(self, setting.get())
end
AddControl(page, dropdown, setting, 56)
return dropdown
end
local function AddSlider(page, label, tooltip, setting, minValue, maxValue)
local slider = CreateFrame("Slider", NextName(), page, "OptionsSliderTemplate")
slider:SetPoint("TOPLEFT", page, "TOPLEFT", 24, page.y - 20)
slider:SetWidth(200)
slider:SetMinMaxValues(minValue, maxValue)
slider:SetValueStep(1)
slider.tooltipText = tooltip
getglobal(slider:GetName() .. "Text"):SetText(label)
getglobal(slider:GetName() .. "Low"):SetText(minValue)
getglobal(slider:GetName() .. "High"):SetText(maxValue)
local value = slider:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall")
value:SetPoint("LEFT", slider, "RIGHT", 8, 0)
slider:SetScript("OnValueChanged", function()
local v = math.floor(this:GetValue() + 0.5)
value:SetText(v)
if not page.updating and v ~= setting.get() then
setting.set(v)
end
end)
slider.Update = function(self)
local v = setting.get()
self:SetValue(v)
value:SetText(v)
end
AddControl(page, slider, setting, 56)
return slider
end
-- ---------------------------------------------------------------------------
-- ClassicAPI
-- ---------------------------------------------------------------------------
local version = GetAddOnMetadata("!!!ClassicAPI", "Version")
local main = NewPage("ClassicAPI", nil, "ClassicAPI " .. version, L.ABOUT)
AddHeader(main, L.PLAYER_CACHE)
AddCheckbox(main, L.REMEMBER_PLAYERS, L.REMEMBER_PLAYERS_TOOLTIP, rememberSetting)
AddCheckbox(main, L.SCAN_PLAYERS, L.SCAN_PLAYERS_TOOLTIP, scanSetting, rememberSetting.get)
-- ---------------------------------------------------------------------------
-- Controls
-- ---------------------------------------------------------------------------
local controls = NewPage(CONTROLS_LABEL, main.name, CONTROLS_LABEL)
AddCheckbox(controls, ENABLE_INTERACT_TEXT, OPTION_TOOLTIP_ENABLE_INTERACT, SoftTargetSetting("SoftTargetInteract"))
AddDropDown(controls, INTERACT_ICONS_OPTION, OPTION_TOOLTIP_INTERACT_ICONS, interactIconsSetting, function()
return {
{ value = 1, text = INTERACT_ICONS_DEFAULT },
{ value = 2, text = INTERACT_ICONS_SHOW_ALL },
{ value = 3, text = INTERACT_ICONS_SHOW_NONE },
}
end)
AddCheckbox(controls, L.AUTO_STAND, L.AUTO_STAND_TOOLTIP, CVarSetting("autoStand"))
AddCheckbox(controls, L.AUTO_DISMOUNT, L.AUTO_DISMOUNT_TOOLTIP, CVarSetting("autoDismount"))
AddCheckbox(controls, L.AUTO_UNSHIFT, L.AUTO_UNSHIFT_TOOLTIP, CVarSetting("autoUnshift"))
-- ---------------------------------------------------------------------------
-- Combat
-- ---------------------------------------------------------------------------
local combat = NewPage(COMBAT_LABEL, main.name, COMBAT_LABEL)
AddCheckbox(combat, ACTION_TARGETING_OPTION, OPTION_TOOLTIP_ACTION_TARGETING, SoftTargetSetting("SoftTargetEnemy"))
-- Retail has no friend checkbox; same mapping as its enemy one.
AddCheckbox(combat, L.FRIEND_TARGETING, L.FRIEND_TARGETING_TOOLTIP, SoftTargetSetting("SoftTargetFriend"))
AddCheckbox(combat, ACTION_BUTTON_USE_KEY_DOWN, OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN, CVarSetting("ActionButtonUseKeyDown"))
AddCheckbox(combat, STOP_AUTO_ATTACK, OPTION_TOOLTIP_STOP_AUTO_ATTACK, CVarSetting("stopAutoAttackOnTargetChange"))
-- ---------------------------------------------------------------------------
-- Text To Speech
-- ---------------------------------------------------------------------------
local tts = NewPage(TEXT_TO_SPEECH, main.name, TEXT_TO_SPEECH)
AddDropDown(tts, TEXT_TO_SPEECH_VOICE_OPTION, TEXT_TO_SPEECH_VOICE_OPTION, NumberCVarSetting("ttsVoice"), function()
local options = {}
for _, voice in ipairs(C_VoiceChat.GetTtsVoices()) do
table.insert(options, { value = voice.voiceID, text = voice.name })
end
return options
end)
AddSlider(tts, TEXT_TO_SPEECH_ADJUST_RATE, TEXT_TO_SPEECH_ADJUST_RATE, NumberCVarSetting("ttsSpeed"), -10, 10)
AddSlider(tts, TEXT_TO_SPEECH_ADJUST_VOLUME, TEXT_TO_SPEECH_ADJUST_VOLUME, NumberCVarSetting("ttsVolume"), 0, 100)
+3 -2
View File
@@ -72,8 +72,9 @@ end
-- Returns the AuraData table's fields in the classic `UnitAura` positional order,
-- for code written against that shape. Retail appends `unpack(auraData.points)`;
-- vanilla auras carry no points, so the tuple ends at timeMod (15 values). Field
-- #13 is `isFromPlayerOrPlayerPet` (the table's field) -- note the positional
-- C_UnitAuras.UnitAura reports the higher-fidelity `castByPlayer` at #13 instead.
-- #13 is `isFromPlayerOrPlayerPet` (the table's field), as retail's UnpackAuraData
-- does -- the positional C_UnitAuras.UnitAura reports `castByPlayer` there
-- instead, which is false for a pet's aura.
function AuraUtil.UnpackAuraData(auraData)
if not auraData then
return nil;
+116 -1
View File
@@ -137,4 +137,119 @@ BASE_MOVEMENT_SPEED = 7;
UPPER_LEFT_VERTEX = 1;
LOWER_LEFT_VERTEX = 2;
UPPER_RIGHT_VERTEX = 3;
LOWER_RIGHT_VERTEX = 4;
LOWER_RIGHT_VERTEX = 4;
-- Combat log object flags: sourceFlags / destFlags of COMBAT_LOG_EVENT.
COMBATLOG_OBJECT_AFFILIATION_MINE = tonumber("00000001", 16);
COMBATLOG_OBJECT_AFFILIATION_PARTY = tonumber("00000002", 16);
COMBATLOG_OBJECT_AFFILIATION_RAID = tonumber("00000004", 16);
COMBATLOG_OBJECT_AFFILIATION_OUTSIDER = tonumber("00000008", 16);
COMBATLOG_OBJECT_AFFILIATION_MASK = tonumber("0000000F", 16);
COMBATLOG_OBJECT_REACTION_FRIENDLY = tonumber("00000010", 16);
COMBATLOG_OBJECT_REACTION_NEUTRAL = tonumber("00000020", 16);
COMBATLOG_OBJECT_REACTION_HOSTILE = tonumber("00000040", 16);
COMBATLOG_OBJECT_REACTION_MASK = tonumber("000000F0", 16);
COMBATLOG_OBJECT_CONTROL_PLAYER = tonumber("00000100", 16);
COMBATLOG_OBJECT_CONTROL_NPC = tonumber("00000200", 16);
COMBATLOG_OBJECT_CONTROL_MASK = tonumber("00000300", 16);
COMBATLOG_OBJECT_TYPE_PLAYER = tonumber("00000400", 16);
COMBATLOG_OBJECT_TYPE_NPC = tonumber("00000800", 16);
COMBATLOG_OBJECT_TYPE_PET = tonumber("00001000", 16);
COMBATLOG_OBJECT_TYPE_GUARDIAN = tonumber("00002000", 16);
COMBATLOG_OBJECT_TYPE_OBJECT = tonumber("00004000", 16);
COMBATLOG_OBJECT_TYPE_MASK = tonumber("0000FC00", 16);
COMBATLOG_OBJECT_TARGET = tonumber("00010000", 16);
COMBATLOG_OBJECT_FOCUS = tonumber("00020000", 16);
COMBATLOG_OBJECT_MAINTANK = tonumber("00040000", 16);
COMBATLOG_OBJECT_MAINASSIST = tonumber("00080000", 16);
COMBATLOG_OBJECT_NONE = tonumber("80000000", 16);
COMBATLOG_OBJECT_SPECIAL_MASK = tonumber("FFFF0000", 16);
-- Combat log raid flags: sourceRaidFlags / destRaidFlags of COMBAT_LOG_EVENT.
COMBATLOG_OBJECT_RAIDTARGET1 = tonumber("00000001", 16);
COMBATLOG_OBJECT_RAIDTARGET2 = tonumber("00000002", 16);
COMBATLOG_OBJECT_RAIDTARGET3 = tonumber("00000004", 16);
COMBATLOG_OBJECT_RAIDTARGET4 = tonumber("00000008", 16);
COMBATLOG_OBJECT_RAIDTARGET5 = tonumber("00000010", 16);
COMBATLOG_OBJECT_RAIDTARGET6 = tonumber("00000020", 16);
COMBATLOG_OBJECT_RAIDTARGET7 = tonumber("00000040", 16);
COMBATLOG_OBJECT_RAIDTARGET8 = tonumber("00000080", 16);
COMBATLOG_OBJECT_RAIDTARGET_MASK = tonumber("000000FF", 16);
-- Combat log filter masks, for CombatLogAddFilter and CombatLog_Object_IsA.
COMBATLOG_FILTER_ME = bit.bor(
COMBATLOG_OBJECT_AFFILIATION_MINE,
COMBATLOG_OBJECT_REACTION_FRIENDLY,
COMBATLOG_OBJECT_CONTROL_PLAYER,
COMBATLOG_OBJECT_TYPE_PLAYER
);
COMBATLOG_FILTER_MINE = bit.bor(
COMBATLOG_OBJECT_AFFILIATION_MINE,
COMBATLOG_OBJECT_REACTION_FRIENDLY,
COMBATLOG_OBJECT_CONTROL_PLAYER,
COMBATLOG_OBJECT_TYPE_PLAYER,
COMBATLOG_OBJECT_TYPE_OBJECT
);
COMBATLOG_FILTER_MY_PET = bit.bor(
COMBATLOG_OBJECT_AFFILIATION_MINE,
COMBATLOG_OBJECT_REACTION_FRIENDLY,
COMBATLOG_OBJECT_CONTROL_PLAYER,
COMBATLOG_OBJECT_TYPE_GUARDIAN,
COMBATLOG_OBJECT_TYPE_PET
);
COMBATLOG_FILTER_FRIENDLY_UNITS = bit.bor(
COMBATLOG_OBJECT_AFFILIATION_PARTY,
COMBATLOG_OBJECT_AFFILIATION_RAID,
COMBATLOG_OBJECT_AFFILIATION_OUTSIDER,
COMBATLOG_OBJECT_REACTION_FRIENDLY,
COMBATLOG_OBJECT_CONTROL_PLAYER,
COMBATLOG_OBJECT_CONTROL_NPC,
COMBATLOG_OBJECT_TYPE_PLAYER,
COMBATLOG_OBJECT_TYPE_NPC,
COMBATLOG_OBJECT_TYPE_PET,
COMBATLOG_OBJECT_TYPE_GUARDIAN,
COMBATLOG_OBJECT_TYPE_OBJECT
);
COMBATLOG_FILTER_HOSTILE_PLAYERS = bit.bor(
COMBATLOG_OBJECT_AFFILIATION_PARTY,
COMBATLOG_OBJECT_AFFILIATION_RAID,
COMBATLOG_OBJECT_AFFILIATION_OUTSIDER,
COMBATLOG_OBJECT_REACTION_HOSTILE,
COMBATLOG_OBJECT_CONTROL_PLAYER,
COMBATLOG_OBJECT_TYPE_PLAYER,
COMBATLOG_OBJECT_TYPE_NPC,
COMBATLOG_OBJECT_TYPE_PET,
COMBATLOG_OBJECT_TYPE_GUARDIAN,
COMBATLOG_OBJECT_TYPE_OBJECT
);
COMBATLOG_FILTER_HOSTILE_UNITS = bit.bor(
COMBATLOG_OBJECT_AFFILIATION_PARTY,
COMBATLOG_OBJECT_AFFILIATION_RAID,
COMBATLOG_OBJECT_AFFILIATION_OUTSIDER,
COMBATLOG_OBJECT_REACTION_HOSTILE,
COMBATLOG_OBJECT_CONTROL_NPC,
COMBATLOG_OBJECT_TYPE_PLAYER,
COMBATLOG_OBJECT_TYPE_NPC,
COMBATLOG_OBJECT_TYPE_PET,
COMBATLOG_OBJECT_TYPE_GUARDIAN,
COMBATLOG_OBJECT_TYPE_OBJECT
);
COMBATLOG_FILTER_NEUTRAL_UNITS = bit.bor(
COMBATLOG_OBJECT_AFFILIATION_PARTY,
COMBATLOG_OBJECT_AFFILIATION_RAID,
COMBATLOG_OBJECT_AFFILIATION_OUTSIDER,
COMBATLOG_OBJECT_REACTION_NEUTRAL,
COMBATLOG_OBJECT_CONTROL_PLAYER,
COMBATLOG_OBJECT_CONTROL_NPC,
COMBATLOG_OBJECT_TYPE_PLAYER,
COMBATLOG_OBJECT_TYPE_NPC,
COMBATLOG_OBJECT_TYPE_PET,
COMBATLOG_OBJECT_TYPE_GUARDIAN,
COMBATLOG_OBJECT_TYPE_OBJECT
);
COMBATLOG_FILTER_UNKNOWN_UNITS = COMBATLOG_OBJECT_NONE;
COMBATLOG_FILTER_EVERYTHING = tonumber("FFFFFFFF", 16);
+27
View File
@@ -0,0 +1,27 @@
-- CooldownFrame_SetTimer for both kinds of cooldown frame. A Cooldown frame
-- (CreateFrame("Cooldown") or <Cooldown>) takes 4.3.4's body (Cooldown.lua),
-- which hands the timer to the frame's own SetCooldown. Every other frame is
-- one of 1.12's indicator Models (CooldownFrameTemplate, as the action, bag,
-- spellbook and paper-doll buttons build theirs) and keeps 1.12's body, which
-- drives the model's sweep from CooldownFrame_OnUpdateModel.
function CooldownFrame_SetTimer(this, start, duration, enable)
if ( this:IsObjectType("Cooldown") ) then
if ( start and start > 0 and duration > 0 and enable > 0 ) then
this:SetCooldown(start, duration);
this:Show();
else
this:Hide();
end
return;
end
if ( start > 0 and duration > 0 and enable > 0) then
this.start = start;
this.duration = duration;
this.stopping = 0;
this:SetSequence(0);
this:Show();
else
this:Hide();
end
end
+29
View File
@@ -1,7 +1,12 @@
if GetLocale() ~= "deDE" then return end
ACTION_BUTTON_USE_KEY_DOWN = "Aktionen werden beim Drücken von\nTasten ausgelöst";
ACTION_TARGETING_OPTION = "Zielerfassung für Aktionen aktivieren";
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
BINDING_NAME_INTERACTMOUSEOVER = "Mit Mouseover interagieren";
BINDING_NAME_INTERACTTARGET = "Mit Ziel interagieren";
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
COMBAT_LABEL = "Kampf";
CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?";
CONFIRM_SAVE_EQUIPMENT_SET = "Möchtet Ihr das Ausrüstungsset \"%s\" speichern?";
DEBUFF_SYMBOL_CURSE = "Fl";
@@ -9,6 +14,7 @@ DEBUFF_SYMBOL_DISEASE = "Kr";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Gi";
DEBUG_FRAMESTACK = "Frame Stack";
ENABLE_INTERACT_TEXT = "Interaktionstaste aktivieren";
EQUIPMENT_MANAGER = "Ausrüstungsmanager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Diesen Platz ignorieren";
EQUIPMENT_MANAGER_IS_DISABLED = "Der Ausrüstungsmanager ist abgeschaltet.";
@@ -26,6 +32,15 @@ ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Ausrüstungstausch fehlgeschlagen. Inventar i
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Einige Gegenstände des Sets '%s' können nicht gewechselt werden, da Ihr Euch im Kampf befindet.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Mindestens ein Teil des Sets '%s' fehlt und kann nicht ausgerüstet werden.";
CLEAR_FOCUS = "Fokus löschen";
INTERACT_ICONS_DEFAULT = "Nur NSCs (Standard)";
INTERACT_ICONS_OPTION = "Interaktionstastensymbole";
INTERACT_ICONS_SHOW_ALL = "Alles anzeigen";
INTERACT_ICONS_SHOW_NONE = "Nichts anzeigen";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "Vordefinierte Tastaturbelegungen für Aktionen reagieren bei gedrückter statt bei losgelassener Taste.";
OPTION_TOOLTIP_ACTION_TARGETING = "Ein Zielerfassungssystem, das Gegner auf Grundlage Eurer Blickrichtung dynamisch anvisiert. Funktioniert im Zusammenspiel mit dem Standardzielerfassungssystem.";
OPTION_TOOLTIP_ENABLE_INTERACT = "Erlaubt Interaktion mit NSCs und Objekten per Tastendruck";
OPTION_TOOLTIP_INTERACT_ICONS = "Legt fest, welche Symbole über NSCs und Gegenständen angezeigt werden, wenn die Interaktionstastenfunktion aktiviert ist.";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Automatischen Angriff stoppen, sobald das Ziel gewechselt wird.";
SET_FOCUS = "Fokus setzen";
FOCUSTARGET = "Ziel als Fokus festlegen";
GEARSETS_POPUP_TEXT = "Setnamen eingeben (Max. 16 Zeichen):";
@@ -35,6 +50,7 @@ ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
ITEM_MISSING = "%s fehlt";
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
STOP_AUTO_ATTACK = "Autom. Angriff stoppen";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "K";
@@ -69,6 +85,15 @@ ITEM_MOD_DODGE_RATING_SHORT = "Ausweichen";
ITEM_MOD_PARRY_RATING_SHORT = "Parieren";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocken";
ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "Heiligzauberschaden";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "Feuerzauberschaden";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "Naturzauberschaden";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "Frostzauberschaden";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "Schattenzauberschaden";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "Arkanzauberschaden";
ITEM_MOD_SPELL_PENETRATION_SHORT = "Zauberdurchschlagskraft";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Rüstungsdurchschlag";
ITEM_MOD_HEALTH_REGEN_SHORT = "Gesundheitsregeneration";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
MINUTES_SECONDS = "%.2d:%.2d";
@@ -147,3 +172,7 @@ SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/benutzen";
SLASH_USE2 = "/use";
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
TEXT_TO_SPEECH = "Text-to-Speech";
TEXT_TO_SPEECH_ADJUST_RATE = "Sprechgeschwindigkeit anpassen";
TEXT_TO_SPEECH_ADJUST_VOLUME = "Sprechlautstärke";
TEXT_TO_SPEECH_VOICE_OPTION = "Stimmenauswahl";
+31 -1
View File
@@ -1,6 +1,12 @@
ACTION_BUTTON_USE_KEY_DOWN = "Cast action keybinds on key down";
ACTION_TARGETING_OPTION = "Enable Action Targeting";
ADDON_OPTIONS = "AddOn Options";
BINDING_NAME_AOELOOT = "AoE Loot";
BINDING_NAME_FOCUSTARGET = "Focus Target";
BINDING_NAME_INTERACTMOUSEOVER = "Interact With Mouseover";
BINDING_NAME_INTERACTTARGET = "Interact With Target";
BINDING_NAME_TARGETFOCUS = "Target Focus";
COMBAT_LABEL = "Combat";
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
COPPER_AMOUNT_SYMBOL = "c";
@@ -10,6 +16,7 @@ DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Frame Stack";
ENABLE_INTERACT_TEXT = "Enable Interact Key";
EQUIPMENT_MANAGER = "Equipment Manager";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignore This Slot";
EQUIPMENT_MANAGER_IS_DISABLED = "The Equipment Manager is disabled.";
@@ -27,6 +34,15 @@ ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Equipment swap failed. Inventory is full.";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped because you are in combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
CLEAR_FOCUS = "Clear Focus";
INTERACT_ICONS_DEFAULT = "NPCs Only (Default)";
INTERACT_ICONS_OPTION = "Interact Key Icons";
INTERACT_ICONS_SHOW_ALL = "Show All";
INTERACT_ICONS_SHOW_NONE = "Show None";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "Action button keybinds will respond on key down, rather than on key up.";
OPTION_TOOLTIP_ACTION_TARGETING = "A targeting system that dynamically targets enemies based on where you're looking. Works alongside the standard targeting system.";
OPTION_TOOLTIP_ENABLE_INTERACT = "Allows you to interact with NPCs and objects using a keypress";
OPTION_TOOLTIP_INTERACT_ICONS = "Controls which icons appear above NPCs and objects when the Interact Key feature is enabled";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Stop auto attacking when you switch targets.";
SET_FOCUS = "Set Focus";
FOCUS_TOKEN_NOT_FOUND = "<no focus>";
FOCUSTARGET = "Focus Target";
@@ -64,6 +80,15 @@ ITEM_MOD_DODGE_RATING_SHORT = "Dodge";
ITEM_MOD_PARRY_RATING_SHORT = "Parry";
ITEM_MOD_BLOCK_RATING_SHORT = "Block";
ITEM_MOD_BLOCK_VALUE_SHORT = "Block Value";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "Holy Spell Damage";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "Fire Spell Damage";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "Nature Spell Damage";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "Frost Spell Damage";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "Shadow Spell Damage";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "Arcane Spell Damage";
ITEM_MOD_SPELL_PENETRATION_SHORT = "Spell Penetration";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Armor Penetration";
ITEM_MOD_HEALTH_REGEN_SHORT = "Health Regeneration";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Damage Per Second";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
@@ -149,5 +174,10 @@ SLASH_TARGET_NEAREST_RAID1 = "/targetraid";
SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/use";
STOP_AUTO_ATTACK = "Stop Auto Attack";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
TEXT_TO_SPEECH = "Text To Speech";
TEXT_TO_SPEECH_ADJUST_RATE = "Adjust Rate of Speech";
TEXT_TO_SPEECH_ADJUST_VOLUME = "Speech Volume";
TEXT_TO_SPEECH_VOICE_OPTION = "Voice Option";
+29
View File
@@ -1,7 +1,12 @@
if GetLocale() ~= "esES" then return end
ACTION_BUTTON_USE_KEY_DOWN = "Ejecutar acción al pulsar tecla";
ACTION_TARGETING_OPTION = "Habilitar selección de objetivo de acción";
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
BINDING_NAME_INTERACTMOUSEOVER = "Interactuar al pasar el ratón";
BINDING_NAME_INTERACTTARGET = "Interactuar con objetivo";
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
COMBAT_LABEL = "Combate";
CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "¿Quieres guardar el conjunto de equipamiento \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Mal";
@@ -9,6 +14,7 @@ DEBUFF_SYMBOL_DISEASE = "En";
DEBUFF_SYMBOL_MAGIC = "Mag";
DEBUFF_SYMBOL_POISON = "Ve";
DEBUG_FRAMESTACK = "Trama de pila";
ENABLE_INTERACT_TEXT = "Habilitar tecla Interactuar";
EQUIPMENT_MANAGER = "Gestor de equipamiento";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorar esta casilla";
EQUIPMENT_MANAGER_IS_DISABLED = "El Gestor de equipamiento está desactivado.";
@@ -26,6 +32,15 @@ ERR_EQUIPMENT_MANAGER_BAGS_FULL = "No se ha podido cambiar el equipamiento. Inve
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Algunos objetos del equipo %s no se cambiarán porque estás en combate.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Falta uno o más objetos del equipo %s y no se ha podido equipar.";
CLEAR_FOCUS = "Quitar foco";
INTERACT_ICONS_DEFAULT = "Solo PNJ (por defecto)";
INTERACT_ICONS_OPTION = "Iconos de tecla Interactuar";
INTERACT_ICONS_SHOW_ALL = "Mostrar todos";
INTERACT_ICONS_SHOW_NONE = "No mostrar ninguno";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "Los atajos de los botones de acción responderán al pulsar una tecla y no al soltarla.";
OPTION_TOOLTIP_ACTION_TARGETING = "Un sistema de selección de objetivo que selecciona de forma dinámica a los enemigos en función de la dirección en la que mires. Funciona junto al sistema de selección de objetivos estándar.";
OPTION_TOOLTIP_ENABLE_INTERACT = "Te permite interactuar con PNJ y objetos pulsando una tecla.";
OPTION_TOOLTIP_INTERACT_ICONS = "Controla los iconos que aparecen sobre los PNJ y objetos cuando se habilita la opción de tecla Interactuar";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Detiene el ataque automático al cambiar de objetivo.";
SET_FOCUS = "Establecer foco";
FOCUSTARGET = "Establecer objetivo";
GEARSETS_POPUP_TEXT = "Nombra este equipamiento (Máx. 16 carac.):";
@@ -35,6 +50,7 @@ ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
ITEM_MISSING = "Falta: %s";
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
STOP_AUTO_ATTACK = "Detener ataque automático";
TARGETFOCUS = "Fijar unidad objetivo";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
@@ -69,6 +85,15 @@ ITEM_MOD_DODGE_RATING_SHORT = "Esquiva";
ITEM_MOD_PARRY_RATING_SHORT = "Parada";
ITEM_MOD_BLOCK_RATING_SHORT = "Bloqueo";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "Daño de hechizos Sagrados";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "Daño de hechizos de Fuego";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "Daño de hechizos de Naturaleza";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "Daño de hechizos de Escarcha";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "Daño de hechizos de Sombras";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "Daño de hechizos Arcanos";
ITEM_MOD_SPELL_PENETRATION_SHORT = "Penetración de hechizos";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Penetración de armadura";
ITEM_MOD_HEALTH_REGEN_SHORT = "Regeneración de salud";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
@@ -147,3 +172,7 @@ SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/usar";
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
TEXT_TO_SPEECH = "Texto a voz";
TEXT_TO_SPEECH_ADJUST_RATE = "Ajustar velocidad de voz";
TEXT_TO_SPEECH_ADJUST_VOLUME = "Volumen de voz";
TEXT_TO_SPEECH_VOICE_OPTION = "Opción de voz";
+29
View File
@@ -1,7 +1,12 @@
if GetLocale() ~= "frFR" then return end
ACTION_BUTTON_USE_KEY_DOWN = "Enfoncer la touche déclenche l’action";
ACTION_TARGETING_OPTION = "Activer le ciblage des actions";
BINDING_NAME_FOCUSTARGET = "Définir cible";
BINDING_NAME_INTERACTMOUSEOVER = "Interagir par survol de la souris";
BINDING_NAME_INTERACTTARGET = "Interagir avec la cible";
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
COMBAT_LABEL = "Combat";
CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?";
CONFIRM_SAVE_EQUIPMENT_SET = "Voulez-vous sauvegarder l'ensemble d'équipement %s ?";
DEBUFF_SYMBOL_CURSE = "Mn";
@@ -9,6 +14,7 @@ DEBUFF_SYMBOL_DISEASE = "Md";
DEBUFF_SYMBOL_MAGIC = "Mg";
DEBUFF_SYMBOL_POISON = "Po";
DEBUG_FRAMESTACK = "Empilement des cadres";
ENABLE_INTERACT_TEXT = "Activer la commande d’interaction";
EQUIPMENT_MANAGER = "Gestionnaire d'équipement";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorer cet emplacement";
EQUIPMENT_MANAGER_IS_DISABLED = "Le Gestionnaire d'équipement est désactivé.";
@@ -26,6 +32,15 @@ ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Échec de l'échange d'équipement. L'inventa
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Certains objets de l'ensemble %s ne peuvent pas être échangés car vous êtes en combat.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Une ou plusieurs pièces de l'ensemble %s manquaient et n'ont pu être équipées.";
CLEAR_FOCUS = "Effacer la focalisation";
INTERACT_ICONS_DEFAULT = "PNJ uniquement (Par défaut)";
INTERACT_ICONS_OPTION = "Icônes de commande d’interaction";
INTERACT_ICONS_SHOW_ALL = "Tout afficher";
INTERACT_ICONS_SHOW_NONE = "Ne rien afficher";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "Les boutons d’action associés à un raccourci clavier se déclencheront au moment où la touche est enfoncée, et non relâchée.";
OPTION_TOOLTIP_ACTION_TARGETING = "Un système de ciblage qui vise en temps réel les adversaires en fonction de l’endroit où vous regardez. Fonctionne en parallèle du système de ciblage standard.";
OPTION_TOOLTIP_ENABLE_INTERACT = "Vous permet d’interagir avec les PNJ et les objets en appuyant sur une touche";
OPTION_TOOLTIP_INTERACT_ICONS = "Contrôle les icônes qui s’affichent au-dessus des PNJ et des objets quand la fonction Commande d’interaction est activée";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Interrompt l'attaque automatique lorsque vous changez de cible.";
SET_FOCUS = "Définir la focalisation";
FOCUSTARGET = "Définir la cible de focalisation";
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
@@ -34,6 +49,7 @@ ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
ITEM_MISSING = "Objet manquant : %s";
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
STOP_AUTO_ATTACK = "Interrompre attaque auto.";
TARGETFOCUS = "Cibler l'unité focalisée";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
@@ -69,6 +85,15 @@ ITEM_MOD_DODGE_RATING_SHORT = "Esquive";
ITEM_MOD_PARRY_RATING_SHORT = "Parade";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocage";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "Dégâts du Sacré";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "Dégâts de Feu";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "Dégâts de Nature";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "Dégâts de Givre";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "Dégâts d'Ombre";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "Dégâts des Arcanes";
ITEM_MOD_SPELL_PENETRATION_SHORT = "Pénétration des sorts";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Pénétration d'armure";
ITEM_MOD_HEALTH_REGEN_SHORT = "Régénération des points de vie";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
@@ -147,3 +172,7 @@ SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/utiliser";
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
TEXT_TO_SPEECH = "Narrateur";
TEXT_TO_SPEECH_ADJUST_RATE = "Modifie la vitesse de la voix";
TEXT_TO_SPEECH_ADJUST_VOLUME = "Volume de la voix";
TEXT_TO_SPEECH_VOICE_OPTION = "Option vocale";
+29
View File
@@ -1,7 +1,12 @@
if GetLocale() ~= "ruRU" then return end
ACTION_BUTTON_USE_KEY_DOWN = "Способности при нажатии клавиш";
ACTION_TARGETING_OPTION = "Разрешить динамическое выделение";
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
BINDING_NAME_INTERACTMOUSEOVER = "Взаимодействовать указателем мыши";
BINDING_NAME_INTERACTTARGET = "Взаимодействовать с целью";
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
COMBAT_LABEL = "Бой";
CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?";
CONFIRM_SAVE_EQUIPMENT_SET = "Хотите сохранить комплект экипировки \"%s\"?";
DEBUFF_SYMBOL_CURSE = "Пр";
@@ -9,6 +14,7 @@ DEBUFF_SYMBOL_DISEASE = "Бз";
DEBUFF_SYMBOL_MAGIC = "Мг";
DEBUFF_SYMBOL_POISON = "Яд";
DEBUG_FRAMESTACK = "Соединение рамок";
ENABLE_INTERACT_TEXT = "Включить клавишу взаимодействия";
EQUIPMENT_MANAGER = "Комплекты экипировки";
EQUIPMENT_MANAGER_IGNORE_SLOT = "Пропустить ячейку";
EQUIPMENT_MANAGER_IS_DISABLED = "Управление экипировкой отключено.";
@@ -26,6 +32,15 @@ ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Не удалось заменить комп
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Некоторые предметы из набора \"%s\" не будут экипированы, поскольку вы участвуете в бою.";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Недостает одного или более предметов из набора \"%s\".";
CLEAR_FOCUS = "Отменить запоминание цели";
INTERACT_ICONS_DEFAULT = "Только NPC (по умолчанию)";
INTERACT_ICONS_OPTION = "Значки клавиш взаимодействия";
INTERACT_ICONS_SHOW_ALL = "Показать все";
INTERACT_ICONS_SHOW_NONE = "Не показывать";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "Способности будут задействоваться, когда пользователь нажимает, а не отпускает клавишу.";
OPTION_TOOLTIP_ACTION_TARGETING = "Система захвата целей, динамически выбирающая цели в поле зрения. Работает параллельно со стандартной системой наведения.";
OPTION_TOOLTIP_ENABLE_INTERACT = "Позволяет взаимодействовать с NPC и объектами по нажатию клавиши.";
OPTION_TOOLTIP_INTERACT_ICONS = "Определяет, какие значки появляются над NPC и объектами, если включен параметр клавиши взаимодействия.";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "Прекращение автоматической атаки при смене цели.";
SET_FOCUS = "Запомнить цель";
FOCUSTARGET = "Запомнить цель";
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
@@ -34,6 +49,7 @@ ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов;
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
STOP_AUTO_ATTACK = "Прекращение автоатаки";
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
@@ -69,6 +85,15 @@ ITEM_MOD_DODGE_RATING_SHORT = "Уклонение";
ITEM_MOD_PARRY_RATING_SHORT = "Парирование";
ITEM_MOD_BLOCK_RATING_SHORT = "Блок";
ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "к урону от магии света";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "к урону от магии огня";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "к урону от сил природы";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "к урону от магии льда";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "к урону от темной магии";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "к урону от тайной магии";
ITEM_MOD_SPELL_PENETRATION_SHORT = "к проникающей способности заклинаний";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "к пробиванию брони";
ITEM_MOD_HEALTH_REGEN_SHORT = "к восполнению здоровья";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
@@ -147,3 +172,7 @@ SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/использовать";
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
TEXT_TO_SPEECH = "Текст в речь";
TEXT_TO_SPEECH_ADJUST_RATE = "Изменить скорость речи.";
TEXT_TO_SPEECH_ADJUST_VOLUME = "Громкость речи";
TEXT_TO_SPEECH_VOICE_OPTION = "Параметры голоса";
+29
View File
@@ -1,7 +1,12 @@
if GetLocale() ~= "zhCN" then return end
ACTION_BUTTON_USE_KEY_DOWN = "按下快捷键时施法";
ACTION_TARGETING_OPTION = "开启动作瞄准";
BINDING_NAME_FOCUSTARGET = "焦点目标";
BINDING_NAME_INTERACTMOUSEOVER = "与鼠标悬停处互动";
BINDING_NAME_INTERACTTARGET = "与目标互动";
BINDING_NAME_TARGETFOCUS = "目标焦点";
COMBAT_LABEL = "战斗";
CONFIRM_DELETE_EQUIPMENT_SET = "你确认要删除装备方案%s吗?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要保存装备方案\"%s\"吗?";
DEBUFF_SYMBOL_CURSE = "诅";
@@ -9,6 +14,7 @@ DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架栈";
ENABLE_INTERACT_TEXT = "开启交互按键";
EQUIPMENT_MANAGER = "装备管理";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此栏位";
EQUIPMENT_MANAGER_IS_DISABLED = "装备管理已禁用。";
@@ -26,6 +32,15 @@ ERR_EQUIPMENT_MANAGER_BAGS_FULL = "装备更换失败。背包已满。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "装备方案%s中的某些物品不能在战斗中切换。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "未找到套装方案%s中的一件或多件物品,无法装备。";
CLEAR_FOCUS = "清除焦点";
INTERACT_ICONS_DEFAULT = "仅限NPC(默认)";
INTERACT_ICONS_OPTION = "交互按键图标";
INTERACT_ICONS_SHOW_ALL = "显示全部";
INTERACT_ICONS_SHOW_NONE = "全不显示";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "在按下快捷键时施法,而不是在松开快捷键时施法。";
OPTION_TOOLTIP_ACTION_TARGETING = "此瞄准系统可以以你观看的方向动态地瞄准敌人。可以和标准的瞄准系统一起使用。";
OPTION_TOOLTIP_ENABLE_INTERACT = "使你可以通过按键与NPC和物体进行交互";
OPTION_TOOLTIP_INTERACT_ICONS = "控制交互按键启用后NPC和物体上方出现的图标";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "当你切换目标时停止自动攻击。";
SET_FOCUS = "设置焦点";
FOCUSTARGET = "设定焦点目标";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
@@ -34,6 +49,7 @@ ITEMS_IN_INVENTORY = "背包中有%d件物品";
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
ITEM_MISSING = "%s缺失";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
STOP_AUTO_ATTACK = "停止自动攻击";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
@@ -69,6 +85,15 @@ ITEM_MOD_DODGE_RATING_SHORT = "躲闪";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格挡";
ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "神圣伤害加成";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "火焰伤害加成";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "自然伤害加成";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "冰霜伤害加成";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "暗影伤害加成";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "奥术伤害加成";
ITEM_MOD_SPELL_PENETRATION_SHORT = "法术穿透";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "护甲穿透";
ITEM_MOD_HEALTH_REGEN_SHORT = "生命值恢复";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
@@ -147,3 +172,7 @@ SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/use";
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
TEXT_TO_SPEECH = "文本转语音";
TEXT_TO_SPEECH_ADJUST_RATE = "调节讲话速度";
TEXT_TO_SPEECH_ADJUST_VOLUME = "音量";
TEXT_TO_SPEECH_VOICE_OPTION = "语音设置";
+29
View File
@@ -1,7 +1,12 @@
if GetLocale() ~= "zhTW" then return end
ACTION_BUTTON_USE_KEY_DOWN = "於按下按鍵時施放技能";
ACTION_TARGETING_OPTION = "啟用行動鎖定";
BINDING_NAME_FOCUSTARGET = "鎖定目標";
BINDING_NAME_INTERACTMOUSEOVER = "與滑鼠指定的目標互動";
BINDING_NAME_INTERACTTARGET = "與目標互動";
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
COMBAT_LABEL = "戰鬥";
CONFIRM_DELETE_EQUIPMENT_SET = "你確定要刪除裝備設定:%s嗎?";
CONFIRM_SAVE_EQUIPMENT_SET = "你想要儲存裝備設定「%s」嗎?";
DEBUFF_SYMBOL_CURSE = "咒";
@@ -9,6 +14,7 @@ DEBUFF_SYMBOL_DISEASE = "疾";
DEBUFF_SYMBOL_MAGIC = "魔";
DEBUFF_SYMBOL_POISON = "毒";
DEBUG_FRAMESTACK = "框架堆疊";
ENABLE_INTERACT_TEXT = "啟用互動鍵";
EQUIPMENT_MANAGER = "裝備管理員";
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此欄位";
EQUIPMENT_MANAGER_IS_DISABLED = "裝備管理員已關閉。";
@@ -26,6 +32,15 @@ ERR_EQUIPMENT_MANAGER_BAGS_FULL = "裝備切換失敗。物品欄已滿。";
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "%s設定中的某些物品必須等到你脫離戰鬥後才能更換。";
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "%s設定中的一個或多個物品未尋獲,因此無法裝備。";
CLEAR_FOCUS = "清除專注目標";
INTERACT_ICONS_DEFAULT = "僅限NPC(預設)";
INTERACT_ICONS_OPTION = "互動鍵圖示";
INTERACT_ICONS_SHOW_ALL = "顯示全部";
INTERACT_ICONS_SHOW_NONE = "不顯示圖示";
OPTION_TOOLTIP_ACTION_BUTTON_USE_KEY_DOWN = "快捷鍵按鍵設定會在按下按鍵時生效,而不是在鬆開按鍵的時候。";
OPTION_TOOLTIP_ACTION_TARGETING = "根據你的視線方向,動態鎖定敵人的鎖定系統。與標準鎖定系統相輔相成。";
OPTION_TOOLTIP_ENABLE_INTERACT = "可透過按鍵與NPC和物件互動";
OPTION_TOOLTIP_INTERACT_ICONS = "在啟用互動鍵功能後,控制出現在NPC和物件上方的圖示";
OPTION_TOOLTIP_STOP_AUTO_ATTACK = "當你切換目標時停止自動攻擊。";
SET_FOCUS = "設定專注目標";
FOCUSTARGET = "設定鎖定目標";
ITEMS_VARIABLE_QUANTITY = "%d件物品";
@@ -34,6 +49,7 @@ ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
ITEM_MISSING = "缺少%s物品";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
STOP_AUTO_ATTACK = "停止自動攻擊";
TARGETFOCUS = "選定鎖定單位";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "銅";
@@ -69,6 +85,15 @@ ITEM_MOD_DODGE_RATING_SHORT = "閃躲";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格擋";
ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
ITEM_MOD_HOLY_SPELL_DAMAGE_DONE_SHORT = "神聖傷害加成";
ITEM_MOD_FIRE_SPELL_DAMAGE_DONE_SHORT = "火焰傷害加成";
ITEM_MOD_NATURE_SPELL_DAMAGE_DONE_SHORT = "自然傷害加成";
ITEM_MOD_FROST_SPELL_DAMAGE_DONE_SHORT = "冰霜傷害加成";
ITEM_MOD_SHADOW_SPELL_DAMAGE_DONE_SHORT = "暗影傷害加成";
ITEM_MOD_ARCANE_SPELL_DAMAGE_DONE_SHORT = "秘法傷害加成";
ITEM_MOD_SPELL_PENETRATION_SHORT = "法術穿透";
ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "護甲穿透";
ITEM_MOD_HEALTH_REGEN_SHORT = "生命力恢復";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
@@ -147,3 +172,7 @@ SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/使用";
SLASH_USE2 = "/use";
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
TEXT_TO_SPEECH = "文字轉語音";
TEXT_TO_SPEECH_ADJUST_RATE = "調整語速";
TEXT_TO_SPEECH_ADJUST_VOLUME = "語音音量";
TEXT_TO_SPEECH_VOICE_OPTION = "聲音選項";
+104 -68
View File
@@ -3,7 +3,7 @@
--
-- Major changes from the 3.3.5 source:
-- * Lua 5.0 has no "..." varargs and no select(); script handlers take
-- explicit (this, event, arg1..arg9) parameters from XML inline scripts.
-- explicit (this, event, arg1..arg19) parameters from XML inline scripts.
-- * RegisterAllEvents is used when available; the curated default list is
-- a fallback if the client doesn't expose it.
-- * /fstack accepts "true" (as on 3.3.5) or "hidden" to list hidden
@@ -155,8 +155,8 @@ end);
local _EventTraceFrame;
_framesSinceLast = 0;
_timeSinceLast = 0;
local _framesSinceLast = 0;
local _timeSinceLast = 0;
local _timer = CreateFrame("Frame");
_timer:SetScript("OnUpdate", function()
@@ -168,12 +168,55 @@ end);
-- EventTraceFrame
------------------------------------------------------------------------------
-- The engine sets at most arg1..arg19 for an event, so the XML OnEvent passes
-- all 19 globals and trailing nils mark the end.
EVENT_TRACE_MAX_ARGS = 19;
-- Entries keep an ever-growing index (lastIndex, filteredIndices,
-- button.index), but are stored in a ring of EVENT_TRACE_MAX_ENTRIES fixed
-- slots. Keys that only grow would make every column table rehash, and
-- reallocate, every few events.
local function _Slot(index)
local f = _EventTraceFrame;
if (not index or index < 1 or index > f.lastIndex
or index <= f.lastIndex - EVENT_TRACE_MAX_ENTRIES) then
return nil;
end
return math.mod(index - 1, EVENT_TRACE_MAX_ENTRIES) + 1;
end
-- An event row stores only its GetTime(); the text is built when the row
-- is drawn, so a closed window builds no strings.
local function _TimeText(slot)
local f = _EventTraceFrame;
local kind = f.times[slot];
if (kind) then return kind; end
local seconds = f.rawtimes[slot];
local minutes = math.floor(math.floor(seconds) / 60);
local hours = math.floor(minutes / 60);
seconds = seconds - 60 * minutes;
minutes = minutes - 60 * hours;
hours = math.mod(hours, 1000);
return string.format("%.2d:%.2d:%06.3f", hours, minutes, seconds);
end
local function _EventText(slot)
local f = _EventTraceFrame;
if (f.times[slot] == "Elapsed") then
return string.format("%.3f sec - %d frame(s)", f.elapsed[slot], f.frames[slot]);
end
return f.events[slot];
end
function EventTraceFrame_OnLoad(self)
self.buttons = {};
self.events = {};
self.times = {};
self.rawtimes = {};
self.args = { {}, {}, {}, {}, {}, {}, {}, {}, {} };
self.elapsed = {};
self.frames = {};
self.args = {};
for i = 1, EVENT_TRACE_MAX_ARGS do self.args[i] = {}; end
self.ignoredEvents = {};
self.lastIndex = 0;
self.visibleButtons = EVENT_TRACE_NUM_BUTTONS;
@@ -205,7 +248,8 @@ function EventTraceFrame_OnLoad(self)
end
end
function EventTraceFrame_OnEvent(self, event, a1, a2, a3, a4, a5, a6, a7, a8, a9)
function EventTraceFrame_OnEvent(self, event, a1, a2, a3, a4, a5, a6, a7, a8, a9,
a10, a11, a12, a13, a14, a15, a16, a17, a18, a19)
if (self.ignoredEvents[event]) then return; end
-- Pause swallows real engine events but lets the synthetic
-- "Begin Capture"/"End Capture"/"On Update" markers through so the
@@ -222,67 +266,54 @@ function EventTraceFrame_OnEvent(self, event, a1, a2, a3, a4, a5, a6, a7, a8, a9
end
local nextIndex = self.lastIndex + 1;
if (nextIndex > EVENT_TRACE_MAX_ENTRIES) then
local staleIndex = nextIndex - EVENT_TRACE_MAX_ENTRIES;
self.events[staleIndex] = nil;
self.times[staleIndex] = nil;
self.rawtimes[staleIndex] = nil;
for k, v in pairs(self.args) do
self.args[k][staleIndex] = nil;
end
end
local slot = math.mod(nextIndex - 1, EVENT_TRACE_MAX_ENTRIES) + 1;
-- Every column of the slot is written, so nothing from the entry it
-- replaces is left behind.
self.times[slot] = nil;
self.elapsed[slot] = nil;
self.frames[slot] = nil;
if (string.find(event, "Begin Capture", 1, true) or
string.find(event, "End Capture", 1, true)) then
self.times[nextIndex] = "System";
self.times[slot] = "System";
if (self.eventsToCapture) then
self.events[nextIndex] = string.format("%s (%s events)", event,
tostring(self.eventsToCapture));
self.events[slot] = string.format("%s (%s events)", event,
tostring(self.eventsToCapture));
else
self.events[nextIndex] = event;
self.events[slot] = event;
end
elseif (event == "On Update") then
self.times[nextIndex] = "Elapsed";
self.events[nextIndex] = string.format(
string.format("%.3f sec", _timeSinceLast) .. " - %d frame(s)",
_framesSinceLast);
self.times[slot] = "Elapsed";
self.events[slot] = event;
self.elapsed[slot] = _timeSinceLast;
self.frames[slot] = _framesSinceLast;
_timeSinceLast = 0;
_framesSinceLast = 0;
else
self.events[nextIndex] = event;
local seconds = GetTime();
local minutes = math.floor(math.floor(seconds) / 60);
local hours = math.floor(minutes / 60);
seconds = seconds - 60 * minutes;
minutes = minutes - 60 * hours;
hours = math.mod(hours, 1000);
self.times[nextIndex] = string.format("%.2d:%.2d:%06.3f", hours, minutes, seconds);
local packed = { a1, a2, a3, a4, a5, a6, a7, a8, a9 };
local numArgs = 0;
for i = 9, 1, -1 do
if (packed[i] ~= nil) then
numArgs = i;
break;
end
end
for i = 1, numArgs do
if (not self.args[i]) then self.args[i] = {}; end
self.args[i][nextIndex] = packed[i];
end
self.events[slot] = event;
if (self.eventsToCapture) then
self.eventsToCapture = self.eventsToCapture - 1;
end
end
self.rawtimes[nextIndex] = GetTime();
-- Stored straight into the per-argument columns: a table to pack them
-- would be one more allocation per event. The markers pass none.
local args = self.args;
args[1][slot] = a1; args[2][slot] = a2; args[3][slot] = a3;
args[4][slot] = a4; args[5][slot] = a5; args[6][slot] = a6;
args[7][slot] = a7; args[8][slot] = a8; args[9][slot] = a9;
args[10][slot] = a10; args[11][slot] = a11; args[12][slot] = a12;
args[13][slot] = a13; args[14][slot] = a14; args[15][slot] = a15;
args[16][slot] = a16; args[17][slot] = a17; args[18][slot] = a18;
args[19][slot] = a19;
self.rawtimes[slot] = GetTime();
self.lastIndex = nextIndex;
-- Keep the filtered view in sync if a search is active. We append-
-- on-match here rather than re-walking the whole event list each
-- tick (would be O(n) per event for the most common case where
-- the search matches none / many).
if (self.filteredIndices and _EventTrace_MatchesSearch(self.events[nextIndex])) then
if (self.filteredIndices and _EventTrace_MatchesSearch(self.events[slot])) then
table.insert(self.filteredIndices, nextIndex);
-- Bound the filtered list to the same MAX_ENTRIES window the
-- raw store enforces. Drop the head when we overflow.
@@ -291,7 +322,8 @@ function EventTraceFrame_OnEvent(self, event, a1, a2, a3, a4, a5, a6, a7, a8, a9
table.remove(self.filteredIndices, 1);
end
end
EventTraceFrame_Update();
-- No redraw here: OnUpdate redraws once per frame while the window is
-- shown, and a hidden window needs none.
if (self.eventsToCapture and self.eventsToCapture <= 0) then
self.eventsToCapture = nil;
EventTraceFrame_StopEventCapture();
@@ -331,7 +363,7 @@ function _EventTrace_RebuildFilter()
local indices = {};
local first = math.max(1, (_EventTraceFrame.lastIndex or 0) - EVENT_TRACE_MAX_ENTRIES + 1);
for i = first, (_EventTraceFrame.lastIndex or 0) do
local ev = _EventTraceFrame.events[i];
local ev = _EventTraceFrame.events[_Slot(i)];
if (ev and string.find(string.lower(ev), needle, 1, true)) then
table.insert(indices, i);
end
@@ -368,16 +400,16 @@ end
-- on, appends a comma-separated rendering of the captured args after
-- the event name. System rows (Begin/End Capture, On Update markers)
-- never get args.
function _EventTrace_FormatRow(index)
local ev = _EventTraceFrame.events[index];
function _EventTrace_FormatRow(slot)
local ev = _EventText(slot);
if (not ev) then return nil; end
if (not _EventTraceFrame.showArguments) then return ev; end
if (EVENT_TRACE_SYSTEM_TIMES[_EventTraceFrame.times[index]]) then return ev; end
if (EVENT_TRACE_SYSTEM_TIMES[_EventTraceFrame.times[slot]]) then return ev; end
local parts = {};
for i = 1, 9 do
for i = 1, EVENT_TRACE_MAX_ARGS do
local argList = _EventTraceFrame.args[i];
if (argList and argList[index] ~= nil) then
table.insert(parts, EventTrace_FormatArgValue(argList[index]));
if (argList and argList[slot] ~= nil) then
table.insert(parts, EventTrace_FormatArgValue(argList[slot]));
end
end
if (table.getn(parts) == 0) then return ev; end
@@ -502,9 +534,10 @@ function EventTraceFrame_Update()
local button = _EventTraceFrame.buttons[i];
if (button) then
local index = _EventTrace_ResolveIndex(scrollBarValue, i - 1);
local event = index and _EventTraceFrame.events[index];
local slot = _Slot(index);
local event = slot and _EventTraceFrame.events[slot];
if (event) then
local timeString = _EventTraceFrame.times[index];
local timeString = _EventTraceFrame.times[slot];
button.index = index;
local timeFS = getglobal(button:GetName() .. "Time");
local eventFS = getglobal(button:GetName() .. "Event");
@@ -513,11 +546,11 @@ function EventTraceFrame_Update()
-- left edge stable, which matters when toggling on/off
-- mid-session.
if (_EventTraceFrame.showTimestamp) then
timeFS:SetText(timeString);
timeFS:SetText(_TimeText(slot));
else
timeFS:SetText("");
end
eventFS:SetText(_EventTrace_FormatRow(index));
eventFS:SetText(_EventTrace_FormatRow(slot));
local color = EVENT_TRACE_EVENT_COLORS[event]
or EVENT_TRACE_EVENT_COLORS[timeString];
if (color) then
@@ -649,7 +682,10 @@ function EventTraceFrame_HandleSlashCmd(msg)
_EventTraceFrame.events = {};
_EventTraceFrame.times = {};
_EventTraceFrame.rawtimes = {};
_EventTraceFrame.args = { {}, {}, {}, {}, {}, {}, {}, {}, {} };
_EventTraceFrame.elapsed = {};
_EventTraceFrame.frames = {};
_EventTraceFrame.args = {};
for i = 1, EVENT_TRACE_MAX_ARGS do _EventTraceFrame.args[i] = {}; end
_EventTraceFrame.lastIndex = 0;
_EventTraceFrame.filteredIndices = nil;
EventTraceFrame_Update();
@@ -730,30 +766,30 @@ function EventTrace_FormatArgValue(val)
end
function EventTraceFrameEvent_DisplayTooltip(eventButton)
local index = eventButton.index;
if (not index) then return; end
local slot = _Slot(eventButton.index);
if (not slot) then return; end
local tooltip = EventTraceTooltip;
tooltip:SetOwner(eventButton, "ANCHOR_NONE");
tooltip:SetPoint("TOPLEFT", eventButton, "TOPRIGHT", 24, 2);
local timeString = _EventTraceFrame.times[index];
local timeString = _EventTraceFrame.times[slot];
if (EVENT_TRACE_SYSTEM_TIMES[timeString]) then
tooltip:AddLine(timeString, 1, 1, 1);
tooltip:AddDoubleLine(TIME_ENTRY_FORMAT,
tostring(_EventTraceFrame.rawtimes[index]),
tostring(_EventTraceFrame.rawtimes[slot]),
1, .82, 0, 1, 1, 1);
tooltip:AddDoubleLine(DETAILS_ENTRY_FORMAT,
_EventTraceFrame.events[index],
_EventText(slot),
1, .82, 0, 1, 1, 1);
else
tooltip:AddLine(_EventTraceFrame.events[index], 1, 1, 1);
tooltip:AddLine(_EventTraceFrame.events[slot], 1, 1, 1);
tooltip:AddDoubleLine(TIME_ENTRY_FORMAT,
tostring(_EventTraceFrame.rawtimes[index]),
tostring(_EventTraceFrame.rawtimes[slot]),
1, .82, 0, 1, 1, 1);
for k, v in ipairs(_EventTraceFrame.args) do
if (v[index] ~= nil) then
if (v[slot] ~= nil) then
tooltip:AddDoubleLine(string.format(ARGUMENT_ENTRY_FORMAT, k),
EventTrace_FormatArgValue(v[index]),
EventTrace_FormatArgValue(v[slot]),
1, .82, 0, 1, 1, 1);
end
end
+1 -1
View File
@@ -256,7 +256,7 @@
</Frames>
<Scripts>
<OnLoad>EventTraceFrame_OnLoad(this);</OnLoad>
<OnEvent>EventTraceFrame_OnEvent(this, event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9);</OnEvent>
<OnEvent>EventTraceFrame_OnEvent(this, event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, arg14, arg15, arg16, arg17, arg18, arg19);</OnEvent>
<OnUpdate>EventTraceFrame_OnUpdate(this, arg1);</OnUpdate>
</Scripts>
</Frame>
+24 -49
View File
@@ -45,7 +45,7 @@ set(TINYCBOR_SOURCES
# always pass locally. CI release builds pass `-DCLASSICAPI_TAG=vX.Y.Z`,
# which drives BOTH the compiled `CLASSIC_API_VERSION` int AND the
# embedded addon's `## Version:` toc line (substituted in
# embed_addon.cmake). The on-disk source toc stays `## Version: DEV`;
# embed_resources.cmake). The on-disk source toc stays `## Version: DEV`;
# only the embedded copy is stamped, so a release build needs nothing
# but the flag — no source edit.
set(CLASSICAPI_VERSION_VALUE 99999999)
@@ -73,55 +73,30 @@ elseif(CLASSICAPI_TAG)
)
endif()
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
# surface the addon to the engine even when the user doesn't have it
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
# returns these buffers when the engine asks for paths under
# `Interface\AddOns\!!!ClassicAPI\`.
set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
"${EMBEDDED_CLASSICAPI_SRC}/*")
# The `.classicapi-dev` marker is a disk-only developer signal — keep it
# out of the embed inputs (embed_addon.cmake filters it too).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "/\\.classicapi-dev$")
add_custom_command(
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
-DADDON_NAME=ClassicAPI
-DADDON_VERSION=${CLASSICAPI_TOC_VERSION}
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
DEPENDS
${EMBEDDED_CLASSICAPI_FILES}
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
COMMENT "Embedding !!!ClassicAPI addon into ${EMBEDDED_CLASSICAPI_HEADER}"
VERBATIM)
# Bundle `src/bindings/Bindings.xml` (our TARGETING-group additions)
# into a generated header. `src/bindings/Inject.cpp` extracts the
# inner `<Binding>` elements from the embedded buffer and splices them
# into the engine's `Interface\FrameXML\Bindings.xml` at load time.
set(EMBEDDED_BINDINGS_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml")
set(EMBEDDED_BINDINGS_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_bindings.h")
add_custom_command(
OUTPUT "${EMBEDDED_BINDINGS_HEADER}"
COMMAND ${CMAKE_COMMAND}
-DINPUT_FILE=${EMBEDDED_BINDINGS_SRC}
-DOUT_HEADER=${EMBEDDED_BINDINGS_HEADER}
-DSYMBOL=kEmbeddedBindingsXml
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
DEPENDS
"${EMBEDDED_BINDINGS_SRC}"
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_file.cmake"
COMMENT "Embedding bindings XML into ${EMBEDDED_BINDINGS_HEADER}"
VERBATIM)
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES}
${EMBEDDED_CLASSICAPI_HEADER}
${EMBEDDED_BINDINGS_HEADER})
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
# Embed `AddOns/!!!ClassicAPI/` and `src/bindings/Bindings.xml` as RCDATA
# resources. The file-read hook in `src/addons/Embedded.cpp` serves the
# addon files when the engine asks for paths under
# `Interface\AddOns\!!!ClassicAPI\`, so the addon loads even when the user
# doesn't have it on disk. `src/bindings/Inject.cpp` splices the bindings
# XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time.
include(cmake/embed_resources.cmake)
set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS RELATIVE
"${EMBEDDED_CLASSICAPI_SRC}" "${EMBEDDED_CLASSICAPI_SRC}/*")
# Never embed the developer marker: it must reflect on-disk presence only
# (see DiskHasDevMarker in src/addons/Embedded.cpp).
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
embed_resources(
TARGET ${PROJECT_NAME}
ADDON_DIR "${EMBEDDED_CLASSICAPI_SRC}"
ADDON_FILES ${EMBEDDED_CLASSICAPI_FILES}
ADDON_VERSION "${CLASSICAPI_TOC_VERSION}"
BINDINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml"
OUT_DIR "${CMAKE_BINARY_DIR}/generated")
target_compile_definitions(
${PROJECT_NAME}
+86 -14
View File
@@ -33,8 +33,11 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| Inline textures | Draws inline texture markup (`\|T…\|t`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
| [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract) | The Interact Key. After you bind **Interact With Target** to a key, the key interacts with the nearest object in front of you. This object can be an NPC with a service, a corpse that you can loot or skin, a mailbox, a chest, or an herb or mining node. The `softinteract` and `anyinteract` unit tokens point at the object, and `PLAYER_SOFT_INTERACT_CHANGED` fires when it changes. When `SoftTargetIconInteract` is `1`, an icon over the object shows the cursor that the mouse shows over it. |
| [Soft enemy and soft friend](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) | The `softenemy` and `softfriend` unit tokens point at the nearest enemy and the nearest friendly unit, and your target does not change. When your target qualifies, they point at your target. `anyenemy` and `anyfriend` use your target first. `PLAYER_SOFT_ENEMY_CHANGED` and `PLAYER_SOFT_FRIEND_CHANGED` fire when the units change. With no target, an attack goes to `softenemy`, a harmful spell goes to `softenemy` and a helpful spell goes to `softfriend`. A harmful spell makes its unit a loose target, which follows the nearest soft enemy. A dimmer selection circle shows under each unit. `SoftTargetIconEnemy` and `SoftTargetIconFriend` show an icon over each unit. Turn them on with `SoftTargetEnemy` and `SoftTargetFriend`. |
| [Animations](docs/API.md#animation) | Animation groups on frames, textures and font strings, made in Lua or XML: `CreateAnimationGroup`, looping, smoothing, scripts and templates, with `Alpha`, `Translation`, `Scale`, `Rotation` and `Path` applied. |
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, and a button's `<NormalFont style="..."/>` sets its font. |
| [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. |
| [Macro conditions](docs/API.md#more-commands-with-conditions) | `[conditions]` and `@unit` work on 38 slash commands, from `/cast` and `/use` to `/target`, `/follow`, `/cancelaura` and the `/pet` family. With `/cast [@mouseover,harm][] Fireball`, the spell goes to the mouseover unit. With no mouseover unit, it goes to your target. `@cursor` places a ground-target spell or item under the mouse and `@player` places it at your feet. A character name works where a unit token does (`[target=Feral]`). Addons read the same rules through `SecureCmdOptionParse`. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows what the macro does. The icon, tooltip, cooldown, range, usable state and auto-repeat glow all follow the spell or item that the directive resolves to, conditions included. They update as the conditions change. The result goes into the engine field that action-bar addons already read, so they need no code of their own. An addon with its own macro parser can publish its result through `C_Macro.SetMacroDisplay`. It then does not need to replace the action globals. |
| [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. |
@@ -56,21 +59,25 @@ reference in **[docs/API.md](docs/API.md)**.
<details>
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
In the Frame rows, a name that starts with `:` is a method of the widget that the row names, for example `:SetShown` is `frame:SetShown`.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnInterfaceVersion`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [APIDocumentation](docs/API.md#apidocumentation) | `/classicapi` in-game browser, `C_APIDocumentation.GetSystem`, `C_APIDocumentation.GetSystems` |
| [Animation](docs/API.md#animation) | `region:CreateAnimationGroup`, `region:GetAnimationGroups`, `region:StopAnimating`, `SetMaxAnimFramerate`, `GetMaxAnimFramerate`, `AnimationGroup` and `Animation` / `Alpha` / `Translation` / `Scale` / `Rotation` / `Path` / `ControlPoint` methods |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings`, `GetBindingAction` (`checkOverride`), `IsModifiedClick`, `GetModifiedClick`, `SetModifiedClick`, `GetNumModifiedClickActions`, `GetModifiedClickAction`, `ActionButtonUseKeyDown` (cvar) |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings`, `GetBindingAction` (`checkOverride`), `GetBindingByKey`, `IsModifiedClick`, `GetModifiedClick`, `SetModifiedClick`, `GetNumModifiedClickActions`, `GetModifiedClickAction` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ClassColor](docs/API.md#classcolor) | `C_ClassColor.GetClassColor` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown`, `StartAttack`, `StopAttack` |
| [CombatLog](docs/API.md#combatlog) | `CombatLogGetCurrentEventInfo`, `COMBATLOG_OBJECT_*` (constants) |
| [Console](docs/API.md#console) | `CalculateStringEditDistance`, `ConsoleEcho`, `ConsoleExec`, `ConsoleGetAllCommands`, `ConsoleGetColorFromType`, `ConsoleGetFontHeight`, `ConsoleIsActive`, `ConsolePrintAllMatchingCommands`, `SetConsoleKey` |
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.RemoveTempCVar`, `C_CVar.SetCVarBitfield`, `C_CVar.SetTempCVar`, `GetCVar`, `GetCVarDefault` |
| [CVar](docs/API.md#cvar) | `C_CVar.AreCVarsLoaded`, `C_CVar.DoesCVarExist`, `C_CVar.GetCVarBitfield`, `C_CVar.GetCVarBool`, `C_CVar.GetCVarInfo`, `C_CVar.RemoveTempCVar`, `C_CVar.SetCVar`, `C_CVar.SetCVarBitfield`, `C_CVar.SetTempCVar`, `GetCVar`, `GetCVarDefault` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
@@ -81,38 +88,52 @@ reference in **[docs/API.md](docs/API.md)**.
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `CreateFrame("Cooldown")`, `cooldown:SetCooldown`, `cooldown:SetReverse`, `cooldown:GetReverse`, `cooldown:SetDrawEdge`, `cooldown:GetDrawEdge`, `frame:SetShown`, `frame:SetClampRectInsets`, `frame:GetClampRectInsets`, `smf:SetHyperlinksEnabled`, `smf:GetHyperlinksEnabled`, `editBox:SetCountInvisibleLetters`, `editBox:IsCountInvisibleLetters`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `texture:SetAtlas`, `texture:GetAtlas`, `texture:ResetTexCoord`, `texture:SetSpriteSheetCell`, `texture:SetDesaturation`, `texture:GetDesaturation`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsProtected`, `button:SetNormalFontObject`, `button:GetNormalFontObject`, `frame:IsEventRegistered`, `frame:RegisterUnitEvent`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `button:RegisterForClicks("AnyUp"/"AnyDown")`, `GetClickFrame` |
| [Frame: Region](docs/API.md#regionsetpointpoint-one-argument-form) | `:SetPoint("point")` (one-arg form), `:SetSize` / `:GetSize`, `:GetRect`, `:IsMouseOver`, `:IsDragging`, `:IsProtected` / `:CanChangeProtectedState` |
| [Frame: Frame](docs/API.md#createframeframetype--name-parent-template-id) | `CreateFrame` template lists and `id`, `GetMouseFoci`, `:SetShown`, `:SetClampRectInsets` / `:GetClampRectInsets`, `:SetResizeBounds`, `:GetBoundsRect`, `:GetEffectiveAlpha`, `:HookScript`, `:IsEventRegistered`, `:RegisterUnitEvent`, `:SetPropagateKeyboardInput` / `:GetPropagateKeyboardInput`, `:SetDontSavePosition` / `:GetDontSavePosition`, `:SetAttribute`, `:SetAttributeNoHandler`, `:ClearAttribute`, `:GetAttribute`, `:CanChangeAttribute`, `:AllowAttributeChanges` |
| [Frame: Button](docs/API.md#buttonsetnormalfontobjectfont--buttongetnormalfontobject) | `:SetEnabled`, `:SetFormattedText`, `:SetNormalFontObject` / `:GetNormalFontObject`, `:SetMotionScriptsWhileDisabled` / `:GetMotionScriptsWhileDisabled`, `:RegisterForClicks("AnyUp"/"AnyDown")`, `:RegisterForMouse`, `GetClickFrame` |
| [Frame: FontString](docs/API.md#fontstringgetstringheight) | `:GetStringHeight`, `:GetUnboundedStringWidth`, `:GetWrappedWidth`, `:GetNumLines`, `:GetLineHeight`, `:GetFontHeight` / `:SetFontHeight` (also on fonts), `:GetFieldSize`, `:IsTruncated`, `:SetMaxLines` / `:GetMaxLines`, `:SetWordWrap` / `:CanWordWrap`, `:SetIndentedWordWrap` / `:GetIndentedWordWrap` (also on fonts, edit boxes, message frames, and SimpleHTML), `:SetFormattedText`, `:SetRotation` / `:GetRotation` |
| [Frame: Texture](docs/API.md#texturesetrotationangle--cx-cy) | `:SetRotation` / `:GetRotation`, `:SetVertexOffset` / `:GetVertexOffset`, `:SetColorTexture`, `:SetAtlas` / `:GetAtlas`, `:ResetTexCoord`, `:SetSpriteSheetCell`, `:SetDesaturation` / `:GetDesaturation`, `IsDesaturateSupported`, `:SetMask`, `frame:CreateMaskTexture`, `:AddMaskTexture` / `:RemoveMaskTexture`, `:GetNumMaskTextures`, `:GetMaskTexture` |
| [Frame: EditBox](docs/API.md#editboxsetcursorpositionposition) | `:SetCursorPosition` / `:GetCursorPosition`, `:GetUTF8CursorPosition`, `:ClearHighlightText`, `:SetHighlightColor` / `:GetHighlightColor`, `:HasFocus`, `GetCurrentKeyBoardFocus`, `:HasText`, `:ClearHistory`, `:Enable` / `:Disable` / `:IsEnabled`, `:SetCountInvisibleLetters` / `:IsCountInvisibleLetters`, `:IsInIMECompositionMode` |
| [Frame: StatusBar](docs/API.md#statusbarsetreversefillisreversefill--statusbargetreversefill) | `:SetReverseFill` / `:GetReverseFill`, `:SetRotatesTexture` / `:GetRotatesTexture`, `:SetFillStyle` / `:GetFillStyle`, `:SetColorFill`, `:SetStatusBarDesaturated` / `:IsStatusBarDesaturated`, `:SetStatusBarDesaturation` / `:GetStatusBarDesaturation` |
| [Frame: Slider](docs/API.md#sliderenable--sliderdisable--sliderisenabled) | `:Enable` / `:Disable` / `:IsEnabled` |
| [Frame: Cooldown](docs/API.md#createframecooldown--name-parent-template) | `CreateFrame("Cooldown")`, `:SetCooldown`, `:SetReverse` / `:GetReverse`, `:SetDrawEdge` / `:GetDrawEdge` |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled` |
| [Frame: scripts](docs/API.md#onenable-and-ondisable-scripts) | `OnEnable`, `OnDisable`, `OnMinMaxChanged`, `OnAttributeChanged`, `OnCharComposition`, `OnTextChanged` (`userInput`), `PreClick`, `PostClick`, `SetModernScriptArgs` / `GetModernScriptArgs` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:AddTexture`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnit`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetFrameStack`, `GameTooltip:SetHyperlink` (full links, `spell:`, `quest:`), `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject`, `OnTooltipSetFrameStack` (scripts) |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:AddTexture`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetMinimumWidth`, `GameTooltip:GetOwner`, `GameTooltip:GetPadding`, `GameTooltip:GetSpell`, `GameTooltip:GetUnit`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:IsUnit`, `GameTooltip:SetAnchorType`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetFrameStack`, `GameTooltip:SetHyperlink` (full links, `spell:`, `quest:`), `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject`, `OnTooltipSetFrameStack` (scripts) |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex`, `GetNumGossipActiveQuests`, `GetNumGossipAvailableQuests` |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo`, `IsActiveBattlefieldArena` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemTempEnchantInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Item: C_Item](docs/API.md#item) | `C_Item.DoesItemExist` / `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemClassInfo`, `C_Item.GetItemCount`, `C_Item.GetItemData` / `C_Item.GetItemDataByID`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon` / `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType` / `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize` / `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName` / `C_Item.GetItemNameByID`, `C_Item.GetItemQuality` / `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice` / `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID` / `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemTempEnchantInfo`, `C_Item.GetItemUniqueness` / `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached` / `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.IsUsableItem`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockItem`, `C_Item.UnlockAllItems`, `C_Item.PickupItem`, `C_Item.RequestLoadItemData` / `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName` |
| [Item: info](docs/API.md#getitemclassinfoclassid) | `GetItemClassInfo`, `GetItemSubClassInfo`, `GetItemIcon`, `IsConsumableItem`, `IsUsableItem` |
| [Item: inventory](docs/API.md#getaverageitemlevel) | `GetAverageItemLevel`, `GetInventoryItemID`, `GetInventoryItemDurability`, `GetInventoryItemRepairCost`, `GetInventoryItemsForSlot`, `OffhandHasWeapon` |
| [Item: item IDs](docs/API.md#getitemid--companions-to-the-engines-getitemlink-family) | `GetAuctionItemID`, `GetAuctionSellItemID`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeTargetItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount`, `C_LossOfControl.GetSchoolLockout` |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `StopMacro` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`, `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names), `DressUpModel:UndressSlot`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature`, `PlayerModel:SetDisplayInfo`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`) |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetInfo`, `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestLogTitle`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Sound](docs/API.md#sound) | `C_Sound.GetRecentSoundFiles`, `C_Sound.GetSoundScaledVolume`, `C_Sound.IsPlaying`, `C_Sound.PlayItemSound`, `C_Sound.PlaySound`, `C_Sound.PlaySoundWithOptions`, `C_Sound.PlayVocalErrorSound`, `MuteSoundFile`, `PlaySound`, `UnmuteSoundFile` |
| [Sound](docs/API.md#sound) | `C_Sound.GetRecentSoundFiles`, `C_Sound.GetSoundScaledVolume`, `C_Sound.IsPlaying`, `C_Sound.PlayItemSound`, `C_Sound.PlaySound`, `C_Sound.PlaySoundWithOptions`, `C_Sound.PlayVocalErrorSound`, `MuteSoundFile`, `PlaySound`, `PlaySoundFile`, `StopSound`, `UnmuteSoundFile` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.ContainsAnyDisenchantSpell`, `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetNumSpellBookSkillLines`, `C_SpellBook.GetPlayerSpellsByAura`, `C_SpellBook.GetSkillLineIndexByID`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemCastCount`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo`, `C_SpellBook.GetSpellBookItemSkillLineIndex`, `C_SpellBook.GetSpellBookSkillLineInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsClassTalentSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [SwingTimer](docs/API.md#swingtimer) | `C_SwingTimer.EnableRangeCheck`, `C_SwingTimer.IsTargetWithinSwingRange` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetDirectionFinished`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetDirectionFinished`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer`, `InteractUnit`, `C_PlayerInteractionManager.InteractUnit`, `IsTargetLoose` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
@@ -120,7 +141,7 @@ reference in **[docs/API.md](docs/API.md)**.
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `SetUnitCursorTexture`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSelectionColor`, `UnitSelectionType`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -188,6 +209,42 @@ for what each command does, and
</details>
<details>
<summary><b>CVars</b> — new settings, set with <code>/console name value</code></summary>
Each setting is saved in `WTF\Config.wtf`. Read and write them with `GetCVar`
/ `SetCVar` or `/console`. Click a group name for the full rules. To change
the most common settings and the player cache in the game, open the game
menu and click AddOn Options, then ClassicAPI.
| Group | CVar | Default | What it does |
|-------|------|---------|--------------|
| [Bindings](docs/API.md#actionbuttonusekeydown) | `ActionButtonUseKeyDown` | `0` | `1` uses an action-bar key binding when you press the key. `0` uses it when you release the key. |
| [Combat](docs/API.md#stopautoattackontargetchange) | `stopAutoAttackOnTargetChange` | `0` | `1` stops melee auto-attack, Auto Shot and wand shots when you change target. |
| [Cast settings](docs/API.md#cast-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when a spell cannot be cast in it. |
| [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract) | `SoftTargetInteract` | `3` | Turns `softinteract` on: `2` keyboard and mouse, `3` always, `0` / `1` off. |
| | `SoftTargetInteractArc` | `0` | Where the object can be: `0` straight ahead of the camera, `1` within 30° of it, `2` any direction. |
| | `SoftTargetInteractRange` | `10` | Maximum distance in yards. |
| | `SoftTargetInteractRangeIsHard` | `0` | `1` always applies the range. `0` also picks an object past it that you can use now. |
| | `SoftTargetIconInteract` | `0` | `1` shows an icon over the soft-interact object. |
| | `SoftTargetIconGameObject` | `0` | `1` also shows that icon over game objects. |
| [Soft enemy / friend](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) | `SoftTargetEnemy` / `SoftTargetFriend` | `1` / `0` | Turns `softenemy` / `softfriend` on: `2` keyboard and mouse, `3` always, `0` / `1` off. |
| | `SoftTargetEnemyArc` / `SoftTargetFriendArc` | `0` | The same values as `SoftTargetInteractArc`. |
| | `SoftTargetEnemyRange` / `SoftTargetFriendRange` | `45` | Maximum distance in yards. |
| | `SoftTargetMatchLocked` | `1` | `1` uses your target as the soft unit when it qualifies. `0` always uses the nearest unit. |
| | `SoftTargetWithLocked` | `1` | `0` turns soft enemy and soft friend off while you have a locked target. |
| | `SoftTargetForce` | `1` | A loose target follows `softenemy` (`1`) or `softfriend` (`2`). `0` keeps it. |
| | `SoftTargetIconEnemy` / `SoftTargetIconFriend` | `0` | `1` shows an icon over `softenemy` / `softfriend`. |
| | `SoftTargetLowPriorityIcons` | `0` | `1` also shows the normal pointer as an icon. |
| | `SoftTargetNameplateSize` | `19` | Size of all soft-target icons. |
| [Text to speech](docs/API.md#tts-cvars) | `ttsVoice` | `0` | The voice that `C_VoiceChat.SpeakText` uses. |
| | `ttsSpeed` | `0` | Speech rate, `-10` to `10`. |
| | `ttsVolume` | `100` | Speech volume, `0` to `100`. |
</details>
<details>
<summary><b>Console commands</b> — developer console (<code>-console</code>)</summary>
@@ -201,6 +258,9 @@ when launching with `-console`), not as Lua functions. See the
| `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>
@@ -211,6 +271,9 @@ when launching with `-console`), not as Lua functions. See the
|-------|---------|
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `CHAT_MSG_*` | adds `lineID, guid` as `arg11, arg12` after the existing ten ([details](docs/API.md#chat_msg_-arguments-lineid-arg11-and-guid-arg12)) |
| `COMBAT_LOG_EVENT` | `timestamp, subevent, hideCaster, sourceGUID, sourceName, sourceFlags, sourceRaidFlags, destGUID, destName, destFlags, destRaidFlags, ...` ([details](docs/API.md#combat_log_event_unfiltered--combat_log_event-events)) |
| `COMBAT_LOG_EVENT_UNFILTERED` | same as `COMBAT_LOG_EVENT` |
| `CURSOR_CHANGED` | `isDefault, newCursorType, oldCursorType, oldCursorVirtualID` |
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
| `EQUIPMENT_SWAP_PENDING` | `setID` |
@@ -226,12 +289,17 @@ when launching with `-console`), not as Lua functions. See the
| `LOOT_SCAN_COMPLETED` | *(none)* |
| `LOSS_OF_CONTROL_ADDED` | `eventIndex` |
| `LOSS_OF_CONTROL_UPDATE` | `unitToken` (always `"player"`) |
| `MAP_EXPLORATION_UPDATED` | *(none)* |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
| `PLAYER_AVG_ITEM_LEVEL_READY` | *(none)* |
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
| `PLAYER_FOCUS_CHANGED` | *(none)* |
| `PLAYER_SOFT_ENEMY_CHANGED` | *(none)* |
| `PLAYER_SOFT_FRIEND_CHANGED` | *(none)* |
| `PLAYER_SOFT_INTERACT_CHANGED` | `oldTarget, newTarget` |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
| `PLAYER_STARTED_MOVING` | *(none)* |
@@ -259,6 +327,8 @@ when launching with `-console`), not as Lua functions. See the
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
| `UNIT_SPELLCAST_INTERRUPTIBLE` | `unit` |
| `UNIT_SPELLCAST_NOT_INTERRUPTIBLE` | `unit` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `USER_WAYPOINT_UPDATED` | *(none)* |
@@ -293,13 +363,15 @@ when launching with `-console`), not as Lua functions. See the
</details>
<details>
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code></summary>
<summary><b>Unit tokens</b> — <code>nameplateN</code>, <code>focus</code>, <code>markN</code>, <code>softinteract</code>, <code>softenemy</code>, <code>softfriend</code>, <code>anyinteract</code>, <code>anyenemy</code>, <code>anyfriend</code></summary>
| Token | Resolves to |
|-------|-------------|
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
| `softinteract` / `anyinteract` / `anyleftinteract` | The object that the Interact Key uses. `softinteract` is the nearest object in front of you that you can interact with. `anyinteract` is your target when you can interact with it, else `softinteract`. Both can be a game object or a corpse, which only `UnitGUID` reads. See [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract). |
| `softenemy` / `softfriend` / `anyenemy` / `anyfriend` | The nearest enemy and the nearest friendly unit, or your target when it qualifies. `anyenemy` and `anyfriend` use your target first. See [Soft enemy and soft friend](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend). |
</details>
+216
View File
@@ -3913,3 +3913,219 @@ permanently-shown dummy frame for their tickers. `Disabled` duplicates
`SetScript("OnUpdate", nil)` and `RunOnce` overlaps `C_Timer.After(0, …)`, so
the rest is API parity. Worth it for the one capability plus a clean enum, not
as a performance feature.
## 102. Texture tiling: `SetHorizTile` / `SetVertTile` (+ `SetNonBlocking`) — medium
The only texture methods 4.3.4 has that 1.12 + ClassicAPI lack
(`wowdecomp15595/Engine/Source/Frame/CSimpleTexture_Scripts.cpp`): six
setter/getter pairs on `m_textureFlags`. Getters answer 1 or nil.
| Methods | 4.3.4 flag | Effect |
|---|---|---|
| `SetHorizTile` / `GetHorizTile` | `0x4` | repeat on U |
| `SetVertTile` / `GetVertTile` | `0x8` | repeat on V |
| `SetNonBlocking` / `GetNonBlocking` | `0x1` | skip drawing an unloaded image this frame instead of force-loading it |
**How 4.3.4 tiles.** `CRenderBatch::QueueTexture` (`CRenderBatch.cpp:164`):
while the dirty flag `0x2` is set, every non-zero U texcoord becomes
`rectWidthInPixels / gxTex->m_width` (V likewise with height), using
`DDCToNDCWidth(aspect × 1024 × (right − left)) / m_layoutScale` from the drawn
corners. A layout change (`CSimpleRender.cpp:2391`) sets `0x2` again when either
tile flag is on. Repeat only shows if the image was loaded with wrap.
**Wrap on 1.12.** `CSimpleTexture::SetTexture` `0x770200` already takes
`(name, wrap, filter, shader)` (benilla-re `ui/texture#settexture`); the Lua
`SetTexture` always passes wrap 0. 4.3.4's Lua `SetTexture(file, tile)` reads
arg 3 as wrap for both axes — add that. To verify: vanilla has one wrap flag,
4.3.4 has `wrapU`/`wrapV`; check whether `CGxTexFlags` `0x58A980` can wrap one
axis alone.
**Where to hook.** The rescale belongs where the drawn corners are built — the
texture `OnFrameSizeChanged` co-hook `0x770670` the animation system already
owns (`src/animation/Region.cpp`).
**XML.** `<Texture horizTile= vertTile= nonBlocking=>`; 4.3.4's loader also
turns wrap on when `<TexCoords>` leave 0..1 and errors
`Texture %s: Invalid TexCoords value (horizTile is on)` (`CSimpleRender.cpp:3024`).
**NonBlocking.** Only meaningful if 1.12 has a lazy texture-load path (4.3.4:
`TextureForceLoadForDiskOnly` vs `NotifyDelayedLoad`). Not checked — if 1.12
always loads synchronously it is a stored flag with no effect.
## ~~103. FontString `SetWordWrap` / `CanWordWrap` / `GetFieldSize`~~ — DONE
The only FontString methods 4.3.4 has that 1.12 + ClassicAPI lacked
(`CSimpleFontString_Scripts.cpp`). Code: `src/fontstring/WordWrap.cpp`,
`GetFieldSize` in `src/fontstring/Metrics.cpp`.
- **SetWordWrap / CanWordWrap.** 4.3.4 style bit `0x40` (set = no wrap). The
setter is 1.12's own `SetNonSpaceWrap` (`0x79E9F0`, identical body on bit
`0x1000`) with `0x40` inverted. `TextBlockCreate` `0x44D420` already maps
`0x40` to text flag `0x2` exactly as 4.3.4's `ConvertStringFlags`, and 1.12's
layout `0x5CDC20` / line count `0x5C2070` stop after one line on it.
Verified in-game: one line, `...` at the width (the fit `0x771EC0`), lines
after `\n` dropped, no width = the whole line. A change must lay the string
out again (`FontString::Layout::Invalidate`, as `SetText`), not only rebuild
it: a text-sized string kept its two-line height and the fit cut at a whole
word with no `...`.
- **GetFieldSize.** 4.3.4 returns a constant 8191. 1.12's truth: a string with a
fixed text size `+0xE8` (XML `bytes="n"`) keeps `n − 1` bytes (`SetText`'s
`SStrCopy(text, src, n)`, `0x771E0F`); without one the buffer grows to any
length, so it returns infinity.
## ~~104. XML `<KeyValues>`~~ — DONE
Shipped in a2e3eed (`mixin=` and `method=` followed in 2cab0dc). Code:
`src/xml/KeyValues.cpp`; the texture / font string hooks in
`src/xml/ParentKey.cpp` call it directly (no `RegionLoad` fan-out), and
`src/animation/Xml.cpp` reads it for groups and animations. Notes below
are the original plan.
Not in 4.3.4 (no decomp); the contract comes from retail's `UI.xsd`
(`_classic_era_/BlizzardInterfaceCode/.../Blizzard_SharedXML/UI.xsd`) and
`XMLUtilDocumentation.lua`. Blizzard's classic-era UI uses it in 356 files.
- `<KeyValues><KeyValue key= value= type= keyType=/></KeyValues>`; `type` and
`keyType` are `nil` / `boolean` / `number` / `string` (default) / `global`
(`_G[value]`). Sets `object[key] = value` on the Lua wrapper.
- A LayoutField: frames, textures, font strings. Also a child of
`AnimationGroup` and `Animation`.
- `C_XMLUtil.GetTemplateInfo(name).keyValues` is a list of
`{key, keyType, type, value}` (all strings); ours always returns `{}`
(`src/xml/Templates.cpp`).
**Ordering.** `NineSlicePanelMixin:OnLoad` reads `self:GetParent().layoutType`
— a parent's KeyValue must be set before its child frames load. 1.12's frame
load (benilla-re `ui/frames#loading-from-xml`): `0x769820` loads the inherited
node first, then attributes, `Layers`, `Scripts`; `0x76A060` then loads the
child `<Frames>` and the load ends (OnLoad). So the existing
`Xml::FrameLoad::AutoSubscribe` (after `0x769820`, as `<Attributes>` uses) is
the right point: template values first, the node's own win, before children
and OnLoad.
**Plan.**
1. Textures / font strings: `ParentKey.cpp` owns the `0x76FE20` / `0x770F40`
loader hooks and calls `Animation::Xml::LoadAnimations` from them by hand.
Turn them into a shared `Xml::RegionLoad::AutoSubscribe` fan-out (like
`FrameLoad`) and subscribe ParentKey, Animations and KeyValues.
2. `Xml::PushTypedValue(L, type, value)` shared with `<Attributes>`
(`Frame::Attributes::LoadXmlAttributes`, engine `FUN_XML_ATTR_TO_BOOL` /
`FUN_SSTR_TO_FLOAT`), plus `global`.
3. `src/xml/KeyValues.cpp` for frames + regions; the animation loader
(`src/animation/Xml.cpp`) reads it for groups and animations.
4. Fill `GetTemplateInfo`'s `keyValues` from the template node.
**Unverifiable without a retail binary** — follow `<Attributes>` and say so in
the code: a `boolean` value other than true/false, a `number` that fails to
parse, and whether `GetTemplateInfo` includes inherited templates' entries
(plan: the node's own only).
## 105. Button methods — small to medium
Button methods later clients have that 1.12 + ClassicAPI lack. 4.3.4 adds three
over 1.12 (`CSimpleButton_Scripts.cpp`); retail's `SimpleButtonAPIDocumentation`
(`_classic_era_` install) lists the rest. `Get/SetNormalFontObject` are done
(`src/frame/Modern.cpp`).
**From 4.3.4 (decomp available).**
- **DONE** (`src/frame/MotionScriptsWhileDisabled.cpp`, verified in-game
2026-10-19). `SetMotionScriptsWhileDisabled(enabled)` / `GetMotionScriptsWhileDisabled()`
— the one real new capability: tooltips on disabled buttons. 4.3.4
`CSimpleButton::OnLayerCursorEnter/Exit` (`CSimpleButton.cpp:827/844`): an
enabled button runs the frame's enter/leave and the label refresh; a disabled
one does nothing unless the flag is set, then shows `m_hoverFrame` and runs
ONLY the OnEnter/OnLeave script (no highlight, no label refresh). 1.12 is
4.3.4 without the flag: `0x779490` (slot 19) / `0x7794E0` (slot 20) skip the
frame's enter/leave (`0x76B6A0` / `0x76B6F0`, scripts + highlight) and the
label refresh `0x779810` for state 0 (benilla-re `ui/button#entering-and-leaving`).
Needs: a per-button flag (side map, reset on reload), co-hooks on the two
slots, a way to run just the script (find the script runner inside
`0x76B6A0`), what 1.12 has for `m_hoverFrame`, and the XML attribute
`motionScriptsWhileDisabled` (4.3.4 `m_buttonFlags` bits 0x4 set / 0x8 value,
`CSimpleButton.cpp:1058`). Default false.
- **DONE** (`src/frame/FormattedText.cpp`, shared with the font string's;
verified in-game 2026-10-19).
`button:SetFormattedText(fmt, ...)` — 4.3.4 formats into a 4096-byte buffer
and calls the button's `SetText`. We have it on font strings only
(`src/fontstring/Metrics.cpp`, engine `str_format` under pcall); register the
same on the Button registry with the button's own SetText.
**From later clients (contract from retail docs only — no decomp).**
- **DONE** (`src/frame/SetEnabled.cpp`). `SetEnabled(enabled)` — `Enable()` /
`Disable()` by a bool, through the button's Enable vmethod as they do.
- `SetNormalAtlas` / `SetPushedAtlas` / `SetDisabledAtlas` /
`SetHighlightAtlas(atlas[, blendMode])` — the state texture made or reused
through `SetNormalTexture` etc., then `Texture::Atlas`'s `SetAtlas`.
- `ClearNormalTexture` / `ClearPushedTexture` / `ClearDisabledTexture` /
`ClearHighlightTexture` — remove the state texture. Check what 1.12's
`SetNormalTexture(nil)` does first (`CSimpleButton::SetStateTexture`
`0x778FD0` deletes the old one).
- **DONE** (`src/frame/MouseRegistration.cpp`). `RegisterForMouse(buttons...)`
— which mouse buttons fire OnMouseDown/Up. 1.12 filters nothing: the base
frame pair 0x76BF70 / 0x76C040 fire both for any button through the runner,
where `Frame::ClickEvents`' gate now refuses unregistered ones.
## 106. Script-object API sweep — remaining methods, globals and scripts
A name-level diff, 2026-10-19: every 4.3.4 method table
(`wowdecomp15595/Engine/Source/Frame/*_Scripts.cpp`) and retail's
`Blizzard_APIDocumentationGenerated/Simple*APIDocumentation.lua` (`_classic_era_`
install) against 1.12 (`docs/BlizzardScriptAPI.md`) and what `src/` registers.
Texture (#102), FontString (#103 done) and Button (#105) have their own items.
Each entry still needs the usual check of how the engine does it before building.
### From 4.3.4 (decomp available)
- **DONE** (`src/slider/Enable.cpp`). **Slider** `Enable` / `Disable` / `IsEnabled`: frame flag `0x400`, read by the
thumb-drag path (`CSimpleSlider.cpp:317`); `Disable` runs OnDisable unless
loading (we have OnEnable/OnDisable in `src/frame/EnableScripts.cpp`).
- **DONE** (`src/frame/MinMaxChanged.cpp`). **OnMinMaxChanged** (Slider, StatusBar): `SetMinMaxValues` runs it with
`(min, max)` when the range changes (`CSimpleStatusBar.cpp:39/141`). 1.12 has
no such script.
- **DONE** (`src/statusbar/Fill.cpp`, `src/statusbar/BarTexture.cpp`; XML
`reverseFill` / `rotatesTexture` / `fillStyle` attributes not backported).
**StatusBar** `SetReverseFill` / `GetReverseFill`, `SetRotatesTexture` /
`GetRotatesTexture` (`CSimpleStatusBar.cpp:135`, the vertical texcoord path
at `:285`). Retail adds `SetFillStyle` (STANDARD / CENTER / REVERSE),
`SetColorFill`, `SetStatusBarDesaturated` / `Desaturation`.
- **ScrollingMessageFrame** `SetInsertMode` / `GetInsertMode`,
`GetMessageInfo`, `RemoveMessagesByAccessID`, `RemoveMessagesByExtraData`,
`SetScrollOffset` — in C in 4.3.4 (retail moved the type to Lua).
- **DONE** (`src/editbox/Focus.cpp`, `src/animation/Framerate.cpp`).
**Globals** (`SimpleFrameScript_Scripts.cpp`): `GetCurrentKeyBoardFocus()`
(the focused edit box's wrapper, or nil); `SetMaxAnimFramerate(fps)` /
`GetMaxAnimFramerate()` — 4.3.4's `CSimpleAnim::s_frameInterval`, the 1/30 s
throttle our animation port already has.
- Not worth it: Frame `EnableJoystick` / `IsJoystickEnabled`; the stereo-depth
set `SetDepth` / `GetDepth` / `GetEffectiveDepth` / `IgnoreDepth` /
`IsIgnoringDepth`; `GetText(token[, gender[, ordinal]])` — gendered
global-string lookup, little use on 1.12.
### From later clients (retail docs only — contract, no decomp)
Likely worth it (small, ported code uses them):
- Object: `GetParentKey` / `SetParentKey` / `ClearParentKey`, `GetDebugName`.
- Region: `SetIgnoreParentAlpha` / `IsIgnoringParentAlpha` (4.3.4 has it
internally: its texture XML load calls `SetIgnoreParentAlpha` from
`m_regionFlags 0x10/0x20`, `CSimpleRender.cpp`),
`SetIgnoreParentScale` / `IsIgnoringParentScale`.
- ScriptRegion: `GetScaledRect`, `IsRectValid`, `Intersects`.
- Frame: `GetResizeBounds` (we have `SetResizeBounds`), `SetFixedFrameStrata` /
`SetFixedFrameLevel` / `HasFixed*`, `GetHighestFrameLevel` /
`GetRaisedFrameLevel`, `SetUsingParentLevel`, `ClearAttributes`,
`IsObjectLoaded`, `RotateTextures` (on `Texture::Transform`), `AbortDrag`.
- Slider: `SetObeyStepOnDrag`, `SetStepsPerPage`, `IsDraggingThumb`,
`SetEnabled` (also EditBox `SetEnabled`).
- FontString / Font: `SetTextScale` /
`GetTextScale`, `ClearText`, `SetTextToFit`,
`GetUnboundedStringWidthForText`, `FindCharacterIndexAtCoordinate`.
- Texture: `GetTextureFilePath`, `ClearVertexOffsets`.
- SimpleHTML `GetContentHeight`; ColorSelect alpha (`SetColorAlpha` + its
textures).
Larger, decide separately: mouse-motion / click split and propagation
(`SetMouseMotionEnabled`, `SetMouseClickEnabled`, `SetPropagateMouseClicks`,
`SetPassThroughButtons`); `SetClipsChildren` (scissoring); `CreateLine` (the
Line region type); texture slicing (`SetTextureSliceMargins` / `Mode`); Model
camera / transform / alpha (~40 methods); `RegisterEventCallback`; EditBox
secure-text and byte-limit options; MessageFrame fade power and message IDs.
-138
View File
@@ -1,138 +0,0 @@
# embed_addon.cmake — bundle an addon directory into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads every
# file under SRC_DIR recursively and emits OUT_HEADER containing:
#
# namespace Addons::Embedded {
# struct File {
# const char *path;
# const unsigned char *data;
# size_t size;
# };
# extern const File kFiles[];
# extern const size_t kFileCount;
# }
#
# Paths are normalized to lowercase backslash-form relative to
# `Interface\AddOns\` so they match what the engine's TOC parser
# constructs when it asks for the addon's files (see
# `s_Interface_AddOns__s__s_toc_00853840` in the binary). The
# manifest is hash-table-friendly for fast lookup but the lookup
# walks the array linearly — fine for 25 files.
#
# Usage:
# cmake -DSRC_DIR=... -DOUT_HEADER=... -DADDON_NAME=... -P embed_addon.cmake
if(NOT SRC_DIR)
message(FATAL_ERROR "embed_addon.cmake: SRC_DIR required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_addon.cmake: OUT_HEADER required")
endif()
if(NOT ADDON_NAME)
message(FATAL_ERROR "embed_addon.cmake: ADDON_NAME required")
endif()
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
# Never embed the developer marker — it must reflect on-disk presence
# only (see src/addons/Embedded.cpp DiskHasDevMarker), and it is
# gitignored so it only exists in a developer working tree anyway.
list(FILTER INPUT_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(SORT INPUT_FILES) # deterministic codegen
set(BODY "")
set(MANIFEST "")
set(INDEX 0)
foreach(REL_PATH ${INPUT_FILES})
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
# Stamp the addon's `## Version:` line from the build flag rather
# than the committed source (which stays `DEV`). This is what makes
# a release build a pure function of `-DCLASSICAPI_TAG` — the source
# toc is never edited. Only the `.toc` file is rewritten, in-memory,
# before hex-encoding; every other file is embedded verbatim.
if(ADDON_VERSION AND REL_PATH MATCHES "\\.toc$")
file(READ "${ABS_PATH}" TOC_TEXT)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${ADDON_VERSION}" TOC_TEXT "${TOC_TEXT}")
# Round-trip through a temp file to hex-encode the modified text
# (portable to CMake 3.10; avoids requiring string(HEX), 3.18+).
set(TOC_TMP "${OUT_HEADER}.toc.tmp")
file(WRITE "${TOC_TMP}" "${TOC_TEXT}")
file(READ "${TOC_TMP}" CONTENT_HEX HEX)
file(REMOVE "${TOC_TMP}")
else()
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
endif()
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
# Convert hex string to comma-separated byte literals. 16 bytes per line
# keeps the generated header readable.
set(BYTES "")
set(COL 0)
set(POS 0)
while(POS LESS HEX_LEN)
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
if(COL EQUAL 0)
string(APPEND BYTES " ")
endif()
string(APPEND BYTES "0x${BYTE},")
math(EXPR POS "${POS} + 2")
math(EXPR COL "${COL} + 1")
if(COL EQUAL 16)
string(APPEND BYTES "\n")
set(COL 0)
else()
string(APPEND BYTES " ")
endif()
endwhile()
if(NOT COL EQUAL 0)
string(APPEND BYTES "\n")
endif()
# Normalize path to engine-style: `<AddonName>\Sub\Path.lua` with
# backslashes. Engine constructs full path as
# `Interface\AddOns\<AddonName>\<file>` — we strip the
# `Interface\AddOns\<AddonName>\` prefix at lookup time so storing
# just the relative form keeps the manifest small.
string(REPLACE "/" "\\\\" REL_PATH_BS "${REL_PATH}")
string(APPEND BODY "static const unsigned char kData_${INDEX}[] = {\n${BYTES}};\n\n")
string(APPEND MANIFEST " { \"${REL_PATH_BS}\", kData_${INDEX}, ${FILE_SIZE} },\n")
math(EXPR INDEX "${INDEX} + 1")
endforeach()
set(HEADER_CONTENT "// Auto-generated by embed_addon.cmake — do not edit.
// Source: ${SRC_DIR}
//
// Addon \"${ADDON_NAME}\" embedded as a fallback for users who don't have
// it installed on disk. See src/addons/Embedded.cpp for the file-read
// hook that surfaces these buffers to the engine.
#pragma once
#include <cstddef>
namespace Addons::Embedded {
namespace ${ADDON_NAME}Files {
${BODY}static const struct File {
const char *path;
const unsigned char *data;
size_t size;
} kFiles[] = {
${MANIFEST}};
static constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace ${ADDON_NAME}Files
} // namespace Addons::Embedded
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_addon: wrote ${OUT_HEADER} (${INDEX} files)")
-62
View File
@@ -1,62 +0,0 @@
# embed_file.cmake — bundle a single file into a generated C++ header.
#
# Invoked from CMakeLists.txt via `cmake -P` as a custom command. Reads
# INPUT_FILE and emits OUT_HEADER containing a constexpr byte array
# named SYMBOL plus a `SYMBOL ## Size` size constant. Both live at file
# scope; the header is meant to be included exactly once.
#
# Usage:
# cmake -DINPUT_FILE=... -DOUT_HEADER=... -DSYMBOL=... -P embed_file.cmake
if(NOT INPUT_FILE)
message(FATAL_ERROR "embed_file.cmake: INPUT_FILE required")
endif()
if(NOT OUT_HEADER)
message(FATAL_ERROR "embed_file.cmake: OUT_HEADER required")
endif()
if(NOT SYMBOL)
message(FATAL_ERROR "embed_file.cmake: SYMBOL required")
endif()
file(READ "${INPUT_FILE}" CONTENT_HEX HEX)
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
# Convert hex string to comma-separated byte literals. 16 bytes per
# line keeps the generated header readable.
set(BYTES "")
set(COL 0)
set(POS 0)
while(POS LESS HEX_LEN)
string(SUBSTRING "${CONTENT_HEX}" ${POS} 2 BYTE)
if(COL EQUAL 0)
string(APPEND BYTES " ")
endif()
string(APPEND BYTES "0x${BYTE},")
math(EXPR POS "${POS} + 2")
math(EXPR COL "${COL} + 1")
if(COL EQUAL 16)
string(APPEND BYTES "\n")
set(COL 0)
else()
string(APPEND BYTES " ")
endif()
endwhile()
if(NOT COL EQUAL 0)
string(APPEND BYTES "\n")
endif()
set(HEADER_CONTENT "// Auto-generated by embed_file.cmake — do not edit.
// Source: ${INPUT_FILE}
#pragma once
#include <cstddef>
static constexpr unsigned char ${SYMBOL}[] = {
${BYTES}};
static constexpr size_t ${SYMBOL}Size = sizeof(${SYMBOL});
")
file(WRITE "${OUT_HEADER}" "${HEADER_CONTENT}")
message(STATUS "embed_file: wrote ${OUT_HEADER} (${FILE_SIZE} bytes)")
+111
View File
@@ -0,0 +1,111 @@
# embed_resources.cmake — embed the addon and the bindings XML as Win32
# RCDATA resources.
#
# Runs at configure time. It writes two files into OUT_DIR:
#
# embedded.rc one `<id> RCDATA "<path>"` line per file; rc.exe
# reads each file straight from disk.
# embedded_resources.h the IDs the DLL looks up at runtime through
# src/embed/Resource.h:
#
# namespace Embed::Ids { constexpr int kBindingsXml = ...; }
# namespace Addons::Embedded::ClassicAPIFiles {
# struct File { const char *path; int resourceId; };
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
#
# Addon paths are relative to the addon folder, in backslash form
# (`Util\Print.lua`) — the suffix the engine builds after
# `Interface\AddOns\!!!ClassicAPI\`. The DLL compares them ignoring case.
#
# The `.toc` is the one file that is not embedded verbatim: its
# `## Version:` line is stamped with ADDON_VERSION into a copy under
# OUT_DIR, so a release build is a pure function of `-DCLASSICAPI_TAG` and
# the source toc stays `DEV`.
#
# Rebuild triggers: adding or removing a file reconfigures through the
# caller's CONFIGURE_DEPENDS glob; editing the toc reconfigures through
# CMAKE_CONFIGURE_DEPENDS; editing any other file recompiles the .rc
# through its OBJECT_DEPENDS.
#
# Usage:
# embed_resources(TARGET <t> ADDON_DIR <dir> ADDON_FILES <rel paths...>
# ADDON_VERSION <v> BINDINGS_XML <file> OUT_DIR <dir>)
# Write `content` to `path` only when it differs, so an unchanged
# reconfigure does not touch the file and trigger a rebuild.
function(_embed_write_if_changed path content)
set(tmp "${path}.tmp")
file(WRITE "${tmp}" "${content}")
configure_file("${tmp}" "${path}" COPYONLY)
file(REMOVE "${tmp}")
endfunction()
function(embed_resources)
cmake_parse_arguments(E "" "TARGET;ADDON_DIR;ADDON_VERSION;BINDINGS_XML;OUT_DIR"
"ADDON_FILES" ${ARGN})
set(rc "// Generated by cmake/embed_resources.cmake - do not edit.\n\n")
set(manifest "")
set(inputs "")
set(id 1)
string(APPEND rc "${id} RCDATA \"${E_BINDINGS_XML}\"\n")
list(APPEND inputs "${E_BINDINGS_XML}")
set(bindings_id ${id})
math(EXPR id "${id} + 1")
set(files ${E_ADDON_FILES})
list(SORT files) # deterministic IDs
foreach(rel ${files})
set(abs "${E_ADDON_DIR}/${rel}")
if(rel MATCHES "\\.toc$")
file(READ "${abs}" toc)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${E_ADDON_VERSION}" toc "${toc}")
set(stamped "${E_OUT_DIR}/embed/${rel}")
_embed_write_if_changed("${stamped}" "${toc}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${abs}")
set(abs "${stamped}")
endif()
string(APPEND rc "${id} RCDATA \"${abs}\"\n")
list(APPEND inputs "${abs}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND manifest " { \"${rel_bs}\", ${id} },\n")
math(EXPR id "${id} + 1")
endforeach()
_embed_write_if_changed("${E_OUT_DIR}/embedded.rc" "${rc}")
_embed_write_if_changed("${E_OUT_DIR}/embedded_resources.h"
"// Generated by cmake/embed_resources.cmake — do not edit.
// Resource IDs for the RCDATA entries in embedded.rc. Load them through
// src/embed/Resource.h.
#pragma once
#include <cstddef>
namespace Embed::Ids {
constexpr int kBindingsXml = ${bindings_id};
} // namespace Embed::Ids
namespace Addons::Embedded::ClassicAPIFiles {
struct File {
const char *path;
int resourceId;
};
constexpr File kFiles[] = {
${manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Addons::Embedded::ClassicAPIFiles
")
target_sources(${E_TARGET} PRIVATE "${E_OUT_DIR}/embedded.rc")
set_source_files_properties("${E_OUT_DIR}/embedded.rc" PROPERTIES
OBJECT_DEPENDS "${inputs}")
endfunction()
+2731 -853
View File
File diff suppressed because it is too large Load Diff
+77 -86
View File
@@ -85,13 +85,19 @@ The DLL hooks the engine's own text pipeline — no companion addon:
that contains a tall icon is hoverable across the whole icon
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
- **Editboxes** — an editbox keeps the raw markup, so the caret stays aligned
(two complementary signals, below).
- **Edit boxes** — render the icons in their text, as 4.3.4 does; the caret,
the mouse and the arrow keys treat an icon as one character, and Ctrl+C
drops icons (4.3.4's 0x1500 strip mask). Typed markup stays literal by the
engine's own input escape (below).
- **Payload** — the full
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
TGA.
- **Anti-spoof** — chat strips player-injected `|T` icons.
- **Anti-spoof** — chat neutralizes a player-injected `|T` the way the edit
box neutralizes a typed one (the `|` is doubled).
- **Raid markers** — `{rt1}`/`{skull}`/… become icons in the CHAT_MSG text and,
separately, in the speech bubble after its escape strip (4.3.4's
`CGChatBubbleFrame::ParseRaidIcons`).
Every fs-level measure path now counts icons: width (`FUN_00772890`), height
(`FUN_007729B0`), wrap (`FUN_005C7260`), and the substring measure
@@ -170,7 +176,7 @@ SetTexture `+0x104`). Selector map (shared across GL/D3D backends): 7=blend,
0x10=depthtest, 0x14=cullface, 0x17-0x1e=texture bind (8 units),
0x1f-0x26=colorop/alphaop preset (unit 0-7).
## SOLVED — positioning, measure/wrap, editbox exclusion, texcoords
## SOLVED — positioning, measure/wrap, edit boxes, texcoords
The primitive draws at arbitrary coords; the rest is knowing WHERE and WHEN.
Everything is in [src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp);
@@ -195,8 +201,7 @@ frame → renderNode [frame+0x118] → group [renderNode+0x18]
- The **emitter** `FUN_005ccbe0(line, text, len, colorState, penXYZ, pageMask,
linkState)` builds one wrapped line's glyph quads. `this` (the "line"/node the
icon list keys on) owns batches `[+0xa0+page*4]` and origin `[+0x70]`. **Note
the render node the emitter sees has NO back-pointer up to the frame** — the
editbox discriminator had to be found another way (see below).
the render node the emitter sees has NO back-pointer up to the frame.**
### Positioning — segmented delegation (co-hook the emitter)
@@ -223,14 +228,27 @@ We do NOT reimplement the emitter's intricate glyph vertex math. Instead, in the
does NOT reset it between left-justified lines, so leaving it mutated
cascade-shifts every following line).
### FontString pipe-doubling — the escape arrives as `||T…||t`
### The span rule — `|T…|t` only, `||` is always a literal pipe
The FontString text sanitizer **doubles any pipe that doesn't begin a recognized
escape** so it renders as a literal `|`. 1.12 doesn't know `|T`, so a caller's
`|Tpath:h|t` reaches the emitter as `||Tpath:h||t` (verified: emitter text length
+2, pipes doubled). The detector therefore matches BOTH the doubled form (the
real-world case) and a clean `|T` (should one ever arrive undoubled). Normal
doubled pipes (`|| `) never match because the next char must be `T`.
One scanner, `IconSpanAt`, decides what a span is, with 4.3.4's
`GxuDetermineQuotedCode` CODE_TEXTURESTART rule: after `|T` (or `|A`), the
FIRST `|` must be followed by `t` (`a`), and an empty `|T|t` is literal.
Anything else is not a span and draws as text. Every walker steps over `||`
as a pair, because `||` is the escaped pipe in both engines.
That is what keeps typed markup literal everywhere — chat display, edit
boxes, macros: the edit box stores a typed `|` as `||` (`0x77c254`; Paste
goes through the same `InsertChar`, as in 4.3.4), so a typed `|Tpath|t` is
`||Tpath||t` in every buffer it reaches.
(An earlier version matched a "doubled" `||T…||t` form too, on the belief
that the FontString sanitizer doubled unknown escapes. Stock 1.12 has no
such sanitizer — not SetText `0x771d80`, its grammar pass `0x7716f0`,
FitText `0x771ec0`, nor the edit box's RebuildLabel `0x77d4d0`. The `||T`
seen in chat lines was typed text, and our own `{rt1}` expansion emitted it
on purpose to survive the bubble's escape strip. Matching it turned every
typed `|T` into an icon, which is why the edit box needed the suppression
gates the span rule replaced.)
### bit-3 batch-clear mode
@@ -368,13 +386,9 @@ the measure core `FUN_0044D670`, then divides the pen-space result by
`fs+0x7C`.
The co-hook lives in `src/text/InlineTexture.cpp`. It calls the original,
then adds `SumIconAdvances(text) / K` when two gates pass:
- `InlineInterceptActive(text)` is true — the **same predicate** the tokenizer
hook stands down on, factored into one function so measure and render can
never disagree.
- The fs is not an editbox: `fs+0x120 & 0x1000` is clear (the fs-level bit
`FUN_0044D670` translates into the gxu editbox flag 0x40).
then adds the icons' advances (`SumIconAdvances`) whenever the feature is
on — the same condition the tokenizer hook intercepts on, so measure and
render never disagree.
The icon sum is computed in TRUE PEN PX — the exact numbers the emitter
reserves — and bridged into the getter's return space by the font height
@@ -415,8 +429,7 @@ measure `FUN_005C2070`, the chars-that-fit counter `FUN_005C21C0` (ellipsis),
and the break-array computer `FUN_005C2430` (behind `FUN_00772B60`).
The co-hook shrinks the `wrapWidth` argument by a **minimal feasible
shrink**, gated on the same `InlineInterceptActive` predicate plus the flags
editbox bit 0x40 the dispatcher already carries. The stepper reports each
shrink** whenever the feature is on. The stepper reports each
probe's measured (icons-at-zero) line width in `outWidth`, which gives a
direct feasibility check: the line renders inside the frame iff its icons
fit in the shrink plus the spare the break left (`iconSum ≤ s + (probeW −
@@ -470,10 +483,11 @@ line through our co-hooked tokenizer (`FUN_005c2810`) and wrap-stepper
(`FUN_005c7260`). So the recorded rect rides the same icon-aware position
accounting the wrap fix installs; the icon lands inside the hover region for free,
and hovering it fires the frame's `OnHyperlinkEnter`. This is a consequence of the
wrap fix, not a separate feature. (Ghidra caveat: the decompile of the
`FUN_005cd310` call args is mangled — a pointer-as-float + an uninitialized temp —
so this is confirmed by the call path plus in-game test, not the byte-level
formula.)
wrap fix, not a separate feature. (`FUN_005cd310` itself, from benilla
font/string "Hyperlink regions", difftested: it multiplies the rect's y fields
`+0x08`/`+0x10` by 1/H and its x fields `+0x0c`/`+0x14` by 1/W and appends the
32-byte record to the array at `+0x7c`. Ghidra's own decompile of the call
args is mangled.)
**Still icon-blind (accepted residuals):**
@@ -488,69 +502,46 @@ formula.)
differently (`_classicapi_InlineTexEnable`) keeps its old base width until
the next `SetText`/font change re-dirties it. Debug-toggle-only.
**Editbox measure stays raw (by design).** The caret/measure path reads the
focused editbox's INPUT buffer in place, and the tokenizer stands down on it
per-editbox (`TextInFocusedEditbox` pointer-range test — not a global focus
check), so caret math measures the raw `|T…|t` literal the editbox renders.
The width hook mirrors that with the `fs+0x120 & 0x1000` editable gate. One
marginal wobble: the focused single-line chat editbox's display fontstring
carries editable=0 (only multi-line editors set the bit), so the caret
positioner's line-boundary branch (multi-line-only in practice) can see an
icon-adjusted `FUN_00772890` on it. Closing that costs a per-token content
compare, which is not worth it.
### Edit boxes — 4.3.4's model, no special case
### Editbox exclusion — two COMPLEMENTARY signals (bit 6 + focus global)
4.3.4's edit box renders `|T…|t` in its text: no frame code ever sets the
FontString style bit 0x10000 that `ConvertStringFlags` maps to the gxu
texture-off flag 0x1000. Its cursor treats the span as one markup token
(`UpdateTextInfo` / `GetNumToLen`), and its `CopyToClipboard` strips with
0x1500 — colours, links AND textures. Typed markup never renders because a
typed `|` is stored as `||` (`CSimpleEditBox.cpp`, `ch == '|'`). 1.12 does
the same input escape, so ClassicAPI's edit boxes need no special case:
Editable text must show raw, editable `|T…|t` markup, not rendered icons — you
edit macros, tweak icon fields, copy/paste texture strings. **This is
retail-correct, verified in-game on a modern client:** typing
`|TInterface\Icons\INV_Misc_Coin_01:16|t` into the chat entry box OR a macro body
shows it **raw** (no icon) — Blizzard neutralizes player-*typed* escape sequences
as anti-abuse — while addon/system text (`AddMessage`, `GetCoinTextureString`)
renders. Retail's discriminator is the text's SOURCE (trusted vs player-typed),
not editbox-vs-display; a shift-clicked item link renders in the chat box because
it's inserted through the trusted path, but a typed `|T`/`|c` does not. Our
editbox-raw + display-renders split reproduces the same observable outcome. (One
mechanism-level divergence we accept: our editbox-raw uses the GLOBAL focus signal
below, so while an editbox is focused OTHER display text that rebuilds also
briefly shows raw; retail suppresses per-source. Cosmetic, and a per-editbox fix
would need hot-path text-buffer matching — not worth it.) 1.12's tokenizer,
unlike 4.3.4's, has no per-render texture-disable bit (4.3.4 uses tokenizer flag
**0x1000** to kill only `|T`, **0x800** to kill all escapes), and editbox text
nodes are NOT distinguishable by class from the render node — the emitter can't
reach the owning frame. It takes **two signals together**, not one (an earlier
version of this note wrongly called the focus global a redundant "belt-and-
suspenders fallback" — removing it renders the chat/copy boxes' `|T` as icons):
- **Render** — the label FontString (`RebuildLabel` `0x77d4d0`,
`SetText(buffer + scroll, 0)`, no pipe doubling) renders like any other,
and its regions parent to the edit box, so a ScrollFrame clips them.
- **Cursor** — the class array (`0x77ba90`) tokenizes through our tokenizer
hook, so a span is one class-6 letter: Left/Right cross it in one step,
Backspace deletes it whole, and an insert can never land inside it.
Class 6 is also the safe class — a skip class (0/4) as the last token
hangs the step loop (benilla ui/editboxmarkup "Faults"). 4.3.4 joins an
icon with the following character into one step; ours is its own step.
- **Caret x** — `FUN_0077DA80` measures the line through
`FUN_00772AE0`, whose co-hook adds the icons.
- **Click → cursor, single-line window** — `GetIndexAtPoint` (`0x77d0d0`),
`RebuildLabel` and `MeasureWindow` count characters with
`FUN_FONTSTRING_FIT_TEXT` (`0x772bc0` → `0x44d860` → `0x5c6c50`), whose
kernel counts an icon as one character of no width. Its co-hook mirrors
4.3.4's `GxuFontGetMaxCharsWithinWidth`: the run before each icon is fitted
by the original with the budget the earlier icons left, and an icon fits
when the icon-aware width through its end does.
- **Ctrl+C** — `CopySelection` (`0x77e1d0`) pushes 0x500; patched to 4.3.4's
0x1500, and the `GxuStripControlCodes` (`0x5c2590`) co-hook honours 0x1000
by dropping spans and stray `|t`. Paste re-escapes every `|`
(`InsertChar` per character), as in 4.3.4.
- **Node flag `[node+0x5c]` bit 6 (0x40) = EDITABLE** — catches the **macro
editor** (`MacroFrameText`, flags `0x4D`), which builds its layout once,
**un-focused**, so the focus global never sees it. But single-line inputs
(chat entry box, `pfChatCopyBox`) have bit 6 **CLEAR** — their nodes are
`0x0D`/`0x20D`, identical to display FontStrings — so bit 6 can't catch them.
- **Focused-editbox global `DAT_00cf4dc8`** (`InputFocused()`; holds the editbox
with keyboard focus, cleared by `ClearFocus` = `FUN_0077e410`) — catches those
single-line inputs. It's GLOBAL (true UI-wide, not per-node), which is why,
while an editbox is focused, other display text that *rebuilds* also briefly
renders raw. That's an accepted tradeoff: there is no per-node signal for
single-line inputs, and when you're in an editbox seeing raw markup is what you
want anyway.
**Why the tokenizer's bit-6 gate is load-bearing for RENDER, not just measure:**
when the emitter suppresses an editbox it delegates to the ORIGINAL emitter,
which **re-enters the tokenizer** to lay out glyphs (`FUN_005ccbe0` →
`FUN_005c2810`). On that render path `flags` is the node's own flags, so bit
0x40 makes the tokenizer stand down and the `|T` draws as literal text. Drop the
bit-6 gate and the (un-focused) macro editor draws **BLANK** — the tokenizer eats
the span as a zero-width token and the original emitter renders nothing (verified
in-game). Focused single-line inputs get the same "don't intercept" via
`Suppressed()` → `InputFocused()`. So both the emitter and the tokenizer consult
`Suppressed() || bit-6`; the two must agree or you get blank-vs-raw mismatches.
(Dead end: **monochrome render flag `DAT_00c2b9dc`** (`FUN_005c8b70` sets it per
group) — un-set at MacroFrameText's build time and flickered as a flush gate.
Dropped. The editbox class vtable is `0x0081c8c0`, its script-slot resolver
`FUN_0077a310` with focus slots at `+0x438`/`+0x440` — useful if a frame-level
hook is ever needed.)
(History: before the span rule, every typed `|T` reached the emitter as a
matching `||T`, so edit boxes were special-cased with three gates — node
flag bit 6, a focused-buffer pointer test, and a content compare of the
render line against the focused input — plus a re-entry bracket so the
delegated raw layout did not draw blank. All removed. Node flag bit 6 is
NON-SPACE-WRAP, not "editable"; the gate only worked because the macro
editor sets it.)
### Descriptor fields + the vertical flip
+46
View File
@@ -14,6 +14,7 @@
#include "Common.h"
#include <cstring>
#include <string>
#include <windows.h>
namespace Common {
@@ -31,6 +32,38 @@ bool PatchBytes(void *dst, const void *src, size_t len) {
return true;
}
bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target) {
const auto *code = reinterpret_cast<const uint8_t *>(site);
if (code[0] != 0xE8)
return false;
const auto callee = [site](int32_t rel) { return site + 5 + static_cast<uintptr_t>(rel); };
int32_t rel = 0;
std::memcpy(&rel, code + 1, sizeof rel);
const uintptr_t to = reinterpret_cast<uintptr_t>(target);
if (callee(rel) == to)
return true;
if (callee(rel) != expected)
return false;
rel = static_cast<int32_t>(to - (site + 5));
return PatchBytes(reinterpret_cast<uint8_t *>(site) + 1, &rel, sizeof rel);
}
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target) {
uint8_t code[16];
if (len < 5 || len > sizeof code)
return false;
code[0] = 0xE8;
const int32_t rel = static_cast<int32_t>(reinterpret_cast<uintptr_t>(target) - (site + 5));
std::memcpy(code + 1, &rel, sizeof rel);
std::memset(code + 5, 0x90, len - 5);
const void *at = reinterpret_cast<const void *>(site);
if (std::memcmp(at, code, len) == 0)
return true;
if (std::memcmp(at, original, len) != 0)
return false;
return PatchBytes(reinterpret_cast<void *>(site), code, len);
}
void BoundedCopy(char *dst, const char *src, size_t n) {
if (dst == nullptr || n == 0)
return;
@@ -40,4 +73,17 @@ void BoundedCopy(char *dst, const char *src, size_t n) {
dst[i] = '\0';
}
void EnsureParentDirs(const char *relPath) {
std::string accum;
for (const char *p = relPath; *p != '\0'; ++p) {
if (*p == '\\' || *p == '/') {
if (!accum.empty())
CreateDirectoryA(accum.c_str(), nullptr);
accum.push_back('\\');
} else {
accum.push_back(*p);
}
}
}
} // namespace Common
+18
View File
@@ -12,15 +12,33 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include <cstddef>
#include <cstdint>
namespace Common {
bool PatchBytes(void *dst, const void *src, size_t len);
// Points the `E8 rel32` call at `site` to `target` when it still calls
// `expected`; true when the call now reaches `target`. A call that reaches
// anything else (another DLL's patch, a different build) is left alone.
// Idempotent, so it can run on every registration pass.
bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target);
// Writes `call target` plus NOP padding over the `len`-byte (>= 5)
// instruction at `site` when it still holds `original`; true when the site
// now calls `target`. A site holding anything else (another DLL's patch, a
// different build) is left alone. Idempotent, like RedirectCall.
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target);
// Bounded, always-terminated string copy: writes at most `n - 1`
// characters plus the terminator, and treats a null `src` as empty. `n`
// is the size of `dst`. Stands in for `strncpy`, which MSVC deprecates
// (C4996) and which does not terminate on truncation.
void BoundedCopy(char *dst, const char *src, size_t n);
// Creates every parent directory of `relPath` (backslash- or slash-separated,
// e.g. "BlizzardInterfaceCode\FrameXML\Foo.lua"), relative to the working
// directory. Directories that already exist are a no-op.
void EnsureParentDirs(const char *relPath);
} // namespace Common
+9 -7
View File
@@ -15,6 +15,7 @@
#include "Game.h"
#include "MinHook.h"
#include "Offsets.h"
#include "config/CommandLine.h"
#include "config/FileSwitch.h"
#include "event/Custom.h"
#include "player/NameCache.h"
@@ -208,13 +209,14 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hModule);
// The ONE thing that has to happen this early, and the only module this
// file names directly. The engine reads its config file during boot,
// several calls before VanillaFixes invokes `Load`, so a `-config`
// switch cannot be applied from the normal install path — by then the
// file has been read. This is a single 4-byte store, not a hook, so it
// carries none of the loader-lock cost described above.
Config::FileSwitch::Apply();
// The ONE thing that has to happen this early, and the only modules this
// file names directly. The engine parses its launch switches and reads
// its config file during boot, several calls before VanillaFixes invokes
// `Load`, so our switches cannot be wired from the normal install path.
// Each is a single rel32 store, not a hook, so it carries none of the
// loader-lock cost described above.
Config::CommandLine::Install();
Config::FileSwitch::Install();
// Install nothing here (loader lock — see the block comment above).
//
+7 -35
View File
@@ -459,45 +459,14 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback)
}
}
// In-place `string.gsub(stack[-1], pattern, replacement)` — replaces
// the string at the top of the stack with the substitution result.
// Discards gsub's second return (the substitution count) by
// requesting only one return from `Call`.
static void GsubInPlace(void *L, const char *pattern, const char *replacement) {
PushString(L, "string");
GetTable(L, GLOBALS_INDEX);
PushString(L, "gsub");
GetTable(L, -2); // [..., str, string, gsub]
Remove(L, -2); // [..., str, gsub]
Insert(L, -2); // [..., gsub, str]
PushString(L, pattern);
PushString(L, replacement);
Call(L, 3, 1); // [..., result]
}
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n) {
// Resolve `string.format` from _G.
PushString(L, "format");
GetTable(L, GLOBALS_INDEX);
PushLocalizedString(L, globalName, fallback);
// Expand WoW's `|4singular:plural;` (and 3-form
// `|4nom:gen_sing:plural;`) pluralization template by piggybacking
// on Lua's `string.gsub`. The 2- vs 3-form distinction is locale
// grammar (Slavic counting has a separate genitive-singular form
// for 2..4); we collapse to nominative/plural since vanilla has
// no proper locale-aware counting rules. 3-form pattern runs
// first because it's more specific. Vanilla's `string.format`
// doesn't process `|4` natively, so without this the template
// would render literally.
const char *picker3 = (n == 1) ? "%1" : "%3";
const char *picker2 = (n == 1) ? "%1" : "%2";
GsubInPlace(L, "|4([^:;]+):([^:;]+):([^;]+);", picker3);
GsubInPlace(L, "|4([^:;]+):([^;]+);", picker2);
PushNumber(L, static_cast<double>(n));
Call(L, 2, 1); // format(expandedFmt, n) → string
Call(L, 2, 1); // format(fmt, n) → string
}
} // namespace Lua
@@ -505,7 +474,7 @@ namespace Console {
namespace {
using RegisterCommand_t = int(__fastcall *)(const char *name, void *handler,
int category, const char *description);
using Write_t = void(__fastcall *)(const char *line, int colorFlag);
using Write_t = void(__fastcall *)(const char *line, int color);
} // namespace
void RegisterCommand(const char *name, CommandHandler handler, int category,
@@ -514,10 +483,13 @@ void RegisterCommand(const char *name, CommandHandler handler, int category,
fn(name, reinterpret_cast<void *>(handler), category, description);
}
void Write(const char *line) {
void Write(const char *line, Color color) {
auto fn = reinterpret_cast<Write_t>(Offsets::FUN_CONSOLE_WRITE);
fn(line, 0);
fn(line, color);
}
void(__cdecl *const Printf)(const char *format, ...) =
reinterpret_cast<void(__cdecl *)(const char *, ...)>(Offsets::FUN_CONSOLE_PRINTF);
} // namespace Console
namespace {
+50 -10
View File
@@ -58,6 +58,22 @@ inline T &Ref(uintptr_t address) {
return *reinterpret_cast<T *>(address);
}
// Calls `fn(object)` for each object of one of a frame's intrusive lists,
// OFF_FRAME_REGION_LIST or OFF_FRAME_CHILD_LIST: node+4 next, node+8 object,
// a NULL or low-bit-1 link ends it - the walk the engine's own frame code
// does (the alpha setter, the bounds walk 0x00786F80).
template <typename Fn>
void ForEachFrameListObject(void *frame, uintptr_t listOffset, Fn fn) {
uintptr_t link = Read<uintptr_t>(frame, listOffset);
while (link != 0 && (link & 1) == 0) {
void *obj = *reinterpret_cast<void *const *>(link + 8);
const uintptr_t next = *reinterpret_cast<const uintptr_t *>(link + 4);
if (obj != nullptr)
fn(obj);
link = next;
}
}
// --- Engine primitives ------------------------------------------------------
// Resolve a unit token (`"player"`, `"target"`, `"party3"`, `"nameplate1"`,
@@ -516,6 +532,8 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
// `ITEM_SLOTS_IGNORED = "%d slot(s) ignored"`, etc.). Callers that
// need string or float args, or multiple args, can build their own
// using `PushLocalizedString` + manual `string.format` invocation.
// A `|4singular:plural;` in the string stays; the engine's grammar pass
// (Text::Grammar) expands it when the text is set.
void PushLocalizedFormatInt(void *L, const char *globalName,
const char *fallback, int n);
@@ -543,16 +561,17 @@ bool CallGlobalString(void *L, const char *name, const char *arg);
// section in CLAUDE.md for the underlying offsets and ABI.
namespace Console {
// Console-command handler ABI: `args` is the command-line text after
// the command name (empty string when invoked bare). The engine
// ignores the return value; return 1 by convention.
using CommandHandler = int(__fastcall *)(void *unused, const char *args);
// Console-command handler ABI — 4.3.4's `COMMANDHANDLER(command,
// arguments)`. `command` is the name the command was invoked by, which
// is how one handler serves several names: every CVar is registered with
// the shared FUN_CONSOLE_CVAR_COMMAND_HANDLER, which looks the CVar up by
// `command`. `args` is the text after the name (empty string when
// invoked bare). The engine ignores the return value; return 1.
using CommandHandler = int(__fastcall *)(const char *command, const char *args);
// Help-grouping categories — the engine's `help` enumerates these nine
// (in this order). Cosmetic: the value only affects which group `help`
// lists the command under; the engine doesn't validate it. Values are
// sequential table indices (graphics=1 confirmed via FUN_0066F6C0, the
// rest follow the displayed order).
// Help-grouping categories — 4.3.4's `CATEGORY` enum, which matches the
// nine-entry table the 1.12 `help` command lists (VAR_CONSOLE_CATEGORY_TABLE).
// Cosmetic: only `help` grouping reads it; the registrar doesn't validate it.
enum Category {
CATEGORY_DEBUG = 0,
CATEGORY_GRAPHICS = 1,
@@ -574,9 +593,30 @@ enum Category {
void RegisterCommand(const char *name, CommandHandler handler, int category,
const char *description);
// Line colour types — 4.3.4's `COLOR_T`, indexing the nine-entry
// VAR_CONSOLE_COLORS table. The engine's `help` writes WARNING (yellow),
// its failures ERROR (red), and its plain output DEFAULT.
enum Color {
COLOR_DEFAULT = 0,
COLOR_INPUT = 1,
COLOR_ECHO = 2,
COLOR_ERROR = 3,
COLOR_WARNING = 4,
COLOR_GLOBAL = 5,
COLOR_ADMIN = 6,
COLOR_HIGHLIGHT = 7,
COLOR_BACKGROUND = 8,
};
// Writes a line to the console output buffer. No-ops cleanly when the
// console isn't active, so it's always safe to call.
void Write(const char *line);
void Write(const char *line, Color color = COLOR_DEFAULT);
// The engine's own `ConsolePrintf` (FUN_CONSOLE_PRINTF): formats with
// Storm's printf into a 1024-byte buffer, then writes it in
// COLOR_DEFAULT. Exposed as the engine's entry itself rather than a
// wrapper, because a cdecl varargs call cannot be forwarded.
extern void(__cdecl *const Printf)(const char *format, ...);
} // namespace Console
+2084 -497
View File
File diff suppressed because it is too large Load Diff
+24 -45
View File
@@ -12,19 +12,18 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetActionInfo(slot)` — modern-WoW-style action descriptor lookup.
// Returns `actionType, id, subType` for the slot, or nil if empty.
// Returns `actionType, id, subType` for the slot, or nothing if the slot
// holds nothing the descriptor can describe (4.3.4 returns nothing there
// too, rather than a nil).
//
// The vanilla 1.12.1 engine stores action descriptors as packed
// uint32s in `VAR_ACTION_TABLE` (`0x00BC6980`, 120 slots). Top 4 bits
// are the type tag; the `0x40000000` tag is ambiguous between
// macros and bag-items and is disambiguated by checking the per-
// character macro-slot map at `VAR_MACRO_SLOT_MAP` (36 entries).
// are the type tag — the server's ACTION_BUTTON_* values shifted into the
// top byte: 0x00 spell, 0x40 macro, 0x80 item.
//
// empty slot: nil
// spell action: "spell", spellID, "spell"
// macro: "macro", macroIndex (1-based)
// bag item: "item", nil (see note below)
// item-by-id: "item", itemID
// item: "item", itemID
// equipment set: "equipmentset", setName
//
// The subType is always "spell" for spell-type entries — the engine
@@ -32,19 +31,13 @@
// live in a separate table; that's `PetHasActionBar` + the pet-action
// helpers, not this function.
//
// The macro discriminator is the same trick SuperWoWhook uses in its
// modified `GetActionText`: walk the 36-entry macro-slot map and check
// whether `entry & 0xBFFFFFFF` matches one of those macro IDs. If yes,
// the slot holds a macro; if no, it's a bag-item.
//
// **Bag-item itemID note.** For real bag items (top-4 bits = `0x4`
// and the payload isn't in the macro-slot map), the engine stores a
// bag-instance key, not the itemID. Resolving to itemID requires
// walking the per-character action-item hash table at `[0x00BDCC54]`
// and reading a struct field whose offset we haven't fully derived
// yet. For now we report the action *type* ("item") but return nil
// for the id. Callers that need it can fall back to
// `GetActionTexture` + Lua-side item lookups.
// A 0x40 entry is a macro only while its macroID is in the macro-slot map
// (`FUN_ACTION_IS_MACRO`, which every engine reader gates its macro branch
// on). One that is not — a macro deleted out from under its button — is
// nothing, as in 4.3.4: its IsItem and IsMacro are both false, so it falls
// through to an empty return. There is no bag-item tag: items are always
// by-itemID (0x80), and an earlier reading of the 0x40 "miss" as a bag item
// was wrong.
#include "Game.h"
#include "Offsets.h"
@@ -72,16 +65,12 @@ int __fastcall Script_GetActionInfo(void *L) {
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS) {
Game::Lua::PushNil(L);
return 1;
}
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
const uint32_t entry = ReadEntry(slot0);
if (entry == 0) {
Game::Lua::PushNil(L);
return 1;
}
if (entry == 0)
return 0;
const uint32_t type = entry & Offsets::ACTION_TYPE_MASK;
@@ -94,10 +83,8 @@ int __fastcall Script_GetActionInfo(void *L) {
static_cast<uintptr_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL));
uint32_t petFlag = 0;
const uint32_t spellID = fn(static_cast<uint32_t>(slot0), &petFlag);
if (spellID == 0) {
Game::Lua::PushNil(L);
return 1;
}
if (spellID == 0)
return 0;
(void)petFlag; // hardcoded 0 in the engine helper for this table
Game::Lua::PushString(L, "spell");
Game::Lua::PushNumber(L, static_cast<double>(spellID));
@@ -113,12 +100,7 @@ int __fastcall Script_GetActionInfo(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(macroSlot));
return 2;
}
// Not a macro — it's a bag item. We know the type but extracting
// the itemID from the bag-instance hash needs more reversing;
// see the file-level comment.
Game::Lua::PushString(L, "item");
Game::Lua::PushNil(L);
return 2;
return 0; // a macro button whose macro is gone — see the file comment
}
if (type == Offsets::ACTION_TYPE_ITEM_BY_ID) {
@@ -133,18 +115,15 @@ int __fastcall Script_GetActionInfo(void *L) {
// set's name.
const EquipmentSet::Set *set = EquipmentSet::Data::FindByID(
entry & ~static_cast<uint32_t>(Offsets::ACTION_TYPE_EQUIPMENT_SET));
if (set == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (set == nullptr)
return 0;
Game::Lua::PushString(L, "equipmentset");
Game::Lua::PushString(L, set->name.c_str());
return 2;
}
// Unknown type tag — surface as nil so callers don't get garbage.
Game::Lua::PushNil(L);
return 1;
// Unknown type tag — nothing, so callers don't get garbage.
return 0;
}
} // namespace
+54
View File
@@ -0,0 +1,54 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Dropping a spell on a slot that already holds it — 4.3.4's
// CGActionBar::PutActionInSlot:
//
// else if (CGActionBar::IsSpell(slot) && CGActionBar::GetSpell(slot, petSpell) == spellId)
// CGGameUI::DropCursorSpell();
//
// 1.12's FUN_ACTION_PLACE runs the same compare without the IsSpell gate,
// and FUN_ACTION_SLOT_TO_SPELL resolves a macro slot to the macro's spell.
// So a spell dropped on a `/cast <that spell>` macro was taken for a
// duplicate: the cursor cleared and the macro stayed. The gate is one call
// redirect: a slot that is not a spell action has no spell to match.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Action::Place {
namespace {
using SlotToSpell_t = uint32_t(__fastcall *)(uint32_t slot0, uint32_t *outPetFlag);
uint32_t __fastcall SpellSlotSpell(uint32_t slot0, uint32_t *outPetFlag) {
const uint32_t action = reinterpret_cast<const uint32_t *>(Offsets::VAR_ACTION_TABLE)[slot0];
if (action == 0 || (action & Offsets::ACTION_TYPE_MASK) != Offsets::ACTION_TYPE_SPELL)
return 0;
return reinterpret_cast<SlotToSpell_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL)(slot0, outPetFlag);
}
void Register() {
Common::RedirectCall(Offsets::CALL_ACTION_PLACE_SAME_SPELL, Offsets::FUN_ACTION_SLOT_TO_SPELL,
reinterpret_cast<const void *>(&SpellSlotSpell));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::Place
+5 -7
View File
@@ -20,13 +20,11 @@ namespace Action::Slot {
int MacroSlotForID(uint32_t macroID) {
if (macroID == 0)
return 0;
auto *map = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_MACRO_SLOT_MAP));
for (int i = 0; i < Offsets::MACRO_SLOT_MAP_COUNT; ++i) {
if (map[i] == macroID)
return i + 1;
}
return 0;
// The engine's own scan: the 0-based slot, or 0xFFFFFFFF on a miss.
using IdToSlot_t = uint32_t(__fastcall *)(int macroID);
const uint32_t slot0 = reinterpret_cast<IdToSlot_t>(
static_cast<uintptr_t>(Offsets::FUN_MACRO_ID_TO_SLOT))(static_cast<int>(macroID));
return slot0 == 0xFFFFFFFFu ? 0 : static_cast<int>(slot0) + 1;
}
uint32_t MacroIDForSlot(int slot0) {
+4 -4
View File
@@ -18,8 +18,9 @@
// Action-slot ↔ macro decoding shared by every module that has to answer
// "which macro sits in this action slot" (`GetActionInfo`, the
// `#showtooltip` display overrides). The action table stores macroIDs under
// the ambiguous `0x40000000` tag; the macro-slot map says which of those ids
// are macros (the rest are bag items). Pure C — no Lua stack.
// the `0x40000000` tag; the macro-slot map says which of those ids still
// name a macro (the rest are buttons whose macro is gone). Pure C — no Lua
// stack.
namespace Action::Slot {
@@ -29,8 +30,7 @@ namespace Action::Slot {
int MacroSlotForID(uint32_t macroID);
// The macroID in action slot `slot0` (0-based, `< ACTION_TABLE_MAX_SLOTS`),
// or 0 when the slot is empty, isn't `0x40000000`-tagged, or holds a bag
// item rather than a macro.
// or 0 when the slot is empty, isn't `0x40000000`-tagged, or its macro is gone.
uint32_t MacroIDForSlot(int slot0);
} // namespace Action::Slot
+5 -1
View File
@@ -18,13 +18,17 @@
// is 0, keeping 1.12's action buttons firing on key release; 4.3.4 shipped 1.
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
namespace Action::UseKeyDown {
namespace {
void Register() { CVar::Factory::Register("ActionButtonUseKeyDown", "0"); }
void Register() {
CVar::Factory::Register("ActionButtonUseKeyDown", "Activate the action button on a keydown",
Offsets::CVAR_CATEGORY_GAME, "0");
}
const Game::ModuleAutoRegister _autoreg{&Register};
+82 -95
View File
@@ -18,8 +18,8 @@
// How:
//
// 1. The CMake build embeds every file under `AddOns/!!!ClassicAPI/`
// into a generated header (`embedded_classicapi.h`) as a byte
// array per file plus a `{path, data, size}` manifest.
// as an RCDATA resource, plus a generated `{path, resourceId}`
// manifest (`embedded_resources.h`).
//
// 2. We hook the engine's file-read function at `FUN_FILE_READ`.
// When the engine asks for a path under
@@ -28,14 +28,20 @@
// embedded content in, and hand it back. The caller's normal
// `SMemFree` reclaims it on completion — no special lifetime.
//
// 3. We post-hook the engine's `FUN_ADDON_INIT`. After the engine's
// own `Interface\AddOns\` scan finishes, we call the TOC parser
// directly with `"!!!ClassicAPI"`. The parser opens its TOC via
// our hooked read function and registers the addon as a normal
// entry. The TOC parser is dedup-safe: if the user already has
// the addon on disk, the engine's scan picked it up, the
// hash-table lookup hits, and our call is a no-op. So the
// conditional "only if not on disk" behavior is free.
// 3. We hook the exists-check `FUN_FILE_EXISTS` so it answers the same
// way for the same paths. Without it every embedded file reads fine
// but reports as missing — the per-addon loader would skip an
// embedded `Bindings.xml`, and `Xml::ScriptPath` would never find an
// embedded `<Script file="sub\x.lua">`.
//
// 4. We pre-hook the engine's `FUN_ADDON_SCAN_FULL` and call the TOC
// parser with `"!!!ClassicAPI"` before the scan runs. The registry
// is empty there (the login init resets it just before), so our
// entry is the first record and the load pass — which walks the
// records in registration order — loads it first. The scan's own
// `## LoadWith:` pass then sees it like any disk addon. The parser
// is dedup-safe: when the user also has the addon on disk, the
// scan's call for that folder hits the hash table and is a no-op.
//
// Same pattern WeirdUtils.dll uses (we reverse-engineered it from
// `0x10015A74` while figuring this out). The addon load pass then
@@ -43,7 +49,7 @@
// `Compat.lua`, `Util\Print.lua`, etc. all surface from
// embedded buffers.
#include "embedded_classicapi.h"
#include "embedded_resources.h"
#include "Game.h"
#include "Offsets.h"
@@ -54,6 +60,7 @@
#include "addons/Toc.h"
#include "addons/TocRewrite.h"
#include "bindings/Inject.h"
#include "embed/Resource.h"
#include <cstdint>
#include <cstdio>
@@ -109,23 +116,27 @@ const char *StripAddonPrefix(const char *path) {
return p;
}
const ClassicAPIFiles::File *LookupEmbedded(const char *suffix) {
// The embedded bytes for `suffix`; `data` is NULL when it is not embedded.
Embed::Bytes LookupEmbedded(const char *suffix) {
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(suffix, ClassicAPIFiles::kFiles[i].path))
return &ClassicAPIFiles::kFiles[i];
return Embed::Resource(ClassicAPIFiles::kFiles[i].resourceId);
}
return nullptr;
return {nullptr, 0};
}
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
// the __stdcall / ESP-drift hazard. A buffer we SMemAlloc here is freed cleanly
// by the caller's standard SMemFree in turn.
using AddOns::EngineIO::FileExistsFn;
using AddOns::EngineIO::FileReadFn;
using AddOns::EngineIO::SMemAllocFn;
using AddOns::EngineIO::SMemFreeFn;
// The trampoline to the original FUN_FILE_READ (installed by the co-hook below).
// The trampolines to the original FUN_FILE_READ / FUN_FILE_EXISTS (installed
// by the co-hooks below).
FileReadFn FileRead_o = nullptr;
FileExistsFn FileExists_o = nullptr;
// Extracts the value of the `## Version: X` line from a TOC byte
// buffer. Writes into `out` (size `outSize`) and returns true on
@@ -187,19 +198,14 @@ Source g_source = Source::Undecided;
void EnsureEmbeddedVersionExtracted() {
if (g_embeddedVersion[0] != '\0') return;
for (size_t i = 0; i < ClassicAPIFiles::kFileCount; ++i) {
if (PathEqualsCI(kAddonTocFile, ClassicAPIFiles::kFiles[i].path)) {
ExtractTocVersion(
reinterpret_cast<const char *>(ClassicAPIFiles::kFiles[i].data),
ClassicAPIFiles::kFiles[i].size,
g_embeddedVersion, sizeof(g_embeddedVersion));
return;
}
}
const Embed::Bytes toc = LookupEmbedded(kAddonTocFile);
if (toc.data != nullptr)
ExtractTocVersion(reinterpret_cast<const char *>(toc.data), toc.size,
g_embeddedVersion, sizeof(g_embeddedVersion));
}
// True iff the on-disk addon folder contains the `.classicapi-dev`
// marker. Read via the ORIGINAL `FUN_FILE_READ` (bypassing our embed
// marker. Asked of the ORIGINAL `FUN_FILE_EXISTS` (bypassing our embed
// hook), so it reflects a real file on disk / in an MPQ — never an
// embedded copy. That makes it a clean "is this a developer working
// tree" signal: the marker is gitignored + excluded from the embed and
@@ -209,14 +215,7 @@ bool DiskHasDevMarker() {
char fullPath[256];
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
kAddonPathPrefix, kDevMarkerFile);
void *buf = nullptr;
size_t size = 0;
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
if (ok == 0 || buf == nullptr)
return false;
auto SMemFree = reinterpret_cast<SMemFreeFn>(Offsets::FUN_STORM_SMEM_FREE);
SMemFree(buf, __FILE__, __LINE__, 0);
return true;
return FileExists_o(fullPath, 1) != 0;
}
// Decide whether disk or embedded should serve all reads for this
@@ -329,8 +328,8 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
// Look up the embedded version and synthesize a Storm buffer the
// caller can free normally.
const auto *entry = LookupEmbedded(suffix);
if (entry == nullptr) {
const Embed::Bytes entry = LookupEmbedded(suffix);
if (entry.data == nullptr) {
// Not in our embedded set either — let the engine try disk
// one more time (returns 0 if that also fails). Covers
// pathological cases like an empty disk install + embedded
@@ -341,48 +340,46 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
const size_t totalSize = entry->size + extraBytes;
const size_t totalSize = entry.size + extraBytes;
void *buf = SMemAlloc(totalSize, __FILE__, __LINE__, 0);
if (buf == nullptr) return 0;
std::memcpy(buf, entry->data, entry->size);
std::memcpy(buf, entry.data, entry.size);
if (extraBytes > 0) {
std::memset(static_cast<uint8_t *>(buf) + entry->size, 0, extraBytes);
std::memset(static_cast<uint8_t *>(buf) + entry.size, 0, extraBytes);
}
if (outBuf != nullptr) *outBuf = buf;
if (outSize != nullptr) *outSize = entry->size;
if (outSize != nullptr) *outSize = entry.size;
return 1;
}
// `FUN_ADDON_INIT` — `__fastcall(accountName)`. We post-hook it: let
// the engine's normal `Interface\AddOns\` scan run first, then call
// the TOC parser with `"!!!ClassicAPI"`. Dedup-safe — see file header.
using AddonInit_t = void(__fastcall *)(const char *accountName);
AddonInit_t AddonInit_o = nullptr;
// The exists-check counterpart of FileRead_h: the same source decision and
// the same fallbacks, so a path reports as existing exactly when FileRead_h
// would serve it. Other paths go to FlavorBindings (flavor-only addon
// Bindings.xml), then to the original.
int __stdcall FileExists_h(const char *path, int mode) {
const char *suffix = StripAddonPrefix(path);
if (suffix == nullptr) {
if (FileExists_o(path, mode) != 0)
return 1;
return AddOns::FlavorBindings::TryExists(path, mode, FileExists_o) ? 1 : 0;
}
DecideSource();
if (g_source == Source::Disk && FileExists_o(path, mode) != 0)
return 1;
if (LookupEmbedded(suffix).data != nullptr)
return 1;
return FileExists_o(path, mode);
}
// `FUN_ADDON_SCAN_FULL` — `void __cdecl()`. We pre-hook it: register
// `"!!!ClassicAPI"` into the empty registry, then let the engine's scan
// run. Dedup-safe — see file header.
using AddonScan_t = void(__cdecl *)();
AddonScan_t AddonScan_o = nullptr;
using TocParser_t = void(__fastcall *)(const char *name);
using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
int position, int anchor);
// `FUN_TOC_PARSER` appends new entries to the *tail* of the addon
// registry's linked list, and the engine's addon-load pass at
// `FUN_0051F600` walks that list in *insertion order* (NOT
// alphabetical order — the alphabetical sort only applies to the
// flat array `GetAddOnInfo(i)` reads from). So our post-hook
// injection lands at the very end of the load order, after every
// disk-scanned addon. Addons that consume our globals from their
// main chunk (idTip's `FrameWatcher`, etc.) would see `nil` and
// hard-error during their boot.
//
// Fix: walk the linked list, find our entry by name, and re-link
// it to the head via `FUN_INTRUSIVE_LIST_INSERT(position=1)`. The
// engine's own insert helper handles remove-then-insert atomically,
// so it's safe to call on an entry that's already in the list.
//
// The list is walked via the shared `Addons::ForEachEntry`
// (`addons/Registry.h`); the callback stops the walk once it finds and
// re-links our entry, since the re-link invalidates the current
// next-pointer.
//
// `entry + OFF_ADDON_ENTRY_FILTER_OUT` is the "filter-out" byte. The
// flat display-array builder `FUN_0051da70` walks the linked list and
// copies an entry into `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when
@@ -394,19 +391,14 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
// loading (the load pass `FUN_0051f600` never reads `+0x29`) and does
// NOT break dependency resolution or by-name queries (those use the
// addon hash table, which keeps the entry regardless).
//
// DefaultState (`+0x2b`) needs no write: the entry constructor
// `FUN_00520550` stores 1 there, our toc has no `## DefaultState:`, and
// the resolver co-hook below forces enabled anyway.
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
// when the player has no per-character override. Belt-and-suspenders
// alongside the resolver co-hook below (which forces enabled anyway).
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
// Post-registration finalize for the embedded entry: move it to the
// head of the load-order list (so consumers that read our globals from
// their main chunk see them — see the block comment above the offsets),
// hide it from the addon list, and mark it default-enabled. Combined
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
// loads and never appears as a toggleable entry.
// Post-registration finalize for the embedded entry: hide it from the
// addon list. Combined with the `FUN_ADDON_ENABLE_RESOLVE` co-hook,
// `!!!ClassicAPI` always loads and never appears as a toggleable entry.
void FinalizeEmbeddedEntry() {
Addons::ForEachEntry([](uintptr_t entry) -> bool {
const char *name = *reinterpret_cast<const char *const *>(
@@ -414,27 +406,17 @@ void FinalizeEmbeddedEntry() {
if (name == nullptr || std::strcmp(name, kAddonName) != 0)
return true; // keep walking
// Hide from the character-select AddOns list and default it to
// enabled, then move it to the head of the load order.
*reinterpret_cast<uint8_t *>(
entry + Offsets::OFF_ADDON_ENTRY_FILTER_OUT) = 1;
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
auto fn = reinterpret_cast<ListInsert_t>(
Offsets::FUN_INTRUSIVE_LIST_INSERT);
fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL)),
reinterpret_cast<void *>(entry),
/*position=*/ 1, /*anchor=*/ 0);
return false; // stop — the re-link invalidated our next-pointer
return false;
});
}
void __fastcall AddonInit_h(const char *accountName) {
AddonInit_o(accountName);
void __cdecl AddonScan_h() {
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
TocParser(kAddonName);
FinalizeEmbeddedEntry();
AddonScan_o();
}
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
@@ -459,10 +441,15 @@ const Game::HookAutoRegister _hookFileRead{
reinterpret_cast<void *>(&FileRead_h),
reinterpret_cast<void **>(&FileRead_o)};
const Game::HookAutoRegister _hookAddonInit{
Offsets::FUN_ADDON_INIT,
reinterpret_cast<void *>(&AddonInit_h),
reinterpret_cast<void **>(&AddonInit_o)};
const Game::HookAutoRegister _hookFileExists{
Offsets::FUN_FILE_EXISTS,
reinterpret_cast<void *>(&FileExists_h),
reinterpret_cast<void **>(&FileExists_o)};
const Game::HookAutoRegister _hookAddonScan{
Offsets::FUN_ADDON_SCAN_FULL,
reinterpret_cast<void *>(&AddonScan_h),
reinterpret_cast<void **>(&AddonScan_o)};
const Game::HookAutoRegister _hookEnableResolve{
Offsets::FUN_ADDON_ENABLE_RESOLVE,
+6 -24
View File
@@ -15,8 +15,6 @@
#include "addons/FlavorBindings.h"
#include "addons/Toc.h"
#include "Game.h"
#include "Offsets.h"
#include "turtle/Detect.h"
#include <cstddef>
@@ -72,40 +70,24 @@ int FlavorOrder(const char *(&order)[2]) {
return count;
}
// Engine file-exists probe FUN_00648a30 — __stdcall(path, mode) (RET 8).
using EngineIO::FileExistsFn;
FileExistsFn g_origExists = nullptr;
} // namespace
// Co-hook the exists-check so `Interface\AddOns\<Name>\Bindings.xml` reports as
// existing when the base file is absent but a flavor variant is present — so
// the loader (`FUN_004b6f70`, then our TryHandle redirect) runs for a
// flavor-only addon. Every non-Bindings path passes straight through.
int __stdcall FileExists_h(const char *path, int mode) {
bool TryExists(const char *path, int mode, FileExistsFn orig) {
const size_t prefixLen = AddonBindingsPrefix(path);
if (prefixLen == NPOS)
return g_origExists(path, mode);
if (g_origExists(path, mode) != 0)
return 1; // the plain Bindings.xml exists
return false;
const char *order[2];
const int count = FlavorOrder(order);
char cand[512];
for (int i = 0; i < count; ++i) {
if (BuildFlavorPath(path, prefixLen, order[i], cand, sizeof cand) &&
g_origExists(cand, mode) != 0)
return 1;
orig(cand, mode) != 0)
return true;
}
return 0;
return false;
}
const Game::HookAutoRegister _hookExists{
Offsets::FUN_FILE_EXISTS,
reinterpret_cast<void *>(&FileExists_h),
reinterpret_cast<void **>(&g_origExists)};
} // namespace
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig) {
const size_t prefixLen = AddonBindingsPrefix(path);
+10 -4
View File
@@ -19,6 +19,7 @@
namespace AddOns::FlavorBindings {
using EngineIO::FileExistsFn;
using EngineIO::FileReadFn;
// Flavor-specific addon Bindings selection — the Bindings.xml analog of
@@ -36,10 +37,10 @@ using EngineIO::FileReadFn;
// place if one exists (a selected flavor wins over a plain `Bindings.xml`,
// matching FlavorToc). Probe order `Bindings_Turtle.xml` (only when
// Turtle::Detected) then `Bindings_ClassicAPI.xml`.
// 2. A co-hook on the exists-check `FUN_00648a30` (inside FlavorBindings.cpp):
// report `Bindings.xml` as existing when the base is absent but a flavor
// variant is present — otherwise the loader is never called for a
// flavor-only addon.
// 2. TryExists, called from the exists-check `FUN_FILE_EXISTS` hook: report
// `Bindings.xml` as existing when the base is absent but a flavor variant
// is present — otherwise the loader is never called for a flavor-only
// addon.
//
// Only `_Turtle` / `_ClassicAPI` are selected — never `_Vanilla` / `_Classic`
// (which target the 1.15 Classic Era build; its bindings reference handlers
@@ -52,4 +53,9 @@ using EngineIO::FileReadFn;
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn orig);
// True when `path` is an addon `…\AddOns\<Name>\Bindings.xml` and a flavor
// variant exists through `orig` (the original exists-check). The caller asks
// `orig` about the base path itself; this answers only the flavor case.
bool TryExists(const char *path, int mode, FileExistsFn orig);
} // namespace AddOns::FlavorBindings
+86 -34
View File
@@ -35,6 +35,7 @@ namespace AddOns::Info {
namespace {
using GetByIndex_t = void *(__fastcall *)(int idx0Based);
using GetAddOnCount_t = int(__fastcall *)();
using FieldByName_t = const char *(__fastcall *)(const char *name);
using EntryByteFn_t = uint8_t(__fastcall *)(const uint8_t *entry);
using GetSecurityIndex_t = uint32_t(__fastcall *)(const uint8_t *entry);
@@ -45,7 +46,7 @@ using GetAccountName_t = const char *(*)();
// Hash-lookup by name → the matched record's RequiredDeps descriptor
// (`record + 0x48`), NULL on miss. Accepts an entry pointer too (its
// first bytes are the inline name). We use it only to recover the
// record base for string input; see `Script_GetAddOnOptionalDependencies`.
// record base; see `Script_GetAddOnOptionalDependencies`.
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
// Resolves the user's arg to a `const uint8_t *` that the engine's
@@ -308,49 +309,66 @@ int __fastcall Script_DoesAddOnExist(void *L) {
return 1;
}
// Resolves arg 1 to the addon's registry record, with the arg handling
// of stock `Script_GetAddOnDependencies` (`0x0048E5E0`, identical to
// 4.3.4's `Script_GetAddOnOptionalDependencies`), not `ResolveAddOnName`:
// `lua_isnumber` first (so a numeric string like `"3"` is an index), an
// out-of-range index raises the engine's own range error with the addon
// count, anything else non-string raises `usage`, and an unknown *name*
// returns nullptr with no error.
//
// Either input is then routed through the by-name hash lookup
// `FUN_ADDON_RESOLVE_REQ_DEPS` to reach the record.
// `FUN_ADDON_GET_BY_INDEX` returns a *name pointer*, NOT the record —
// reading record offsets off it lands in string memory and crashes (a
// numeric-path caller like pfUI's addon list hit exactly that). The
// resolver returns the RequiredDeps descriptor at `record+0x48`;
// subtract to recover the base.
const uint8_t *ResolveRecord(void *L, const char *usage) {
const char *name;
if (Game::Lua::IsNumber(L, 1)) {
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
auto byIndex = reinterpret_cast<GetByIndex_t>(Offsets::FUN_ADDON_GET_BY_INDEX);
name = static_cast<const char *>(byIndex(idx));
if (name == nullptr) {
auto count = reinterpret_cast<GetAddOnCount_t>(Offsets::FUN_ADDON_GET_COUNT);
Game::Lua::Error(L, reinterpret_cast<const char *>(static_cast<uintptr_t>(
Offsets::VAR_ADDON_INDEX_RANGE_ERROR_FMT)),
count());
return nullptr;
}
} else if (Game::Lua::IsString(L, 1)) {
name = Game::Lua::ToString(L, 1);
} else {
Game::Lua::Error(L, usage);
return nullptr;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(name);
if (reqDesc == nullptr)
return nullptr;
return reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
}
// `C_AddOns.GetAddOnOptionalDependencies(indexOrName)` — the addon's
// `## OptionalDeps:` names as multiple return values, in declared order.
// The exact shape of the stock `GetAddOnDependencies`, but reading the
// *optional* dep array instead of the required one. No optional deps →
// returns nothing.
// *optional* dep array instead of the required one. No optional deps or
// an unknown name → returns nothing.
//
// Vanilla parses `## OptionalDeps:` in the TOC parser `FUN_0051c9b0`
// into a `{cap, count, data, quantum}` name-array descriptor at
// `record+0x38` (count@+0x3C, data@+0x40), but ships no getter for it.
//
// Arg/error handling mirrors stock `Script_GetAddOnDependencies`
// (`0x0048E5E0`): numeric out-of-range or a non-string/non-number arg
// raises the usage error; an unknown *name* string returns nothing
// (no error), matching the engine's own not-found path.
//
// Resolution mirrors stock `Script_GetAddOnDependencies` exactly: the
// arg is first turned into a name-usable pointer (`ResolveAddOnName` —
// numeric via `FUN_ADDON_GET_BY_INDEX`, string is the raw string), then
// routed through the by-name hash lookup `FUN_ADDON_RESOLVE_REQ_DEPS`
// to reach the deps-bearing record. `FUN_ADDON_GET_BY_INDEX` returns a
// *name pointer*, NOT the record — reading dep offsets off it lands in
// string memory and crashes (a numeric-path caller like pfUI's addon
// list hit exactly that). The resolver returns the RequiredDeps
// descriptor at `record+0x48`; subtract to recover the base. An unknown
// name yields NULL → return nothing (no error), like stock.
// Like stock, `lua_checkstack(count)` before the pushes — a C function
// is only guaranteed LUA_MINSTACK (20) free slots.
int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
static constexpr const char *kUsage =
"Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")";
// NULL ⇒ out-of-range numeric index or a non-string/non-number arg;
// stock raises the usage error in both cases.
const uint8_t *nameOrEntry = ResolveAddOnName(L);
if (nameOrEntry == nullptr) {
Game::Lua::Error(L, "%s", kUsage);
const uint8_t *record = ResolveRecord(
L, "Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")");
if (record == nullptr)
return 0;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(reinterpret_cast<const char *>(nameOrEntry));
if (reqDesc == nullptr)
return 0; // unknown addon name — no error, push nothing
const uint8_t *record = reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_COUNT);
@@ -360,11 +378,30 @@ int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
record + Offsets::OFF_ADDON_OPTIONALDEPS_ARRAY);
if (names == nullptr)
return 0;
Game::Lua::CheckStack(L, static_cast<int>(count));
for (uint32_t i = 0; i < count; ++i)
Game::Lua::PushString(L, names[i]); // NULL → pushnil, per engine
return static_cast<int>(count);
}
// `C_AddOns.GetAddOnInterfaceVersion(indexOrName)` — the `## Interface:`
// number the engine parsed into `record+0x1C`, the same value its
// loadability check compares against the client version. A comma-list
// is already reduced to retail's pick (closest value at or below the
// client version) by `AddOns::TocRewrite` before the parser sees it.
// nil for an unknown name.
int __fastcall Script_GetAddOnInterfaceVersion(void *L) {
const uint8_t *record = ResolveRecord(
L, "Usage: C_AddOns.GetAddOnInterfaceVersion(index or \"name\")");
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ADDON_INTERFACE_VERSION)));
return 1;
}
// `Enum.AddOnSecurityStatus` — what `C_AddOns.GetAddOnSecurity`
// returns. Slots 0..2 match the engine's own ordering at
// `VAR_ADDON_SECURITY_TABLE`; slot 3 is our addition for
@@ -376,6 +413,18 @@ constexpr Game::Lua::EnumIntegerEntry kAddOnSecurityStatusEntries[] = {
{"NotAvailable", 3},
};
const Game::Doc::Field kInterfaceVersionArgs[] = {
Game::Doc::Req("name", "uiAddon", "An addon index or folder name."),
};
const Game::Doc::Field kInterfaceVersionRets[] = {
Game::Doc::Opt("interfaceVersion", "number", nullptr,
"The ## Interface: value from the TOC, or nil for an unknown name."),
};
const Game::Doc::Function kGetAddOnInterfaceVersion{
"Returns the Interface directive of an addon TOC. For a comma-separated list, "
"returns the closest value at or below the client interface version.",
kInterfaceVersionArgs, kInterfaceVersionRets};
} // namespace
static void RegisterLuaFunctions() {
@@ -401,6 +450,9 @@ static void RegisterLuaFunctions() {
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_LOAD_ADDON)));
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
&Script_GetAddOnOptionalDependencies);
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnInterfaceVersion",
&Script_GetAddOnInterfaceVersion,
&kGetAddOnInterfaceVersion);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+4 -4
View File
@@ -27,10 +27,10 @@ namespace Addons {
// engine's own load pass (`FUN_0051F600`) and scan tail loop use.
//
// `fn` may return `void` (always continue) or `bool` (return `false` to STOP
// early). Early stop is load-bearing for a visitor that mutates the list —
// e.g. re-linking an entry to the head invalidates the current next-pointer,
// so the walk must not follow it. Shared by `Addons::Embedded` (find + re-link
// the embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
// early, e.g. once a by-name search finds its entry). A visitor must not
// unlink the entry it is given and then continue: the walk reads that
// entry's next-pointer after the call. Shared by `Addons::Embedded` (find the
// embedded entry) and `Addons::Rescan` (snapshot / diff / fix-up passes).
template <typename Fn> void ForEachEntry(Fn fn) {
const int linkOffset = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
+4 -4
View File
@@ -62,13 +62,13 @@
//
// Excluded from the evict path (everything else is fair game):
// - `!!!ClassicAPI` — embedded; `Addons::Embedded` owns its entry
// (head re-link + filter byte);
// (registered first + filter byte);
// - `## Secure:` / SMSG-managed entries — the parser cannot restore
// packet-delivered state (see OFF_ADDON_ENTRY_SECURE).
//
// NOTE the surgical discipline. A previous attempt re-invoked the
// login-only teardown+rescan (`FUN_ADDON_INIT`) mid-/reload and
// corrupted the registry (duplicate loads + Lua memory explosion on the
// login-only teardown+rescan (`LoadAddOnsForAccount`, `0x0051C740`)
// mid-/reload and corrupted the registry (duplicate loads + Lua memory explosion on the
// NEXT login). This module never runs bulk teardown; the only entry
// mutation beyond what a login scan produces is the per-entry evict
// above, which is (a) the engine's own complete destructor, (b) always
@@ -165,7 +165,7 @@ void RefreshLooseFileIndex() {
// ── Step 1.5: `##` metadata refresh (evict + re-register on change) ──
// The embedded addon's name — its entry is owned by `Addons::Embedded`
// (head re-link + filter byte) and never refreshed here.
// (registered first + filter byte) and never refreshed here.
constexpr const char *kEmbeddedAddon = "!!!ClassicAPI";
// name → FNV-1a hash of the addon's `##` lines, as last parsed.
+18 -13
View File
@@ -78,13 +78,18 @@ uint32_t ClientInterfaceVersion() {
static_cast<uintptr_t>(Offsets::FUN_ADDON_CLIENT_INTERFACE_VERSION))();
}
// If `line` is a `## Interface:` directive whose value is a comma-list that
// contains the client version but does NOT lead with it, append a normalized
// `<prefix>: <clientVersion>` line to `out` and return true. Vanilla's parser
// reads only the first number, so without this a later 11200 is never seen and
// the addon is marked out of date. Returns false for any other line, and for
// an interface line that needs no change (single value, no client version, or
// client version already first) — the caller then copies the line verbatim.
// If `line` is a `## Interface:` directive whose value is a comma-list, pick
// the retail value — the closest entry at or below the client version — and,
// when that is not already the first entry, append a normalized
// `<prefix>: <value>` line to `out` and return true. Vanilla's parser reads
// only the first number, so without this a later 11200 is never seen and the
// addon is marked out of date. Loadability stays an exact match on the client
// version, and the pick equals it iff it is in the list, so this only changes
// the stored value (what GetAddOnInterfaceVersion reads) for lists that were
// out of date anyway. Returns false for any other line, and for an interface
// line that needs no change (single value, nothing at or below the client
// version, or the pick already first) — the caller then copies the line
// verbatim.
bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
size_t s = 0;
while (s < len && IsSpace(line[s])) ++s;
@@ -103,8 +108,8 @@ bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
const size_t prefixEnd = p; // "## Interface:" (author spacing preserved)
const uint32_t client = ClientInterfaceVersion();
long first = -1;
bool hasComma = false, hasClient = false;
long first = -1, best = -1;
bool hasComma = false;
size_t q = p;
while (q < len) {
while (q < len && (IsSpace(line[q]) || line[q] == ',')) {
@@ -120,17 +125,17 @@ bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
}
if (digit) {
if (first < 0) first = v;
if (static_cast<uint32_t>(v) == client) hasClient = true;
if (static_cast<uint32_t>(v) <= client && v > best) best = v;
} else if (q < len) {
++q; // skip a stray non-digit so the scan always advances
}
}
if (!hasComma || !hasClient || first == static_cast<long>(client))
return false; // single value / no client version / already first
if (!hasComma || best < 0 || first == best)
return false; // single value / nothing at or below client / already first
out.append(line, prefixEnd);
out.push_back(' ');
out += std::to_string(client);
out += std::to_string(best);
return true;
}
+7 -6
View File
@@ -59,12 +59,13 @@ namespace AddOns::TocRewrite {
// Multi-flavor `## Interface:` list. Retail lets a TOC list several
// interface versions (`## Interface: 120100, 50504, 11200`). Vanilla's
// parser is a plain atoi that reads only the FIRST number, so it never
// sees a later 11200 and marks the addon out of date. When the client
// version (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) appears anywhere in
// the list but is not first, the `## Interface:` line is rewritten to that
// single value so the engine marks the addon compatible. A list WITHOUT
// the client version is left unchanged — it still parses to its first
// value and stays out of date, exactly as before.
// sees a later 11200 and marks the addon out of date. The line is rewritten
// to retail's pick — the closest value at or below the client version
// (FUN_ADDON_CLIENT_INTERFACE_VERSION, 11200) — when that is not already
// first. If 11200 is listed the pick is 11200 and the addon loads; if not,
// the addon stays out of date exactly as before, but the stored value
// (`GetAddOnInterfaceVersion`) matches retail. A list with nothing at or
// below the client version is left unchanged.
//
// Called from the FUN_FILE_READ hook after the read succeeds. Does
// nothing unless `path` is an addon `.toc` read AND its content contains a
+715
View File
@@ -0,0 +1,715 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// 4.3.4's CSimpleAnim and its Alpha, Translation, Scale and Rotation
// subclasses (wowdecomp15595 CSimpleAnim.cpp, CSimple*Anim.cpp and their
// _Scripts.cpp), on Animation::Object, and the group's CreateAnimation.
// Path and its control points are in Path.cpp.
#include "animation/Classes.h"
#include "animation/Region.h"
#include "animation/Xml.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include <cmath>
#include <cstring>
namespace Animation {
namespace {
const char *const kAnimScripts[] = {"onload", "onplay", "onpause", "onstop", "onfinished", "onupdate"};
Class &ClassOf(AnimKind kind) {
switch (kind) {
case KIND_ALPHA: return Anim::s_alphaClass;
case KIND_TRANSLATION: return Anim::s_translationClass;
case KIND_SCALE: return Anim::s_scaleClass;
case KIND_ROTATION: return Anim::s_rotationClass;
case KIND_PATH: return Anim::s_pathClass;
default: return Anim::s_class;
}
}
} // namespace
AnimKind Anim::KindOf(const char *typeName) {
if (typeName == nullptr || *typeName == '\0')
return KIND_ANIMATION;
if (Ascii::EqualCI(typeName, "Alpha"))
return KIND_ALPHA;
if (Ascii::EqualCI(typeName, "Translation"))
return KIND_TRANSLATION;
if (Ascii::EqualCI(typeName, "Scale"))
return KIND_SCALE;
if (Ascii::EqualCI(typeName, "Rotation"))
return KIND_ROTATION;
if (Ascii::EqualCI(typeName, "Path"))
return KIND_PATH;
return KIND_ANIMATION;
}
// ---- Construction ---------------------------------------------------------------
// 4.3.4's constructor links the animation into the group's list; it joins an
// order list only once it has an order (NotifyAnimAdded, SortAnimations).
Anim::Anim(Group *group, AnimKind kind, const char *name) : Object(ClassOf(kind), group, name), kind(kind) {
if (group != nullptr)
group->animations.push_back(this);
}
// ~CSimplePathAnim: a path's control points go with it.
Anim::~Anim() {
for (ControlPoint *point : controlPoints)
delete point;
}
// ---- Playback --------------------------------------------------------------------
bool Anim::Play() {
if (targetName[0] != '\0')
target = Region::FindRegionByName(targetName);
if (playState != 1) {
playState = 1;
RunScript(A_ONPLAY, 0);
}
return true;
}
void Anim::Pause() {
GetGroup()->Pause();
if (playState == 1) {
playState = 2;
RunScript(A_ONPAUSE, 0);
}
}
void Anim::Stop(bool requested) {
if (playState == 0)
return;
loopState = STATE_NONE;
playState = 0;
elapsed = 0.0f;
progressWithDelay = 0.0f;
progress = 0.0f;
if (HasScript(A_ONSTOP)) {
Game::Lua::PushBool(Game::Lua::State(), requested);
RunScript(A_ONSTOP, 1);
}
}
bool Anim::IsDelaying() const {
if (progressWithDelay <= 0.0f || progressWithDelay >= 1.0f)
return false;
const float total = endDelay + duration + startDelay;
if (std::fabs(total) < 0.00000023841858f)
return false;
if (loopState == STATE_REVERSE) {
if (endDelay > elapsed)
return true;
if (endDelay + duration > elapsed)
return false;
} else {
if (startDelay > elapsed)
return true;
if (duration + startDelay > elapsed)
return false;
}
return total > elapsed;
}
float Anim::UpdateProgress(float elapsedSec) {
const float prevElapsed = elapsed;
elapsed += elapsedSec;
const float total = orderPadding + startDelay + endDelay + duration;
float now;
if (total > elapsed) {
now = elapsed;
progressWithDelay = now / total;
if (progressWithDelay > 1.0f)
progressWithDelay = 1.0f;
if (std::fabs(duration) < 0.00000023841858f) {
progress = 1.0f;
} else {
frameElapsed += elapsedSec;
if ((GetGroup()->flags & GROUP_IGNORE_THROTTLE) || frameElapsed >= g_frameInterval) {
const float delay = loopState == STATE_REVERSE ? orderPadding + endDelay : startDelay;
progress = (now - delay) / duration;
if (progress < 0.0f)
progress = 0.0f;
else if (progress >= 1.0f)
progress = 1.0f;
frameElapsed = 0.0f;
}
}
} else {
now = total;
progressWithDelay = 1.0f;
progress = 1.0f;
}
float p = loopState == STATE_REVERSE ? 1.0f - progress : progress;
if (hasSmoother) {
// CSimpleAnimSCurveSmoother::GetSmoothProgress.
if (std::fabs(smoothIn) >= 0.001f && std::fabs(smoothOut) >= 0.001f)
p = 0.5f - std::cos(p * 3.1415927f) * 0.5f;
else if (std::fabs(smoothIn) >= 0.001f)
p = 1.0f - std::cos(p * 1.5707964f);
else if (std::fabs(smoothOut) >= 0.001f)
p = std::cos((p + 1.0f) * 1.5707964f) * -1.0f;
}
smoothProgress = p;
return now - prevElapsed;
}
bool Anim::OnUpdate(float elapsedSec, float &consumed) {
consumed = 0.0f;
if (progressWithDelay >= 1.0f) {
Apply(smoothProgress);
return true;
}
if (playState == 0)
return true;
if (playState == 2) {
Apply(smoothProgress);
return false;
}
consumed = UpdateProgress(elapsedSec);
if (HasScript(A_ONUPDATE)) {
Game::Lua::PushNumber(Game::Lua::State(), elapsedSec);
RunScript(A_ONUPDATE, 1);
}
Apply(smoothProgress);
if (progressWithDelay < 1.0f)
return false;
RunScript(A_ONFINISHED, 0);
return true;
}
void Anim::Apply(float p) {
void *region = GetRegionParent();
if (region == nullptr)
return;
switch (kind) {
case KIND_ALPHA:
Region::AddAnimAlpha(region, target, static_cast<int16_t>(change * p));
break;
case KIND_TRANSLATION:
Region::AddAnimTranslation(region, target, offsetX * p, offsetY * p);
break;
case KIND_SCALE:
Region::AddAnimScale(region, target, originPoint, originX, originY, scaleChangeX * p, scaleChangeY * p);
break;
case KIND_ROTATION:
Region::AddAnimRotation(region, target, originPoint, originX, originY, radians * p);
break;
case KIND_PATH:
ApplyPath(p);
break;
default:
break;
}
}
// ---- Settings ----------------------------------------------------------------------
void Anim::SetStartDelay(float delay, bool notify) {
startDelay = delay < 0.0f ? 0.0f : delay;
if (notify && parent != nullptr)
GetGroup()->ComputeDuration();
}
void Anim::SetEndDelay(float delay, bool notify) {
endDelay = delay < 0.0f ? 0.0f : delay;
if (notify && parent != nullptr)
GetGroup()->ComputeDuration();
}
void Anim::SetDuration(float value, bool notify) {
duration = value;
if (notify && parent != nullptr)
GetGroup()->ComputeDuration();
}
void Anim::SetOrder(int value, bool notify, bool wrap) {
const int oldOrder = order;
if (wrap)
order = value < 0 || value > 99 ? 0 : value;
else
order = value < 0 ? 0 : (value > 99 ? 99 : value);
// 4.3.4 compares the old order with the argument, not the clamped value.
if (notify && parent != nullptr && oldOrder != value)
GetGroup()->NotifyOrderChanged(this, oldOrder);
}
void Anim::SetSmoothing(float in, float out) {
if ((in > 0.0f && in <= 1.0f) || (out > 0.0f && out <= 1.0f)) {
hasSmoother = true;
smoothIn = in < 0.0f ? 0.0f : (in < 1.0f ? in : 1.0f);
smoothOut = out < 0.0f ? 0.0f : (out < 1.0f ? out : 1.0f);
}
}
bool Anim::GetSmoothing(float &in, float &out) const {
in = hasSmoother ? smoothIn : 0.0f;
out = hasSmoother ? smoothOut : 0.0f;
return hasSmoother;
}
bool Anim::ChangeParent(Group *group) {
if (group == nullptr || parent == group)
return false;
GetGroup()->NotifyAnimRemoved(this);
parent = group;
group->NotifyAnimAdded(this);
return true;
}
void Anim::PostLoadXML() {
flags &= ~1;
if (kind == KIND_PATH) {
// CSimplePathAnim::PostLoadXML: the points' orders and positions.
BuildSpline();
UpdateControlPointPositions();
}
RunScript(A_ONLOAD, 0);
}
// ---- Lua methods ----------------------------------------------------------------------
namespace {
using StringToPoint_t = int(__fastcall *)(const char *name, int *point);
using PointToString_t = const char *(__fastcall *)(int point);
Anim *Self(void *L) { return static_cast<Anim *>(Resolve(L, 1, Anim::s_class)); }
Anim *SelfOf(void *L, Class &cls) { return static_cast<Anim *>(Resolve(L, 1, cls)); }
// Usage error when argument `idx` isn't a number; the number otherwise.
bool NumberArg(void *L, int idx, Anim *anim, const char *usage) {
if (Game::Lua::IsNumber(L, idx))
return true;
Game::Lua::Error(L, usage, anim->DisplayName());
return false;
}
int __fastcall Script_Play(void *L) {
if (Anim *a = Self(L))
a->Play();
return 0;
}
int __fastcall Script_Pause(void *L) {
if (Anim *a = Self(L))
a->Pause();
return 0;
}
int __fastcall Script_Stop(void *L) {
if (Anim *a = Self(L))
a->Stop(true);
return 0;
}
template <typename Fn> int PushBoolOf(void *L, Fn fn) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
Game::Lua::PushBool(L, fn(a));
return 1;
}
template <typename Fn> int PushNumberOf(void *L, Fn fn) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, fn(a));
return 1;
}
int __fastcall Script_IsDone(void *L) { return PushBoolOf(L, [](Anim *a) { return a->progressWithDelay >= 1.0f; }); }
int __fastcall Script_IsPlaying(void *L) { return PushBoolOf(L, [](Anim *a) { return a->playState == 1; }); }
int __fastcall Script_IsPaused(void *L) { return PushBoolOf(L, [](Anim *a) { return a->playState == 2; }); }
int __fastcall Script_IsStopped(void *L) { return PushBoolOf(L, [](Anim *a) { return a->playState == 0; }); }
int __fastcall Script_IsDelaying(void *L) { return PushBoolOf(L, [](Anim *a) { return a->IsDelaying(); }); }
int __fastcall Script_GetElapsed(void *L) { return PushNumberOf(L, [](Anim *a) { return a->elapsed; }); }
int __fastcall Script_GetStartDelay(void *L) { return PushNumberOf(L, [](Anim *a) { return a->startDelay; }); }
int __fastcall Script_GetEndDelay(void *L) { return PushNumberOf(L, [](Anim *a) { return a->endDelay; }); }
int __fastcall Script_GetDuration(void *L) { return PushNumberOf(L, [](Anim *a) { return a->duration; }); }
int __fastcall Script_GetSmoothProgress(void *L) {
return PushNumberOf(L, [](Anim *a) { return a->smoothProgress; });
}
int __fastcall Script_GetProgress(void *L) { return PushNumberOf(L, [](Anim *a) { return a->progress; }); }
int __fastcall Script_GetProgressWithDelay(void *L) {
return PushNumberOf(L, [](Anim *a) { return a->progressWithDelay; });
}
int __fastcall Script_GetOrder(void *L) {
return PushNumberOf(L, [](Anim *a) { return static_cast<double>(a->order + 1); });
}
int __fastcall Script_SetStartDelay(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetStartDelay(delaySec)"))
a->SetStartDelay(static_cast<float>(Game::Lua::ToNumber(L, 2)), true);
return 0;
}
int __fastcall Script_SetEndDelay(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetEndDelay(delaySec)"))
a->SetEndDelay(static_cast<float>(Game::Lua::ToNumber(L, 2)), true);
return 0;
}
int __fastcall Script_SetDuration(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetDuration(durationSec)"))
a->SetDuration(static_cast<float>(Game::Lua::ToNumber(L, 2)), true);
return 0;
}
int __fastcall Script_SetSmoothProgress(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetSmoothProgress(smoothProgress)"))
a->smoothProgress = static_cast<float>(Game::Lua::ToNumber(L, 2));
return 0;
}
int __fastcall Script_SetOrder(void *L) {
Anim *a = Self(L);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetOrder(order)"))
a->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1, true, false);
return 0;
}
int __fastcall Script_SetSmoothing(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetSmoothing(\"smoothingType\")", a->DisplayName());
return 0;
}
float in, out;
if (StringToSmoothWeights(Game::Lua::ToString(L, 2), in, out)) {
a->SetSmoothing(in, out);
return 0;
}
Game::Lua::Error(L, "%s:SetSmoothing(): Unknown smoothing type specified", a->DisplayName());
return 0;
}
int __fastcall Script_GetSmoothing(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
float in, out;
a->GetSmoothing(in, out);
Game::Lua::PushString(L, SmoothWeightsToString(in, out));
return 1;
}
int __fastcall Script_SetParent(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL) {
Game::Lua::Error(L, "%s:SetParent(): Cannot set a 'nil' parent for animations", a->DisplayName());
return 0;
}
Group *group = nullptr;
if (Game::Lua::IsString(L, 2)) {
// CScriptObject::GetScriptObjectByName(name, AnimationGroup).
Game::Lua::PushString(L, Game::Lua::ToString(L, 2));
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
void *object = Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE ? Game::Lua::ResolveObject(L, -1) : nullptr;
Game::Lua::SetTop(L, -2);
if (object != nullptr && IsOurs(object) &&
Game::Lua::IsA(object, reinterpret_cast<uintptr_t>(&Group::s_class.typeId)))
group = static_cast<Group *>(object);
} else if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE) {
void *object = Game::Lua::ResolveObject(L, 2);
if (object == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find 'this' in parent object", a->DisplayName());
return 0;
}
if (!IsOurs(object) || !Game::Lua::IsA(object, reinterpret_cast<uintptr_t>(&Group::s_class.typeId))) {
Game::Lua::Error(L, "%s:SetParent(): Wrong parent object type, expected AnimationGroup", a->DisplayName());
return 0;
}
group = static_cast<Group *>(object);
}
if (group == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find animation group named '%s'", a->DisplayName(),
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "");
return 0;
}
const int oldOrder = a->order;
if (Game::Lua::IsNumber(L, 3)) {
a->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 3)) - 1, false, false);
} else {
int value = a->order;
if (value == -1)
value = group->GetMaxAnimOrder() + 1;
a->SetOrder(value, false, true);
}
if (!a->ChangeParent(group) && oldOrder != a->order)
group->NotifyOrderChanged(a, oldOrder);
return 0;
}
int __fastcall Script_GetRegionParent(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
void *region = a->GetRegionParent();
if (region != nullptr)
Game::Lua::PushObject(L, region);
else
Game::Lua::PushNil(L);
return 1;
}
// ---- Alpha ----
int __fastcall Script_SetChange(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr || !NumberArg(L, 2, a, "Usage: %s:SetChange(change)"))
return 0;
// CSimpleAlphaAnim::SetChange.
const float change = static_cast<float>(Game::Lua::ToNumber(L, 2));
const float value = change < -1.0f ? -1.0f : (change >= 1.0f ? 1.0f : change);
a->change = static_cast<int16_t>(value * 255.0f);
return 0;
}
int __fastcall Script_GetChange(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->change * (1.0f / 255.0f));
return 1;
}
// ---- Translation ----
int __fastcall Script_SetOffset(void *L) {
Anim *a = SelfOf(L, Anim::s_translationClass);
if (a == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetOffset(x, y)", a->DisplayName());
return 0;
}
const float x = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 2)));
const float y = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3)));
// CSimpleTranslationAnim::SetOffset: a near-zero offset is zero.
if (x * x + y * y <= 0.00000023841858f) {
a->offsetX = 0.0f;
a->offsetY = 0.0f;
} else {
a->offsetX = x;
a->offsetY = y;
}
return 0;
}
int __fastcall Script_GetOffset(void *L) {
Anim *a = SelfOf(L, Anim::s_translationClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, Game::InternalToPixel(a->offsetX));
Game::Lua::PushNumber(L, Game::InternalToPixel(a->offsetY));
return 2;
}
// ---- Scale and Rotation origin (ReadOrigin) ----
Anim *OriginSelf(void *L) {
Anim *a = static_cast<Anim *>(Resolve(L, 1, Anim::s_class));
if (a != nullptr && a->kind != KIND_SCALE && a->kind != KIND_ROTATION) {
// The type test of 4.3.4's Scale / Rotation SetOrigin.
Game::Lua::Error(L, "Wrong object type for member function");
return nullptr;
}
return a;
}
int __fastcall Script_SetOrigin(void *L) {
Anim *a = OriginSelf(L);
if (a == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2) || !Game::Lua::IsNumber(L, 3) || !Game::Lua::IsNumber(L, 4)) {
Game::Lua::Error(L, "Usage: %s:SetOrigin(point, offsetX, offsetY)", a->DisplayName());
return 0;
}
int point = Offsets::FRAMEPOINT_CENTER;
if (!reinterpret_cast<StringToPoint_t>(Offsets::FUN_STRING_TO_FRAME_POINT)(Game::Lua::ToString(L, 2), &point))
point = Offsets::FRAMEPOINT_CENTER;
a->originPoint = point;
a->originX = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3)));
a->originY = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 4)));
return 0;
}
// 4.3.4 pushes the stored offsets as they are, in layout units.
int __fastcall Script_GetOrigin(void *L) {
Anim *a = OriginSelf(L);
if (a == nullptr)
return 0;
Game::Lua::PushString(L, reinterpret_cast<PointToString_t>(Offsets::FUN_FRAME_POINT_TO_STRING)(a->originPoint));
Game::Lua::PushNumber(L, a->originX);
Game::Lua::PushNumber(L, a->originY);
return 3;
}
// ---- Scale ----
int __fastcall Script_SetScale(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetScale(x, y)", a->DisplayName());
return 0;
}
// CSimpleScaleAnim::SetScale.
const float x = static_cast<float>(Game::Lua::ToNumber(L, 2));
const float y = static_cast<float>(Game::Lua::ToNumber(L, 3));
a->scaleChangeX = 1.0f - (x >= 0.001f ? x : 0.001f);
a->scaleChangeY = 1.0f - (y >= 0.001f ? y : 0.001f);
return 0;
}
int __fastcall Script_GetScale(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, 1.0f - a->scaleChangeX);
Game::Lua::PushNumber(L, 1.0f - a->scaleChangeY);
return 2;
}
// ---- Rotation ----
int __fastcall Script_SetDegrees(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetDegrees(degrees)"))
a->radians = static_cast<float>(Game::Lua::ToNumber(L, 2) * 0.017453292f);
return 0;
}
int __fastcall Script_GetDegrees(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->radians * 57.29578f);
return 1;
}
int __fastcall Script_SetRadians(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a != nullptr && NumberArg(L, 2, a, "Usage: %s:SetRadians(radians)"))
a->radians = static_cast<float>(Game::Lua::ToNumber(L, 2));
return 0;
}
int __fastcall Script_GetRadians(void *L) {
Anim *a = SelfOf(L, Anim::s_rotationClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->radians);
return 1;
}
const Game::Lua::FrameMethodEntry kAnimMethods[] = {
{"GetObjectType", &Script_GetObjectType},
{"IsObjectType", &Script_IsObjectType},
{"GetName", &Script_GetName},
{"GetParent", &Script_GetParent},
{"Play", &Script_Play},
{"Pause", &Script_Pause},
{"Stop", &Script_Stop},
{"IsDone", &Script_IsDone},
{"IsPlaying", &Script_IsPlaying},
{"IsPaused", &Script_IsPaused},
{"IsStopped", &Script_IsStopped},
{"IsDelaying", &Script_IsDelaying},
{"GetElapsed", &Script_GetElapsed},
{"SetStartDelay", &Script_SetStartDelay},
{"GetStartDelay", &Script_GetStartDelay},
{"SetEndDelay", &Script_SetEndDelay},
{"GetEndDelay", &Script_GetEndDelay},
{"SetDuration", &Script_SetDuration},
{"GetDuration", &Script_GetDuration},
{"GetSmoothProgress", &Script_GetSmoothProgress},
{"SetSmoothProgress", &Script_SetSmoothProgress},
{"GetProgress", &Script_GetProgress},
{"GetProgressWithDelay", &Script_GetProgressWithDelay},
{"SetOrder", &Script_SetOrder},
{"GetOrder", &Script_GetOrder},
{"SetSmoothing", &Script_SetSmoothing},
{"GetSmoothing", &Script_GetSmoothing},
{"SetParent", &Script_SetParent},
{"GetRegionParent", &Script_GetRegionParent},
{"HasScript", &Script_HasScript},
{"GetScript", &Script_GetScript},
{"SetScript", &Script_SetScript},
{"HookScript", &Script_HookScript},
};
const Game::Lua::FrameMethodEntry kAlphaMethods[] = {
{"SetChange", &Script_SetChange},
{"GetChange", &Script_GetChange},
};
const Game::Lua::FrameMethodEntry kTranslationMethods[] = {
{"SetOffset", &Script_SetOffset},
{"GetOffset", &Script_GetOffset},
};
const Game::Lua::FrameMethodEntry kScaleMethods[] = {
{"SetOrigin", &Script_SetOrigin},
{"GetOrigin", &Script_GetOrigin},
{"SetScale", &Script_SetScale},
{"GetScale", &Script_GetScale},
};
const Game::Lua::FrameMethodEntry kRotationMethods[] = {
{"SetOrigin", &Script_SetOrigin}, {"GetOrigin", &Script_GetOrigin}, {"SetDegrees", &Script_SetDegrees},
{"GetDegrees", &Script_GetDegrees}, {"SetRadians", &Script_SetRadians}, {"GetRadians", &Script_GetRadians},
};
template <size_t N> constexpr int Count(const Game::Lua::FrameMethodEntry (&)[N]) { return static_cast<int>(N); }
} // namespace
Class Anim::s_class{"Animation", nullptr, kAnimScripts, 6, kAnimMethods, Count(kAnimMethods), 0, 0};
Class Anim::s_alphaClass{"Alpha", &Anim::s_class, nullptr, 0, kAlphaMethods, Count(kAlphaMethods), 0, 0};
Class Anim::s_translationClass{"Translation", &Anim::s_class, nullptr, 0, kTranslationMethods,
Count(kTranslationMethods), 0, 0};
Class Anim::s_scaleClass{"Scale", &Anim::s_class, nullptr, 0, kScaleMethods, Count(kScaleMethods), 0, 0};
Class Anim::s_rotationClass{"Rotation", &Anim::s_class, nullptr, 0, kRotationMethods, Count(kRotationMethods), 0, 0};
// ---- AnimationGroup:CreateAnimation ------------------------------------------------------
int __fastcall Script_CreateAnimation(void *L) {
Group *group = static_cast<Group *>(Resolve(L, 1, Group::s_class));
if (group == nullptr)
return 0;
const char *type = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "Animation";
const char *name = Game::Lua::IsString(L, 3) ? Game::Lua::ToString(L, 3) : nullptr;
const void *templateNode = nullptr;
if (Game::Lua::Type(L, 4) == Game::Lua::TYPE_STRING) {
const char *inherits = Game::Lua::ToString(L, 4);
templateNode = Xml::Template(inherits);
if (templateNode == nullptr) {
Game::Lua::Error(L, "%s:CreateAnimation(): Couldn't find inherited node \"%s\"", group->DisplayName(),
inherits);
return 0;
}
}
Anim *anim = new Anim(group, Anim::KindOf(type), name);
if (templateNode != nullptr) {
Xml::LoadAnim(anim, templateNode, nullptr);
anim->PostLoadXML();
}
if (anim->order == -1)
anim->SetOrder(group->GetMaxAnimOrder(), false, true);
group->NotifyAnimAdded(anim);
Push(L, anim);
return 1;
}
} // namespace Animation
+213
View File
@@ -0,0 +1,213 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "animation/Object.h"
#include <cstdint>
#include <vector>
// 4.3.4's CSimpleAnimGroup and CSimpleAnim (and its Alpha, Translation,
// Scale, Rotation and Path subclasses, and Path's CSimpleControlPoint), ported from the decompiled client
// (wowdecomp15595 Engine/Source/Frame/CSimpleAnimGroup.cpp, CSimpleAnim.cpp).
// Field and function names follow it; the comments say where 1.12 differs.
namespace Animation {
class Anim;
class ControlPoint;
enum LoopType { LOOP_NONE = 0, LOOP_REPEAT = 1, LOOP_BOUNCE = 2 };
enum LoopState { STATE_NONE = 0, STATE_FORWARD = 1, STATE_REVERSE = 2 };
// m_flags.
constexpr uint8_t GROUP_LOADING = 0x01;
constexpr uint8_t GROUP_PENDING_FINISH = 0x02;
constexpr uint8_t GROUP_PAUSED = 0x04;
constexpr uint8_t GROUP_STOPPING = 0x08;
constexpr uint8_t GROUP_IGNORE_THROTTLE = 0x10;
// Script indexes (Class::scriptNames order).
enum GroupScript { G_ONLOAD, G_ONPLAY, G_ONPAUSE, G_ONSTOP, G_ONFINISHED, G_ONUPDATE, G_ONLOOP };
enum AnimScript { A_ONLOAD, A_ONPLAY, A_ONPAUSE, A_ONSTOP, A_ONFINISHED, A_ONUPDATE };
class Group : public Object {
public:
static Class s_class;
// `region`: the 1.12 region (frame, texture or font string) it animates.
Group(void *region, const char *name);
~Group();
void *RegionObject() const { return parent; }
bool IsDone() const;
bool IsPlaying() const { return loopState != STATE_NONE && !(flags & GROUP_PAUSED); }
bool Play(bool reverse);
void Pause();
void Stop(bool requested);
void Finish();
bool OnUpdate(float elapsedSec);
int GetMaxAnimOrder() const;
bool HasAlphaAnim() const;
bool HasPositionAnim() const;
void NotifyAnimAdded(Anim *anim);
void NotifyOrderChanged(Anim *anim, int oldOrder);
void NotifyAnimRemoved(Anim *anim);
void SortAnimations();
void ComputeDuration();
// XML: 4.3.4's PostLoadXML - every animation's, then sort, then OnLoad.
void PostLoadXML();
LoopType loopType = LOOP_NONE;
float duration = 0.0f;
float initialOffsetX = 0.0f, initialOffsetY = 0.0f; // layout units
LoopState loopState = STATE_NONE;
uint8_t flags = 0;
int currentOrder = -1;
float elapsed = 0.0f;
float progress = 0.0f;
std::vector<Anim *> animations; // m_animations, creation order
std::vector<std::vector<Anim *>> animLists; // m_animLists, by order
private:
std::vector<Anim *> *FindAnimList(int order);
std::vector<Anim *> *RegisterAnimList(int order);
bool AddAnimListNode(std::vector<Anim *> *list, Anim *anim);
bool RemoveAnimListNode(std::vector<Anim *> *list, Anim *anim);
bool AdvanceLoopState(float elapsedSec, float remaining);
bool DoGroupUpdateWork(float elapsedSec);
void ResetAnims();
};
enum AnimKind { KIND_ANIMATION, KIND_ALPHA, KIND_TRANSLATION, KIND_SCALE, KIND_ROTATION, KIND_PATH };
class Anim : public Object {
public:
static Class s_class; // "Animation"
static Class s_alphaClass; // "Alpha"
static Class s_translationClass; // "Translation"
static Class s_scaleClass; // "Scale"
static Class s_rotationClass; // "Rotation"
static Class s_pathClass; // "Path"
// The class of a CreateAnimation / XML type name; Animation for any
// other (4.3.4's CreateAnimation falls back to it).
static AnimKind KindOf(const char *typeName);
Anim(Group *group, AnimKind kind, const char *name);
~Anim();
Group *GetGroup() const { return static_cast<Group *>(parent); }
void *GetRegionParent() const { return parent != nullptr ? GetGroup()->RegionObject() : nullptr; }
bool Play();
void Pause();
void Stop(bool requested);
bool IsDelaying() const;
bool OnUpdate(float elapsedSec, float &consumed);
void Apply(float progress);
void SetStartDelay(float delay, bool notify);
void SetEndDelay(float delay, bool notify);
void SetDuration(float value, bool notify);
void SetOrder(int value, bool notify, bool wrap);
void SetSmoothing(float in, float out);
bool GetSmoothing(float &in, float &out) const;
bool ChangeParent(Group *group);
// XML: 4.3.4's PostLoadXML - OnLoad.
void PostLoadXML();
AnimKind kind;
float startDelay = 0.0f;
float endDelay = 0.0f;
float duration = 0.0f;
float frameElapsed = 0.0f;
float elapsed = 0.0f;
float progressWithDelay = 0.0f;
float progress = 0.0f;
float smoothProgress = 0.0f;
float orderPadding = 0.0f;
int order = -1;
uint8_t flags = 0;
int playState = 0; // 0 stopped, 1 playing, 2 paused
int loopState = STATE_NONE;
void *target = nullptr; // m_target, resolved from targetName on Play
char targetName[128] = {}; // m_targetName
bool hasSmoother = false;
float smoothIn = 0.0f, smoothOut = 0.0f;
// Alpha: m_change, -255..255.
int16_t change = 0;
// Translation: the offset; Scale: the scale and origin; Rotation: the
// angle and origin. Offsets in layout units, as 4.3.4 keeps them.
float offsetX = 0.0f, offsetY = 0.0f;
float scaleChangeX = 0.0f, scaleChangeY = 0.0f; // m_scaleChange: 1 - scale
float radians = 0.0f;
int originPoint = 4; // FRAMEPOINT_CENTER
float originX = 0.0f, originY = 0.0f;
// Path (CSimplePathAnim, Path.cpp): the control points it owns in link
// order (m_controlPoints), the same sorted by order (m_sortedPoints),
// and m_curve (false NONE, true SMOOTH).
std::vector<ControlPoint *> controlPoints;
std::vector<ControlPoint *> sortedPoints;
bool curve = false;
void BuildSpline();
void UpdateControlPointPositions();
int FindControlPoint(int order) const;
int GetMaxControlPointOrder() const;
int RegisterSplineControlPoint(ControlPoint *point);
void NotifyControlPointAdded(ControlPoint *point);
void NotifyControlPointRemoved(ControlPoint *point);
void NotifyControlPointOrderChanged(ControlPoint *point);
void ApplyPath(float progress);
private:
float UpdateProgress(float elapsedSec);
};
// CSimpleControlPoint: a point of a Path, an offset from where the region
// is, in layout units.
class ControlPoint : public Object {
public:
static Class s_class; // "ControlPoint"
// Links the point at the end of the path's points.
ControlPoint(Anim *path, const char *name);
~ControlPoint();
Anim *GetPath() const { return static_cast<Anim *>(parent); }
bool ChangeParent(Anim *path);
void SetOffset(float x, float y);
void SetOrder(int value, bool notify, bool wrap);
float offsetX = 0.0f, offsetY = 0.0f;
float position = 0.0f; // m_position: the path progress the point is at
int order = -1;
};
// 4.3.4's s_frameInterval: progress steps at most this often unless the
// group ignores the framerate throttle. 1/30 s until SetMaxAnimFramerate
// (Framerate.cpp) changes it.
extern float g_frameInterval;
// AnimationGroup:CreateAnimation (Anim.cpp, where the animation classes are).
int __fastcall Script_CreateAnimation(void *L);
const char *LoopTypeToString(LoopType type);
bool StringToLoopType(const char *text, LoopType &type);
const char *LoopStateToString(int state);
bool StringToSmoothWeights(const char *text, float &in, float &out);
const char *SmoothWeightsToString(float in, float out);
} // namespace Animation
+61
View File
@@ -0,0 +1,61 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `SetMaxAnimFramerate(framesPerSec)` / `GetMaxAnimFramerate()` - 4.3.4's
// globals over CSimpleAnim::s_frameInterval (SimpleFrameScript_Scripts.cpp),
// the throttle that Anim::UpdateProgress steps progress by. The setter stores
// 1 / fps unchecked, as 4.3.4 does: 0 stores infinity, so progress stops
// stepping until the animation ends.
#include "Classes.h"
#include "Game.h"
namespace Animation {
float g_frameInterval = 1.0f / 30.0f;
namespace {
int __fastcall Script_SetMaxAnimFramerate(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: SetMaxFramerate(framesPerSec)");
return 0;
}
g_frameInterval = 1.0f / static_cast<float>(Game::Lua::ToNumber(L, 1));
return 0;
}
int __fastcall Script_GetMaxAnimFramerate(void *L) {
Game::Lua::PushNumber(L, 1.0f / g_frameInterval);
return 1;
}
const Game::Doc::Field kSetArgs[] = {Game::Doc::Req("framesPerSec", "number")};
const Game::Doc::Field kGetRets[] = {Game::Doc::Req("framesPerSec", "number")};
const Game::Doc::Function kSet{"Sets how many times per second animations step their progress.", kSetArgs, {},
"AnimationGlobals"};
const Game::Doc::Function kGet{"Returns how many times per second animations step their progress.", {}, kGetRets,
"AnimationGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("SetMaxAnimFramerate", &Script_SetMaxAnimFramerate, &kSet);
Game::Lua::RegisterGlobalFunction("GetMaxAnimFramerate", &Script_GetMaxAnimFramerate, &kGet);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Animation
+676
View File
@@ -0,0 +1,676 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// 4.3.4's CSimpleAnimGroup (wowdecomp15595 CSimpleAnimGroup.cpp and
// CSimpleAnimGroup_Scripts.cpp), on Animation::Object.
#include "animation/Classes.h"
#include "animation/Region.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include <algorithm>
#include <cmath>
namespace Animation {
namespace {
const char *const kGroupScripts[] = {"onload", "onplay", "onpause", "onstop", "onfinished", "onupdate", "onloop"};
} // namespace
// ---- Construction ---------------------------------------------------------------
Group::Group(void *region, const char *name) : Object(s_class, region, name) {
if (region != nullptr)
Region::RegisterGroup(region, this);
}
Group::~Group() {
for (Anim *anim : animations)
delete anim;
}
// ---- State --------------------------------------------------------------------
bool Group::IsDone() const {
if (duration <= 0.0001f)
return true;
if (loopType == LOOP_REPEAT || loopType == LOOP_BOUNCE)
return false;
if (loopState == STATE_FORWARD || loopState == STATE_REVERSE)
return false;
return progress >= 1.0f;
}
void Group::Finish() {
if (!IsDone() && loopState != STATE_NONE)
flags |= GROUP_PENDING_FINISH;
}
int Group::GetMaxAnimOrder() const {
if (animLists.empty())
return -1;
return animLists.back().front()->order;
}
bool Group::HasAlphaAnim() const {
for (const Anim *anim : animations)
if (anim->kind == KIND_ALPHA)
return true;
return false;
}
bool Group::HasPositionAnim() const {
for (const Anim *anim : animations)
if (anim->kind != KIND_ALPHA)
return true;
return false;
}
// ---- Order lists ----------------------------------------------------------------
std::vector<Anim *> *Group::FindAnimList(int order) {
if (order == -1)
return nullptr;
int low = 0;
int high = static_cast<int>(animLists.size()) - 1;
while (low <= high) {
const int middle = static_cast<int>(static_cast<unsigned>(high + low) >> 1);
std::vector<Anim *> &list = animLists[middle];
if (!list.empty()) {
const int listOrder = list.front()->order;
if (listOrder < order)
low = middle + 1;
else if (listOrder > order)
high = middle - 1;
else
return &list;
}
}
return nullptr;
}
std::vector<Anim *> *Group::RegisterAnimList(int order) {
if (order == -1)
return nullptr;
const size_t count = animLists.size();
if (count == 0) {
animLists.emplace_back();
return &animLists.back();
}
size_t index = count;
for (size_t i = 0; i < count; ++i) {
std::vector<Anim *> &list = animLists[i];
if (list.empty())
return &list;
const int listOrder = list.front()->order;
if (order == listOrder)
return &list;
if (order < listOrder) {
index = i;
break;
}
}
animLists.insert(animLists.begin() + static_cast<ptrdiff_t>(index), std::vector<Anim *>());
return &animLists[index];
}
bool Group::AddAnimListNode(std::vector<Anim *> *list, Anim *anim) {
if (list == nullptr)
return false;
list->push_back(anim);
return true;
}
bool Group::RemoveAnimListNode(std::vector<Anim *> *list, Anim *anim) {
if (list == nullptr)
return false;
const auto it = std::find(list->begin(), list->end(), anim);
if (it == list->end())
return false;
list->erase(it);
if (list->empty())
SortAnimations();
return true;
}
void Group::ComputeDuration() {
duration = 0.0f;
for (std::vector<Anim *> &list : animLists) {
float span = 0.0f;
for (const Anim *anim : list) {
const float total = anim->duration + anim->endDelay + anim->startDelay;
if (total > span)
span = total;
}
for (Anim *anim : list)
anim->orderPadding = span - (anim->duration + anim->endDelay + anim->startDelay);
duration += span;
}
}
void Group::NotifyAnimAdded(Anim *anim) {
if (anim == nullptr)
return;
// m_animations.LinkToTail: an animation already in the list moves to its tail.
animations.erase(std::remove(animations.begin(), animations.end(), anim), animations.end());
animations.push_back(anim);
if (AddAnimListNode(RegisterAnimList(anim->order), anim))
ComputeDuration();
}
void Group::NotifyOrderChanged(Anim *anim, int oldOrder) {
if (anim == nullptr)
return;
if (RemoveAnimListNode(FindAnimList(oldOrder), anim))
if (AddAnimListNode(RegisterAnimList(anim->order), anim))
ComputeDuration();
}
void Group::NotifyAnimRemoved(Anim *anim) {
if (anim == nullptr)
return;
animations.erase(std::remove(animations.begin(), animations.end(), anim), animations.end());
std::vector<Anim *> *list = FindAnimList(anim->order);
if (RemoveAnimListNode(list, anim)) {
// RemoveAnimListNode sorts when the list emptied; the list may be gone.
ComputeDuration();
}
}
void Group::SortAnimations() {
if (loopState == STATE_NONE || (flags & GROUP_PAUSED)) {
loopState = STATE_NONE;
flags &= 0xF0;
currentOrder = -1;
elapsed = 0.0f;
progress = 0.0f;
} else {
Stop(false);
}
if (animations.empty()) {
animLists.clear();
return;
}
for (std::vector<Anim *> &list : animLists)
list.clear();
int nextOrder = 0;
for (Anim *anim : animations) {
int order = anim->order;
if (order == -1) {
order = nextOrder++;
anim->order = order < 0 ? 0 : order;
} else {
nextOrder = order + 1;
}
if (nextOrder > 99)
nextOrder = 0;
AddAnimListNode(RegisterAnimList(order), anim);
}
while (!animLists.empty() && animLists.back().empty())
animLists.pop_back();
}
void Group::ResetAnims() {
for (Anim *anim : animations) {
anim->loopState = loopState > 0 && loopState <= 2 ? loopState : 0;
anim->playState = 0;
anim->elapsed = 0.0f;
anim->progressWithDelay = 0.0f;
anim->progress = 0.0f;
}
}
// ---- Playback --------------------------------------------------------------------
void Group::Pause() {
if (loopState == STATE_NONE || (flags & GROUP_PAUSED))
return;
flags |= GROUP_PAUSED;
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Pause();
RunScript(G_ONPAUSE, 0);
}
void Group::Stop(bool requested) {
if (loopState == STATE_NONE || (flags & GROUP_STOPPING))
return;
flags |= GROUP_STOPPING;
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Stop(requested);
Region::NotifyEnd(RegionObject(), this);
flags &= 0xF0;
loopState = STATE_NONE;
currentOrder = -1;
elapsed = 0.0f;
progress = 0.0f;
if (HasScript(G_ONSTOP)) {
Game::Lua::PushBool(Game::Lua::State(), requested);
RunScript(G_ONSTOP, 1);
}
}
bool Group::AdvanceLoopState(float /*elapsedSec*/, float /*remaining*/) {
const int count = static_cast<int>(animLists.size());
const bool reverse = loopState == STATE_REVERSE;
const int step = reverse ? -1 : 1;
const int first = reverse ? count - 1 : 0;
const int end = reverse ? -1 : count;
currentOrder += step;
if (currentOrder == end) {
if (loopType == LOOP_NONE || (flags & GROUP_PENDING_FINISH))
return true;
if (loopType == LOOP_BOUNCE) {
loopState = static_cast<LoopState>(!reverse + 1);
currentOrder -= step;
} else {
currentOrder = first;
}
elapsed = 0.0f;
progress = 0.0f;
ResetAnims();
if (HasScript(G_ONLOOP)) {
Game::Lua::PushString(Game::Lua::State(), LoopStateToString(loopState));
RunScript(G_ONLOOP, 1);
}
if (loopState == STATE_NONE || (flags & GROUP_PAUSED))
return true;
}
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Play();
return false;
}
bool Group::DoGroupUpdateWork(float elapsedSec) {
float remaining = elapsedSec < 0.0f ? 0.0f : (elapsedSec >= 60.0f ? 60.0f : elapsedSec);
while (true) {
for (Anim *anim : animLists[currentOrder]) {
void *region = anim->GetRegionParent();
if (region != nullptr)
Region::PreOnAnimUpdate(region);
}
for (int i = 0; i < currentOrder; ++i)
for (Anim *anim : animLists[i])
anim->Apply(1.0f);
float maxConsumed = 0.0f;
bool finished = true;
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list) {
float consumed;
finished = anim->OnUpdate(remaining, consumed) && finished;
if (consumed > maxConsumed)
maxConsumed = consumed;
}
if (!(flags & GROUP_PAUSED) && loopState != STATE_NONE) {
remaining -= maxConsumed;
elapsed += maxConsumed;
progress = elapsed < duration ? elapsed / duration : 1.0f;
if (finished && AdvanceLoopState(elapsedSec, remaining))
return true;
}
if (remaining <= 0.0001f || IsDone() || loopState == STATE_NONE || (flags & GROUP_PAUSED))
return false;
}
}
bool Group::OnUpdate(float elapsedSec) {
if (IsDone() || loopState == STATE_NONE)
return true;
const bool done = DoGroupUpdateWork(elapsedSec);
if (HasScript(G_ONUPDATE)) {
Game::Lua::PushNumber(Game::Lua::State(), elapsedSec);
RunScript(G_ONUPDATE, 1);
}
if (done) {
Region::NotifyEnd(RegionObject(), this);
const bool requested = (flags & GROUP_PENDING_FINISH) != 0;
loopState = STATE_NONE;
flags &= 0xF0;
currentOrder = -1;
elapsed = 0.0f;
progress = 0.0f;
if (HasScript(G_ONFINISHED)) {
Game::Lua::PushBool(Game::Lua::State(), requested);
RunScript(G_ONFINISHED, 1);
}
}
return IsDone();
}
bool Group::Play(bool reverse) {
if (animations.empty())
return false;
if (loopState != STATE_NONE && !(flags & GROUP_PAUSED))
return true;
if (flags & GROUP_PAUSED) {
flags &= ~GROUP_PAUSED;
} else {
flags &= 0xF0;
loopState = STATE_NONE;
currentOrder = reverse ? 0x7FFF : -1;
elapsed = 0.0f;
progress = 0.0f;
}
RunScript(G_ONPLAY, 0);
const int count = static_cast<int>(animLists.size());
if (count > 0) {
if (currentOrder < 0 || currentOrder >= count) {
if (currentOrder < 0) {
loopState = STATE_FORWARD;
currentOrder = 0;
} else {
loopState = STATE_REVERSE;
currentOrder = count - 1;
}
ResetAnims();
}
const std::vector<Anim *> list = animLists[currentOrder];
for (Anim *anim : list)
anim->Play();
Region::NotifyBegin(RegionObject(), this);
}
OnUpdate(0.0f);
return true;
}
void Group::PostLoadXML() {
flags &= ~GROUP_LOADING;
const std::vector<Anim *> list = animations;
for (Anim *anim : list)
anim->PostLoadXML();
SortAnimations();
ComputeDuration();
RunScript(G_ONLOAD, 0);
}
// ---- Strings ----------------------------------------------------------------------
namespace {
struct Named {
int value;
const char *name;
};
const Named kLoopTypes[] = {{LOOP_NONE, "NONE"}, {LOOP_REPEAT, "REPEAT"}, {LOOP_BOUNCE, "BOUNCE"}};
const Named kLoopStates[] = {{STATE_NONE, "NONE"}, {STATE_FORWARD, "FORWARD"}, {STATE_REVERSE, "REVERSE"}};
struct Weights {
float in, out;
const char *name;
};
const Weights kSmoothWeights[] = {
{0.0f, 0.0f, "NONE"}, {1.0f, 0.0f, "IN"}, {0.0f, 1.0f, "OUT"}, {1.0f, 1.0f, "IN_OUT"}, {1.0f, 1.0f, "OUT_IN"},
};
} // namespace
const char *LoopTypeToString(LoopType type) {
for (const Named &n : kLoopTypes)
if (n.value == type)
return n.name;
return "UNKNOWN";
}
bool StringToLoopType(const char *text, LoopType &type) {
for (const Named &n : kLoopTypes)
if (text != nullptr && Ascii::EqualCI(n.name, text)) {
type = static_cast<LoopType>(n.value);
return true;
}
return false;
}
const char *LoopStateToString(int state) {
for (const Named &n : kLoopStates)
if (n.value == state)
return n.name;
return "UNKNOWN";
}
bool StringToSmoothWeights(const char *text, float &in, float &out) {
for (const Weights &w : kSmoothWeights)
if (text != nullptr && Ascii::EqualCI(w.name, text)) {
in = w.in;
out = w.out;
return true;
}
return false;
}
const char *SmoothWeightsToString(float in, float out) {
for (const Weights &w : kSmoothWeights)
if (std::fabs(in - w.in) < 0.001f && std::fabs(out - w.out) < 0.001f)
return w.name;
return "UNKNOWN";
}
// ---- Lua methods (CSimpleAnimGroup_Scripts.cpp) -------------------------------
namespace {
Group *Self(void *L) { return static_cast<Group *>(Resolve(L, 1, Group::s_class)); }
using LooseBool_t = int(__fastcall *)(void *L, int idx, int defaultValue);
bool LooseBool(void *L, int idx, int dflt) {
return reinterpret_cast<LooseBool_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE)(L, idx, dflt) != 0;
}
int __fastcall Script_Play(void *L) {
if (Group *g = Self(L))
g->Play(LooseBool(L, 2, 0));
return 0;
}
int __fastcall Script_Pause(void *L) {
if (Group *g = Self(L))
g->Pause();
return 0;
}
int __fastcall Script_Stop(void *L) {
if (Group *g = Self(L))
g->Stop(true);
return 0;
}
int __fastcall Script_Finish(void *L) {
if (Group *g = Self(L))
g->Finish();
return 0;
}
int __fastcall Script_GetProgress(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, g->elapsed / g->duration);
return 1;
}
int __fastcall Script_IsDone(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, g->IsDone());
return 1;
}
int __fastcall Script_IsPlaying(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, g->IsPlaying());
return 1;
}
int __fastcall Script_IsPaused(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, (g->flags & GROUP_PAUSED) != 0);
return 1;
}
int __fastcall Script_IsPendingFinish(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, (g->flags & GROUP_PENDING_FINISH) != 0);
return 1;
}
int __fastcall Script_GetDuration(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, g->duration);
return 1;
}
int __fastcall Script_SetLooping(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetLooping(\"loopType\")", g->DisplayName());
return 0;
}
LoopType type;
if (StringToLoopType(Game::Lua::ToString(L, 2), type)) {
g->loopType = type;
return 0;
}
Game::Lua::Error(L, "%s:SetLooping(): Unknown loop type specified", g->DisplayName());
return 0;
}
int __fastcall Script_GetLooping(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushString(L, LoopTypeToString(g->loopType));
return 1;
}
int __fastcall Script_GetLoopState(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushString(L, LoopStateToString(g->loopState));
return 1;
}
int __fastcall Script_GetMaxOrder(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, g->GetMaxAnimOrder() + 1);
return 1;
}
int __fastcall Script_SetInitialOffset(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetInitialOffset(x, y)", g->DisplayName());
return 0;
}
const float x = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 2)));
const float y = Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3)));
// 4.3.4's SetInitialOffset: a near-zero offset is zero.
if (x * x + y * y <= 0.00000023841858f) {
g->initialOffsetX = 0.0f;
g->initialOffsetY = 0.0f;
} else {
g->initialOffsetX = x;
g->initialOffsetY = y;
}
return 0;
}
int __fastcall Script_GetInitialOffset(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushNumber(L, Game::InternalToPixel(g->initialOffsetX));
Game::Lua::PushNumber(L, Game::InternalToPixel(g->initialOffsetY));
return 2;
}
int __fastcall Script_GetAnimations(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
const int count = static_cast<int>(g->animations.size());
if (!Game::Lua::CheckStack(L, count)) {
Game::Lua::Error(L, "%s:GetAnimations(): Stack overflow", g->DisplayName());
return 0;
}
for (Anim *anim : g->animations)
Push(L, anim);
return count;
}
int __fastcall Script_SetIgnoreFramerateThrottle(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (LooseBool(L, 2, 0))
g->flags |= GROUP_IGNORE_THROTTLE;
else
g->flags &= ~GROUP_IGNORE_THROTTLE;
return 0;
}
int __fastcall Script_GetIgnoreFramerateThrottle(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
if (g->flags & GROUP_IGNORE_THROTTLE)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"GetObjectType", &Script_GetObjectType},
{"IsObjectType", &Script_IsObjectType},
{"GetName", &Script_GetName},
{"GetParent", &Script_GetParent},
{"Play", &Script_Play},
{"Pause", &Script_Pause},
{"Stop", &Script_Stop},
{"Finish", &Script_Finish},
{"GetProgress", &Script_GetProgress},
{"IsDone", &Script_IsDone},
{"IsPlaying", &Script_IsPlaying},
{"IsPaused", &Script_IsPaused},
{"IsPendingFinish", &Script_IsPendingFinish},
{"GetDuration", &Script_GetDuration},
{"SetLooping", &Script_SetLooping},
{"GetLooping", &Script_GetLooping},
{"GetLoopState", &Script_GetLoopState},
{"GetMaxOrder", &Script_GetMaxOrder},
{"SetInitialOffset", &Script_SetInitialOffset},
{"GetInitialOffset", &Script_GetInitialOffset},
{"GetAnimations", &Script_GetAnimations},
{"CreateAnimation", &Script_CreateAnimation},
{"HasScript", &Script_HasScript},
{"GetScript", &Script_GetScript},
{"SetScript", &Script_SetScript},
{"HookScript", &Script_HookScript},
{"SetIgnoreFramerateThrottle", &Script_SetIgnoreFramerateThrottle},
{"GetIgnoreFramerateThrottle", &Script_GetIgnoreFramerateThrottle},
};
} // namespace
Class Group::s_class{"AnimationGroup", nullptr, kGroupScripts, 7, kMethods,
static_cast<int>(sizeof(kMethods) / sizeof(kMethods[0])), 0, 0};
} // namespace Animation
+340
View File
@@ -0,0 +1,340 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "animation/Object.h"
#include "HookSecureFunc.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include "frame/ScriptArgs.h"
#include <cstring>
namespace Animation {
namespace {
using Construct_t = void(__fastcall *)(void *object);
using Destruct_t = void(__fastcall *)(void *object);
using Lock_t = void(__fastcall *)(void *object, void *edx, const char *name);
using Unlock_t = void(__fastcall *)(void *object, void *edx, const char *name);
using BaseResolve_t = uint32_t *(__fastcall *)(void *object, void *edx, const char *name);
using Release_t = void(__stdcall *)(uint32_t *slot);
using LuaRef_t = int(__fastcall *)(void *L, int t);
using LuaUnref_t = void(__fastcall *)(void *L, int t, int ref);
using GetName_t = const char *(__fastcall *)(const void *object);
// 4.3.4's CScriptObject type name, which every class's IsA(name) accepts.
constexpr const char *kObjectTypeName = "Object";
Object *Self(void *object) { return static_cast<Object *>(object); }
// ---- The vtable (the engine calls these on our objects) --------------------
void *__fastcall V_Delete(Object *object, void * /*edx*/, unsigned flags);
const char *__fastcall V_GetName(Object *object) { return object->Name(); }
// vtable+0x08: push the method `name` of the object's class or a base.
int __fastcall V_FindMethod(Object *object, void * /*edx*/, void *L, const char *name) {
for (const Class *c = object->cls; c != nullptr; c = c->base) {
if (c->methodsRef <= 0)
continue;
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, c->methodsRef);
Game::Lua::PushString(L, name);
Game::Lua::RawGet(L, -2);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
Game::Lua::Remove(L, -2);
return 1;
}
Game::Lua::SetTop(L, -3);
}
return 0;
}
// vtable+0x0C: 4.3.4's GetScriptByName - the base OnEvent first.
uint32_t *__fastcall V_ResolveScript(Object *object, void * /*edx*/, const char *name) {
uint32_t *slot =
reinterpret_cast<BaseResolve_t>(Offsets::FUN_SCRIPT_OBJECT_BASE_RESOLVE_SCRIPT)(object, nullptr, name);
return slot != nullptr ? slot : object->ScriptSlot(name);
}
int __fastcall V_IsA(Object *object, void * /*edx*/, int typeId) {
for (const Class *c = object->cls; c != nullptr; c = c->base)
if (c->typeId != 0 && c->typeId == typeId)
return 1;
return 0;
}
// vtable+0x14 differs per engine class; nothing calls it on ours.
int __fastcall V_Unused(Object * /*object*/) { return 0; }
int __fastcall V_IsAName(Object *object, void * /*edx*/, const char *typeName) {
if (typeName == nullptr)
return 0;
for (const Class *c = object->cls; c != nullptr; c = c->base)
if (Ascii::EqualCI(typeName, c->typeName))
return 1;
return Ascii::EqualCI(typeName, kObjectTypeName) ? 1 : 0;
}
const char *__fastcall V_GetObjectTypeName(Object *object) { return object->cls->typeName; }
void *g_vtable[] = {
reinterpret_cast<void *>(&V_Delete), reinterpret_cast<void *>(&V_GetName),
reinterpret_cast<void *>(&V_FindMethod), reinterpret_cast<void *>(&V_ResolveScript),
reinterpret_cast<void *>(&V_IsA), reinterpret_cast<void *>(&V_Unused),
reinterpret_cast<void *>(&V_IsAName), reinterpret_cast<void *>(&V_GetObjectTypeName),
};
// Slot 0, the engine's deleting destructor. The engine never deletes our
// objects - their owners (a region, a group) do, by their concrete type.
void *__fastcall V_Delete(Object *object, void * /*edx*/, unsigned /*flags*/) { return object; }
} // namespace
// ---- Object -------------------------------------------------------------------
Object::Object(const Class &cls, void *parent, const char *name) : cls(&cls), parent(parent) {
reinterpret_cast<Construct_t>(Offsets::FUN_SCRIPT_OBJECT_CONSTRUCT)(this);
vtable = g_vtable;
if (name != nullptr && *name != '\0')
this->name = CreateName(name, parent);
reinterpret_cast<Lock_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(this, nullptr, Name());
}
bool Object::s_detached = false;
Object::~Object() {
if (s_detached)
return;
for (auto &slot : scripts)
reinterpret_cast<Release_t>(Offsets::FUN_SCRIPT_RELEASE)(slot);
while (holders > 1)
reinterpret_cast<Unlock_t>(Offsets::FUN_SCRIPT_OBJECT_UNLOCK)(this, nullptr, nullptr);
if (holders > 0)
reinterpret_cast<Unlock_t>(Offsets::FUN_SCRIPT_OBJECT_UNLOCK)(this, nullptr, Name());
reinterpret_cast<Destruct_t>(Offsets::FUN_SCRIPT_OBJECT_DESTRUCT)(this);
}
uint32_t *Object::ScriptSlot(const char *scriptName) {
if (scriptName == nullptr)
return nullptr;
for (const Class *c = cls; c != nullptr; c = c->base) {
for (int i = 0; i < c->scriptCount; ++i)
if (Ascii::EqualCI(scriptName, c->scriptNames[i]))
return scripts[i];
if (c->scriptCount > 0)
break; // only one class in a chain declares the scripts
}
return nullptr;
}
void Object::RunScript(int index, int nargs) {
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int first = Game::Lua::GetTop(L) - nargs + 1;
if (HasScript(index))
Frame::ScriptArgs::RunSlotWithValues(L, scripts[index], this, first, nargs);
Game::Lua::SetTop(L, first - 1);
}
// ---- Helpers --------------------------------------------------------------------
int TypeId(Class &cls) { return Game::Lua::FrameScriptTypeId(reinterpret_cast<uintptr_t>(&cls.typeId)); }
Object *Resolve(void *L, int idx, Class &cls) {
return static_cast<Object *>(Game::Lua::ResolveTypedObject(L, idx, reinterpret_cast<uintptr_t>(&cls.typeId)));
}
void Push(void *L, Object *object) { Game::Lua::PushObject(L, object); }
bool IsOurs(const void *object) { return object != nullptr && Game::Read<void **>(object, 0) == g_vtable; }
std::string CreateName(const char *source, const void *parent) {
constexpr const char kParent[] = "$parent";
constexpr size_t kParentLen = sizeof(kParent) - 1;
if (std::strlen(source) < kParentLen || !Ascii::EqualCI(std::string(source, kParentLen).c_str(), kParent))
return source;
std::string name = "Top";
for (const void *p = parent; p != nullptr;) {
const char *pname = IsOurs(p) ? static_cast<const Object *>(p)->Name()
: Game::Read<GetName_t>(Game::Read<void *>(p, 0),
Offsets::VTBL_SCRIPTOBJECT_GET_NAME)(p);
if (pname != nullptr && *pname != '\0') {
name = pname;
break;
}
p = IsOurs(p) ? static_cast<const Object *>(p)->parent : Game::Read<void *>(p, Offsets::OFF_REGION_PARENT);
}
return name + (source + kParentLen);
}
void RegisterClass(Class &cls) {
void *L = Game::Lua::State();
TypeId(cls);
Game::Lua::NewTable(L);
for (int i = 0; i < cls.methodCount; ++i) {
Game::Lua::PushString(L, cls.methods[i].name);
Game::Lua::PushCClosure(L, cls.methods[i].func, 0);
Game::Lua::RawSet(L, -3);
}
cls.methodsRef = reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX);
}
// ---- The base methods -----------------------------------------------------------
namespace {
// The object at self, of any of our classes.
Object *AnySelf(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE)
return nullptr;
void *object = Game::Lua::ResolveObject(L, 1);
return IsOurs(object) ? Self(object) : nullptr;
}
} // namespace
int __fastcall Script_GetObjectType(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
Game::Lua::PushString(L, object->cls->typeName);
return 1;
}
int __fastcall Script_IsObjectType(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:IsObjectType(\"TYPE\")", object->DisplayName());
return 0;
}
if (V_IsAName(object, nullptr, Game::Lua::ToString(L, 2)))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_GetName(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
Game::Lua::PushString(L, object->Name());
return 1;
}
int __fastcall Script_GetParent(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (object->parent != nullptr)
Game::Lua::PushObject(L, object->parent);
else
Game::Lua::PushNil(L);
return 1;
}
// 4.3.4's HasScript: 1 when the class has the script, else nil.
int __fastcall Script_HasScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:HasScript(\"type\")", object->DisplayName());
return 0;
}
if (V_ResolveScript(object, nullptr, Game::Lua::ToString(L, 2)) != nullptr)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
// The engine's Script GetScript (0x00774780) on our object: the slot's
// context becomes current, then the handler (nil for none) is pushed.
int __fastcall Script_GetScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:GetScript(\"type\")", object->DisplayName());
return 0;
}
const uint32_t *slot = V_ResolveScript(object, nullptr, Game::Lua::ToString(L, 2));
if (slot == nullptr) {
Game::Lua::Error(L, "%s doesn't have a \"%s\" script", object->DisplayName(), Game::Lua::ToString(L, 2));
return 0;
}
if (slot[1] != 0 && Game::Read<int>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH) != 0)
Game::Ref<uint32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT) = slot[1];
Game::Lua::RawGetI(L, Game::Lua::REGISTRY_INDEX, static_cast<int>(slot[0]));
return 1;
}
// The engine's Script SetScript (0x007748D0) on our object: release the old
// handler, store a ref of the new one (0 for nil) with the current context.
int __fastcall Script_SetScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
const int handlerType = Game::Lua::Type(L, 3);
if (!Game::Lua::IsString(L, 2) ||
(handlerType != Game::Lua::TYPE_FUNCTION && handlerType != Game::Lua::TYPE_NIL &&
handlerType != -1 /* none */)) {
Game::Lua::Error(L, "Usage: %s:SetScript(\"type\", function)", object->DisplayName());
return 0;
}
uint32_t *slot = V_ResolveScript(object, nullptr, Game::Lua::ToString(L, 2));
if (slot == nullptr) {
Game::Lua::Error(L, "%s doesn't have a \"%s\" script", object->DisplayName(), Game::Lua::ToString(L, 2));
return 0;
}
reinterpret_cast<Release_t>(Offsets::FUN_SCRIPT_RELEASE)(slot);
Game::Lua::SetTop(L, 3);
const int ref = reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX);
slot[0] = ref > 0 ? static_cast<uint32_t>(ref) : 0;
slot[1] = Game::Read<uint32_t>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
return 0;
}
// Frame HookScript's (frame/Modern.cpp) on our object: no handler yet -> the
// hook is the handler; else the hooksecurefunc wrapper over (old, hook).
int __fastcall Script_HookScript(void *L) {
Object *object = AnySelf(L);
if (object == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2) || Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "Usage: %s:HookScript(\"type\", function)", object->DisplayName());
return 0;
}
Game::Lua::SetTop(L, 3); // (self, type, hook)
Game::Lua::PushValue(L, 1);
Game::Lua::PushValue(L, 2);
Game::Lua::PushCClosure(L, &Script_GetScript, 0);
Game::Lua::Insert(L, -3);
Game::Lua::Call(L, 2, 1); // (self, type, hook, old)
if (Game::Lua::Type(L, 4) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::Insert(L, 3); // (self, type, old, hook)
HookSecureFunc::PushWrapper(L); // (self, type, wrapper)
} else {
Game::Lua::SetTop(L, 3);
}
return Script_SetScript(L);
}
} // namespace Animation
+123
View File
@@ -0,0 +1,123 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Game.h"
#include <cstdint>
#include <string>
// Animation groups and animations as real 1.12 script objects.
//
// 4.3.4's CSimpleAnimGroup and CSimpleAnim are CScriptObjects: the engine
// hands them to Lua as the same kind of table as a frame (`t[0]` = the
// object, metatable `__framescript_meta`), finds their methods through the
// object's vtable, resolves `self` with the object's IsA, and stores their
// scripts in {handler, context} slots it finds through the object's script
// resolver. 1.12 has the same machinery (benilla-re ui/methods, ui/script):
// - ConstructScriptObject (0x00701AE0) builds the 0x24-byte header below;
// DestructScriptObject (0x00701B40) releases it;
// - LockScriptObject / UnlockScriptObject (0x00701BD0 / 0x00701CD0) make and
// free the object's table and its name global;
// - `__framescript_meta.__index` (0x007020B0) calls vtable+0x08 (L, name)
// to push a method; ResolveTypedObject's type test calls vtable+0x10;
// - SetScriptObjectScript (0x007025C0) compiles an XML script body into
// the slot vtable+0x0C returns, and FrameScriptReleaseScript (0x00702670)
// frees a slot.
// So an animation object is an engine script object whose vtable is ours.
// It has no C++ virtual functions - its first bytes are the engine header -
// and its class (Class) carries what differs per type.
namespace Animation {
// The 1.12 script-object header, as ConstructScriptObject fills it.
struct ScriptObjectHeader {
void **vtable;
int32_t holders; // +0x04, LockScriptObject's count
int32_t luaRef; // +0x08, the table's registry ref (-2 when none)
uint32_t onEvent[2]; // +0x0C, the base OnEvent slot
uint32_t events[4]; // +0x14, the event index array
};
static_assert(sizeof(ScriptObjectHeader) == 0x24, "the engine's script-object header");
struct Class {
const char *typeName; // GetObjectType, IsObjectType
const Class *base;
// The class's own script names, lower case; a slot per name.
const char *const *scriptNames;
int scriptCount;
const Game::Lua::FrameMethodEntry *methods;
int methodCount;
int typeId; // from the engine's type counter, on first use
int methodsRef; // registry ref of the method table, per Lua state
};
constexpr int kMaxScripts = 8;
class Object : public ScriptObjectHeader {
public:
const Class *cls;
// The script object this one hangs from: a group's region, an
// animation's group (GetParent, "$parent").
void *parent;
std::string name;
uint32_t scripts[kMaxScripts][2] = {}; // {handler ref, context}
// The object's script slot for `name` (any case), or nullptr.
uint32_t *ScriptSlot(const char *name);
bool HasScript(int index) const { return scripts[index][0] != 0; }
// Runs script `index` with `nargs` values pushed on top of the stack,
// and pops them.
void RunScript(int index, int nargs);
const char *Name() const { return name.empty() ? nullptr : name.c_str(); }
// While set, a destroyed object leaves the engine alone: its table and
// handlers belonged to a Lua state that is gone (/reload leftovers).
static bool s_detached;
const char *DisplayName() const { return name.empty() ? "<unnamed>" : name.c_str(); }
protected:
// Registers the object's table, under its name when it has one ("$parent"
// resolved from `parent`).
Object(const Class &cls, void *parent, const char *name);
~Object();
};
// The class's type id (the engine's counter, assigned on first use).
int TypeId(Class &cls);
// The object at `idx` when it is of `cls` or a class derived from it, else
// raises the engine's errors (as every method does) and returns nullptr.
Object *Resolve(void *L, int idx, Class &cls);
// Pushes the object's table.
void Push(void *L, Object *object);
// Whether a script object is one of ours.
bool IsOurs(const void *object);
// 4.3.4's CScriptObject::CreateName: "$parent" at the start of `source`
// becomes the nearest named ancestor's name, "Top" when none.
std::string CreateName(const char *source, const void *parent);
// Builds every class's method table in the current Lua state.
void RegisterClass(Class &cls);
// The base methods every class has (4.3.4's CScriptObject methods), and the
// script methods groups and animations share.
int __fastcall Script_GetObjectType(void *L);
int __fastcall Script_IsObjectType(void *L);
int __fastcall Script_GetName(void *L);
int __fastcall Script_GetParent(void *L);
int __fastcall Script_HasScript(void *L);
int __fastcall Script_GetScript(void *L);
int __fastcall Script_SetScript(void *L);
int __fastcall Script_HookScript(void *L);
} // namespace Animation
+501
View File
@@ -0,0 +1,501 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// 4.3.4's CSimplePathAnim and CSimpleControlPoint (wowdecomp15595
// CSimplePathAnim.cpp, CSimpleControlPoint.cpp and their _Scripts.cpp).
//
// A Path moves the region through its control points: each point is an
// offset from where the region is, the points are spread evenly over the
// animation's progress (point i of n at (i + 1) / n), and the path starts
// at offset 0. Between points the offset is a straight line, or with the
// SMOOTH curve a Catmull-Rom spline. The offset goes through the same
// AddAnimTranslation as a Translation.
#include "animation/Classes.h"
#include "animation/Region.h"
#include "animation/Xml.h"
#include "Game.h"
#include "baselib/Ascii.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
namespace Animation {
// ---- CSimpleControlPoint ------------------------------------------------------------
ControlPoint::ControlPoint(Anim *path, const char *name) : Object(s_class, path, name) {
if (path != nullptr)
path->controlPoints.push_back(this);
}
ControlPoint::~ControlPoint() = default;
void ControlPoint::SetOffset(float x, float y) {
if (x * x + y * y <= 0.00000023841858f) {
offsetX = 0.0f;
offsetY = 0.0f;
} else {
offsetX = x;
offsetY = y;
}
}
void ControlPoint::SetOrder(int value, bool notify, bool wrap) {
const int oldOrder = order;
if (wrap)
order = value < 0 || value > 99 ? 0 : value;
else
order = value < 0 ? 0 : (value > 99 ? 99 : value);
if (parent != nullptr && notify && oldOrder != order)
GetPath()->NotifyControlPointOrderChanged(this);
}
bool ControlPoint::ChangeParent(Anim *path) {
if (path == nullptr || GetPath() == path)
return false;
// LinkToTail unlinks the point from the old path's list first.
Anim *old = GetPath();
if (old != nullptr)
old->controlPoints.erase(std::remove(old->controlPoints.begin(), old->controlPoints.end(), this),
old->controlPoints.end());
path->controlPoints.push_back(this);
if (old != nullptr)
old->NotifyControlPointRemoved(this);
parent = path;
path->NotifyControlPointAdded(this);
return true;
}
// ---- CSimplePathAnim -------------------------------------------------------------------
int Anim::GetMaxControlPointOrder() const { return sortedPoints.empty() ? -1 : sortedPoints.back()->order; }
int Anim::FindControlPoint(int value) const {
if (value == -1)
return -1;
int low = 0;
int high = static_cast<int>(sortedPoints.size()) - 1;
while (low <= high) {
const int middle = static_cast<int>(static_cast<unsigned>(high + low) >> 1);
const ControlPoint *point = sortedPoints[middle];
if (point == nullptr)
break; // 4.3.4 spins here; the array has no holes after BuildSpline
if (point->order < value)
low = middle + 1;
else if (point->order > value)
high = middle - 1;
else
return middle;
}
return -1;
}
void Anim::UpdateControlPointPositions() {
const size_t count = sortedPoints.size();
if (count == 0)
return;
const float step = 1.0f / static_cast<float>(count);
for (size_t i = 0; i < count; ++i) {
float position = static_cast<float>(i + 1) * step;
position = position < 0.0f ? 0.0f : (position >= 1.0f ? 1.0f : position);
sortedPoints[i]->position = position;
}
}
int Anim::RegisterSplineControlPoint(ControlPoint *point) {
const int count = static_cast<int>(sortedPoints.size());
if (count == 0) {
sortedPoints.push_back(point);
return 0;
}
int index = count;
for (int i = 0; i < count; ++i) {
ControlPoint *existing = sortedPoints[i];
if (existing == nullptr) {
sortedPoints[i] = point;
return i;
}
if (point->order == existing->order)
return i;
if (point->order < existing->order) {
index = i;
break;
}
}
sortedPoints.insert(sortedPoints.begin() + index, point);
return index;
}
void Anim::BuildSpline() {
Stop(false);
if (controlPoints.empty()) {
sortedPoints.clear();
return;
}
std::fill(sortedPoints.begin(), sortedPoints.end(), nullptr);
// Points take their order in link order; a point without one, or with
// one below the next free order, takes the next free order.
int nextOrder = 0;
for (ControlPoint *point : controlPoints) {
int value = point->order;
if (value == -1 || value < nextOrder) {
value = nextOrder++;
point->order = value < 0 ? 0 : value;
} else {
nextOrder = value + 1;
}
if (nextOrder > 99)
nextOrder = 0;
RegisterSplineControlPoint(point);
}
while (!sortedPoints.empty() && sortedPoints.back() == nullptr)
sortedPoints.pop_back();
}
void Anim::NotifyControlPointAdded(ControlPoint *point) {
if (point == nullptr)
return;
const int index = FindControlPoint(point->order);
if (index < 0) {
if (RegisterSplineControlPoint(point) >= 0)
UpdateControlPointPositions();
return;
}
// The order is taken: the point goes before the one that has it and
// takes the order; BuildSpline moves the rest up.
ControlPoint *existing = sortedPoints[index];
controlPoints.erase(std::remove(controlPoints.begin(), controlPoints.end(), point), controlPoints.end());
controlPoints.insert(std::find(controlPoints.begin(), controlPoints.end(), existing), point);
BuildSpline();
UpdateControlPointPositions();
}
void Anim::NotifyControlPointRemoved(ControlPoint *point) {
if (point != nullptr && FindControlPoint(point->order) >= 0) {
BuildSpline();
UpdateControlPointPositions();
}
}
void Anim::NotifyControlPointOrderChanged(ControlPoint *point) {
if (point == nullptr)
return;
const int index = FindControlPoint(point->order);
if (index >= 0 && sortedPoints[index] != point) {
ControlPoint *existing = sortedPoints[index];
controlPoints.erase(std::remove(controlPoints.begin(), controlPoints.end(), point), controlPoints.end());
controlPoints.insert(std::find(controlPoints.begin(), controlPoints.end(), existing), point);
}
BuildSpline();
UpdateControlPointPositions();
}
void Anim::ApplyPath(float progress) {
const int count = static_cast<int>(sortedPoints.size());
if (count == 0)
return;
void *region = GetRegionParent();
if (region == nullptr)
return;
const float t = std::fabs(progress);
// The segment: 4.3.4 converts count * t with the 2^16 + x float trick,
// which rounds to 1/128 before it truncates. Kept, so a progress just
// under a point's lands on the same segment.
const float scaled = static_cast<float>(count) * t;
const float biased = std::fabs(scaled) + 65536.0f;
int32_t bits;
std::memcpy(&bits, &biased, sizeof(bits));
uint32_t scaledBits;
std::memcpy(&scaledBits, &scaled, sizeof(scaledBits));
const int32_t sign = static_cast<int32_t>(scaledBits >> 31);
const int index = static_cast<int16_t>((((bits >> 7) & 0x7FFF) ^ sign) - sign);
float x = 0.0f, y = 0.0f;
const auto offsetOf = [this](int i, float &ox, float &oy) {
ox = sortedPoints[i]->offsetX;
oy = sortedPoints[i]->offsetY;
};
if (static_cast<unsigned>(index) >= static_cast<unsigned>(count)) {
offsetOf(count - 1, x, y);
} else if (curve && count > 1) {
// Catmull-Rom through p1 -> p2; the start is offset 0.
float p0x = 0.0f, p0y = 0.0f, p1x = 0.0f, p1y = 0.0f, p2x, p2y, p3x, p3y, t0, t1;
if (index != 0) {
offsetOf(index - 1, p1x, p1y);
t0 = sortedPoints[index - 1]->position;
offsetOf(index, p2x, p2y);
t1 = sortedPoints[index]->position;
if (index != 1)
offsetOf(index - 2, p0x, p0y);
if (index == count - 1) {
p3x = p2x * 2.0f - p1x;
p3y = p2y * 2.0f - p1y;
} else {
offsetOf(index + 1, p3x, p3y);
}
} else {
t0 = 0.0f;
offsetOf(0, p2x, p2y);
t1 = sortedPoints[0]->position;
p0x = -p2x;
p0y = -p2y;
offsetOf(1, p3x, p3y);
}
const float f = (t - t0) / (t1 - t0);
const float f2 = f * f;
const float f3 = f2 * f;
const float ax = -p0x + p1x * 3.0f - p2x * 3.0f + p3x;
const float ay = -p0y + p1y * 3.0f - p2y * 3.0f + p3y;
const float bx = p0x * 2.0f - p1x * 5.0f + p2x * 4.0f - p3x;
const float by = p0y * 2.0f - p1y * 5.0f + p2y * 4.0f - p3y;
const float cx = -p0x + p2x;
const float cy = -p0y + p2y;
x = (cx * f + p1x * 2.0f + bx * f2 + ax * f3) * 0.5f;
y = (cy * f + p1y * 2.0f + by * f2 + ay * f3) * 0.5f;
} else {
float ax = 0.0f, ay = 0.0f, bx, by, t0;
if (index != 0) {
offsetOf(index - 1, ax, ay);
t0 = sortedPoints[index - 1]->position;
} else {
t0 = 0.0f;
}
offsetOf(index, bx, by);
const float t1 = sortedPoints[index]->position;
const float f = (t - t0) / (t1 - t0);
x = ax + (bx - ax) * f;
y = f * (by - ay) + ay;
}
if (progress >= 0.0f)
Region::AddAnimTranslation(region, target, x, y);
else
Region::AddAnimTranslation(region, target, -x, -y);
}
// ---- Lua methods ---------------------------------------------------------------------
namespace {
Anim *PathSelf(void *L) { return static_cast<Anim *>(Resolve(L, 1, Anim::s_pathClass)); }
ControlPoint *PointSelf(void *L) { return static_cast<ControlPoint *>(Resolve(L, 1, ControlPoint::s_class)); }
// LoadXML.cpp's s_curveTypes.
bool StringToCurve(const char *text, bool &curve) {
if (Ascii::EqualCI(text, "NONE")) {
curve = false;
return true;
}
if (Ascii::EqualCI(text, "SMOOTH")) {
curve = true;
return true;
}
return false;
}
int __fastcall Script_SetCurve(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetCurve(\"curveType\")", path->DisplayName());
return 0;
}
if (!StringToCurve(Game::Lua::ToString(L, 2), path->curve))
Game::Lua::Error(L, "%s:SetCurve(): Unknown curve type specified", path->DisplayName());
return 0;
}
int __fastcall Script_GetCurve(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
Game::Lua::PushString(L, path->curve ? "SMOOTH" : "NONE");
return 1;
}
int __fastcall Script_GetMaxOrder(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
Game::Lua::PushNumber(L, path->GetMaxControlPointOrder() + 1);
return 1;
}
int __fastcall Script_GetControlPoints(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
const int count = static_cast<int>(path->sortedPoints.size());
if (count == 0)
return 0;
if (!Game::Lua::CheckStack(L, count)) {
Game::Lua::Error(L, "%s:GetControlPoints(): Stack overflow", path->DisplayName());
return 0;
}
for (ControlPoint *point : path->sortedPoints)
Push(L, point);
return count;
}
int __fastcall Script_CreateControlPoint(void *L) {
Anim *path = PathSelf(L);
if (path == nullptr)
return 0;
const char *name = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
const void *templateNode = nullptr;
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING) {
const char *inherits = Game::Lua::ToString(L, 3);
templateNode = Xml::Template(inherits);
if (templateNode == nullptr) {
Game::Lua::Error(L, "%s:CreateControlPoint(): Couldn't find inherited node \"%s\"", path->DisplayName(),
inherits);
return 0;
}
}
ControlPoint *point = new ControlPoint(path, name);
if (templateNode != nullptr)
Xml::LoadControlPoint(point, templateNode, nullptr);
if (Game::Lua::IsNumber(L, 4))
point->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 4)) - 1, false, false);
// The decompiled CreateControlPoint passes the highest order itself,
// which takes the last point's place and reverses points made one after
// another; one past it, as SetParent does, puts the point last.
if (point->order == -1)
point->SetOrder(path->GetMaxControlPointOrder() + 1, false, true);
path->NotifyControlPointAdded(point);
Push(L, point);
return 1;
}
int __fastcall Script_PointSetParent(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_NIL) {
Game::Lua::Error(L, "%s:SetParent(): Cannot set a 'nil' parent for control points", point->DisplayName());
return 0;
}
const uintptr_t pathType = reinterpret_cast<uintptr_t>(&Anim::s_pathClass.typeId);
Anim *path = nullptr;
if (Game::Lua::IsString(L, 2)) {
// CScriptObject::GetScriptObjectByName(name, Path).
Game::Lua::PushString(L, Game::Lua::ToString(L, 2));
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
void *object = Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE ? Game::Lua::ResolveObject(L, -1) : nullptr;
Game::Lua::SetTop(L, -2);
if (object != nullptr && IsOurs(object) && Game::Lua::IsA(object, pathType))
path = static_cast<Anim *>(object);
} else if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE) {
void *object = Game::Lua::ResolveObject(L, 2);
if (object == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find 'this' in parent object", point->DisplayName());
return 0;
}
if (!IsOurs(object) || !Game::Lua::IsA(object, pathType)) {
Game::Lua::Error(L, "%s:SetParent(): Wrong parent object type, expected Path", point->DisplayName());
return 0;
}
path = static_cast<Anim *>(object);
}
if (path == nullptr) {
Game::Lua::Error(L, "%s:SetParent(): Couldn't find Path named '%s'", point->DisplayName(),
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "");
return 0;
}
const int oldOrder = point->order;
if (Game::Lua::IsNumber(L, 3)) {
point->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 3)) - 1, false, false);
} else {
int value = point->order;
if (value == -1)
value = path->GetMaxControlPointOrder() + 1;
point->SetOrder(value, false, true);
}
if (!point->ChangeParent(path) && oldOrder != point->order)
path->NotifyControlPointOrderChanged(point);
return 0;
}
int __fastcall Script_PointSetOffset(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: %s:SetOffset(x, y)", point->DisplayName());
return 0;
}
point->SetOffset(Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 2))),
Game::PixelToInternal(static_cast<float>(Game::Lua::ToNumber(L, 3))));
return 0;
}
int __fastcall Script_PointGetOffset(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
Game::Lua::PushNumber(L, Game::InternalToPixel(point->offsetX));
Game::Lua::PushNumber(L, Game::InternalToPixel(point->offsetY));
return 2;
}
int __fastcall Script_PointSetOrder(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetOrder(order)", point->DisplayName());
return 0;
}
point->SetOrder(static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1, true, false);
return 0;
}
int __fastcall Script_PointGetOrder(void *L) {
ControlPoint *point = PointSelf(L);
if (point == nullptr)
return 0;
Game::Lua::PushNumber(L, point->order + 1);
return 1;
}
const Game::Lua::FrameMethodEntry kPathMethods[] = {
{"SetCurve", &Script_SetCurve},
{"GetCurve", &Script_GetCurve},
{"GetControlPoints", &Script_GetControlPoints},
{"CreateControlPoint", &Script_CreateControlPoint},
{"GetMaxOrder", &Script_GetMaxOrder},
};
// CScriptObject's methods, then CSimpleControlPoint's.
const Game::Lua::FrameMethodEntry kPointMethods[] = {
{"GetObjectType", &Script_GetObjectType}, {"IsObjectType", &Script_IsObjectType},
{"GetName", &Script_GetName}, {"GetParent", &Script_GetParent},
{"SetParent", &Script_PointSetParent}, {"SetOffset", &Script_PointSetOffset},
{"GetOffset", &Script_PointGetOffset}, {"SetOrder", &Script_PointSetOrder},
{"GetOrder", &Script_PointGetOrder},
};
template <size_t N> constexpr int Count(const Game::Lua::FrameMethodEntry (&)[N]) { return static_cast<int>(N); }
} // namespace
Class Anim::s_pathClass{"Path", &Anim::s_class, nullptr, 0, kPathMethods, Count(kPathMethods), 0, 0};
Class ControlPoint::s_class{"ControlPoint", nullptr, nullptr, 0, kPointMethods, Count(kPointMethods), 0, 0};
} // namespace Animation
+833
View File
@@ -0,0 +1,833 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The region side of the animation system, and the region methods
// CreateAnimationGroup / GetAnimationGroups / StopAnimating.
//
// 4.3.4 (wowdecomp15595 CScriptRegion.cpp, CSimpleFrame.cpp, CSimpleRegion.cpp,
// CSimpleRender.cpp, CLayoutFrame.cpp):
// - a region owns its groups (m_animGroups) and lists the playing ones
// (m_playingAnimGroups);
// - CSimpleFrame::OnLayerUpdate runs OnUpdate, then every group of the
// frame, then every group of each of its regions;
// - when a group starts, the region stores the state its animations change
// (AnimActivated: a frame its own alpha, a texture or font string its
// vertex alpha), unless an earlier group already did; a frame does this
// for every frame and region below it too;
// - every update first puts that state back (PreOnAnimUpdate), then the
// animations add to it (AddAnimAlpha: a frame's own alpha becomes
// change + its drawn alpha; a region's vertex alpha change + its own);
// - when the last group with that kind of animation on the region or any
// ancestor ends, the stored state goes back (AnimDeactivated).
// So, as in 4.3.4, GetAlpha returns the animated alpha while it plays.
//
// Position animations (Translation, Scale, Rotation) move only what
// is DRAWN, never a frame's rect: a frame hands them to every texture and font
// string below it. A texture moves the four corners its draw record points at
// (+0xD4, TL BL TR BR, the order of 4.3.4's m_positions); a font string moves
// its text block's string position, and is turned and scaled about its origin
// when its glyphs are baked (4.3.4 bakes them again whenever the string's
// rotation or scale changes; so does this, through the engine's node
// invalidate). The glyphs are scaled, not drawn at a larger font size: one
// size per update would fill the glyph cache. Their stored base is what the engine
// itself lays out (4.3.4's StoreAnimPosition): a texture's corners from its
// rect through the engine's corner store, a font string's inset x layout
// scale + its rect's min corner. When the layout moves a region mid-animation,
// the co-hooks on the two OnFrameSizeChanged callbacks move the base and keep
// the animation's offset, as 4.3.4's own OnFrameSizeChanged legs do.
//
// 1.12's regions have no room for these, so they are kept here, keyed by the
// region. A frame's own alpha is Frame::Alpha's (3.x's m_alpha), set through
// the frame's vtable setter as SetAlpha sets it; a region's vertex alpha is the
// engine's region color (FUN_REGION_GET_COLOR / FUN_FONTSTRING_SET_COLOR).
// The groups update from a co-hook of CSimpleFrame::OnFrameUpdate, which the
// engine calls for every shown frame after OnUpdate, so a hidden frame's
// groups wait, as in 4.3.4. Groups die with their frame (Frame::Destruction).
#include "animation/Region.h"
#include "animation/Classes.h"
#include "animation/Xml.h"
#include "Game.h"
#include "Offsets.h"
#include "frame/Alpha.h"
#include "frame/Destruction.h"
#include "texture/Transform.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <unordered_map>
#include <vector>
namespace Animation::Region {
namespace {
struct State {
std::vector<Group *> groups; // m_animGroups (owned)
std::vector<Group *> playing; // m_playingAnimGroups
};
// CAnimationData.
struct AnimData {
bool hasAlpha = false;
uint8_t alpha = 0xFF;
bool hasPosition = false;
// m_position[4]: a texture's four corners {x, y, z}; a font string uses
// the first as its string position.
float position[12] = {};
// A font string's animated rotation and scale (4.3.4's m_rotation /
// m_scale over the animation base), and what its glyphs were last baked
// with.
float rotation = 0.0f;
float scale = 1.0f;
float bakedRotation = 0.0f;
float bakedScale = 1.0f;
};
std::unordered_map<void *, State> g_states;
std::unordered_map<void *, AnimData> g_data;
// Font strings whose rotation or scale an update reset: checked against what
// they were baked with once the update has run.
std::vector<void *> g_touchedStrings;
// Regions (not frames) with groups: their frame's update has to look at its
// regions.
int g_regionsWithGroups = 0;
using FrameUpdate_t = void(__fastcall *)(void *frame, void *edx, float elapsed);
using SetFrameAlpha_t = void(__fastcall *)(void *frame, void *edx, uint32_t alpha);
using GetColor_t = void(__fastcall *)(void *region, void *edx, uint32_t *bgra);
using SetColor_t = void(__fastcall *)(void *region, void *edx, const uint32_t *bgra);
using StoreCorners_t = void(__fastcall *)(void *region, void *edx, float *rect);
using Crop_t = void(__fastcall *)(void *region, void *edx, float *rect);
using SetStringPos_t = void(__fastcall *)(void *block, const float *position);
using NdcToDdc_t = void(__fastcall *)(float *outX, float *outY, float x, float y);
using SizeChanged_t = void(__fastcall *)(void *layout, void *edx, const float *oldRect);
using InvalidateNode_t = void(__fastcall *)(void *node);
FrameUpdate_t g_origFrameUpdate = nullptr;
SizeChanged_t g_origTextureSizeChanged = nullptr;
SizeChanged_t g_origFontStringSizeChanged = nullptr;
bool IsFrame(void *region) { return Game::Lua::IsA(region, Offsets::VAR_FRAME_LUA_TYPE_ID); }
bool IsTexture(void *region) { return Game::Lua::IsA(region, Offsets::VAR_TEXTURE_LUA_TYPE_ID); }
bool IsFontString(void *region) { return Game::Lua::IsA(region, Offsets::VAR_FONTSTRING_LUA_TYPE_ID); }
void *ParentOf(void *region) { return Game::Read<void *>(region, Offsets::OFF_REGION_PARENT); }
State *Find(void *region) {
const auto it = g_states.find(region);
return it != g_states.end() ? &it->second : nullptr;
}
AnimData *DataOf(void *region) {
const auto it = g_data.find(region);
return it != g_data.end() ? &it->second : nullptr;
}
template <typename Fn> void ForEachChild(void *frame, Fn fn) {
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_CHILD_LIST, fn);
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_REGION_LIST, fn);
}
// ---- The state alpha animations change -----------------------------------------
uint8_t OwnAlpha(void *frame) { return Frame::Alpha::OwnAlpha(frame); }
void SetOwnAlpha(void *frame, uint8_t alpha) {
void **vtable = *static_cast<void ***>(frame);
reinterpret_cast<SetFrameAlpha_t>(vtable[Offsets::VTBL_FRAME_SET_FRAME_ALPHA / sizeof(void *)])(frame, nullptr,
alpha);
}
uint8_t DrawnParentAlpha(void *frame) {
void *parent = ParentOf(frame);
return parent != nullptr ? Game::Read<uint8_t>(parent, Offsets::OFF_FRAME_ALPHA) : 0xFF;
}
uint32_t VertexColor(void *region) {
uint32_t bgra = 0xFFFFFFFF;
reinterpret_cast<GetColor_t>(Offsets::FUN_REGION_GET_COLOR)(region, nullptr, &bgra);
return bgra;
}
void SetVertexAlpha(void *region, uint8_t alpha) {
const uint32_t bgra = (VertexColor(region) & 0x00FFFFFF) | (static_cast<uint32_t>(alpha) << 24);
reinterpret_cast<SetColor_t>(Offsets::FUN_FONTSTRING_SET_COLOR)(region, nullptr, &bgra);
}
// ---- The state position animations change --------------------------------------
float *Corners(void *texture) { return Game::Ptr<float>(texture, Offsets::OFF_SIMPLETEXTURE_CORNERS); }
// CSimpleTexture::SetPosition(m_rect, m_positions): the corners from the rect,
// written the way the texture's layout callback (FUN_TEXTURE_ON_FRAME_SIZE_CHANGED)
// writes them - cropped when SetTexCoordModifiesRect is on - so the corner
// store's co-hooks (Texture::Transform) shape them as they do on a layout.
void StoreTextureCorners(void *texture) {
float rect[4];
std::memcpy(rect, Game::Ptr<float>(texture, Offsets::OFF_REGION_RECT), sizeof(rect));
if (Game::Read<int>(texture, Offsets::OFF_REGION_TEXCOORD_MODIFIES_RECT) != 0)
reinterpret_cast<Crop_t>(Offsets::FUN_REGION_TEXCOORD_CROP)(texture, nullptr, rect);
reinterpret_cast<StoreCorners_t>(Offsets::FUN_REGION_STORE_CORNERS)(texture, nullptr, rect);
}
void *StringBlock(void *fontString) { return Game::Read<void *>(fontString, Offsets::OFF_FONTSTRING_TEXT_BLOCK); }
// Where the engine lays the string out: inset x layout scale + the rect's min
// corner (the position FUN_FONTSTRING_ON_FRAME_SIZE_CHANGED and SetTextOffset
// pass), as 4.3.4's StoreAnimPosition computes it.
void StringBase(void *fontString, float out[3]) {
const float *rect = Game::Ptr<const float>(fontString, Offsets::OFF_REGION_RECT);
const float scale = Game::Read<float>(fontString, Offsets::OFF_LAYOUT_SCALE);
out[0] = Game::Read<float>(fontString, Offsets::OFF_FONTSTRING_INSET_X) * scale + rect[1];
out[1] = Game::Read<float>(fontString, Offsets::OFF_FONTSTRING_INSET_Y) * scale + rect[0];
out[2] = 0.0f;
}
// TextBlockGetStringPos: the string node's position, back in DDC.
bool GetStringPos(void *block, float out[3]) {
const void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
if (node == nullptr)
return false;
const float *position = Game::Ptr<const float>(node, Offsets::OFF_TEXT_NODE_POSITION);
reinterpret_cast<NdcToDdc_t>(Offsets::FUN_COORD_NDC_TO_DDC)(&out[0], &out[1], position[0], position[1]);
out[2] = position[2];
return true;
}
void SetStringPos(void *block, const float position[3]) {
reinterpret_cast<SetStringPos_t>(Offsets::FUN_TEXTBLOCK_SET_STRING_POS)(block, position);
}
// CSimpleTexture / CSimpleFontString::StoreAnimPosition.
void StoreAnimPosition(void *region, AnimData &data) {
if (IsTexture(region)) {
StoreTextureCorners(region);
std::memcpy(data.position, Corners(region), sizeof(data.position));
data.hasPosition = true;
} else if (IsFontString(region)) {
StringBase(region, data.position);
data.hasPosition = true;
}
}
void *StringNode(void *fontString) {
void *block = StringBlock(fontString);
return block != nullptr ? Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE) : nullptr;
}
// GxuFontStringInvalidate after GxuFontSetRotation / GxuFontSetScale: the
// glyphs are baked again on the next paint, where FontStringBaked turns and
// scales them. Only when the values differ from the baked ones.
void RebakeIfChanged(void *fontString, const AnimData &data) {
if (data.rotation == data.bakedRotation && data.scale == data.bakedScale)
return;
if (void *node = StringNode(fontString))
reinterpret_cast<InvalidateNode_t>(Offsets::FUN_TEXT_NODE_INVALIDATE)(node);
}
// AnimData_ResetPositionData: the region goes back to where the layout puts it.
void ResetPosition(void *region, AnimData &data) {
data.hasPosition = false;
if (IsTexture(region)) {
StoreTextureCorners(region);
} else if (IsFontString(region)) {
// 4.3.4 leaves the string where the last update put it until the next
// layout; it goes back now, as the corners of a texture do.
if (void *block = StringBlock(region)) {
float base[3];
StringBase(region, base);
SetStringPos(block, base);
}
data.rotation = 0.0f;
data.scale = 1.0f;
RebakeIfChanged(region, data);
}
}
// CSimpleFrame / CSimpleRegion::AnimActivated.
void AnimActivated(void *region, bool alpha, bool position) {
const bool frame = IsFrame(region);
AnimData &data = g_data[region]; // AnimData_Init
if (alpha) {
data.hasAlpha = true;
data.alpha = frame ? OwnAlpha(region) : static_cast<uint8_t>(VertexColor(region) >> 24);
}
// A frame stores no position of its own, but it is marked, so the end of
// its last position group resets everything below it (NotifyEnd reads
// the mark), as the end of an alpha group does.
if (position) {
if (frame)
data.hasPosition = true;
else
StoreAnimPosition(region, data);
}
if (frame)
ForEachChild(region, [alpha, position](void *child) { AnimActivated(child, alpha, position); });
}
// CSimpleFrame / CSimpleRegion::AnimDeactivated.
void AnimDeactivated(void *region, bool alpha, bool position) {
const bool frame = IsFrame(region);
AnimData *data = DataOf(region);
if (alpha) {
const uint8_t stored = data != nullptr ? data->alpha : 0xFF; // AnimData_GetAlpha
if (frame) {
if (stored != OwnAlpha(region))
SetOwnAlpha(region, stored);
} else {
SetVertexAlpha(region, stored);
}
if (data != nullptr) {
data->hasAlpha = false;
data->alpha = 0xFF;
}
}
if (position && data != nullptr) {
if (frame)
data->hasPosition = false;
else
ResetPosition(region, *data);
}
if (frame)
ForEachChild(region, [alpha, position](void *child) { AnimDeactivated(child, alpha, position); });
}
// CScriptRegion::HasActiveAlphaAnim / HasActivePositionAnim: a playing group
// with that kind of animation on the region or an ancestor.
template <typename Has> bool HasActiveAnim(void *region, Has has) {
for (void *r = region; r != nullptr; r = ParentOf(r)) {
const State *state = Find(r);
if (state == nullptr)
continue;
for (const Group *group : state->playing)
if (has(group))
return true;
}
return false;
}
// ---- The update ----------------------------------------------------------------------
void UpdateGroups(void *region, float elapsed) {
State *state = Find(region);
if (state == nullptr)
return;
const std::vector<Group *> groups = state->groups;
for (Group *group : groups)
group->OnUpdate(elapsed);
}
void __fastcall FrameUpdate_h(void *frame, void *edx, float elapsed) {
g_origFrameUpdate(frame, edx, elapsed);
if (g_states.empty())
return;
UpdateGroups(frame, elapsed);
if (g_regionsWithGroups > 0)
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_REGION_LIST,
[elapsed](void *region) { UpdateGroups(region, elapsed); });
// The font strings this update turned or scaled, or put back.
for (void *fontString : g_touchedStrings)
if (const AnimData *data = DataOf(fontString))
RebakeIfChanged(fontString, *data);
g_touchedStrings.clear();
}
const Game::HookAutoRegister _frameUpdateHook{Offsets::FUN_FRAME_ON_UPDATE, reinterpret_cast<void *>(&FrameUpdate_h),
reinterpret_cast<void **>(&g_origFrameUpdate)};
// ---- A layout during a position animation -----------------------------------------
//
// The layout writes the region's unanimated position. As in 4.3.4's
// OnFrameSizeChanged legs, the stored base becomes that new position and the
// drawn position keeps the animation's offset from it.
AnimData *PositionData(void *region) {
if (g_data.empty())
return nullptr;
AnimData *data = DataOf(region);
return data != nullptr && data->hasPosition ? data : nullptr;
}
void __fastcall TextureSizeChanged_h(void *layout, void *edx, const float *oldRect) {
void *texture = static_cast<char *>(layout) - Offsets::OFF_REGION_ANCHOR;
AnimData *data = PositionData(texture);
if (data == nullptr) {
g_origTextureSizeChanged(layout, edx, oldRect);
return;
}
float animated[12];
std::memcpy(animated, Corners(texture), sizeof(animated));
g_origTextureSizeChanged(layout, edx, oldRect);
float *corners = Corners(texture);
for (int i = 0; i < 12; ++i) {
const float base = corners[i];
corners[i] = animated[i] + (base - data->position[i]);
data->position[i] = base;
}
}
void __fastcall FontStringSizeChanged_h(void *layout, void *edx, const float *oldRect) {
void *fontString = static_cast<char *>(layout) - Offsets::OFF_REGION_ANCHOR;
AnimData *data = PositionData(fontString);
if (data == nullptr) {
g_origFontStringSizeChanged(layout, edx, oldRect);
return;
}
float animated[3];
void *before = StringBlock(fontString);
const bool hadString = before != nullptr && GetStringPos(before, animated);
g_origFontStringSizeChanged(layout, edx, oldRect);
float base[3];
StringBase(fontString, base);
void *block = StringBlock(fontString);
if (hadString && block != nullptr) {
float position[3];
for (int i = 0; i < 3; ++i)
position[i] = animated[i] + (base[i] - data->position[i]);
SetStringPos(block, position);
}
std::memcpy(data->position, base, sizeof(base));
}
const Game::HookAutoRegister _textureSizeHook{Offsets::FUN_TEXTURE_ON_FRAME_SIZE_CHANGED,
reinterpret_cast<void *>(&TextureSizeChanged_h),
reinterpret_cast<void **>(&g_origTextureSizeChanged)};
const Game::HookAutoRegister _fontStringSizeHook{Offsets::FUN_FONTSTRING_ON_FRAME_SIZE_CHANGED,
reinterpret_cast<void *>(&FontStringSizeChanged_h),
reinterpret_cast<void **>(&g_origFontStringSizeChanged)};
// ---- Lifetime ------------------------------------------------------------------------
void DestroyGroupsOf(void *region) {
const auto it = g_states.find(region);
if (it != g_states.end()) {
if (!it->second.groups.empty() && !IsFrame(region))
--g_regionsWithGroups;
const std::vector<Group *> groups = std::move(it->second.groups);
g_states.erase(it);
for (Group *group : groups)
delete group;
}
g_data.erase(region);
}
// The frame and its regions die here; its child frames run their own
// destructors.
void OnFrameDestroyed(void *frame) {
if (g_states.empty() && g_data.empty())
return;
Game::ForEachFrameListObject(frame, Offsets::OFF_FRAME_REGION_LIST, [](void *region) { DestroyGroupsOf(region); });
DestroyGroupsOf(frame);
}
const Frame::Destruction::AutoSubscribe _destructionSub{&OnFrameDestroyed};
// Groups on frames that outlived a /reload: their tables and handlers were
// in the old Lua state, so they are dropped without touching it.
void DropLeftovers() {
Object::s_detached = true;
for (auto &entry : g_states)
for (Group *group : entry.second.groups)
delete group;
Object::s_detached = false;
g_states.clear();
g_data.clear();
g_regionsWithGroups = 0;
}
// ---- Region methods (CScriptRegion_Scripts.cpp) -------------------------------------------
void StopAnimating(void *region) {
if (State *state = Find(region)) {
const std::vector<Group *> playing = state->playing;
for (Group *group : playing)
group->Stop(true);
}
// CSimpleFrame::StopAnimating: and everything below it.
if (IsFrame(region))
ForEachChild(region, [](void *child) { StopAnimating(child); });
}
int __fastcall Script_CreateAnimationGroup(void *L) {
void *region = Game::Lua::ResolveRegion(L);
if (region == nullptr)
return 0;
const char *name = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
const void *templateNode = nullptr;
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING) {
const char *inherits = Game::Lua::ToString(L, 3);
templateNode = Xml::Template(inherits);
if (templateNode == nullptr) {
Game::Lua::Error(L, "%s:CreateAnimationGroup(): Couldn't find inherited node \"%s\"",
Game::Lua::ObjectDisplayName(region), inherits);
return 0;
}
}
Group *group = new Group(region, name);
if (templateNode != nullptr) {
Xml::LoadGroup(group, templateNode, nullptr);
group->PostLoadXML();
}
Push(L, group);
return 1;
}
int __fastcall Script_GetAnimationGroups(void *L) {
void *region = Game::Lua::ResolveRegion(L);
if (region == nullptr)
return 0;
const State *state = Find(region);
if (state == nullptr)
return 0;
const int count = static_cast<int>(state->groups.size());
if (!Game::Lua::CheckStack(L, count)) {
Game::Lua::Error(L, "%s:GetAnimationGroups(): Stack overflow", Game::Lua::ObjectDisplayName(region));
return 0;
}
for (Group *group : state->groups)
Push(L, group);
return count;
}
int __fastcall Script_StopAnimating(void *L) {
void *region = Game::Lua::ResolveRegion(L);
if (region != nullptr)
StopAnimating(region);
return 0;
}
const Game::Lua::FrameMethodEntry kRegionMethods[] = {
{"CreateAnimationGroup", &Script_CreateAnimationGroup},
{"GetAnimationGroups", &Script_GetAnimationGroups},
{"StopAnimating", &Script_StopAnimating},
};
const Game::Doc::Field kCreateArgs[] = {
Game::Doc::Opt("name", "string", nullptr, "The group's name; \"$parent\" is the region's."),
Game::Doc::Opt("templateName", "string", nullptr, "A virtual <AnimationGroup> to inherit."),
};
const Game::Doc::Field kCreateRets[] = {
Game::Doc::Req("animationGroup", "AnimationGroup"),
};
const Game::Doc::Field kGroupsRets[] = {
Game::Doc::Vararg("AnimationGroup", "Every animation group of the region, in creation order."),
};
const Game::Doc::Function kCreateAnimationGroup{"Creates an animation group on the region.", kCreateArgs,
kCreateRets};
const Game::Doc::Function kGetAnimationGroups{"Returns the region's animation groups.", {}, kGroupsRets};
const Game::Doc::Function kStopAnimating{
"Stops every playing animation group of the region and, for a frame, of everything inside it.", {}, {}};
const Game::Doc::Method kRegionDocs[] = {
{"CreateAnimationGroup", &kCreateAnimationGroup},
{"GetAnimationGroups", &kGetAnimationGroups},
{"StopAnimating", &kStopAnimating},
};
void RegisterLuaFunctions() {
DropLeftovers();
RegisterClass(Group::s_class);
RegisterClass(Anim::s_class);
RegisterClass(Anim::s_alphaClass);
RegisterClass(Anim::s_translationClass);
RegisterClass(Anim::s_scaleClass);
RegisterClass(Anim::s_rotationClass);
RegisterClass(Anim::s_pathClass);
RegisterClass(ControlPoint::s_class);
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY), kRegionMethods,
static_cast<int>(sizeof(kRegionMethods) / sizeof(kRegionMethods[0])),
kRegionDocs, static_cast<int>(sizeof(kRegionDocs) / sizeof(kRegionDocs[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
// ---- The CScriptRegion animation members ---------------------------------------------
void RegisterGroup(void *region, Group *group) {
State &state = g_states[region];
if (state.groups.empty() && !IsFrame(region))
++g_regionsWithGroups;
state.groups.push_back(group);
}
void NotifyBegin(void *region, Group *group) {
if (group == nullptr)
return;
State &state = g_states[region];
const AnimData *data = DataOf(region);
const bool alpha = !(data != nullptr && data->hasAlpha) && group->HasAlphaAnim();
const bool position = !(data != nullptr && data->hasPosition) && group->HasPositionAnim();
AnimActivated(region, alpha, position);
state.playing.push_back(group);
}
void NotifyEnd(void *region, Group *group) {
State *state = Find(region);
if (group == nullptr || state == nullptr)
return;
state->playing.erase(std::remove(state->playing.begin(), state->playing.end(), group), state->playing.end());
const AnimData *data = DataOf(region);
const bool alpha = data != nullptr && data->hasAlpha &&
!HasActiveAnim(region, [](const Group *g) { return g->HasAlphaAnim(); });
const bool position = data != nullptr && data->hasPosition &&
!HasActiveAnim(region, [](const Group *g) { return g->HasPositionAnim(); });
AnimDeactivated(region, alpha, position);
}
void PreOnAnimUpdate(void *region) {
const bool frame = IsFrame(region);
const AnimData *data = DataOf(region);
if (data != nullptr && data->hasAlpha) {
if (frame) {
if (data->alpha != OwnAlpha(region))
SetOwnAlpha(region, data->alpha);
} else {
SetVertexAlpha(region, data->alpha);
}
}
if (data != nullptr && data->hasPosition) {
if (IsTexture(region)) {
std::memcpy(Corners(region), data->position, sizeof(data->position));
} else if (IsFontString(region)) {
if (void *block = StringBlock(region))
SetStringPos(block, data->position);
// CSimpleFontString::PreOnAnimUpdate: back to the base rotation
// and scale.
AnimData *own = DataOf(region);
own->rotation = 0.0f;
own->scale = 1.0f;
g_touchedStrings.push_back(region);
}
}
if (frame)
ForEachChild(region, [](void *child) { PreOnAnimUpdate(child); });
}
void AddAnimTranslation(void *region, void *target, float x, float y) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimTranslation: the frame itself does not move;
// everything below it does, or only the target below it.
if (target == region)
target = nullptr;
ForEachChild(region, [target, x, y](void *child) { AddAnimTranslation(child, target, x, y); });
return;
}
if (target != nullptr && target != region)
return;
// Only a region whose base is stored: PreOnAnimUpdate puts it back before
// the next add, so nothing builds up.
const AnimData *data = DataOf(region);
if (data == nullptr || !data->hasPosition)
return;
if (IsTexture(region)) {
// ApplyTranslationAnim(m_positions, translation).
float *corners = Corners(region);
for (int i = 0; i < 4; ++i) {
corners[i * 3 + 0] += x;
corners[i * 3 + 1] += y;
}
} else if (void *block = StringBlock(region)) {
float position[3];
if (!GetStringPos(block, position))
return;
position[0] += x;
position[1] += y;
SetStringPos(block, position);
}
}
void AddAnimAlpha(void *region, void *target, int16_t alpha) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimAlpha: a target below the frame takes it.
if (target != nullptr && target != region) {
ForEachChild(region, [target, alpha](void *child) { AddAnimAlpha(child, target, alpha); });
return;
}
const uint8_t own = OwnAlpha(region);
const int value = alpha + static_cast<uint8_t>(own * DrawnParentAlpha(region) / 255);
const uint8_t newAlpha = static_cast<uint8_t>(value > 255 ? 255 : (value < 0 ? 0 : value));
if (newAlpha != own)
SetOwnAlpha(region, newAlpha);
return;
}
// CSimpleRegion::AddAnimAlpha.
if (target != nullptr && target != region)
return;
const int value = alpha + static_cast<int>(VertexColor(region) >> 24);
SetVertexAlpha(region, static_cast<uint8_t>(value > 255 ? 255 : (value < 0 ? 0 : value)));
}
namespace {
// The corners (TL, BL, TR, BR) as 2D points.
float CornerX(const float *corners, int i) { return corners[i * 3 + 0]; }
float CornerY(const float *corners, int i) { return corners[i * 3 + 1]; }
// GetOriginOffset (4.3.4 CSimpleRegion.cpp): the origin point on the drawn
// quad, then the offset along the quad's bottom edge direction, so the
// offset turns with a quad that is already turned.
void GetOriginOffset(int origin, const float *c, float offsetX, float offsetY, float &x, float &y) {
const auto mid = [c](int a, int b, float &mx, float &my) {
mx = (CornerX(c, b) - CornerX(c, a)) * 0.5f + CornerX(c, a);
my = (CornerY(c, b) - CornerY(c, a)) * 0.5f + CornerY(c, a);
};
switch (origin) {
case Offsets::FRAMEPOINT_TOPLEFT: x = CornerX(c, 0); y = CornerY(c, 0); break;
case Offsets::FRAMEPOINT_TOP: mid(0, 2, x, y); break;
case Offsets::FRAMEPOINT_TOPRIGHT: x = CornerX(c, 2); y = CornerY(c, 2); break;
case Offsets::FRAMEPOINT_LEFT: mid(0, 1, x, y); break;
case Offsets::FRAMEPOINT_CENTER: mid(0, 3, x, y); break;
case Offsets::FRAMEPOINT_RIGHT: mid(2, 3, x, y); break;
case Offsets::FRAMEPOINT_BOTTOMLEFT: x = CornerX(c, 1); y = CornerY(c, 1); break;
case Offsets::FRAMEPOINT_BOTTOM: mid(1, 3, x, y); break;
case Offsets::FRAMEPOINT_BOTTOMRIGHT: x = CornerX(c, 3); y = CornerY(c, 3); break;
default: x = 0.0f; y = 0.0f; break;
}
if (offsetX * offsetX + offsetY * offsetY > 0.00000011920929f) {
float dx = CornerX(c, 3) - CornerX(c, 1);
float dy = CornerY(c, 3) - CornerY(c, 1);
const float length = dy * dy + dx * dx;
if (length > 0.00000023841858f) {
const float scale = 1.0f / std::sqrt(length);
dx *= scale;
dy *= scale;
}
x = offsetX * dx - offsetY * dy + x;
y = offsetY * dx + offsetX * dy + y;
}
}
// The font string that takes a position animation, or nullptr.
AnimData *AnimatedString(void *region, void *target) {
if (target != nullptr && target != region)
return nullptr;
AnimData *data = DataOf(region);
if (data == nullptr || !data->hasPosition || !IsFontString(region))
return nullptr;
return data;
}
// The texture that takes a position animation (the target filter of the
// region overrides), or nullptr.
float *AnimatedCorners(void *region, void *target) {
if (target != nullptr && target != region)
return nullptr;
const AnimData *data = DataOf(region);
if (data == nullptr || !data->hasPosition || !IsTexture(region))
return nullptr;
return Corners(region);
}
} // namespace
void AddAnimScale(void *region, void *target, int origin, float offsetX, float offsetY, float x, float y) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimScale.
if (target == region)
target = nullptr;
ForEachChild(region, [=](void *child) { AddAnimScale(child, target, origin, offsetX, offsetY, x, y); });
return;
}
// CSimpleFontString::AddAnimScale: the text grows from its origin by the
// x scale; the last scale animation of the update wins.
if (AnimData *data = AnimatedString(region, target)) {
data->scale = 1.0f - x;
return;
}
// CSimpleTexture::AddAnimScale -> ApplyScaleAnim.
float *c = AnimatedCorners(region, target);
if (c == nullptr)
return;
float cx, cy;
GetOriginOffset(origin, c, offsetX, offsetY, cx, cy);
for (int i = 0; i < 4; ++i) {
c[i * 3 + 0] = (cx - c[i * 3 + 0]) * x + c[i * 3 + 0];
c[i * 3 + 1] = (cy - c[i * 3 + 1]) * y + c[i * 3 + 1];
}
}
void AddAnimRotation(void *region, void *target, int origin, float offsetX, float offsetY, float radians) {
if (IsFrame(region)) {
// CSimpleFrame::AddAnimRotation.
if (target == region)
target = nullptr;
ForEachChild(region, [=](void *child) { AddAnimRotation(child, target, origin, offsetX, offsetY, radians); });
return;
}
// CSimpleFontString::AddAnimRotation: the text turns about its origin;
// the last rotation animation of the update wins.
if (AnimData *data = AnimatedString(region, target)) {
data->rotation = radians;
return;
}
// CSimpleTexture::AddAnimRotation -> ApplyRotationAnim.
float *c = AnimatedCorners(region, target);
if (c == nullptr)
return;
float cx, cy;
GetOriginOffset(origin, c, offsetX, offsetY, cx, cy);
const float cs = std::cos(radians);
const float sn = std::sin(radians);
for (int i = 0; i < 4; ++i) {
const float x = c[i * 3 + 0] - cx;
const float y = c[i * 3 + 1] - cy;
c[i * 3 + 0] = x * cs - y * sn + cx;
c[i * 3 + 1] = x * sn + y * cs + cy;
}
}
void FontStringBaked(void *node, void *fontString) {
if (g_data.empty())
return;
AnimData *data = DataOf(fontString);
if (data == nullptr)
return;
if (data->rotation != 0.0f || data->scale != 1.0f)
Texture::Transform::ScaleRotateFontStringNode(node, data->scale, data->rotation);
data->bakedRotation = data->rotation;
data->bakedScale = data->scale;
}
float *TextureBaseCorners(void *texture) {
if (g_data.empty())
return nullptr;
AnimData *data = DataOf(texture);
return data != nullptr && data->hasPosition && IsTexture(texture) ? data->position : nullptr;
}
bool StringAnimated(void *fontString) {
if (g_data.empty())
return false;
const AnimData *data = DataOf(fontString);
return data != nullptr && (data->rotation != 0.0f || data->scale != 1.0f || data->bakedRotation != 0.0f ||
data->bakedScale != 1.0f);
}
void *FindRegionByName(const char *name) {
void *L = Game::Lua::State();
if (L == nullptr || name == nullptr)
return nullptr;
Game::Lua::PushString(L, name);
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
void *object = Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE ? Game::Lua::ResolveObject(L, -1) : nullptr;
Game::Lua::SetTop(L, -2);
if (object == nullptr || IsOurs(object) || !Game::Lua::IsA(object, Offsets::VAR_REGION_LUA_TYPE_ID))
return nullptr;
return object;
}
} // namespace Animation::Region
+62
View File
@@ -0,0 +1,62 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// The region side of the animation system: 4.3.4's CScriptRegion animation
// members (m_animGroups, m_playingAnimGroups, the CLayoutFrame animation
// data) and their virtuals on CSimpleFrame and CSimpleRegion, for 1.12's
// frames, textures and font strings, which have no room for them.
namespace Animation {
class Group;
}
namespace Animation::Region {
// CScriptRegion::RegisterAnimGroup - the region owns the group from now on.
void RegisterGroup(void *region, Group *group);
// CScriptRegion::NotifyAnimBegin / NotifyAnimEnd.
void NotifyBegin(void *region, Group *group);
void NotifyEnd(void *region, Group *group);
// CScriptRegion::PreOnAnimUpdate: put the animated state back to its base
// before the animations apply themselves for this update.
void PreOnAnimUpdate(void *region);
// CScriptRegion::AddAnimAlpha(target, alpha) on the group's region.
void AddAnimAlpha(void *region, void *target, int16_t alpha);
// CScriptRegion::AddAnimTranslation(target, translation), in layout units:
// moves the drawn textures and font strings, never a rect.
void AddAnimTranslation(void *region, void *target, float x, float y);
// CScriptRegion::AddAnimScale(target, origin, offset, scale): `x`/`y` are the
// share of the way to the origin (1 - scale at full progress).
void AddAnimScale(void *region, void *target, int origin, float offsetX, float offsetY, float x, float y);
// CScriptRegion::AddAnimRotation(target, origin, offset, radians),
// counter-clockwise.
void AddAnimRotation(void *region, void *target, int origin, float offsetX, float offsetY, float radians);
// A font string's glyphs were just baked (the draw builder): turn and scale
// them by its animation, and remember what they were baked with.
void FontStringBaked(void *node, void *fontString);
// The stored corners (TL BL TR BR {x, y, z}) of a texture a position group
// is playing on, or nullptr. Each update draws the animation on top of them,
// so a change to the texture's own shape (Texture::Transform) goes here, not
// to the drawn corners.
float *TextureBaseCorners(void *texture);
// Whether the font string's glyphs carry an animation's rotation or scale:
// then a change to its own rotation has to bake them again.
bool StringAnimated(void *fontString);
// CScriptObject::GetScriptObjectByName for a region (Animation:Play's
// target lookup): the region named `name`, or nullptr.
void *FindRegionByName(const char *name);
} // namespace Animation::Region
+378
View File
@@ -0,0 +1,378 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// 4.3.4's animation XML (CScriptRegion::LoadXML_Animations,
// CSimpleAnimGroup::LoadXML, CSimpleAnim::LoadXML and the subclasses',
// LoadXML_AnimOrigin; the attributes BinaryXML.cpp parses), on 1.12 nodes.
// A script body compiles through the engine's own SetScriptObjectScript, so
// it is named "<object>:<script>" and handled like a frame's.
#include "animation/Xml.h"
#include "animation/Classes.h"
#include "Game.h"
#include "Offsets.h"
#include "baselib/Ascii.h"
#include "xml/FrameLoad.h"
#include "xml/KeyValues.h"
#include "xml/Mixin.h"
#include <cstdio>
#include <cstdlib>
#include <string>
namespace Animation::Xml {
namespace {
using SetScriptText_t = bool(__fastcall *)(void *object, void *edx, const char *name, const char *text);
using StringToPoint_t = int(__fastcall *)(const char *name, int *point);
using LuaRef_t = int(__fastcall *)(void *L, int t);
const void *FindChild(const void *node, const char *tag) {
for (const void *c = ::Xml::FirstChild(node); c != nullptr; c = ::Xml::NextSibling(c))
if (::Xml::TagIs(c, tag))
return c;
return nullptr;
}
bool FloatAttr(const void *node, const char *name, float &out) {
const char *v = ::Xml::Attr(node, name);
if (v == nullptr || *v == '\0')
return false;
out = static_cast<float>(std::atof(v));
return true;
}
// object[parentKey] = child, as 4.3.4's LoadXML does for both.
void BindParentKey(void *object, Object *child, const char *key) {
if (key == nullptr || *key == '\0')
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
if (::Xml::PushWrapper(L, object)) {
Game::Lua::PushString(L, key);
Push(L, child);
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTop(L, top);
}
// <Scripts>: each element's body, its `function` global, or its `method`
// field on the object.
void LoadScripts(Object *object, const void *node) {
const void *scripts = FindChild(node, "Scripts");
if (scripts == nullptr)
return;
for (const void *s = ::Xml::FirstChild(scripts); s != nullptr; s = ::Xml::NextSibling(s)) {
const char *script = ::Xml::Tag(s);
if (script == nullptr)
continue;
const char *function = ::Xml::Attr(s, "function");
const char *method = ::Xml::Attr(s, "method");
const bool byFunction = function != nullptr && *function != '\0';
if (byFunction || (method != nullptr && *method != '\0')) {
uint32_t *slot = object->ScriptSlot(script);
void *L = Game::Lua::State();
if (slot == nullptr || L == nullptr)
continue;
if (byFunction) {
Game::Lua::PushString(L, function);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
} else {
Push(L, object);
Game::Lua::PushString(L, method);
Game::Lua::GetTable(L, -2);
Game::Lua::Remove(L, -2);
}
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, -2);
continue;
}
reinterpret_cast<void(__stdcall *)(uint32_t *)>(Offsets::FUN_SCRIPT_RELEASE)(slot);
slot[0] = static_cast<uint32_t>(reinterpret_cast<LuaRef_t>(Offsets::LUA_REF_REF)(L, Game::Lua::REGISTRY_INDEX));
slot[1] = 0;
continue;
}
const char *body = Game::Read<const char *>(s, Offsets::OFF_XML_NODE_TEXT);
if (body != nullptr && *body != '\0')
reinterpret_cast<SetScriptText_t>(Offsets::FUN_SCRIPT_OBJECT_SET_SCRIPT_TEXT)(object, nullptr, script,
body);
}
}
// <KeyValues> on a group or an animation, as on later clients' schema.
void LoadKeyValues(Object *object, const void *node, void *status) {
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
Push(L, object);
::Xml::KeyValues::Apply(L, top + 1, node, status, object->DisplayName());
Game::Lua::SetTop(L, top);
}
// LoadXML_AnimOrigin: <Origin point="..."><Offset x= y=/></Origin>.
void LoadOrigin(Anim *anim, const void *node) {
const void *origin = FindChild(node, "Origin");
if (origin == nullptr)
return;
int point = Offsets::FRAMEPOINT_CENTER;
float x = 0.0f, y = 0.0f;
const char *pointName = ::Xml::Attr(origin, "point");
if (pointName != nullptr &&
reinterpret_cast<StringToPoint_t>(Offsets::FUN_STRING_TO_FRAME_POINT)(pointName, &point)) {
if (const void *offset = FindChild(origin, "Offset")) {
const void *abs = FindChild(offset, "AbsDimension");
const void *dim = abs != nullptr ? abs : offset;
FloatAttr(dim, "x", x);
FloatAttr(dim, "y", y);
}
} else {
point = Offsets::FRAMEPOINT_CENTER;
}
anim->originPoint = point;
anim->originX = Game::PixelToInternal(x);
anim->originY = Game::PixelToInternal(y);
}
void LoadAnimChildren(Group *group, const void *node, void *status) {
for (const void *child = ::Xml::FirstChild(node); child != nullptr; child = ::Xml::NextSibling(child)) {
const char *tag = ::Xml::Tag(child);
if (tag == nullptr || ::Xml::TagIs(child, "Scripts"))
continue;
// 4.3.4 knows Animation, Translation, Rotation, Scale, Path and Alpha.
if (!::Xml::TagIs(child, "Animation") && !::Xml::TagIs(child, "Translation") &&
!::Xml::TagIs(child, "Rotation") && !::Xml::TagIs(child, "Scale") && !::Xml::TagIs(child, "Alpha") &&
!::Xml::TagIs(child, "Path"))
continue;
Anim *anim = new Anim(group, Anim::KindOf(tag), ::Xml::Attr(child, "name"));
anim->flags |= 1;
LoadAnim(anim, child, status);
}
}
} // namespace
const void *Template(const char *name) { return ::Xml::Template(name); }
namespace {
// LoadGroup re-enters itself for each inherited template; the mixins of the
// whole chain go in once, from the outermost call, before any node's
// KeyValues or scripts (see Xml::Mixin).
int g_groupDepth = 0;
void LoadGroupNode(Group *group, const void *node, void *status);
} // namespace
void LoadGroup(Group *group, const void *node, void *status) {
if (g_groupDepth == 0) {
if (void *L = Game::Lua::State()) {
const int top = Game::Lua::GetTop(L);
Push(L, group);
::Xml::Mixin::ApplyChain(L, top + 1, node, status, group->DisplayName(), false);
Game::Lua::SetTop(L, top);
}
}
++g_groupDepth;
LoadGroupNode(group, node, status);
--g_groupDepth;
}
namespace {
void LoadGroupNode(Group *group, const void *node, void *status) {
group->flags |= GROUP_LOADING;
if (const char *inherits = ::Xml::Attr(node, "inherits"); inherits != nullptr && *inherits != '\0') {
const void *base = ::Xml::Template(inherits);
if (base == nullptr)
::Xml::Warn(status, "Couldn't find inherited node: %s", inherits);
else
LoadGroupNode(group, base, status);
}
BindParentKey(group->RegionObject(), group, ::Xml::Attr(node, "parentKey"));
if (const char *looping = ::Xml::Attr(node, "looping"); looping != nullptr && *looping != '\0') {
LoopType type;
if (StringToLoopType(looping, type))
group->loopType = type;
else
::Xml::Warn(status, "AnimationGroup %s: Invalid looping value: %s", group->DisplayName(), looping);
}
float x = 0.0f, y = 0.0f;
FloatAttr(node, "initialOffsetX", x);
FloatAttr(node, "initialOffsetY", y);
x = Game::PixelToInternal(x);
y = Game::PixelToInternal(y);
if (x * x + y * y <= 0.00000023841858f) {
group->initialOffsetX = 0.0f;
group->initialOffsetY = 0.0f;
} else {
group->initialOffsetX = x;
group->initialOffsetY = y;
}
LoadScripts(group, node);
LoadKeyValues(group, node, status);
LoadAnimChildren(group, node, status);
}
} // namespace
void LoadAnim(Anim *anim, const void *node, void *status) {
// The subclass reads its values, then CSimpleAnim::LoadXML (inherits
// first, then the shared attributes) - the order 4.3.4's overrides use.
float x = 0.0f, y = 0.0f;
switch (anim->kind) {
case KIND_ALPHA:
break; // CSimpleAlphaAnim::LoadXML reads `change` after the base.
case KIND_TRANSLATION: {
// CSimpleTranslationAnim::LoadXML sets the offset, 0 for a missing axis.
FloatAttr(node, "offsetX", x);
FloatAttr(node, "offsetY", y);
const float ox = Game::PixelToInternal(x), oy = Game::PixelToInternal(y);
const bool zero = ox * ox + oy * oy <= 0.00000023841858f;
anim->offsetX = zero ? 0.0f : ox;
anim->offsetY = zero ? 0.0f : oy;
break;
}
case KIND_ROTATION:
if (FloatAttr(node, "degrees", x))
anim->radians = x * 0.017453292f;
if (FloatAttr(node, "radians", y))
anim->radians = y;
break;
case KIND_SCALE: {
float sx = 1.0f, sy = 1.0f;
if (FloatAttr(node, "scaleX", sx) && sx < 0.001f)
::Xml::Warn(status, "%s: Invalid scaleX value: %s. Value must be at least 0.001.", anim->DisplayName(),
::Xml::Attr(node, "scaleX"));
if (FloatAttr(node, "scaleY", sy) && sy < 0.001f)
::Xml::Warn(status, "%s: Invalid scaleY value: %s. Value must be at least 0.001.", anim->DisplayName(),
::Xml::Attr(node, "scaleY"));
anim->scaleChangeX = 1.0f - (sx >= 0.001f ? sx : 0.001f);
anim->scaleChangeY = 1.0f - (sy >= 0.001f ? sy : 0.001f);
break;
}
default:
break;
}
// CSimpleAnim::LoadXML.
anim->flags |= 1;
if (const char *inherits = ::Xml::Attr(node, "inherits"); inherits != nullptr && *inherits != '\0') {
const void *base = ::Xml::Template(inherits);
if (base == nullptr)
::Xml::Warn(status, "Couldn't find inherited node: %s", inherits);
else
LoadAnim(anim, base, status);
}
BindParentKey(anim->GetGroup(), anim, ::Xml::Attr(node, "parentKey"));
if (const char *target = ::Xml::Attr(node, "target"); target != nullptr && *target != '\0') {
const std::string name = CreateName(target, anim);
if (name.size() + 1 > sizeof(anim->targetName))
::Xml::Warn(status, "target key too long: %s", name.c_str());
std::snprintf(anim->targetName, sizeof(anim->targetName), "%s", name.c_str());
}
if (FloatAttr(node, "startDelay", x))
anim->startDelay = x < 0.0f ? 0.0f : x;
if (FloatAttr(node, "endDelay", x))
anim->endDelay = x < 0.0f ? 0.0f : x;
if (FloatAttr(node, "duration", x))
anim->duration = x;
if (const char *smoothing = ::Xml::Attr(node, "smoothing"); smoothing != nullptr && *smoothing != '\0') {
float in, out;
if (StringToSmoothWeights(smoothing, in, out))
anim->SetSmoothing(in, out);
else
::Xml::Warn(status, "%s: Invalid smoothing value: %s", anim->DisplayName(), smoothing);
}
if (const char *order = ::Xml::Attr(node, "order"); order != nullptr && *order != '\0') {
const int value = std::atoi(order) - 1;
if (value < 0 || value > 99)
::Xml::Warn(status, "%s: Invalid order value: %s. Order must be between 1 and 100.", anim->DisplayName(),
order);
anim->order = value < 0 ? 0 : (value > 99 ? 99 : value);
}
LoadScripts(anim, node);
LoadKeyValues(anim, node, status);
// The subclasses' reads after the base.
// CSimpleAlphaAnim::LoadXML sets the change, 0 when it is missing.
if (anim->kind == KIND_ALPHA) {
x = 0.0f;
if (FloatAttr(node, "change", x) && (x < -1.0f || x > 1.0f)) {
::Xml::Warn(status, "%s: Invalid change value: %s. Value must be between -1 and 1, inclusive.",
anim->DisplayName(), ::Xml::Attr(node, "change"));
x = 0.0f;
}
anim->change = static_cast<int16_t>((x < -1.0f ? -1.0f : (x >= 1.0f ? 1.0f : x)) * 255.0f);
}
if (anim->kind == KIND_SCALE || anim->kind == KIND_ROTATION)
LoadOrigin(anim, node);
// CSimplePathAnim::LoadXML: the curve, then <ControlPoints>.
if (anim->kind == KIND_PATH) {
if (const char *curve = ::Xml::Attr(node, "curve"); curve != nullptr && *curve != '\0') {
if (Ascii::EqualCI(curve, "NONE"))
anim->curve = false;
else if (Ascii::EqualCI(curve, "SMOOTH"))
anim->curve = true;
else
::Xml::Warn(status, "Path %s: Invalid curve value: %s", anim->DisplayName(), curve);
}
if (const void *points = FindChild(node, "ControlPoints")) {
for (const void *child = ::Xml::FirstChild(points); child != nullptr; child = ::Xml::NextSibling(child)) {
if (!::Xml::TagIs(child, "ControlPoint")) {
::Xml::Warn(status, "Path %s: Error loading Origin element", anim->DisplayName());
continue;
}
LoadControlPoint(new ControlPoint(anim, ::Xml::Attr(child, "name")), child, status);
}
}
}
}
void LoadControlPoint(ControlPoint *point, const void *node, void *status) {
// CSimpleControlPoint::LoadXML: inherits first, then parentKey (on the
// path), then the offset - 0 on an axis the node does not give.
if (const char *inherits = ::Xml::Attr(node, "inherits"); inherits != nullptr && *inherits != '\0') {
const void *base = ::Xml::Template(inherits);
if (base == nullptr)
::Xml::Warn(status, "Couldn't find inherited node: %s", inherits);
else
LoadControlPoint(point, base, status);
}
BindParentKey(point->GetPath(), point, ::Xml::Attr(node, "parentKey"));
float x = 0.0f, y = 0.0f;
FloatAttr(node, "offsetX", x);
FloatAttr(node, "offsetY", y);
point->SetOffset(Game::PixelToInternal(x), Game::PixelToInternal(y));
}
void LoadAnimations(void *region, const void *node, void *status) {
const void *animations = FindChild(node, "Animations");
if (animations == nullptr)
return;
for (const void *g = ::Xml::FirstChild(animations); g != nullptr; g = ::Xml::NextSibling(g)) {
if (!::Xml::TagIs(g, "AnimationGroup"))
continue;
Group *group = new Group(region, ::Xml::Attr(g, "name"));
LoadGroup(group, g, status);
group->PostLoadXML();
}
}
} // namespace Animation::Xml
+42
View File
@@ -0,0 +1,42 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// <Animations>, <AnimationGroup> and the animation elements: 4.3.4's
// CScriptRegion::LoadXML_AnimGroup, CSimpleAnimGroup::LoadXML and
// CSimpleAnim::LoadXML (and the subclasses'), on 1.12's XML nodes. A
// virtual top-level <AnimationGroup> or animation element is a template:
// 1.12's file loader registers every virtual named top-level node
// (RegisterXMLTemplate), whatever its tag, so `inherits` resolves through
// Xml::Template as 4.3.4's XMLHashNodeRef does.
namespace Animation {
class Group;
class Anim;
class ControlPoint;
} // namespace Animation
namespace Animation::Xml {
// The <Animations> child of a region's node, if any: each <AnimationGroup>
// becomes a group of `region`, loaded and post-loaded (OnLoad runs).
void LoadAnimations(void *region, const void *node, void *status);
// CSimpleAnimGroup::LoadXML / CSimpleAnim::LoadXML of one node, its
// `inherits` first.
void LoadGroup(Group *group, const void *node, void *status);
void LoadAnim(Anim *anim, const void *node, void *status);
void LoadControlPoint(ControlPoint *point, const void *node, void *status);
// The template node `name` resolves to, or nullptr.
const void *Template(const char *name);
} // namespace Animation::Xml
+13
View File
@@ -162,14 +162,21 @@ const WidgetSystem kWidgetSystems[] = {
{Offsets::VAR_FRAME_METHOD_REGISTRY, "SimpleFrameAPI"},
{Offsets::VAR_TEXTURE_METHOD_REGISTRY, "SimpleTextureAPI"},
{Offsets::VAR_FONTSTRING_METHOD_REGISTRY, "SimpleFontStringAPI"},
{Offsets::VAR_FONT_METHOD_REGISTRY, "SimpleFontAPI"},
{Offsets::VAR_EDITBOX_METHOD_REGISTRY, "SimpleEditBoxAPI"},
{Offsets::VAR_MODEL_METHOD_REGISTRY, "SimpleModelAPI"},
};
// Hashes of the frame classes this DLL adds (NameWidgetSystem).
std::vector<WidgetSystem> g_ownWidgetSystems;
const char *WidgetSystemFor(const void *registry) {
for (const WidgetSystem &w : kWidgetSystems)
if (reinterpret_cast<const void *>(w.registry) == registry)
return w.system;
for (const WidgetSystem &w : g_ownWidgetSystems)
if (reinterpret_cast<const void *>(w.registry) == registry)
return w.system;
return nullptr;
}
@@ -658,6 +665,12 @@ void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry
}
}
void NameWidgetSystem(const void *registry, const char *system) {
if (WidgetSystemFor(registry) != nullptr)
return;
g_ownWidgetSystems.push_back({reinterpret_cast<uintptr_t>(registry), system});
}
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
if (g_pass == ENV_NONE || sub == nullptr)
+3
View File
@@ -34,6 +34,9 @@ void RecordGlobal(const char *name, const Game::Doc::Function *doc);
void RecordTable(const char *table, const char *name, const Game::Doc::Function *doc);
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Game::Doc::Method *docs, int docCount);
// Names the widget system of a method hash this DLL owns (a frame class it
// adds), alongside the engine registries the exporter already knows.
void NameWidgetSystem(const void *registry, const char *system);
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system);
+32 -18
View File
@@ -19,20 +19,22 @@
//
// Filter parsing (`ParseFilters`) tokenizes the modern AuraFilters string:
// tokens separated by `|` and/or whitespace, each optionally negated with a
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
// leading `!`, compared ignoring case as 4.3.4's parser does. Whole-token
// matching, so `RAID_PLAYER_DISPELLABLE` is not mistaken for `PLAYER` and
// `!PLAYER` is a negation rather than a match.
// Honored: `HELPFUL` / `HARMFUL` (the aura's real polarity, read however this
// server encodes it — see `Aura::Data::IsSlotHarmful`), `PLAYER` /
// `!PLAYER` (caster is / isn't the local player or their pet, from the
// Aura::Source cache),
// `!PLAYER` (caster is / isn't the local player or a unit they own, from the
// Aura::Source cache), `RAID` (the engine's raid filter — see
// `Aura::Data::IsRaidAura`), `CANCELABLE` / `NOT_CANCELABLE` (the descriptor's
// cancelable bit),
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
// Poison, matching the server's DISPEL_ALL_MASK), and `CROWD_CONTROL` /
// `!CROWD_CONTROL` (is / isn't a hard control effect, via the shared
// `Spell::CrowdControl` classifier that also backs C_LossOfControl). Every
// other modern token (`RAID`, `CANCELABLE`, `INCLUDE_NAME_PLATE_ONLY`, `MAW`,
// …) is accepted and ignored — they need a class-dispel matrix or systems
// vanilla has no data for.
// other modern token (`INCLUDE_NAME_PLATE_ONLY`, `MAW`, …) is accepted and
// ignored — vanilla has no data for them.
#include "Data.h"
@@ -75,8 +77,10 @@ struct ParsedFilter {
Data::CasterMode caster = Data::CasterMode::Any;
Data::DispelMode dispel = Data::DispelMode::Any;
Data::CcMode cc = Data::CcMode::Any;
Data::CancelMode cancel = Data::CancelMode::Any;
bool raid = false;
Data::Match ToMatch() const { return Data::Match{caster, dispel, cc}; }
Data::Match ToMatch() const { return Data::Match{caster, dispel, cc, cancel, raid}; }
};
// Reduces the parsed range tokens to the single `Data::Filter` the indexed /
@@ -88,8 +92,8 @@ Data::Filter RangeFilter(const ParsedFilter &pf) {
: Data::Filter::Helpful;
}
// Tokenizes the filter string. Case-sensitive (modern documents the tokens as
// upper-case constants). `HELPFUL`/`HARMFUL` are positive range selectors;
// Tokenizes the filter string, ignoring case (4.3.4's Script_GetAuraInfo
// compares tokens with SStrCmpI). `HELPFUL`/`HARMFUL` are positive range selectors;
// negating them is meaningless for our slot-based ranges, so a leading `!` is
// ignored on those. `PLAYER` honors negation (`!PLAYER` → NotPlayer). Any
// unrecognized token is accepted and skipped.
@@ -116,17 +120,25 @@ ParsedFilter ParseFilters(const char *filter) {
}
tok[n] = '\0';
if (strcmp(tok, "HELPFUL") == 0)
if (_stricmp(tok, "HELPFUL") == 0)
out.helpful = true;
else if (strcmp(tok, "HARMFUL") == 0)
else if (_stricmp(tok, "HARMFUL") == 0)
out.harmful = true;
else if (strcmp(tok, "PLAYER") == 0)
else if (_stricmp(tok, "PLAYER") == 0)
out.caster = negate ? Data::CasterMode::NotPlayer
: Data::CasterMode::PlayerOnly;
else if (strcmp(tok, "DISPELLABLE") == 0)
else if (_stricmp(tok, "RAID") == 0)
out.raid = !negate;
else if (_stricmp(tok, "CANCELABLE") == 0)
out.cancel = negate ? Data::CancelMode::NotCancelable
: Data::CancelMode::CancelableOnly;
else if (_stricmp(tok, "NOT_CANCELABLE") == 0)
out.cancel = negate ? Data::CancelMode::CancelableOnly
: Data::CancelMode::NotCancelable;
else if (_stricmp(tok, "DISPELLABLE") == 0)
out.dispel = negate ? Data::DispelMode::NotDispellable
: Data::DispelMode::DispellableOnly;
else if (strcmp(tok, "CROWD_CONTROL") == 0)
else if (_stricmp(tok, "CROWD_CONTROL") == 0)
out.cc = negate ? Data::CcMode::NotCrowdControl
: Data::CcMode::CrowdControlOnly;
// else: accepted and ignored (retail-only / unimplemented token).
@@ -474,14 +486,16 @@ int __fastcall Script_UnitAuraBySlot(void *L) {
return PushAuraBySlot(L, Data::Emit::Positional);
}
// Iterates the auras of one polarity (in `SlotInFilterOrder`, the same order
// the by-index getters use) and pushes AuraData tables into `outer` at
// Iterates the auras of one polarity (in `WalkOrder`, the same order the
// by-index getters use) and pushes AuraData tables into `outer` at
// sequential keys starting from `nextKey`. Updates `nextKey` so a follow-up
// call can append to the same outer table.
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
Data::Filter filter, int &nextKey, Data::Match match) {
int order[Offsets::UNIT_AURA_TOTAL];
Data::WalkOrder(unit, filter, order);
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
const int slot = Data::SlotInFilterOrder(filter, i);
const int slot = order[i];
if (!Data::SlotMatchesFilter(unit, slot, filter, match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
+4 -2
View File
@@ -227,8 +227,10 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
Offsets::VAR_SPELL_DURATION_COUNT,
static_cast<uint32_t>(idx));
if (row != nullptr) {
baseMs = *reinterpret_cast<const int32_t *>(row + 0x4);
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
baseMs = *reinterpret_cast<const int32_t *>(
row + Offsets::OFF_SPELLDURATION_BASE_MS);
maxMs = *reinterpret_cast<const int32_t *>(
row + Offsets::OFF_SPELLDURATION_MAX_MS);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Defer to a registered resolver (the known values are
+463 -179
View File
File diff suppressed because it is too large Load Diff
+60 -29
View File
@@ -63,15 +63,20 @@ enum class Filter { Helpful, Harmful };
enum class Emit { Table, Positional };
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
// `Any` = no restriction; `PlayerOnly` = only auras the local player OR the
// player's pet cast (`PLAYER`); `NotPlayer` = the complement (`!PLAYER`).
// The pet is part of the set by definition — modern documents the token as
// "cast by the player, or by the player's pet or vehicle". Caster
// attribution comes from the `Aura::Source` cache; a cache miss counts as
// "not the player" (so `PlayerOnly` excludes it and `NotPlayer` includes
// it — matching `IsPlayerCast`'s miss semantics).
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast,
// or a unit the player owns cast — pet, guardian, totem, charmed unit
// (`PLAYER`); `NotPlayer` = the complement (`!PLAYER`). This is 4.3.4's
// `CGSpellBook::IsAuraCaster`. Caster attribution comes from the
// `Aura::Source` cache; a cache miss counts as "not the player" (so
// `PlayerOnly` excludes it and `NotPlayer` includes it — matching
// `IsPlayerCast`'s miss semantics).
enum class CasterMode { Any, PlayerOnly, NotPlayer };
// Restriction from the `CANCELABLE` / `NOT_CANCELABLE` aura filter tokens:
// the descriptor nibble's cancelable bit, the test `GetPlayerBuff`'s own
// filter makes (and 4.3.4's GetSortedAuraSlot).
enum class CancelMode { Any, CancelableOnly, NotCancelable };
// Restriction from the `DISPELLABLE` / `!DISPELLABLE` aura filter tokens.
// `Any` = no restriction; `DispellableOnly` = only auras that can be
// dispelled / purged / stolen by SOME mechanism (dispel type Magic, Curse,
@@ -93,6 +98,10 @@ struct Match {
CasterMode caster = CasterMode::Any;
DispelMode dispel = DispelMode::Any;
CcMode cc = CcMode::Any;
CancelMode cancel = CancelMode::Any;
// `RAID`: a helpful aura the player can cast on others, a harmful one the
// player can dispel — the engine's raid filter (see `IsRaidAura`).
bool raid = false;
};
// Reads the spell ID at the given absolute slot (0..47). Returns 0
@@ -119,11 +128,18 @@ bool IsSlotHarmful(const uint8_t *unit, int slot);
// the index the engine's tooltip methods expect for a given slot.
bool IsSlotTooltipVisible(const uint8_t *unit, int slot);
// The i-th slot (0..UNIT_AURA_TOTAL-1) in the order a `filter` walk visits the
// descriptor: the polarity's home range first (helpful 0..31, harmful 32..47),
// then the other range, where spilled debuffs live. Every enumeration that
// hands out indices or slot lists uses this so they agree on order.
int SlotInFilterOrder(Filter filter, int i);
// Fills `out` with all UNIT_AURA_TOTAL absolute slots of `unit` in the order a
// `filter` walk visits them, the order 4.3.4's sorted aura list gives
// (`CGUnit_C::InsertSortedAura`):
// 1. auras the player, or a unit the player owns, cast (`IsPlayerCast`);
// 2. auras the player could apply (`canApplyAura`);
// 3. the rest.
// Within each group, the polarity's home range comes first (helpful 0..31,
// harmful 32..47), then the other range, where spilled debuffs live. Every
// enumeration that hands out indices or slot lists uses this so they agree on
// order. The groups are cached per unit and rebuilt when its aura array changes
// or a new frame starts, so a by-index loop pays for them once.
void WalkOrder(const uint8_t *unit, Filter filter, int *out);
// `SlotMatches` plus the polarity test: populated, of `filter`'s polarity, and
// passing `match`.
@@ -132,7 +148,7 @@ bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
// Finds the absolute slot of the `oneBasedIndex`-th populated aura of
// `filter`'s polarity that passes `match`. Returns -1 if no such aura. Walks
// all 48 slots in `SlotInFilterOrder`.
// all 48 slots in `WalkOrder`.
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
Match match = {});
@@ -144,32 +160,41 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
const Filter *filter, Match match = {});
// Finds the absolute slot of a populated aura whose locale-resolved
// spell name exactly matches `spellName`. Case-sensitive. Otherwise
// spell name matches `spellName`, ignoring case (Storm's SStrCmpI, as
// 4.3.4's GetSortedAuraSlot compares names). Otherwise
// same semantics as `FindSlotBySpellID`. Returns -1 if not found
// or if `spellName` is null/empty.
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
const Filter *filter, Match match = {});
// True iff the aura at `slot` was cast by the local player or by the
// player's pet, per the `Aura::Source` cache. False on a cache miss (caster
// unknown) — so a PLAYER-filtered query excludes auras whose cast we didn't
// observe.
// True iff the aura at `slot` was cast by the local player or by a unit the
// player owns (see `CasterMode`), per the `Aura::Source` cache. False on a
// cache miss (caster unknown) — so a PLAYER-filtered query excludes auras
// whose cast we didn't observe.
bool IsPlayerCast(const uint8_t *unit, int slot);
// Applies a `CasterMode` to a per-aura "was cast by the player or their pet"
// answer. `Any` → always true; `PlayerOnly` → the answer; `NotPlayer` → its
// negation.
// Applies a `CasterMode` to a per-aura "was cast by the player or a unit
// they own" answer. `Any` → always true; `PlayerOnly` → the answer;
// `NotPlayer` → its negation.
bool CasterMatches(CasterMode caster, bool isPlayerCast);
// The engine's raid filter — UnitBuff/UnitDebuff's third argument, and
// 4.3.4's `RAID` token: a helpful aura passes when the player can cast its
// spell on others (`FUN_SPELL_IS_CASTABLE_AURA`), a harmful one when its
// dispel type is in the player's dispel list (`FUN_DISPEL_TYPE_KNOWN`).
bool IsRaidAura(uint32_t spellID, bool harmful);
// True iff the spell's dispel type is one a dispel/purge/steal can remove —
// Magic, Curse, Disease, or Poison (`Spell.dbc` Dispel ∈ 1..4). None,
// Stealth, Invisibility, Enrage, etc. are not dispellable. Verified against
// the server's DISPEL_ALL_MASK.
bool IsDispellable(uint32_t spellID);
// Applies a full `Match` (caster + dispel predicates) to one aura, given the
// per-aura "was cast by the local player" answer and its spell ID.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
// Applies a full `Match` to one aura, given the per-aura "was cast by the
// local player" answer, its spell ID, its polarity, and whether it is
// cancelable.
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID,
bool harmful, bool cancelable);
// True iff descriptor `slot` on `unit` is populated (per `IsSlotPopulated`) AND
// passes `match`. The caster answer (`IsPlayerCast` — a scan of the
@@ -207,13 +232,19 @@ const char *DispelName(uint32_t dispelTypeID);
// sourceGUID (caster "0x..." GUID string; nil on miss — a ClassicAPI
// extension, not in retail AuraData)
//
// And these from the spell and the player's spells:
// isFromPlayerOrPlayerPet (caster GUID is a player or a pet),
// canApplyAura (the player or pet knows the spell and it applies an aura),
// isRaid (the aura passes the RAID filter, `IsRaidAura`),
// isStealable (4.3.4's CanStealAura: a buff on an attackable unit whose
// dispel type a spell the player or pet knows removes from
// an enemy)
//
// And these with vanilla-truthful defaults (matches modern's
// "field present but inapplicable" semantics):
// charges=0, maxCharges=0, auraInstanceID=nil, points=nil,
// isStealable=false, isBossAura=false, isFromPlayerOrPlayerPet=false,
// isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, canApplyAura=false,
// shouldConsolidate=false, isRaid=false
// isBossAura=false, isNameplateOnly=false, nameplateShowAll=false,
// nameplateShowPersonal=false, shouldConsolidate=false
//
// Net stack effect: +1 for `Emit::Table` (the table), +15 for `Emit::Positional`
// (the Classic-Era `UnitAura` tuple — see `Emit`).
@@ -273,7 +304,7 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, Match match = {});
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
// (ignoring case), mirroring `FindSlotBySpellName`.
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, Match match = {});
+88 -30
View File
@@ -24,6 +24,7 @@
#include "object/Resolve.h"
#include "player/StatSignal.h"
#include "spell/CastEvents.h"
#include "spell/Known.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
@@ -61,11 +62,11 @@ using Time::Clock::NowMs;
// Server-authoritative duration (ms). When the local player is the caster
// we let the engine fold in the player's duration modifiers (skipMod = 0);
// for any other caster we take the unmodified base (skipMod = 1) since we
// only have the local player's mod tables. `unit = 0` selects the player
// mod context per FUN_GET_SPELL_DURATION's signature.
// only have the local player's mod tables. `pet = 0` selects the player
// as the caster per FUN_GET_SPELL_DURATION's signature.
uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord,
int unit, char skipMod);
int pet, char skipMod);
const int ms = reinterpret_cast<GetDuration_t>(
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_DURATION))(
spellRecord, 0, casterIsPlayer ? 0 : 1);
@@ -325,7 +326,7 @@ Entry *Register(Entry *e) {
Entry *Claim(uint32_t now) {
for (auto &e : g_cache) {
if (!e.used ||
(e.expirationMs != 0 && now >= e.expirationMs &&
(e.expirationMs != 0 && Time::Clock::Reached(now, e.expirationMs) &&
!DescriptorListsAura(e.targetGuid, e.spellId)))
return Register(&e);
}
@@ -660,7 +661,8 @@ void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
break;
case MOD_REDUCE:
if (e.expirationMs != 0) {
if (e.expirationMs > now + static_cast<uint32_t>(valueMs)) {
if (Time::Clock::Remaining(now, e.expirationMs) >
static_cast<uint32_t>(valueMs)) {
e.expirationMs -= static_cast<uint32_t>(valueMs);
SignalAuraChanged(e.targetGuid);
} else {
@@ -1262,23 +1264,6 @@ constexpr int kTrigAppMax = 32;
TriggeredApplication g_trigApps[kTrigAppMax];
int g_trigAppCount = 0;
// Talent gate: does the player currently know `spellID`? The engine's
// spell-knowledge bitmap (same store IsPlayerSpell reads — covers talent
// passives), checked live at trigger time so respecs are honored.
bool PlayerKnowsSpell(uint32_t spellID) {
if (spellID == 0)
return false;
auto *bitmap = Game::Read<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const uint32_t spellCount = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
}
// Armed rule instance awaiting its application on `victim`. Caster is
// implicitly the local player (only player triggers arm).
struct TrigAppArm {
@@ -1354,7 +1339,7 @@ void ArmTriggeredApplications(uint64_t victim, uint32_t triggerSpellId,
: !Spell::Lookup::IsFitToFamily(triggerRec, r.triggerFamily,
r.triggerMask))
continue;
if (r.gateSpellId != 0 && !PlayerKnowsSpell(r.gateSpellId))
if (r.gateSpellId != 0 && !Spell::Known::IsKnown(r.gateSpellId))
continue;
const uint32_t now = NowMs();
RefreshTriggeredExisting(victim, r, now);
@@ -1706,6 +1691,30 @@ void NotifyIfPlayer(void *unit) {
Player::StatSignal::Notify();
}
const AutoObserve *g_observers = nullptr;
void NotifyObservers(const Change &change) {
for (const AutoObserve *o = g_observers; o != nullptr; o = o->next)
o->callback(change);
}
// The change for the aura in `slot`, with the caster and polarity of the
// instance this cache binds to it. A removed aura's slot already holds the
// next aura's flags, so its polarity is the one recorded when it landed.
Change MakeChange(ChangeType type, void *unit, int slot, uint32_t spellId) {
Change c{type, unit, slot, spellId, 0, false, 0, 0};
const Entry *e = FindInstance(Unit::Identity::GuidForObject(unit), spellId, slot);
if (e != nullptr)
c.caster = e->casterGuid;
if (type != ChangeType::Removed)
c.harmful = KindForSlot(unit, slot) == KIND_HARMFUL;
else if (e != nullptr && e->kind != KIND_UNKNOWN)
c.harmful = e->kind == KIND_HARMFUL;
else
c.harmful = slot >= Offsets::UNIT_AURA_BUFF_COUNT;
return c;
}
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
@@ -1716,6 +1725,7 @@ void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId, static_cast<int>(slot));
NotifyIfPlayer(unit);
NotifyObservers(MakeChange(ChangeType::Added, unit, static_cast<int>(slot), spellId));
}
const Game::HookAutoRegister _hookAuraAdded{
@@ -1725,17 +1735,24 @@ const Game::HookAutoRegister _hookAuraAdded{
// OnAuraStacksChanged — an existing aura's stack count changed (e.g. Shadow
// Weaving climbing). Only the slot is given, so read the spellID back from
// the unit's aura array. Re-stamps expiration so stacking refreshes count.
// The byte argument is the slot's OLD applications byte: the engine compares
// it with the descriptor's new one and logs a stack line when the new is
// higher (0x00612450).
using OnAuraStacksChanged_t = void(__fastcall *)(void *unit, void *edx,
int slot, uint8_t stackCount);
int slot, uint8_t oldApplications);
OnAuraStacksChanged_t g_origOnAuraStacksChanged = nullptr;
void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
uint8_t stackCount) {
g_origOnAuraStacksChanged(unit, edx, slot, stackCount);
StampApplication(
unit,
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot), slot);
uint8_t oldApplications) {
g_origOnAuraStacksChanged(unit, edx, slot, oldApplications);
const auto *u = static_cast<const uint8_t *>(unit);
const uint32_t spellId = Aura::Data::ReadSpellID(u, slot);
StampApplication(unit, spellId, slot);
NotifyIfPlayer(unit);
Change c = MakeChange(ChangeType::Stacks, unit, slot, spellId);
c.oldStacks = static_cast<int>(oldApplications) + 1;
c.newStacks = Aura::Data::ReadStacks(u, slot);
NotifyObservers(c);
}
const Game::HookAutoRegister _hookAuraStacks{
@@ -1763,6 +1780,7 @@ OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraRemoved(unit, edx, slot, spellId);
NotifyObservers(MakeChange(ChangeType::Removed, unit, static_cast<int>(slot), spellId));
Evict(Unit::Identity::GuidForObject(unit), spellId, static_cast<int>(slot));
NotifyIfPlayer(unit);
}
@@ -1790,6 +1808,42 @@ bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
return true;
}
void CastersForSlots(uint64_t unitGuid, const uint32_t *slotSpellIds,
uint64_t *outCaster) {
constexpr int kSlots = Offsets::UNIT_AURA_TOTAL;
const Entry *seated[kSlots] = {};
const Entry *sole[kSlots] = {};
int soleCount[kSlots] = {};
for (int i = 0; i < kSlots; ++i)
outCaster[i] = 0;
if (unitGuid == 0)
return;
// `FindInstance` for all slots in one pass: the entry seated in the slot
// (FindBySlot), else the unit's only entry for the slot's spell (FindSole).
for (int i = 0; i < g_usedHigh; ++i) {
const Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid)
continue;
if (e.slot >= 0 && e.slot < kSlots && slotSpellIds[e.slot] == e.spellId &&
seated[e.slot] == nullptr)
seated[e.slot] = &e;
for (int s = 0; s < kSlots; ++s) {
if (slotSpellIds[s] == e.spellId) {
sole[s] = &e;
++soleCount[s];
}
}
}
for (int s = 0; s < kSlots; ++s) {
if (slotSpellIds[s] == 0)
continue;
if (seated[s] != nullptr)
outCaster[s] = seated[s]->casterGuid;
else if (soleCount[s] == 1)
outCaster[s] = sole[s]->casterGuid;
}
}
bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
uint64_t affectedMask, uint32_t affectedIcon, int op,
int32_t valueMs) {
@@ -1981,7 +2035,7 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
const Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid || e.kind != want)
continue;
if (e.expirationMs != 0 && now >= e.expirationMs)
if (e.expirationMs != 0 && Time::Clock::Reached(now, e.expirationMs))
continue; // expired (infinite-duration entries pass)
out[n++] = {e.spellId, e.casterGuid, e.expirationMs, e.durationMs,
e.slot};
@@ -2040,4 +2094,8 @@ void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray) {
snap->touchMs = now;
}
AutoObserve::AutoObserve(ChangeCallback cb) : callback(cb), next(g_observers) {
g_observers = this;
}
} // namespace Aura::Source
+36
View File
@@ -60,6 +60,15 @@ constexpr int SLOT_UNBOUND = -1;
bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// `Get`'s caster for every descriptor slot at once, in one pass over the
// cache: `outCaster[slot]` is the caster `Get(unitGuid, slotSpellIds[slot],
// slot, ...)` would report, or 0. `slotSpellIds` and `outCaster` hold
// `Offsets::UNIT_AURA_TOTAL` entries. For walks that need every slot's caster
// (the own-auras-first sort), where 48 separate `Get` scans would cost 48
// passes.
void CastersForSlots(uint64_t unitGuid, const uint32_t *slotSpellIds,
uint64_t *outCaster);
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
// SpellIconID, at least one of the two — to a full duration from now. Returns
@@ -228,4 +237,31 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
// any real SpellGo timing we already hold, so this never downgrades better data.
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
// An aura arriving on a unit, leaving it, or changing its stack count, as the
// engine's own handlers see it (`OnAuraAdded`, `OnAuraRemoved`,
// `OnAuraStacksChanged`), with the caster this cache binds to it.
enum class ChangeType { Added, Removed, Stacks };
struct Change {
ChangeType type;
void *unit;
int slot;
uint32_t spellId;
uint64_t caster; // 0 when unknown
bool harmful;
int oldStacks; // Stacks only: the count before the change
int newStacks; // Stacks only: the count now
};
using ChangeCallback = void (*)(const Change &change);
// Declare a file-scope `static const Aura::Source::AutoObserve _obs{&fn};`.
// Added and Stacks reach the callback after the cache has stamped the aura,
// Removed before the cache evicts it, so `caster` is the cache's in all three.
struct AutoObserve {
explicit AutoObserve(ChangeCallback cb);
ChangeCallback callback;
const AutoObserve *next;
};
} // namespace Aura::Source
+63 -51
View File
@@ -16,22 +16,38 @@
// (added 5.4.x) with exact same-semantic coalescing: addons listen
// to DELAYED instead of BAG_UPDATE and rescan once per frame.
//
// See TODO #67 for the full investigation history.
// Architecture: three MinHooks on the engine's three BAG_UPDATE
// emitters, plus one tick subscription. Names are benilla-re's.
//
// Architecture: three MinHooks plus one tick subscription.
//
// 1. `FUN_004F91A0` — bag-slot diff loop. Hook just sets the
// `g_pending` flag.
// 2. `FUN_004F9370` — item→bag resolver (most common path). Hook
// just sets the `g_pending` flag.
// 3. `FUN_004F8DB0` — bag-descriptor change callback. Covers the
// keyring `BAG_UPDATE(slot)` direct-fire branch that doesn't
// route through `FUN_004F9370`.
// 1. `RaiseBagEvents` (`0x004F91A0`) — compares the ten bag slots
// (4 bags, 6 bank bags) with the cached container GUIDs and
// fires BAG_CLOSED / BAG_UPDATE per changed slot. Called by the
// bag-slot field watcher and once at login.
// 2. `RaiseBagUpdate` (`0x004F9370`) — fires BAG_UPDATE for the
// player or one of the ten cached containers; the most common
// path. Its callers are the backpack and container slot
// watchers, and the item-field watcher `0x004F9480`, which
// passes an item's container — the PLAYER for equipped gear, so
// a durability change on worn gear fires BAG_UPDATE(0) and
// therefore DELAYED. That is the engine's behaviour, kept.
// 3. `OnBackpackSlotChanged` (`0x004F8DB0`) — the field watcher on
// backpack slots 23..38 and keyring slots 81..112. Keyring
// changes fire BAG_UPDATE directly here; backpack changes go
// through `RaiseBagUpdate`. Standard watcher ABI
// `__fastcall(offset, unused, guidLo, guidHi, previous*, user)`,
// ret 0x10 — the same shape as `OnContainerSlotChanged`.
// 4. `Tick::WorldTick::AutoSubscribe` — shared per-frame
// subscription (the engine's world-subsystem update at
// `FUN_0066FD50`, single-hooked in `src/tick/WorldTick.cpp`).
// Our callback drains `g_pending` and fires DELAYED if set.
//
// The hooks set `g_pending` after every call rather than only when
// an event fired. The one call that fires nothing is
// `RaiseBagUpdate` for a container outside the cache, which does not
// occur in practice; mirroring that check here would be a parallel
// copy of the engine's test. The bank's main slots 39..62 have no
// engine watcher, so they never fire BAG_UPDATE or DELAYED.
//
// All hook targets are quiet (≤3 callers each, in code regions
// other DLLs don't touch). The previous attempt at this feature
// hooked `FUN_FIRE_EVENT` (100+ callers, in the chat path) and a
@@ -68,7 +84,12 @@ namespace {
constexpr const char *kEventName = "BAG_UPDATE_DELAYED";
const Event::Custom::AutoReserve _reserve{kEventName};
const Game::Doc::Event kUpdateDelayedDoc{
"Container",
"Fires once in each frame in which one or more BAG_UPDATE events fired, "
"after all of them.",
{}};
const Event::Custom::AutoReserve _reserve{kEventName, &kUpdateDelayedDoc};
// Set by the bag-update hooks, drained by the world-tick hook.
// Single bool, no atomics needed — the engine is single-threaded.
@@ -83,40 +104,31 @@ AutoSubscribe::AutoSubscribe(Callback cb) : cb(cb), next(g_subs) {
g_subs = this;
}
// `FUN_004F91A0` — `__cdecl void()`. Bag-slot diff loop. Walks the
// 10 bag slots, fires BAG_UPDATE for each that changed.
using BagSlotDiff_t = void(__cdecl *)();
BagSlotDiff_t BagSlotDiff_o = nullptr;
void __cdecl BagSlotDiff_h() {
BagSlotDiff_o();
// `RaiseBagEvents` — `__cdecl void()`.
using RaiseBagEvents_t = void(__cdecl *)();
RaiseBagEvents_t RaiseBagEvents_o = nullptr;
void __cdecl RaiseBagEvents_h() {
RaiseBagEvents_o();
g_pending = true;
}
// `FUN_004F9370` — `__stdcall void(int guidLo, int guidHi)`. Most
// common BAG_UPDATE path — fires from every per-item descriptor
// field change.
using BagItemToBag_t = void(__stdcall *)(int guidLo, int guidHi);
BagItemToBag_t BagItemToBag_o = nullptr;
void __stdcall BagItemToBag_h(int guidLo, int guidHi) {
BagItemToBag_o(guidLo, guidHi);
// `RaiseBagUpdate` — `__stdcall void(containerGuidLo, containerGuidHi)`.
using RaiseBagUpdate_t = void(__stdcall *)(int guidLo, int guidHi);
RaiseBagUpdate_t RaiseBagUpdate_o = nullptr;
void __stdcall RaiseBagUpdate_h(int guidLo, int guidHi) {
RaiseBagUpdate_o(guidLo, guidHi);
g_pending = true;
}
// `FUN_004F8DB0` — `__thiscall unsigned(this, a1, a2, a3, a4)` in the
// Octo build. Wired with `__fastcall` + dummy EDX to match the
// thiscall ABI: ECX takes `this`, EDX unused, stack takes the four
// dword args, callee cleans 16 bytes (`RET 0x10`). Same trick
// `Frame::RegisterEvent` uses in DllMain, just with four stack args
// instead of one — the Octo function is wider than the vanilla
// signature Ghidra showed when this hook was first written.
using KeyringDescChange_t = unsigned(__fastcall *)(void *bagDesc, void *edx_unused,
unsigned a1, unsigned a2,
unsigned a3, unsigned a4);
KeyringDescChange_t KeyringDescChange_o = nullptr;
unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
unsigned a1, unsigned a2,
unsigned a3, unsigned a4) {
const unsigned result = KeyringDescChange_o(bagDesc, edx, a1, a2, a3, a4);
// `OnBackpackSlotChanged` — field-watcher ABI, `__fastcall(offset,
// unused, guidLo, guidHi, previous*, user)`, callee cleans 0x10.
using BackpackSlotChanged_t = unsigned(__fastcall *)(unsigned offset, unsigned unused,
unsigned guidLo, unsigned guidHi,
const void *previous, void *user);
BackpackSlotChanged_t BackpackSlotChanged_o = nullptr;
unsigned __fastcall BackpackSlotChanged_h(unsigned offset, unsigned unused, unsigned guidLo,
unsigned guidHi, const void *previous, void *user) {
const unsigned result = BackpackSlotChanged_o(offset, unused, guidLo, guidHi, previous, user);
g_pending = true;
return result;
}
@@ -151,20 +163,20 @@ void OnWorldTick() {
node->cb();
}
static const Game::HookAutoRegister _hookSlotDiff{
Offsets::FUN_BAG_SLOT_DIFF,
reinterpret_cast<void *>(&BagSlotDiff_h),
reinterpret_cast<void **>(&BagSlotDiff_o)};
static const Game::HookAutoRegister _hookRaiseBagEvents{
Offsets::FUN_RAISE_BAG_EVENTS,
reinterpret_cast<void *>(&RaiseBagEvents_h),
reinterpret_cast<void **>(&RaiseBagEvents_o)};
static const Game::HookAutoRegister _hookItemToBag{
Offsets::FUN_BAG_ITEM_TO_BAG,
reinterpret_cast<void *>(&BagItemToBag_h),
reinterpret_cast<void **>(&BagItemToBag_o)};
static const Game::HookAutoRegister _hookRaiseBagUpdate{
Offsets::FUN_RAISE_BAG_UPDATE,
reinterpret_cast<void *>(&RaiseBagUpdate_h),
reinterpret_cast<void **>(&RaiseBagUpdate_o)};
static const Game::HookAutoRegister _hookKeyringDesc{
Offsets::FUN_BAG_KEYRING_DESC_CHANGE,
reinterpret_cast<void *>(&KeyringDescChange_h),
reinterpret_cast<void **>(&KeyringDescChange_o)};
static const Game::HookAutoRegister _hookBackpackSlot{
Offsets::FUN_ON_BACKPACK_SLOT_CHANGED,
reinterpret_cast<void *>(&BackpackSlotChanged_h),
reinterpret_cast<void **>(&BackpackSlotChanged_o)};
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
+7 -5
View File
@@ -24,15 +24,15 @@
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
#include "baselib/Mixin.h"
#include "Game.h"
namespace BaseLib::Mixin {
namespace {
// Shallow-copy every pair from the table at `srcIdx` onto the table at
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
void MixinInto(void *L, int dstIdx, int srcIdx) {
void Into(void *L, int dstIdx, int srcIdx) {
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, srcIdx) != 0) {
// stack top: ..., key (-2), value (-1). Copy both so the originals
@@ -44,6 +44,8 @@ void MixinInto(void *L, int dstIdx, int srcIdx) {
}
}
namespace {
int __fastcall Script_Mixin(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
@@ -52,7 +54,7 @@ int __fastcall Script_Mixin(void *L) {
}
for (int i = 2; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, 1, i);
Into(L, 1, i);
Game::Lua::PushValue(L, 1); // return object
return 1;
}
@@ -63,7 +65,7 @@ int __fastcall Script_CreateFromMixins(void *L) {
const int objIdx = top + 1;
for (int i = 1; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, objIdx, i);
Into(L, objIdx, i);
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
return 1;
}
+22
View File
@@ -0,0 +1,22 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace BaseLib::Mixin {
// dst[k] = v for every field of the table at `srcIdx` — Mixin()'s copy.
// Both indices must be absolute.
void Into(void *L, int dstIdx, int srcIdx);
} // namespace BaseLib::Mixin
+11 -9
View File
@@ -13,7 +13,8 @@
// String helpers that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.match(s, pattern [, init])` — first-match extraction (5.1),
// also the global `strmatch`, as `string.find` is `strfind`.
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `string.gsub` TABLE replacement — 5.1 form (see the shim below).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
@@ -392,15 +393,16 @@ void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "trim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "join", &Script_strjoin);
// 4.3.4's compat.lua binds each short global and its string-table twin
// to ONE function by assignment (`gsub = str.gsub`, `strrev =
// str.reverse`, `str.trim = strtrim`, `str.split = strsplit`, `str.join =
// strjoin`), so `strtrim == string.trim`. A second Register* call would
// mint a second closure over the same C function, so the globals alias
// the table entries by value instead. The string-table half is what lets
// `(" x "):trim()` work and libraries capture it at file scope
// (AceConfigCmd-3.0: `strtrim = string.trim`); the bare `gsub` gets the
// table-replacement upgrade by being the same function.
// to ONE function by assignment (`gsub = str.gsub`, `strmatch =
// str.match`, `strrev = str.reverse`, `str.trim = strtrim`, `str.split =
// strsplit`, `str.join = strjoin`), so `strtrim == string.trim`. A second
// Register* call would mint a second closure over the same C function, so
// the globals alias the table entries by value instead. The string-table
// half is what lets `(" x "):trim()` work and libraries capture it at
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`); the bare `gsub`
// gets the table-replacement upgrade by being the same function.
Game::Lua::RegisterGlobalAlias("gsub", "string", "gsub");
Game::Lua::RegisterGlobalAlias("strmatch", "string", "match");
Game::Lua::RegisterGlobalAlias("strsplit", "string", "split");
Game::Lua::RegisterGlobalAlias("strjoin", "string", "join");
Game::Lua::RegisterGlobalAlias("strtrim", "string", "trim");
+130 -21
View File
@@ -20,17 +20,23 @@
// SetOverrideBinding[Spell / Item / Macro / Click], ClearOverrideBindings
// — owner-scoped bindings in front of the saved ones, never saved.
// GetBindingAction(key [, checkOverride]) — sees overrides when asked.
// GetBindingByKey(key) — what a press of the key runs.
//
// Overrides — 4.3.4's OVERRIDEKEYBINDING: per key (case-insensitive), two
// lists (normal, priority) of {owner, command}; setting an owner's binding
// unlinks its old one from both and links the new one at the head of its
// priority's list, so the newest priority binding wins, then the newest
// normal one. 4.3.4 consults them inside its key lookup, before the saved
// binding, for every key it tries. 1.12's key dispatch (FUN_BINDING_KEY_DISPATCH)
// tries two keys — the pressed one, then the pressed one minus its first
// modifier — so the co-hook tries an override, then the saved binding
// (FUN_BINDINGS_GET_COMMAND_ACTION), for each in turn, and lets the engine
// run a saved binding it finds.
// binding, for every key it tries.
//
// Reduced keys — 4.3.4's GetReducedKeyBinding. 1.12's key dispatch
// (FUN_BINDING_KEY_DISPATCH) tries only two keys: the pressed one, then the
// pressed one minus its first modifier. 4.3.4 tries every subset of the
// modifiers (ALT-SHIFT-Q: ALT-SHIFT-Q, SHIFT-Q, ALT-Q, Q). The co-hook walks
// 4.3.4's list, tries an override, then the saved binding
// (FUN_BINDINGS_GET_COMMAND_ACTION), for each key in turn — skipping one whose
// command is unknown, as 4.3.4 does — and lets the engine run a saved binding
// it finds.
//
// Around whatever runs, the key-state override (Input::Modifier) hides the
// matched key's own modifiers, as 4.3.4's ExecKey does. Direct actions
@@ -121,6 +127,85 @@ const char *SavedCommand(void *mgr, const char *key) {
key);
}
// Whether the command table has `command` — the walk the engine inlines in
// FUN_BINDING_COMMAND_EXECUTE (see OFF_BINDINGS_COMMAND_TABLE).
bool IsKnownCommand(void *mgr, const char *command) {
using HashHT_t = uint32_t(__stdcall *)(const char *);
using CmpI_t = int(__stdcall *)(const char *, const char *, int);
const uintptr_t table = reinterpret_cast<uintptr_t>(mgr) + Offsets::OFF_BINDINGS_COMMAND_TABLE;
const uint32_t mask = Game::Read<uint32_t>(table + Offsets::OFF_HASHTABLE_MASK);
if (*command == '\0' || mask == 0xFFFFFFFF)
return false;
const uint32_t hash = reinterpret_cast<HashHT_t>(Offsets::FUN_SSTR_HASH_HT)(command);
const uintptr_t bucket =
Game::Read<uintptr_t>(table + Offsets::OFF_HASHTABLE_SLOTS) + (hash & mask) * 12;
const uint32_t link = Game::Read<uint32_t>(bucket);
for (uintptr_t node = Game::Read<uintptr_t>(bucket + 8); node != 0 && (node & 1) == 0;
node = Game::Read<uintptr_t>(node + link + 4)) {
if (Game::Read<uint32_t>(node) == hash &&
reinterpret_cast<CmpI_t>(Offsets::FUN_SSTR_CMP_I)(
Game::Read<const char *>(node + Offsets::OFF_HASHTABLE_NODE_NAME), command,
0x7FFFFFFF) == 0)
return true;
}
return false;
}
// CGUIBindings::GetKeyBinding: the key's active override, else its saved
// binding — and none when that command is neither a direct action nor a known
// command (a saved one is never checked when set). 4.3.4's retry under the
// generic modifier names (LSHIFT- → SHIFT-) is left out: 1.12 builds chords
// from ALT-, CTRL- and SHIFT- only, so a pressed key is already generic.
struct KeyBinding {
const char *command = nullptr;
bool isOverride = false;
};
KeyBinding FindKeyBinding(void *mgr, const char *key) {
KeyBinding binding{ActiveOverride(key), true};
if (binding.command == nullptr)
binding = {SavedCommand(mgr, key), false};
if (binding.command == nullptr ||
!(Command::IsDirectAction(binding.command) || IsKnownCommand(mgr, binding.command)))
return {};
return binding;
}
// CGUIBindings::GetReducedKeyBinding: up to three leading modifiers split off
// at '-', then the key with each subset of them in 4.3.4's fixed order (bit j
// keeps modifier j) — ALT-SHIFT-Q tries ALT-SHIFT-Q, SHIFT-Q, ALT-Q, Q. The
// first reduced key with a binding wins and is left in `reduced`.
KeyBinding FindReducedKeyBinding(void *mgr, const char *key, std::string &reduced) {
std::string modifiers[3];
size_t count = 0;
const char *rest = key;
while (count < 3) {
const char *separator = std::strchr(rest, '-');
if (separator == nullptr || separator == rest)
break;
modifiers[count++].assign(rest, separator - rest + 1);
rest = separator + 1;
}
static constexpr uint8_t kCombinations[4][9] = {
{1, 0},
{2, 1, 0},
{4, 3, 2, 1, 0},
{8, 7, 6, 5, 3, 4, 2, 1, 0},
};
for (uint8_t i = 0; i < kCombinations[count][0]; ++i) {
reduced.clear();
for (size_t j = 0; j < count; ++j)
if ((1u << j) & kCombinations[count][i + 1])
reduced += modifiers[j];
reduced += rest;
const KeyBinding binding = FindKeyBinding(mgr, reduced.c_str());
if (binding.command != nullptr)
return binding;
}
return {};
}
int ExecuteCommand(void *mgr, const char *command, int isDown) {
if (Command::IsDirectAction(command))
return Command::RunKey(command, isDown);
@@ -147,26 +232,21 @@ int RunOverride(void *mgr, const char *command, int isDown) {
return result;
}
// CGUIBindings::ExecKey. A saved binding is run by the engine, handed the
// reduced key: it uses the key only for its table lookup, and finds it as the
// first of its own two. No binding runs nothing, as in 4.3.4.
int __fastcall KeyDispatch_h(void *mgr, void *edx, const char *key, int isDown) {
if (key == nullptr || *key == '\0')
return g_keyDispatchOriginal(mgr, edx, key, isDown);
// The engine's two keys: as pressed, then without its first modifier.
const char *dash = std::strchr(key, '-');
const char *candidates[2] = {key, (dash != nullptr && dash[1] != '\0') ? dash + 1 : nullptr};
for (const char *candidate : candidates) {
if (candidate == nullptr)
break;
if (const char *command = ActiveOverride(candidate)) {
const Input::Modifier::KeyStateOverride keyState(candidate);
return RunOverride(mgr, command, isDown);
}
if (SavedCommand(mgr, candidate) != nullptr) {
const Input::Modifier::KeyStateOverride keyState(candidate);
return g_keyDispatchOriginal(mgr, edx, key, isDown);
}
}
return g_keyDispatchOriginal(mgr, edx, key, isDown);
std::string reduced;
const KeyBinding binding = FindReducedKeyBinding(mgr, key, reduced);
if (binding.command == nullptr)
return 0;
const Input::Modifier::KeyStateOverride keyState(reduced.c_str());
if (binding.isOverride)
return RunOverride(mgr, binding.command, isDown);
return g_keyDispatchOriginal(mgr, edx, reduced.c_str(), isDown);
}
// Direct actions only for a key (executing flag up); RunBinding reaches the
@@ -305,7 +385,36 @@ int __fastcall Script_GetBindingAction(void *L) {
return reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_GET_BINDING_ACTION)(L);
}
// GetBindingByKey(key): the command a press of `key` runs — the reduced-key
// lookup ExecKey makes, overrides included.
int __fastcall Script_GetBindingByKey(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: GetBindingByKey(\"action\"[, mode])");
return 0;
}
std::string reduced;
const KeyBinding binding = FindReducedKeyBinding(Manager(), Game::Lua::ToString(L, 1), reduced);
if (binding.command == nullptr)
return 0;
Game::Lua::PushString(L, binding.command);
return 1;
}
const Game::Doc::Field kGetBindingByKeyArgs[] = {
Game::Doc::Req("key", "string", "A key with its modifiers, such as \"ALT-SHIFT-Q\"."),
};
const Game::Doc::Field kGetBindingByKeyRets[] = {
Game::Doc::Opt("command", "string", nullptr,
"The command a press of the key runs, else nil. When the key itself has no "
"binding, the key with fewer modifiers is tried."),
};
const Game::Doc::Function kGetBindingByKey{"Returns the command that a key press runs.",
kGetBindingByKeyArgs, kGetBindingByKeyRets,
"BindingsGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetBindingByKey", &Script_GetBindingByKey,
&kGetBindingByKey);
Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
+8
View File
@@ -12,6 +12,14 @@
<Binding name="TARGETFOCUS">
TargetUnit("focus");
</Binding>
<Binding name="INTERACTMOUSEOVER">
if ( not C_PlayerInteractionManager.InteractUnit("mouseover", false, false) ) then
C_PlayerInteractionManager.InteractUnit("anyinteract");
end
</Binding>
<Binding name="INTERACTTARGET">
C_PlayerInteractionManager.InteractUnit("anyinteract");
</Binding>
<Binding name="AOELOOT">
C_Loot.LootAllCorpses()
</Binding>
+4 -16
View File
@@ -45,6 +45,8 @@
#include "frame/ClickFrame.h"
#include "item/Arg.h"
#include "item/Location.h"
#include "item/Use.h"
#include "spell/Placement.h"
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
@@ -77,29 +79,15 @@ uint64_t ModifiedTarget() {
}
void CastSpell(const char *name) {
using NameToSlot_t = int(__fastcall *)(const char *name, int *outBookType);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType, unsigned guidLo,
unsigned guidHi);
int book = 0;
const int slot =
reinterpret_cast<NameToSlot_t>(Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT)(name, &book);
if (slot < 0)
return;
const uint64_t target = ModifiedTarget();
reinterpret_cast<CastDispatch_t>(Offsets::FUN_SPELL_CAST_DISPATCH)(
static_cast<unsigned>(slot), book, static_cast<unsigned>(target),
static_cast<unsigned>(target >> 32));
Spell::Placement::DispatchSpellCastByName(name, ModifiedTarget());
}
void UseItem(const char *name) {
using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targetGuid,
int flag);
const Item::Arg::Resolved byName{0, 0, name};
Item::Location::ByGUIDResult found{};
if (!Item::Location::FindByArgNoLua(byName, &found) || found.item == nullptr)
return;
const uint64_t target = ModifiedTarget();
reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE)(found.item, &target, 0);
Item::Use::UseItem(found.item, ModifiedTarget());
}
void RunMacro(const char *name) {
+6 -3
View File
@@ -49,7 +49,8 @@
#include "Inject.h"
#include "Offsets.h"
#include "embedded_bindings.h"
#include "embed/Resource.h"
#include "embedded_resources.h"
#include <cstdio>
#include <cstring>
@@ -132,8 +133,10 @@ bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
EmbeddedConfig ResolveEmbeddedConfig() {
EmbeddedConfig out = {};
const char *content = reinterpret_cast<const char *>(kEmbeddedBindingsXml);
const char *end = content + kEmbeddedBindingsXmlSize;
const Embed::Bytes xml = Embed::Resource(Embed::Ids::kBindingsXml);
if (xml.data == nullptr) return out;
const char *content = reinterpret_cast<const char *>(xml.data);
const char *end = content + xml.size;
// Locate `<Bindings` opening tag (skipping any comment blocks
// that mention the tag literally for documentation purposes).
+34 -6
View File
@@ -21,8 +21,11 @@
// gains the modified-click half 4.3.4 has, through a co-hook on the step:
//
// - Load (Bindings.xml): `<ModifiedClick action="X" default="Y"/>` defines
// an action and its default. The engine loader warns on the node it does
// not know; we register the actions from the same file first.
// an action and its default. We register the actions from the same file
// first. The engine's loop tests each root tag inline and logs "Unknown
// node type" for anything but <Binding>; 4.3.4's loop has a ModifiedClick
// branch there, so the loader runs with a status that drops exactly that
// warning for a ModifiedClick node and forwards everything else.
// - LoadBindings (a binding file into a set): the set starts from the
// defaults, then `modifiedclick BINDING ACTION` lines apply.
// - SaveBindings: `modifiedclick` lines for every action not left at its
@@ -40,6 +43,7 @@
#include "Game.h"
#include "Offsets.h"
#include "addons/EngineIO.h"
#include "baselib/Ascii.h"
#include "frame/ClickEvents.h"
#include "input/Modifier.h"
#include "xml/FrameLoad.h"
@@ -241,12 +245,36 @@ void LoadModifiedClicks(const char *path, void *status) {
reinterpret_cast<TreeFree_t>(Offsets::FUN_XML_TREE_FREE)(tree);
}
// The status the engine loader is given: a CStatus whose only used slot,
// Add (vtable +0xC), forwards to the real status. Load passes at most two
// string arguments after the format, and Add is __cdecl, so taking exactly
// two is safe whatever the format consumes.
struct LoadStatus {
const void *const *vtable;
void *real;
};
void __cdecl LoadStatusAdd(LoadStatus *self, int level, const char *fmt, const char *a,
const char *b) {
if (fmt == reinterpret_cast<const char *>(Offsets::STR_BINDINGS_UNKNOWN_NODE) &&
a != nullptr && Ascii::EqualCI(a, "ModifiedClick"))
return; // a node this loader now knows (LoadModifiedClicks)
using Add_t = void(__cdecl *)(void *status, int level, const char *fmt, ...);
const void *vtable = Game::Read<const void *>(self->real, 0);
Game::Read<Add_t>(vtable, Offsets::VTBL_XML_STATUS_ADD)(self->real, level, fmt, a, b);
}
static_assert(Offsets::VTBL_XML_STATUS_ADD == 3 * sizeof(void *));
const void *const kLoadStatusVtable[4] = {nullptr, nullptr, nullptr,
reinterpret_cast<const void *>(&LoadStatusAdd)};
int __fastcall LoadFile_h(void *mgr, void *edx, const char *path, void *md5, void *status) {
if (status == nullptr)
status = reinterpret_cast<void *>(Offsets::VAR_BINDINGS_LOAD_STATUS);
if (path != nullptr)
LoadModifiedClicks(path, status != nullptr
? status
: reinterpret_cast<void *>(Offsets::VAR_BINDINGS_LOAD_STATUS));
return g_loadFileOriginal(mgr, edx, path, md5, status);
LoadModifiedClicks(path, status);
LoadStatus filtered{kLoadStatusVtable, status};
return g_loadFileOriginal(mgr, edx, path, md5, &filtered);
}
// --- binding files: modifiedclick lines -----------------------------------
+59
View File
@@ -0,0 +1,59 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "cache/Query.h"
#include "Game.h"
#include "Offsets.h"
namespace Cache::Query {
namespace {
// The engine's stack CDataStore (see Offsets::VTABLE_CDATASTORE).
struct DataStore {
uintptr_t vtable;
void *buffer;
uint32_t base;
uint32_t alloc;
uint32_t size;
uint32_t read;
};
static_assert(sizeof(DataStore) == 0x18, "CDataStore layout");
using PutU32_t = void(__thiscall *)(DataStore *self, uint32_t value);
using PutGuid_t = void(__thiscall *)(DataStore *self, uint32_t lo, uint32_t hi);
using Send_t = void(__thiscall *)(DataStore *self);
using Free_t = void(__thiscall *)(DataStore *self, void **buffer, uint32_t *base, uint32_t *alloc);
} // namespace
// The send of FUN_DBCACHE_ITEMSTATS_GET_RECORD (0x0055BB18..0x0055BB89),
// step for step. Every DBCache<T> clone builds the same packet.
void Send(void *cache, uint32_t key) {
DataStore packet{Offsets::VTABLE_CDATASTORE, nullptr, 0, 0, 0, 0xFFFFFFFF};
const auto putU32 = reinterpret_cast<PutU32_t>(Offsets::FUN_DATASTORE_PUT_U32);
putU32(&packet, Game::Read<uint32_t>(cache, Offsets::OFF_DBCACHE_QUERY_OPCODE));
putU32(&packet, key);
if (Game::Read<uint8_t>(cache, Offsets::OFF_DBCACHE_OWNER_FLAG) != 0)
reinterpret_cast<PutGuid_t>(Offsets::FUN_DATASTORE_PUT_GUID)(&packet, 0, 0);
packet.read = 0;
reinterpret_cast<Send_t>(Offsets::FUN_CLIENT_SERVICES_SEND)(&packet);
if (packet.alloc != 0xFFFFFFFF) {
const auto release =
*reinterpret_cast<const Free_t *>(Offsets::VTABLE_CDATASTORE + sizeof(uintptr_t));
release(&packet, &packet.buffer, &packet.base, &packet.alloc);
}
}
} // namespace Cache::Query
+36
View File
@@ -0,0 +1,36 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Re-sending a DBCache query.
//
// A DBCache `_GetRecord` sends its query only when it creates the pending
// node; a lookup that finds the node pending just adds its callback. The
// server drops a STATUS_LOGGEDIN query (every *_QUERY opcode) while the
// player is not in the world - before login completes and behind every
// loading screen - so a query sent then is never answered, and the node
// stays pending for the rest of the session. Sending the query again is the
// only way out; the answer then fills the node and runs every callback on it
// through the engine's own receive.
namespace Cache::Query {
// Sends `cache`'s query for `key` exactly as its `_GetRecord` does for a new
// node: the cache's opcode, the key, and a zero GUID when the cache's owner
// flag is set.
void Send(void *cache, uint32_t key);
} // namespace Cache::Query
+61
View File
@@ -0,0 +1,61 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `pitchLimit <degrees>` — 4.3.4's console command (CGCamera::CCommand_PitchLimit)
// that sets how far the camera may pitch up and down.
//
// 4.3.4 writes `degrees` (and its negation) into the two globals every camera
// pitch clamp reads. 1.12 reads the very same pair, so this writes them and the
// engine's own clamps, smooth-pitch target and cameraPitch range check all
// follow. The only difference is that 1.12 keeps them in .rdata, hence the
// unprotected write.
//
// Like 4.3.4 there is no validation: a bare `pitchLimit` parses as 0 and locks
// the camera level, and values past 90 degrees are taken as given.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
namespace Camera::PitchLimit {
namespace {
using ToFloat_t = float(__stdcall *)(const char *);
constexpr float kDegreesToRadians = 0.017453292f; // 4.3.4's own constant
int __fastcall Console_PitchLimit(const char * /*command*/, const char *args) {
const float limit =
reinterpret_cast<ToFloat_t>(Offsets::FUN_SSTR_TO_FLOAT)(args) * kDegreesToRadians;
const float max = limit;
const float min = -limit;
Common::PatchBytes(reinterpret_cast<void *>(Offsets::VAR_CAMERA_MAX_PITCH), &max,
sizeof(max));
Common::PatchBytes(reinterpret_cast<void *>(Offsets::VAR_CAMERA_MIN_PITCH), &min,
sizeof(min));
return 1;
}
// Process-global, so the first glue boot covers the session; the registrar
// dedups the repeat on each logout.
void Register() {
Game::Console::RegisterCommand("pitchLimit", &Console_PitchLimit,
Game::Console::CATEGORY_GAME, nullptr);
}
const Game::GlueModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Camera::PitchLimit
-75
View File
@@ -1,75 +0,0 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetCurrentChatGUID()` — call from inside an addon's OnEvent for
// any CHAT_MSG_* to get the sender's GUID. Vanilla 1.12 omits the
// GUID from chat event args (added as arg12 of CHAT_MSG_* only in
// 3.0+), so addons currently strcmp the localized sender name string
// against an internal cache to identify the player. With this we
// just read the GUID directly.
//
// The GUID is published for the duration of a chat dispatch by a
// `DispatchScope` (see the header), which Chat::Dispatch — the single
// owner of the FUN_0049A870 chat-dispatch hook — constructs around the
// original call. The engine fires the CHAT_MSG_* event synchronously
// inside that call, so `GetCurrentChatGUID` reads the right GUID from
// the addon's OnEvent. The dispatcher is also called for synthetic chat
// (system notifications, arena team events, etc.) with GUID = 0; for
// those `GetCurrentChatGUID()` returns nil — matching the modern API's
// behavior of a nil GUID for non-player-sourced chat.
#include "CurrentGUID.h"
#include "Game.h"
#include "guid/Guid.h"
#include <cstdint>
namespace Chat::CurrentGUID {
namespace {
uint32_t g_currentGuidLo = 0;
uint32_t g_currentGuidHi = 0;
int __fastcall Script_GetCurrentChatGUID(void *L) {
if (g_currentGuidLo == 0 && g_currentGuidHi == 0)
return 0;
const uint64_t guid =
(static_cast<uint64_t>(g_currentGuidHi) << 32) | g_currentGuidLo;
char buf[Guid::STRING_SIZE];
Game::Lua::PushString(L, Guid::FormatAsString(guid, buf, sizeof buf));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetCurrentChatGUID",
&Script_GetCurrentChatGUID);
}
} // namespace
DispatchScope::DispatchScope(uint32_t guidLo, uint32_t guidHi)
: prevLo_(g_currentGuidLo), prevHi_(g_currentGuidHi) {
g_currentGuidLo = guidLo;
g_currentGuidHi = guidHi;
}
DispatchScope::~DispatchScope() {
g_currentGuidLo = prevLo_;
g_currentGuidHi = prevHi_;
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Chat::CurrentGUID
-40
View File
@@ -1,40 +0,0 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Chat::CurrentGUID {
// RAII scope that publishes the sender GUID of the chat message currently being
// dispatched, for `GetCurrentChatGUID()` to read. Chat::Dispatch constructs one
// around the original chat-dispatch call: the engine fires the CHAT_MSG_* event
// synchronously inside that call, so an addon's OnEvent sees the GUID of the
// message that triggered it. Restores the prior value on destruction rather than
// clearing to 0, so nested dispatch (a chat event firing during another's
// OnEvent — e.g. an addon calling SendChatMessage from its handler) leaves the
// outer context's GUID intact when the inner returns.
class DispatchScope {
public:
DispatchScope(uint32_t guidLo, uint32_t guidHi);
~DispatchScope();
DispatchScope(const DispatchScope &) = delete;
DispatchScope &operator=(const DispatchScope &) = delete;
private:
uint32_t prevLo_;
uint32_t prevHi_;
};
} // namespace Chat::CurrentGUID
+108 -36
View File
@@ -23,56 +23,128 @@
// - Chat::IconFilter::Sanitize — strip player-injected `|T` icon spoofs from
// the message before the engine builds the chat line AND the speech bubble
// (both come from the same buffer, so one sanitize covers both).
// - Chat::RaidMarkers::Substitute — expand `{rt1}`/`{skull}`/… raid-target
// tokens into inline marker icons. Runs AFTER Sanitize so a player gets the
// fixed marker icons but still can't inject an arbitrary `|T` path.
// - Chat::CurrentGUID::DispatchScope — publish the sender GUID for the
// synchronous CHAT_MSG_* event so `GetCurrentChatGUID()` can read it.
// - Chat::RaidMarkers::EventScope — mark the message whose CHAT_MSG_* text
// gets `{rt1}`/`{skull}`/… expanded into marker icons (at the event fire;
// the bubble expands its own). AFTER Sanitize, so a player gets the fixed
// marker icons but still can't inject an arbitrary `|T` path.
// - Chat::EventArgs::SenderScope — name the sender for the CHAT_MSG_* event
// the original fires synchronously: its arg12 and `GetCurrentChatGUID()`.
//
// Signature — `__fastcall`, `RET 0x28`:
// ECX = arg1 — the MESSAGE text (verified from the call site in FUN_0049D560:
// `MOV ECX,[EBP-0x10]` = the raw/processed message. An older
// note mislabeled this "sender name".) Copied into one buffer
// fed to both the chat frame and the bubble spawn FUN_00608AC0.
// EDX = arg2 — chat type byte
// [esp+04] = arg3 [esp+08] = arg4 [esp+0c] = arg5
// [esp+10] = arg6 [esp+14] = arg7
// [esp+18] = arg8 — target GUID lo (whisper/channel target)
// [esp+1c] = arg9 — target GUID hi
// [esp+20] = arg10
// [esp+24] = arg11 — sender GUID lo ← published
// [esp+28] = arg12 — sender GUID hi ← published
// Signature — `__fastcall`, `RET 0x28` (stack args at entry):
// ECX = the MESSAGE text (`MOV ECX,[EBP-0x10]` in FUN_0049D560; copied into
// one buffer that feeds both the chat frame and the bubble spawn
// FUN_00608AC0)
// EDX = chat type
// [esp+04] senderName [esp+08] languageID [esp+0c] channel
// [esp+10] targetName [esp+14] flags ("AFK"/"DND"/"GM")
// [esp+18] / [esp+1c] speaker GUID lo/hi — the unit the bubble goes on
// [esp+20] rank
// [esp+24] / [esp+28] GUID lo/hi read right before the message text
//
// The `RET 0x28` cleanup (40 bytes = 10 stack args) MUST match the declared
// signature exactly; an earlier 10-arg / `ret 0x20` version left 8 bytes
// uncleaned per call and crashed the engine on a later indirect call.
//
// WHICH GUID IS THE SENDER. Neither pair is the sender for every caller, so
// Sender() picks per caller and chat type (packet layouts in Offsets.h at
// FUN_CHAT_DISPATCH):
// - Monster say/yell/emote/whisper and raid boss emote: the last pair is the
// monster's TARGET — often the local player — and the speaker pair, where
// the packet has one, is a creature. 4.3.4 reports no GUID for a creature
// sender, so these report none.
// - Raid boss whisper (wire type 0x59): FUN_0049D560 reads name + TARGET and
// dispatches it as a plain whisper (type 6), so the chat type no longer
// shows it. The SMSG_MESSAGECHAT subscriber below keeps the wire type for
// the dispatch the handler is about to make.
// - The two pending-chat callers pass zeros for both pairs: the name-query
// callback that sends a sender's queued messages once the name arrives
// (every message from a sender the name cache did not know yet), and the
// enter-world drain. The entry keeps the GUID it was waiting on, and
// arg 5 / arg 7 point into that entry.
// - Everything else: the last pair.
#include "CurrentGUID.h"
#include "EventArgs.h"
#include "IconFilter.h"
#include "RaidMarkers.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include <cstdint>
#include <intrin.h>
namespace Chat::Dispatch {
namespace {
constexpr int CHAT_MSG_MONSTER_SAY = 0x0B;
constexpr int CHAT_MSG_MONSTER_YELL = 0x0C;
constexpr int CHAT_MSG_MONSTER_EMOTE = 0x0D;
constexpr int CHAT_MSG_MONSTER_WHISPER = 0x1A;
constexpr int CHAT_MSG_RAID_BOSS_WHISPER = 0x59;
constexpr int CHAT_MSG_RAID_BOSS_EMOTE = 0x5A;
using ChatDispatch_t = void(__fastcall *)(
const char *message, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi);
const char *senderName, int languageID, const char *channel, const char *targetName,
const char *flags, int speakerGuidLo, int speakerGuidHi, int rank,
int guidLo, int guidHi);
ChatDispatch_t g_original = nullptr;
// Wire chat type of the SMSG_MESSAGECHAT being handled. Set before the engine's
// handler runs, which is the only caller that returns to
// CHAT_DISPATCH_RET_PACKET, so it is always that packet's type there.
int g_packetChatType = -1;
void OnPacket(uint32_t opcode, Net::CDataStore *packet) {
if (opcode == Offsets::SMSG_MESSAGECHAT && packet != nullptr)
g_packetChatType = Net::Read<uint8_t>(packet);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
bool IsMonsterChat(int chatType) {
switch (chatType) {
case CHAT_MSG_MONSTER_SAY:
case CHAT_MSG_MONSTER_YELL:
case CHAT_MSG_MONSTER_EMOTE:
case CHAT_MSG_MONSTER_WHISPER:
case CHAT_MSG_RAID_BOSS_EMOTE:
return true;
default:
return false;
}
}
uint64_t Sender(const void *caller, int chatType, const char *channel, const char *flags,
int guidLo, int guidHi) {
if (IsMonsterChat(chatType))
return 0;
const auto at = reinterpret_cast<uintptr_t>(caller);
if (at == Offsets::CHAT_DISPATCH_RET_PENDING_NAME ||
at == Offsets::CHAT_DISPATCH_RET_PENDING_DRAIN) {
const auto *entry =
reinterpret_cast<const uint8_t *>(channel) - Offsets::OFF_PENDING_CHAT_CHANNEL;
// Both pointers must land in one entry, or this is not the layout the
// drain is known to pass.
if (reinterpret_cast<const uint8_t *>(flags) != entry + Offsets::OFF_PENDING_CHAT_FLAGS)
return 0;
return *reinterpret_cast<const uint64_t *>(entry + Offsets::OFF_PENDING_CHAT_GUID);
}
if (at == Offsets::CHAT_DISPATCH_RET_PACKET && g_packetChatType == CHAT_MSG_RAID_BOSS_WHISPER)
return 0;
return (static_cast<uint64_t>(static_cast<uint32_t>(guidHi)) << 32) |
static_cast<uint32_t>(guidLo);
}
void __fastcall ChatDispatch_h(
const char *message, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi)
const char *senderName, int languageID, const char *channel, const char *targetName,
const char *flags, int speakerGuidLo, int speakerGuidHi, int rank,
int guidLo, int guidHi)
{
// Strip player-injected `|T` icon escapes (covers chat line + speech bubble;
// both build from this message). The 0x800 buffer matches the engine's own
@@ -80,19 +152,19 @@ void __fastcall ChatDispatch_h(
char iconBuf[0x800];
const char *msg = Chat::IconFilter::Sanitize(message, iconBuf, sizeof iconBuf);
// Expand raid-target tokens ({rt1}/{skull}/…) into inline marker icons —
// after the sanitize, so only these fixed, safe escapes are added.
char markerBuf[0x800];
msg = Chat::RaidMarkers::Substitute(msg, markerBuf, sizeof markerBuf);
// Raid-target tokens ({rt1}/{skull}/…) become marker icons in the CHAT_MSG_*
// text the original fires (and, separately, in the bubble after its strip);
// the buffer keeps the tags. After the sanitize, so only these fixed, safe
// escapes are added.
Chat::RaidMarkers::EventScope markers;
// Publish the sender GUID for the CHAT_MSG_* event the original fires
// synchronously; restored when this scope ends (after the original returns).
Chat::CurrentGUID::DispatchScope guid(static_cast<uint32_t>(senderGuidLo),
static_cast<uint32_t>(senderGuidHi));
// The sender for the CHAT_MSG_* event the original fires synchronously;
// restored when this scope ends (after the original returns).
Chat::EventArgs::SenderScope sender(
Sender(_ReturnAddress(), chatType, channel, flags, guidLo, guidHi), flags, true);
g_original(msg, chatType, arg3, arg4, arg5, arg6, arg7,
targetGuidLo, targetGuidHi, arg10,
senderGuidLo, senderGuidHi);
g_original(msg, chatType, senderName, languageID, channel, targetName, flags,
speakerGuidLo, speakerGuidHi, rank, guidLo, guidHi);
}
static const Game::HookAutoRegister _hookreg{
+163
View File
@@ -0,0 +1,163 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// CHAT_MSG_* arg11 (lineID) and arg12 (sender GUID), as 4.3.4 fires them, and
// `GetCurrentChatGUID()`, which reads the same sender from inside the handler.
//
// 4.3.4's SendChatEvent (ChatFrame.cpp, 0x008E82E0) fires every chat event
// with "%s%s%s%s%s%s%d%d%s%d%d%s%u%b": the ten arguments 1.12 already passes,
// then lineID, the sender GUID string, and two Battle.net fields. 1.12 fires
// the first ten from one place, FUN_CHAT_EVENT_FIRE, so its FUN_FIRE_EVENT
// call is redirected here and the two are appended:
//
// arg11 lineID — ++counter for each message, before its event fires
// (4.3.4: `uint32_t lineID = ++s_chatLineID`). Text emotes
// get 0, as 4.3.4's AddTextEmoteMessage passes.
// arg12 guid — the sender's GUID string, only for a PLAYER GUID and not
// for a GM-tagged message; "" otherwise, never nil (4.3.4
// leaves its guidString buffer empty).
//
// 4.3.4's arg13 (Battle.net presence ID) and arg14 (mobile flag) have no
// 1.12 source and are left off.
//
// `GetCurrentChatGUID()` returns that same sender, as nil where arg12 is "",
// so code written for the function and code reading arg12 always agree.
//
// The sender comes from a SenderScope. Chat::Dispatch opens one around the
// chat-message dispatch with the GUID it picks for that chat type; the text
// emote builder is co-hooked below because it fires without passing its GUID
// on. A message fired outside any scope (FUN_0049B1B0, client-made notices)
// has no sender and still takes a lineID, as every 4.3.4 AddChatMessage does.
//
// The call site is redirected rather than FUN_FIRE_EVENT hooked: every event
// goes through FUN_FIRE_EVENT, chat goes through this one call. It also works
// under Turtle's patch, whose .tdata detour returns to the PUSH just before
// the redirected CALL.
#include "EventArgs.h"
#include "Common.h"
#include "RaidMarkers.h"
#include "Game.h"
#include "Offsets.h"
#include "debug/Log.h"
#include "guid/Guid.h"
#include <cstdint>
#include <cstring>
namespace Chat::EventArgs {
namespace {
uint64_t g_sender = 0; // 0 unless a player sender, already filtered
bool g_takesLineID = true;
uint32_t g_lineID = 0;
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
using TextEmote_t = void(__fastcall *)(const uint64_t *senderGuid, int emote, const char *target,
int emoteNum);
TextEmote_t g_textEmoteOriginal = nullptr;
// 1.12's chat tags are "AFK", "DND" and "GM"; 4.3.4's IsStaffChatFlag also
// matches "DEV", which this client never sends.
bool IsStaffFlag(const char *flags) { return flags != nullptr && std::strcmp(flags, "GM") == 0; }
// Same order as the engine's own ten; see Offsets.h at FUN_CHAT_EVENT_FIRE.
// cdecl with exactly the pushed argument count, so the caller's ADD ESP,0x30
// stays balanced.
void __cdecl FireSite(int eventId, const char *fmt, const char *text, const char *sender,
const char *language, const char *channelString, const char *target,
const char *flags, int zoneChannelID, int channelNumber,
const char *channelName, int channelUnk) {
const auto fire = reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT));
// `{rt1}` → marker icon in the event text only; the engine's buffer keeps
// the tag for the bubble. 0x800 matches the engine's message-buffer cap.
char markerBuf[0x800];
text = Chat::RaidMarkers::ForChatEvent(text, markerBuf, sizeof markerBuf);
// Another loader passing its own format: the arguments may not be ours to
// extend, so fire exactly what was asked.
if (fmt != reinterpret_cast<const char *>(Offsets::VAR_STR_CHAT_EVENT_FORMAT)) {
fire(eventId, fmt, text, sender, language, channelString, target, flags, zoneChannelID,
channelNumber, channelName, channelUnk);
return;
}
const uint32_t lineID = g_takesLineID ? ++g_lineID : 0;
char guid[Guid::STRING_SIZE] = "";
if (g_sender != 0)
Guid::FormatAsString(g_sender, guid, sizeof guid);
fire(eventId, "%s%s%s%s%s%s%d%d%s%d%d%s", text, sender, language, channelString, target, flags,
zoneChannelID, channelNumber, channelName, channelUnk, static_cast<int>(lineID), guid);
}
void __fastcall TextEmote_h(const uint64_t *senderGuid, int emote, const char *target,
int emoteNum) {
SenderScope scope(*senderGuid, nullptr, false); // the engine dereferences it unchecked too
g_textEmoteOriginal(senderGuid, emote, target, emoteNum);
}
int __fastcall Script_GetCurrentChatGUID(void *L) {
if (g_sender == 0)
return 0;
char buf[Guid::STRING_SIZE];
Game::Lua::PushString(L, Guid::FormatAsString(g_sender, buf, sizeof buf));
return 1;
}
// Once per process; a patched site no longer reaches FUN_FIRE_EVENT, so a
// second pass would refuse anyway.
bool g_applied = false;
void Apply() {
if (g_applied)
return;
g_applied = true;
auto *site = reinterpret_cast<uint8_t *>(Offsets::PATCH_CHAT_EVENT_FIRE_CALL);
int32_t rel;
std::memcpy(&rel, site + 1, sizeof rel);
if (site[0] != 0xE8 ||
reinterpret_cast<uintptr_t>(site) + 5 + rel != Offsets::FUN_FIRE_EVENT) {
Debug::Log::Printf("Chat::EventArgs: chat event fire call is not the stock CALL; "
"CHAT_MSG_* events keep 10 arguments");
return;
}
rel = static_cast<int32_t>(reinterpret_cast<uintptr_t>(&FireSite) -
(reinterpret_cast<uintptr_t>(site) + 5));
Common::PatchBytes(site + 1, &rel, sizeof rel);
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetCurrentChatGUID", &Script_GetCurrentChatGUID);
Apply();
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::HookAutoRegister _textEmoteHook{Offsets::FUN_CHAT_TEXT_EMOTE,
reinterpret_cast<void *>(&TextEmote_h),
reinterpret_cast<void **>(&g_textEmoteOriginal)};
} // namespace
SenderScope::SenderScope(uint64_t senderGuid, const char *flags, bool takesLineID)
: prevGuid_(g_sender), prevTakesLineID_(g_takesLineID) {
g_sender = (Guid::IsPlayer(senderGuid) && !IsStaffFlag(flags)) ? senderGuid : 0;
g_takesLineID = takesLineID;
}
SenderScope::~SenderScope() {
g_sender = prevGuid_;
g_takesLineID = prevTakesLineID_;
}
} // namespace Chat::EventArgs
+42
View File
@@ -0,0 +1,42 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Chat::EventArgs {
// RAII scope naming the sender of the CHAT_MSG_* event fired inside it, for
// the event's arg12 and for `GetCurrentChatGUID()`. The engine fires the event
// synchronously inside the call the scope wraps. Restores the prior state on
// destruction rather than resetting it, so a chat event fired from another's
// handler leaves the outer message's sender intact when the inner one returns.
//
// `flags` is the message's chat tag ("AFK"/"DND"/"GM", or null). A sender
// counts only when it is a player and the message is not GM-tagged, as in
// 4.3.4. `takesLineID` is false only for text emotes, which 4.3.4 fires with
// lineID 0.
class SenderScope {
public:
SenderScope(uint64_t senderGuid, const char *flags, bool takesLineID);
~SenderScope();
SenderScope(const SenderScope &) = delete;
SenderScope &operator=(const SenderScope &) = delete;
private:
uint64_t prevGuid_;
bool prevTakesLineID_;
};
} // namespace Chat::EventArgs
+45 -43
View File
@@ -13,27 +13,19 @@
// Inline-texture (`|T`, `|A`) chat anti-spoof.
//
// Vanilla neutralizes player-*typed* escapes in chat (a typed `|cff…|r`,
// `|Hitem…|h` or `|T…|t` shows as raw text) — only *trusted* text renders them
// (addon `AddMessage`, shift-clicked links). Our Text::InlineTexture backport
// breaks that parity for the two texture markers: it detects the neutralized
// `||T` / `||A` form and draws the art anyway, so a player could `/say`
// `|Tpath|t` and have a real icon appear in everyone's chat AND in the speech
// bubble over their head.
// A player-typed escape never renders: the edit box stores a typed `|` as `||`,
// the escaped pipe, which every text path draws as a literal `|`. That alone
// keeps a typed `|Tpath|t` literal. The send-side escape check
// (SendChatMessage) also rejects a `|` followed by anything but `|` or `c` —
// but it skips from a `|c` to the next `|r` unchecked, so a modified client
// or an addon can still smuggle a real `|T` inside a colour run, and it would
// render as art in everyone's chat and in the bubble over the sender.
//
// This restores vanilla's behavior for both markers by defanging them in the
// chat path, while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw
// for typed input). It CANNOT be done in the text emitter: addon `|T` and player
// `|T` reach the emitter as byte-identical `||T` (vanilla doubles the
// unrecognized escape for both), so the only place they're distinguishable is
// the source — server-delivered chat flows through the chat dispatcher, addon
// `print` does not. Chat::Dispatch owns that hook and calls this on the message.
//
// Defang = replace the `|` of each opener with a space, breaking the pipe→letter
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
// `|T` substring, so this catches the doubled form too). The payload text stays
// readable, the closer is inert without an opener, and every other escape is
// untouched.
// So server-delivered chat is checked once at the source — Chat::Dispatch
// owns the dispatch hook and calls this on the message — and each real opener
// gets the engine's own neutralization: its `|` is doubled, the bytes a typed
// one has, so it shows as the literal text the sender typed. `||` pairs are
// already literal and are left alone, as is every other escape.
//
// ANY new marker the emitter learns MUST be added here in the same change, or it
// becomes a chat-spoof vector.
@@ -44,17 +36,19 @@ namespace Chat::IconFilter {
namespace {
// True if `s` contains an inline-texture opener anywhere. Both markers count:
// `|T` names a texture path and `|A` names an atlas, and either one would render
// art from player-typed text if it survived to the fontstring.
bool IsIconOpener(const char *s) {
return s[0] == '|' && (s[1] == 'T' || s[1] == 'A');
}
// A real inline-texture opener at `s`: `|T` names a texture path and `|A` an
// atlas. Callers step over `||` pairs, so `s` is always at a token start.
bool IsIconOpener(const char *s) { return s[0] == '|' && (s[1] == 'T' || s[1] == 'A'); }
bool HasIconEscape(const char *s) {
for (; *s != '\0'; ++s)
for (; *s != '\0'; ++s) {
if (s[0] == '|' && s[1] == '|') {
++s; // escaped pipe
continue;
}
if (IsIconOpener(s))
return true;
}
return false;
}
@@ -66,27 +60,35 @@ const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
// NEVER write `msg` in place: several of the chat handler's ~50 callers pass
// read-only `.rdata` literals (system notifications), so an in-place edit
// would fault. We copy only when a `|T` is actually present.
// would fault. We copy only when an opener is actually present.
//
// Defanging must not leave a LONE `|` followed by a non-escape char — that's
// an invalid escape, which the chat frame tolerates but the speech-bubble
// text setter chokes on, producing an empty bubble (verified in-game). By the
// time chat reaches us the escape is usually the doubled `||T` (vanilla's own
// neutralization of typed escapes), where `||` is a VALID literal-pipe escape.
// So: keep the pipe(s) and blank the `T` when the pipe completes a `||`; blank
// BOTH chars for a lone `|T`. Either way no dangling `|` remains, and no `|T`
// opener survives for the inline-texture detector to fire on.
// Never leave a lone `|` before a non-escape character: the chat frame
// tolerates it, but the speech-bubble builder ends the text there (it drops
// a `|` with everything up to the next `[`). A doubled `||` is the valid
// literal-pipe escape; when the buffer has no room for the extra byte, the
// opener is blanked instead.
size_t j = 0;
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
if (IsIconOpener(msg + i)) {
const bool doubledPipe = (i > 0 && msg[i - 1] == '|');
buf[j++] = doubledPipe ? '|' : ' '; // keep pipe only if it completes `||`
if (j + 1 < bufSize)
buf[j++] = ' '; // blank the `T` / `A`
i += 2; // consumed the opener
} else {
if (msg[i] == '|' && msg[i + 1] == '|') {
buf[j++] = msg[i++];
if (j + 1 < bufSize)
buf[j++] = msg[i++];
continue;
}
if (IsIconOpener(msg + i)) {
if (j + 3 < bufSize) {
buf[j++] = '|';
buf[j++] = '|';
buf[j++] = msg[i + 1];
} else {
buf[j++] = ' ';
if (j + 1 < bufSize)
buf[j++] = ' ';
}
i += 2;
continue;
}
buf[j++] = msg[i++];
}
buf[j] = '\0';
return buf;
+6 -6
View File
@@ -17,12 +17,12 @@
namespace Chat::IconFilter {
// Strips player-injected inline-texture (`|T…|t`) escapes from a chat message.
// Returns `msg` unchanged when it contains no `|T` (the common case — no copy),
// otherwise a sanitized copy written into `buf`. Called by Chat::Dispatch on the
// message the engine's chat handler is about to display. See IconFilter.cpp for
// the anti-spoof rationale and why sanitizing here (not in the text emitter) is
// the only place player-typed and addon `|T` are distinguishable.
// Neutralizes player-injected inline-texture (`|T…|t`, `|A…|a`) openers in a
// chat message, as the edit box neutralizes typed ones: the `|` is doubled.
// Returns `msg` unchanged when it contains no opener (the common case — no
// copy), otherwise a sanitized copy written into `buf`. Called by
// Chat::Dispatch on the message the engine's chat handler is about to display.
// See IconFilter.cpp for how a real opener can reach chat at all.
const char *Sanitize(const char *msg, char *buf, size_t bufSize);
} // namespace Chat::IconFilter
+23 -13
View File
@@ -19,10 +19,15 @@
// a `|T` escape that crops the shared raid-target sprite sheet
// `Interface\TargetingFrame\UI-RaidTargetingIcons` to the matching 64×64 cell.
//
// Runs in Chat::Dispatch AFTER Chat::IconFilter::Sanitize. Order is deliberate:
// sanitize first defangs any player-typed raw `|T` spoof, then we add ONLY the
// fixed, known-safe marker escapes — so players get the markers without a path to
// inject arbitrary textures.
// Two consumers, as in 4.3.4, both AFTER Chat::Dispatch's
// Chat::IconFilter::Sanitize (which defangs a smuggled `|T`, so only these
// fixed, known-safe marker escapes are added):
// - the CHAT_MSG_* event text (ForChatEvent, at Chat::EventArgs' fire site)
// — 4.3.4's chat frame expands the tags for display;
// - the speech bubble, after the bubble's own escape strip
// (chatbubble/RaidIcons.cpp) — 4.3.4's CGChatBubbleFrame::ParseRaidIcons.
// The message buffer itself keeps the `{rt1}` text: the bubble builder drops
// every `|` code it does not know, so it must see the tags, not the markup.
//
// Icon→texcoord mapping verified against the client's own UnitPopup.lua
// (RAID_TARGET_1..8): the sheet is 256×256 with 64-pixel cells, marker N at
@@ -113,13 +118,6 @@ const char *Substitute(const char *msg, char *buf, size_t bufSize) {
const int col = (marker - 1) % 4;
const int row = (marker - 1) / 4;
const int l = col * 64, r = l + 64, t = row * 64, b = t + 64;
// Emit the pipe-DOUBLED form `||T…||t`. The inline emitter
// renders both `|T` and `||T`, but the vanilla speech-bubble
// text builder (FUN_004b1600) TRUNCATES at a lone `|T` while
// PRESERVING `||` — so the doubled form is what survives into
// the bubble's FontString (which pfUI's bubble reskin copies
// and renders). Anti-spoof Sanitize runs before this, so these
// are the only `||T` openers a player can produce.
char markup[128];
// `:0:0` = auto-size. The inline emitter resolves height/width
// to the target line's font pixel height at draw time, so a
@@ -128,8 +126,8 @@ const char *Substitute(const char *msg, char *buf, size_t bufSize) {
// payload crops the 256x256 sheet to the cell (l,r,t,b).
const int m = std::snprintf(
markup, sizeof markup,
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:0:0:0:0:256:256:"
"%d:%d:%d:%d||t",
"|TInterface\\TargetingFrame\\UI-RaidTargetingIcons:0:0:0:0:256:256:"
"%d:%d:%d:%d|t",
l, r, t, b);
// Reserve room for the literal tail after this token
// (msg[k+1..]) so the rest of the message always fits.
@@ -151,4 +149,16 @@ const char *Substitute(const char *msg, char *buf, size_t bufSize) {
return buf;
}
namespace {
bool g_inDispatch = false;
} // namespace
EventScope::EventScope() : prev_(g_inDispatch) { g_inDispatch = true; }
EventScope::~EventScope() { g_inDispatch = prev_; }
const char *ForChatEvent(const char *text, char *buf, size_t bufSize) {
return g_inDispatch ? Substitute(text, buf, bufSize) : text;
}
} // namespace Chat::RaidMarkers
+22 -4
View File
@@ -21,10 +21,28 @@ namespace Chat::RaidMarkers {
// icon textures — the modern `{rt1}`..`{rt8}` / `{star}` / `{circle}` / `{skull}`
// (etc.) chat substitutions, backported onto the inline-texture renderer. Returns
// `msg` unchanged when it contains no `{` (the common case — no copy), otherwise a
// rewritten copy in `buf`. Called by Chat::Dispatch AFTER the `|T` anti-spoof
// sanitize, so a player can use the fixed, safe marker icons but still can't
// inject an arbitrary texture path. See RaidMarkers.cpp for the token table and
// the icon→texcoord mapping.
// rewritten copy in `buf`. Only ever applied to text that went through the `|T`
// anti-spoof sanitize, so a player can use the fixed, safe marker icons but
// still can't inject an arbitrary texture path. See RaidMarkers.cpp for the
// token table, the icon→texcoord mapping and the two consumers.
const char *Substitute(const char *msg, char *buf, size_t bufSize);
// Marks a server chat message being dispatched (Chat::Dispatch opens it around
// the engine's dispatch). Only its CHAT_MSG_* event gets the markers; client
// notices and text emotes fire outside one. Restores the prior state.
class EventScope {
public:
EventScope();
~EventScope();
EventScope(const EventScope &) = delete;
EventScope &operator=(const EventScope &) = delete;
private:
bool prev_;
};
// The CHAT_MSG_* event text: Substitute inside an EventScope, else `text`.
// Called from Chat::EventArgs' fire site.
const char *ForChatEvent(const char *text, char *buf, size_t bufSize);
} // namespace Chat::RaidMarkers
+76
View File
@@ -0,0 +1,76 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Raid-target icons in speech bubbles — 4.3.4's CGChatBubbleFrame::
// ParseRaidIcons. 4.3.4's bubble Initialize strips the line's hyperlinks,
// expands `{rt1}`/`{skull}`/… into icon markup, then sets the text. 1.12's
// bubble builder (FUN_004B1600) strips into a stack buffer and sets it with
// one CALL; that call is redirected here, so the tags are expanded after the
// strip, exactly where 4.3.4 does it. The strip has to come first: it drops
// every `|` code it does not know, an icon included.
//
// The message reached the builder through Chat::Dispatch, so the `|T`
// anti-spoof sanitize already ran; only the fixed marker escapes are added.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "chat/RaidMarkers.h"
#include "debug/Log.h"
#include <cstdint>
#include <cstring>
namespace ChatBubble::RaidIcons {
namespace {
using SetText_t = void(__fastcall *)(void *fs, void *edx, const char *text, int expand);
// The redirected call: `__thiscall(fs, text, expand)`, RET 8 — the dummy-EDX
// __fastcall shape matches its registers and stack.
void __fastcall SetBubbleText(void *fs, void *edx, const char *text, int expand) {
// The builder's own buffer is 0x800 bytes.
char buf[0x800];
const char *withIcons = Chat::RaidMarkers::Substitute(text, buf, sizeof buf);
reinterpret_cast<SetText_t>(static_cast<uintptr_t>(Offsets::FUN_FONTSTRING_SET_TEXT))(
fs, edx, withIcons, expand);
}
// Once per process; a patched site no longer reaches FUN_FONTSTRING_SET_TEXT,
// so a second pass would refuse anyway.
bool g_applied = false;
void Apply() {
if (g_applied)
return;
g_applied = true;
auto *site = reinterpret_cast<uint8_t *>(Offsets::PATCH_CHAT_BUBBLE_SET_TEXT_CALL);
int32_t rel;
std::memcpy(&rel, site + 1, sizeof rel);
if (site[0] != 0xE8 ||
reinterpret_cast<uintptr_t>(site) + 5 + rel != Offsets::FUN_FONTSTRING_SET_TEXT) {
Debug::Log::Printf("ChatBubble::RaidIcons: the bubble SetText call is not the stock "
"CALL; bubbles keep raid tags as text");
return;
}
rel = static_cast<int32_t>(reinterpret_cast<uintptr_t>(&SetBubbleText) -
(reinterpret_cast<uintptr_t>(site) + 5));
Common::PatchBytes(site + 1, &rel, sizeof rel);
}
const Game::ModuleAutoRegister _autoreg{&Apply};
} // namespace
} // namespace ChatBubble::RaidIcons
+21 -67
View File
@@ -18,9 +18,13 @@
//
// - `text` the string to copy (numbers are coerced, like Lua does).
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
// visible text, |T..|t textures, |n newline, || → |) before
// copying, so the clipboard gets plain text.
// before copying, so the clipboard gets plain text. This is
// the engine's own GxuStripControlCodes with 4.3.4's copy
// mask — exactly what an edit box's Ctrl+C does: colours
// and hyperlink markers go (the visible text stays),
// `|T..|t` / `|A..|a` icons go (the strip co-hook in
// Text::InlineTexture), `|n` becomes a newline and `||` a
// single `|`.
// - returns byte length of the copied (possibly stripped) string —
// consistent with Lua's `#s`. 0 on an empty string or if
// the clipboard couldn't be opened.
@@ -30,7 +34,10 @@
// synthesizes CF_TEXT for legacy consumers.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
#include <string>
#include <windows.h>
@@ -39,71 +46,18 @@ namespace Clipboard::Copy {
namespace {
// Strip WoW UI escape sequences, leaving readable text. See header comment for
// the set handled; unknown `|x` sequences are left verbatim so real text that
// merely contains a pipe survives.
using StripCodes_t = int(__fastcall *)(const char *text, int length, uint32_t mask, char *out,
int capacity);
// The engine's strip never lengthens a text (every code it rewrites gets
// shorter), so the input length plus the terminator is enough room. It
// returns a pointer, not a count, and always terminates `out`.
std::string StripMarkup(const char *s, unsigned len) {
std::string out;
out.reserve(len);
unsigned i = 0;
while (i < len) {
const char c = s[i];
if (c != '|') {
out.push_back(c);
++i;
continue;
}
const char n = (i + 1 < len) ? s[i + 1] : '\0';
switch (n) {
case '|': // escaped pipe
out.push_back('|');
i += 2;
break;
case 'n': // newline token
out.push_back('\n');
i += 2;
break;
case 'r': // color reset
i += 2;
break;
case 'c': // color open: |c + up to 8 hex digits
i += 2;
for (int k = 0; k < 8 && i < len; ++k) {
const char h = s[i];
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
(h >= 'A' && h <= 'F');
if (!isHex)
break;
++i;
}
break;
case 'h': // hyperlink boundary (text separator or closer) — drop
i += 2;
break;
case 'H': // hyperlink data opener — skip through to the next |h
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
++i;
break; // the |h is dropped by the 'h' case on the next iteration
case 't': // stray texture closer — drop
case 'a': // stray atlas closer — drop
i += 2;
break;
case 'T': // texture opener — skip through to the next |t
case 'A': // atlas opener — skip through to the next |a
{
const char closer = (s[i + 1] == 'A') ? 'a' : 't';
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == closer))
++i;
break; // the closer is dropped by the 't'/'a' case next iteration
}
default: // unknown sequence — keep the pipe literally
out.push_back('|');
++i;
break;
}
}
std::string out(static_cast<size_t>(len) + 1, '\0');
reinterpret_cast<StripCodes_t>(static_cast<uintptr_t>(Offsets::FUN_GXU_STRIP_CONTROL_CODES))(
s, static_cast<int>(len), Offsets::GXU_STRIP_MASK_COPY, out.data(),
static_cast<int>(out.size()));
out.resize(std::strlen(out.c_str()));
return out;
}
+5 -6
View File
@@ -26,6 +26,7 @@
#include "Game.h"
#include "Offsets.h"
#include "target/SoftAttack.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -36,7 +37,6 @@ namespace {
using Start_t = void(__thiscall *)(void *player, const uint64_t *targetGuid);
using Stop_t = void(__fastcall *)(void *player);
using ResolveTarget_t = int(__thiscall *)(void *player, uint64_t *outGuid);
// Resolve an optional unit-token argument to a GUID without raising.
// `Unit::Identity::GuidForToken` raises for non-token strings (a name or
@@ -79,11 +79,10 @@ static int __fastcall Script_StartAttack(void *L) {
uint64_t target = SafeResolveToken(L, Game::Lua::ToString(L, 1));
if (target == 0) {
// No explicit target given (or unresolvable): validate and take the
// current target. Returns 0 — and posts the engine's own UI error —
// when there's nothing valid to attack.
auto resolve =
reinterpret_cast<ResolveTarget_t>(Offsets::FUN_ATTACK_RESOLVE_TARGET);
if (resolve(player, &target) == 0)
// current target, or with none the soft enemy (action targeting).
// Returns 0 — and posts the engine's own UI error — when there's
// nothing valid to attack.
if (Target::SoftAttack::ResolveMelee(player, &target) == 0)
return 0;
}
+34 -11
View File
@@ -74,13 +74,25 @@ struct State {
};
State g_swing[3];
void MarkReset(SwingType type, uint32_t durationMs) {
// `notify` false records the server's new timer without firing PLAYER_SWING:
// the reset is real, but no swing is coming yet (see OnSpellGo).
void MarkReset(SwingType type, uint32_t durationMs, bool notify = true) {
if (durationMs == 0)
return; // no weapon for this hand right now -- nothing to report
State &s = g_swing[type];
s.endMs = NowMs() + durationMs;
s.active = true;
s.dirty = true;
s.dirty = s.dirty || notify;
}
using HasAttackTarget_t = int(__fastcall *)(const void *unit);
// The engine's own "is the player auto-attacking" test (CGUnit_C::
// HasAttackTarget, the victim GUID the attack start stamps).
bool PlayerAttacking() {
const uint8_t *player = Unit::Identity::PlayerObject();
return player != nullptr &&
reinterpret_cast<HasAttackTarget_t>(Offsets::FUN_UNIT_HAS_ATTACK_TARGET)(player) != 0;
}
bool PlayerInCombat() {
@@ -276,7 +288,7 @@ void ApplyParryHaste() {
// ---- Ranged wind-up compensation ------------------------------------------
using GetCastTime_t = int(__fastcall *)(int spellID, int unit, int flag);
using GetCastTime_t = int(__fastcall *)(int spellID, int pet, int allowNegative);
// Auto Shot / Shoot are re-cast by the server as TRIGGERED spells, so the
// per-shot SMSG_SPELL_START is skipped and SMSG_SPELL_GO is the only packet
@@ -335,8 +347,13 @@ void OnSpellGo(uint32_t spellId) {
(ForcedSwingReset(spellId) ||
!(attrEx2 & Offsets::SPELL_ATTR_EX2_NOT_RESET_AUTO_ACTIONS)) &&
WasNonTriggeredCast(spellId)) {
MarkReset(MAIN_HAND, AttackTimeMs(MAIN_HAND));
MarkReset(OFF_HAND, AttackTimeMs(OFF_HAND));
// The server resets both timers on every such cast, attacking or
// not (Spell::cast). Out of an attack no swing follows, so a buff
// cast must not start a bar; the attack start below reports a reset
// that is still running when the first swing becomes real.
const bool attacking = PlayerAttacking();
MarkReset(MAIN_HAND, AttackTimeMs(MAIN_HAND), attacking);
MarkReset(OFF_HAND, AttackTimeMs(OFF_HAND), attacking);
}
}
@@ -390,8 +407,14 @@ void OnPacket(uint32_t opcode, CDataStore *packet) {
auto *victimObj = Object::ByGuid(
Offsets::TYPEMASK_UNIT | Offsets::TYPEMASK_PLAYER, victim);
auto *playerObj = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (InMeleeRange(playerObj, victimObj))
if (InMeleeRange(playerObj, victimObj)) {
MarkReset(OFF_HAND, AttackTimeMs(OFF_HAND));
// A cast reset made before the attack still holds the
// main-hand swing back; report it now that one is coming.
State &main = g_swing[MAIN_HAND];
if (main.active && Time::Clock::Remaining(NowMs(), main.endMs) > 0)
main.dirty = true;
}
}
return;
}
@@ -413,11 +436,11 @@ const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
// A weapon equipped while `IsInCombat()` resets that hand's timer
// server-side (`Player::_ApplyWeaponDependentAuraMods` -> `SetAttackTime`
// with `resetTimer = IsInCombat()`). There's no dedicated packet for this;
// `Player::Equipment` already tracks the same invMgr GUIDs via a real
// descriptor observer for `PLAYER_EQUIPMENT_CHANGED`, but that's a one-way
// Lua event fire with nothing to subscribe to in C++. Only 3 slots matter
// here, so a plain 3-GUID compare on the existing WorldTick pass is simpler
// than standing up a second observer registration for the same fields.
// `Player::Equipment` already sees the same invMgr GUIDs change via the
// engine's inventory-slot observer for `PLAYER_EQUIPMENT_CHANGED`, but
// that's a one-way Lua event fire with nothing to subscribe to in C++. Only
// 3 slots matter here, so a plain 3-GUID compare on the existing WorldTick
// pass is simpler than a second hook on the same observer.
uint64_t g_lastMainGuid = 0;
uint64_t g_lastOffGuid = 0;
+70
View File
@@ -0,0 +1,70 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The `stopAutoAttackOnTargetChange` cvar — 4.3.4's CGGameUI::Target tail:
//
// FrameScript_SignalEvent(PLAYER_TARGET_CHANGED);
// if (cvar) { if (Spell_C_GetAutoRepeatingSpell()) Spell_C_CancelAutoRepeat(true); }
// else if (inMelee) player->CombatModeEnter(newTarget);
//
// 1.12's target setter (FUN_SET_SELECTION_BY_GUID) already has the cvar-0
// branch: releasing the old target stops melee, and after the event it
// restarts melee on the new target when the player was attacking. Both are
// direct calls, so the cvar is two call redirects in that function only:
// the event fire also stops auto-repeat, and the melee restart is skipped.
// Defaults to 0 as in 4.3.4, which is 1.12's behavior.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
#include <cstdint>
namespace Combat::TargetChange {
namespace {
using FireEvent_t = void(__fastcall *)(int eventID);
using AttackStart_t = void(__fastcall *)(void *player, void *edx, const uint64_t *guid);
using StopAutoRepeat_t = void(__cdecl *)();
CVar::Factory::Handle g_stopOnChange = nullptr;
bool StopOnChange() { return CVar::Factory::GetInt(g_stopOnChange) != 0; }
void __fastcall FireTargetChanged(int eventID) {
reinterpret_cast<FireEvent_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS)(eventID);
if (StopOnChange())
reinterpret_cast<StopAutoRepeat_t>(Offsets::FUN_STOP_AUTO_REPEAT_SPELL)();
}
void __fastcall RestartMelee(void *player, void *, const uint64_t *guid) {
if (!StopOnChange())
reinterpret_cast<AttackStart_t>(Offsets::FUN_ATTACK_START)(player, nullptr, guid);
}
void Register() {
g_stopOnChange = CVar::Factory::Register("stopAutoAttackOnTargetChange", "Whether to stop attacking when changing targets", Offsets::CVAR_CATEGORY_GAME, "0");
Common::RedirectCall(Offsets::CALL_SET_SELECTION_FIRE_TARGET_CHANGED,
Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<const void *>(&FireTargetChanged));
Common::RedirectCall(Offsets::CALL_SET_SELECTION_ATTACK_START, Offsets::FUN_ATTACK_START,
reinterpret_cast<const void *>(&RestartMelee));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Combat::TargetChange
+156
View File
@@ -0,0 +1,156 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Auras and dispels — 4.3.4's UnitCombatLogAuraAddedOrRemoved,
// UnitCombatLogAuraModifiedApplications, UnitCombatLogDispelled and
// Packet_SMSG_DISPEL_FAILED.
//
// An aura's arrival, loss and stack change are the engine's own aura
// handlers (OnAuraAdded / OnAuraRemoved / OnAuraStacksChanged, which write
// 1.12's aura lines), reached through Aura::Source, which binds each aura to
// its caster. The rules are 4.3.4's, which are also 1.12's (LogAuraChange
// 0x0062B480, LogAuraApplication 0x0062B800): no passive spell, no loss on a
// dead unit, and stacks only for a spell that stacks. 4.3.4 also logs a
// stack's fall (SPELL_AURA_REMOVED_DOSE), which 1.12 does not write.
//
// 4.3.4's SPELL_AURA_REFRESH needs the server to say that an aura's duration
// restarted, which 1.12 never says for another unit, so it does not occur.
//
// 1.12's dispel messages name no dispelling spell (handlers 0x005E8B60,
// 0x00628C20; vmangos writes none), so the entry's spell is spellId 0 with no
// name, as a damage shield's is:
// 0x27b SPELLDISPELLOG packed target, packed caster, count, count × spell
// 0x262 DISPEL_FAILED caster, target (u64), spells to the end
// A dispel's BUFF or DEBUFF is the slot the aura holds on the target, which
// it still holds: the message comes before the update that removes it.
#include "combatlog/Entry.h"
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "aura/Source.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
#include <cstdint>
namespace CombatLog::Auras {
namespace {
using Net::CDataStore;
using Net::Read;
using Net::ReadPackedGuid;
constexpr uint32_t SMSG_DISPEL_FAILED = 0x262;
constexpr uint32_t SMSG_SPELLDISPELLOG = 0x27b;
bool Overrun(const CDataStore *p) { return p->m_read > p->m_size; }
int Health(const void *unit) {
const auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return fields != nullptr ? Game::Read<int>(fields, Offsets::OFF_UNIT_FIELD_HEALTH) : 0;
}
void OnAuraChange(const Aura::Source::Change &c) {
const uint8_t *spell = SpellRecord(static_cast<int>(c.spellId));
if (c.spellId == 0 || Hidden(spell))
return;
const uint64_t unit = Unit::Identity::GuidForObject(c.unit);
if (c.type == Aura::Source::ChangeType::Stacks) {
if (Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_STACK_AMOUNT) <= 1 ||
c.newStacks == c.oldStacks)
return;
Entry e = Begin(c.caster, unit,
c.newStacks > c.oldStacks ? SPELL_AURA_APPLIED_DOSE
: SPELL_AURA_REMOVED_DOSE,
spell);
e.info |= INFO_AURA_TYPE | INFO_AURA_AMOUNT;
e.harmful = c.harmful;
e.v[2] = c.newStacks;
Queue(e);
return;
}
const bool added = c.type == Aura::Source::ChangeType::Added;
if ((!added && Health(c.unit) <= 0) ||
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
Offsets::SPELL_ATTR_PASSIVE) != 0)
return;
Entry e = Begin(c.caster, unit, added ? SPELL_AURA_APPLIED : SPELL_AURA_REMOVED, spell);
e.info |= INFO_AURA_TYPE;
e.harmful = c.harmful;
Queue(e);
}
const Aura::Source::AutoObserve _obs{&OnAuraChange};
// The polarity of the slot `spellId` holds on `target`; a target the client
// does not know, or no longer holding it, reads as BUFF.
bool Harmful(uint64_t target, uint32_t spellId) {
const auto *unit =
static_cast<const uint8_t *>(Object::ByGuid(Offsets::TYPEMASK_UNIT, target));
if (unit == nullptr)
return false;
const int slot = Aura::Data::FindSlotBySpellID(unit, spellId, nullptr);
return slot >= 0 && Aura::Data::IsSlotHarmful(unit, slot);
}
// One entry with the dispelled spell as its extra spell.
void SendDispel(uint64_t caster, uint64_t target, int event, int dispelledId, bool auraType) {
if (Hidden(SpellRecord(dispelledId)))
return;
Entry e = Begin(caster, target, event, nullptr);
e.spellPrefix = true; // the dispelling spell, which 1.12 does not name
e.info |= INFO_EXTRA_SPELL;
e.v[0] = dispelledId;
if (auraType) {
e.info |= INFO_AURA_TYPE;
e.harmful = Harmful(target, static_cast<uint32_t>(dispelledId));
}
Queue(e);
}
// SMSG_SPELLDISPELLOG → UnitCombatLogDispelled.
void DispelLog(CDataStore *p) {
const uint64_t target = ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const uint32_t count = Read<uint32_t>(p);
for (uint32_t i = 0; i < count && !Overrun(p); ++i) {
const int dispelled = Read<int32_t>(p);
if (!Overrun(p))
SendDispel(caster, target, SPELL_DISPEL, dispelled, true);
}
}
// SMSG_DISPEL_FAILED → Packet_SMSG_DISPEL_FAILED.
void DispelFailed(CDataStore *p) {
const uint64_t caster = Read<uint64_t>(p);
const uint64_t target = Read<uint64_t>(p);
while (!Overrun(p) && p->m_read + sizeof(int32_t) <= p->m_size)
SendDispel(caster, target, SPELL_DISPEL_FAILED, Read<int32_t>(p), false);
}
void OnPacket(uint32_t opcode, CDataStore *p) {
if (opcode == SMSG_SPELLDISPELLOG)
DispelLog(p);
else if (opcode == SMSG_DISPEL_FAILED)
DispelFailed(p);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace CombatLog::Auras
+168
View File
@@ -0,0 +1,168 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Casts — 4.3.4's UnitCombatLogCastStart, UnitCombatLogCastGo and
// UnitCombatLogSpellFail.
//
// SPELL_CAST_START and SPELL_CAST_SUCCESS are read from SMSG_SPELL_START and
// SMSG_SPELL_GO through Net::PacketDispatch (nampower detours both handlers).
// 4.3.4 logs them from the same two messages. Its two builders read the
// unit's casting time for the spell with opposite tests — the 3.3.5 binary
// agrees (0x00751920 and 0x007519E0 call the same vtable slot): a spell with a
// cast time logs its start, an instant spell its success, and a cast-time
// spell no success.
//
// SPELL_CAST_FAILED is the 1.12 failure line's own moment
// (FUN_COMBAT_LOG_SPELL_FAIL), which the failure reporter calls with the
// player and the error text just shown, as 4.3.4 calls UnitCombatLogSpellFail.
//
// 1.12's bodies (handlers 0x006E7640 / 0x006E7A70, target reader 0x007E4ED0):
// 0x131 SPELL_START packed item or caster, packed caster, spell, u16 flags,
// cast time, targets
// 0x132 SPELL_GO packed item or caster, packed caster, spell, u16 flags,
// u8 n, n × u64 hit, u8 m, m × {u64, u8 reason, u8 more
// for a reflect}, targets
// targets u16 mask, a packed object GUID for mask 0x8A02 (unit,
// corpse or game object), then the rest by mask
#include "combatlog/Entry.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "unit/Identity.h"
#include <cstdint>
namespace CombatLog::Casts {
namespace {
using Net::CDataStore;
using Net::Read;
using Net::ReadPackedGuid;
constexpr uint32_t SMSG_SPELL_START = 0x131;
constexpr uint32_t SMSG_SPELL_GO = 0x132;
// The cast flag that keeps a cast out of the combat log (both servers'
// CAST_FLAG_HIDDEN_COMBATLOG); 4.3.4 tests the same bit.
constexpr uint16_t CAST_FLAG_HIDDEN_COMBATLOG = 0x1;
constexpr uint16_t TARGET_FLAG_OBJECT = 0x8A02;
constexpr uint8_t SPELL_MISS_REFLECT = 11;
// 4.3.4's SpellHasOpenLockEffect tests 33 alone; 1.12's success line also
// drops 59 (0x0062AD90), which 4.3.4 logs.
constexpr int SPELL_EFFECT_OPEN_LOCK = 33;
constexpr int SPELL_EFFECT_LEARN_SPELL = 36;
// AttributesEx4 0x1: the cast writes no line (the 1.12 start line tests it,
// 0x0062B30D, as 4.3.4 does).
constexpr uint32_t ATTR_EX4_NO_CAST_LOG = 0x1;
// 4.3.4's failure builder drops only this Attributes bit, not 0x100.
constexpr uint32_t ATTR_FAIL_HIDDEN = 0x80;
bool Overrun(const CDataStore *p) { return p->m_read > p->m_size; }
int CastingTime(void *unit, int spellId) {
using CastingTime_t = int(__thiscall *)(void *unit, int spellId);
const auto *vtbl = Game::Read<const uintptr_t *>(unit, 0);
return reinterpret_cast<CastingTime_t>(
vtbl[Offsets::OFF_CGUNIT_VTBL_GET_SPELL_CASTING_TIME / sizeof(uintptr_t)])(unit,
spellId);
}
// The rules both cast builders share.
bool Logged(const uint8_t *spell) {
return !Hidden(spell) && !HasEffect(spell, SPELL_EFFECT_LEARN_SPELL) &&
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX4) &
ATTR_EX4_NO_CAST_LOG) == 0;
}
// SMSG_SPELL_START → UnitCombatLogCastStart: a cast with a cast time.
void SpellStart(CDataStore *p) {
ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const uint16_t flags = Read<uint16_t>(p);
if (Overrun(p) || (flags & CAST_FLAG_HIDDEN_COMBATLOG) != 0)
return;
void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, caster);
const uint8_t *spell = SpellRecord(spellId);
if (unit == nullptr || !Logged(spell) || CastingTime(unit, spellId) == 0)
return;
Entry e = Begin(caster, 0, SPELL_CAST_START, spell);
Queue(e);
}
// SMSG_SPELL_GO → UnitCombatLogCastGo: an instant cast, at its target.
void SpellGo(CDataStore *p) {
ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const uint16_t flags = Read<uint16_t>(p);
const int hits = Read<uint8_t>(p);
for (int i = 0; i < hits && !Overrun(p); ++i)
Read<uint64_t>(p);
const int misses = Read<uint8_t>(p);
for (int i = 0; i < misses && !Overrun(p); ++i) {
Read<uint64_t>(p);
if (Read<uint8_t>(p) == SPELL_MISS_REFLECT)
Read<uint8_t>(p);
}
const uint16_t targetMask = Read<uint16_t>(p);
const uint64_t target = (targetMask & TARGET_FLAG_OBJECT) ? ReadPackedGuid(p) : 0;
if (Overrun(p) || (flags & CAST_FLAG_HIDDEN_COMBATLOG) != 0)
return;
void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, caster);
const uint8_t *spell = SpellRecord(spellId);
if (unit == nullptr || !Logged(spell) || HasEffect(spell, SPELL_EFFECT_OPEN_LOCK) ||
CastingTime(unit, spellId) != 0)
return;
Entry e = Begin(caster, target, SPELL_CAST_SUCCESS, spell);
Queue(e);
}
void OnPacket(uint32_t opcode, CDataStore *p) {
if (opcode == SMSG_SPELL_START)
SpellStart(p);
else if (opcode == SMSG_SPELL_GO)
SpellGo(p);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
using SpellFail_t = void(__fastcall *)(void *player, int spellId, const char *text);
SpellFail_t SpellFail_o = nullptr;
void __fastcall SpellFail_h(void *player, int spellId, const char *text) {
const uint8_t *spell = SpellRecord(spellId);
if (player != nullptr && text != nullptr && *text != '\0' && spell != nullptr &&
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) & ATTR_FAIL_HIDDEN) ==
0 &&
SpellName(spell) != nullptr) {
Entry e = Begin(Unit::Identity::GuidForObject(player), 0, SPELL_CAST_FAILED, spell);
e.info |= INFO_TEXT;
e.text = text;
Queue(e);
}
SpellFail_o(player, spellId, text);
}
const Game::HookAutoRegister _hook{Offsets::FUN_COMBAT_LOG_SPELL_FAIL,
reinterpret_cast<void *>(&SpellFail_h),
reinterpret_cast<void **>(&SpellFail_o)};
} // namespace
} // namespace CombatLog::Casts
+79
View File
@@ -0,0 +1,79 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Deaths — 4.3.4's UnitCombatLogUnitDead and Packet_SMSG_PARTY_KILL_LOG.
//
// A death is FUN_PRESENT_UNIT_DEATH, 1.12's death presentation (the death
// line, the death animation), which is where 4.3.4's OnDeathAnimate logs it.
// Its line writer (0x0062C160) is not hooked: nampower detours it. A unit
// whose creating spell summons it (FUN_UNIT_IS_SUMMONED, the test behind
// 1.12's UNITDESTROYEDOTHER) is UNIT_DESTROYED, as 4.3.4 logs a totem's.
// 4.3.4's UNIT_DISSIPATES is for creature type 13, which 1.12 does not have.
//
// 0x1f5 PARTYKILLLOG (handler 0x00628890): killer, victim (u64).
#include "combatlog/Entry.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "unit/Identity.h"
#include <cstdint>
namespace CombatLog::Deaths {
namespace {
using Net::CDataStore;
using Net::Read;
constexpr uint32_t SMSG_PARTYKILLLOG = 0x1f5;
bool IsSummoned(void *unit) {
using IsSummoned_t = bool(__thiscall *)(void *unit);
return reinterpret_cast<IsSummoned_t>(static_cast<uintptr_t>(Offsets::FUN_UNIT_IS_SUMMONED))(
unit);
}
using PresentDeath_t = void(__fastcall *)(void *unit);
PresentDeath_t PresentDeath_o = nullptr;
void __fastcall PresentDeath_h(void *unit) {
Entry e = Begin(0, Unit::Identity::GuidForObject(unit),
IsSummoned(unit) ? UNIT_DESTROYED : UNIT_DIED, nullptr);
Queue(e);
PresentDeath_o(unit);
}
const Game::HookAutoRegister _hook{Offsets::FUN_PRESENT_UNIT_DEATH,
reinterpret_cast<void *>(&PresentDeath_h),
reinterpret_cast<void **>(&PresentDeath_o)};
// SMSG_PARTYKILLLOG → Packet_SMSG_PARTY_KILL_LOG.
void OnPacket(uint32_t opcode, CDataStore *p) {
if (opcode != SMSG_PARTYKILLLOG)
return;
const uint64_t killer = Read<uint64_t>(p);
const uint64_t victim = Read<uint64_t>(p);
if (p->m_read > p->m_size)
return;
Entry e = Begin(killer, victim, PARTY_KILL, nullptr);
Queue(e);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace CombatLog::Deaths
+706
View File
@@ -0,0 +1,706 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The combat log entry and its events — 4.3.4's UnitCombatLog_C.cpp:
// GetCombatLogEntry, GetCombatLogObjectFlags / RaidFlags, CombatLogEntry::
// PushEvent and SendCombatLogEvents, plus the modern
// `CombatLogGetCurrentEventInfo()`, which returns the same values while the
// events are being sent and nothing otherwise.
//
// The engine pieces:
// - names follow the 1.12 combat log namer's steps (0x006264E0): the object,
// else the name or creature cache, else UNKNOWNOBJECT — what 4.3.4's
// GetObjectName resolves, without its wait for a name still in flight;
// - flags read the same fields 4.3.4 reads (charmer / creator / summoner,
// a game object's creator, the unit's reaction to the player, the party
// and raid rosters, the target, the focus, the raid marks). 1.12 has no
// main tank / main assist roles, so those two bits are never set;
// - the events are fired with Lua values (Event::Custom::FireValues), as
// 4.3.4's FrameScript_SignalEvent(id, L, count) does: booleans and nils
// land where 4.3.4 puts them.
//
// The timestamp is 4.3.4's: the Unix time the log started plus the
// milliseconds since, in seconds.
#include "combatlog/Entry.h"
#include "Game.h"
#include "combatlog/History.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "guid/Guid.h"
#include "object/Resolve.h"
#include "spell/Lookup.h"
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include <ctime>
namespace CombatLog {
namespace {
constexpr const char *kEventNames[EVENT_COUNT] = {
"ENVIRONMENTAL_DAMAGE",
"SWING_DAMAGE",
"SWING_MISSED",
"RANGE_DAMAGE",
"RANGE_MISSED",
"SPELL_CAST_START",
"SPELL_CAST_SUCCESS",
"SPELL_CAST_FAILED",
"SPELL_MISSED",
"SPELL_DAMAGE",
"SPELL_HEAL",
"SPELL_ENERGIZE",
"SPELL_DRAIN",
"SPELL_LEECH",
"SPELL_INSTAKILL",
"SPELL_SUMMON",
"SPELL_CREATE",
"SPELL_INTERRUPT",
"SPELL_EXTRA_ATTACKS",
"SPELL_DURABILITY_DAMAGE",
"SPELL_DURABILITY_DAMAGE_ALL",
"SPELL_AURA_APPLIED",
"SPELL_AURA_APPLIED_DOSE",
"SPELL_AURA_REMOVED_DOSE",
"SPELL_AURA_REMOVED",
"SPELL_AURA_REFRESH",
"SPELL_DISPEL",
"SPELL_STOLEN",
"SPELL_AURA_BROKEN",
"SPELL_AURA_BROKEN_SPELL",
"DAMAGE_AURA_BROKEN",
"ENCHANT_APPLIED",
"ENCHANT_REMOVED",
"SPELL_PERIODIC_MISSED",
"SPELL_PERIODIC_DAMAGE",
"SPELL_PERIODIC_HEAL",
"SPELL_PERIODIC_ENERGIZE",
"SPELL_PERIODIC_DRAIN",
"SPELL_PERIODIC_LEECH",
"SPELL_DISPEL_FAILED",
"DAMAGE_SHIELD",
"DAMAGE_SHIELD_MISSED",
"DAMAGE_SPLIT",
"PARTY_KILL",
"UNIT_DIED",
"UNIT_DESTROYED",
"SPELL_RESURRECT",
"SPELL_BUILDING_DAMAGE",
"SPELL_BUILDING_HEAL",
"UNIT_DISSIPATES",
};
constexpr const char *kMissNames[MISS_COUNT] = {
"NONE", "MISS", "RESIST", "DODGE", "PARRY", "BLOCK",
"EVADE", "IMMUNE", "IMMUNE", "DEFLECT", "ABSORB", "REFLECT",
};
constexpr const char *kEnvironmentalNames[] = {"FATIGUE", "DROWNING", "FALLING",
"LAVA", "SLIME", "FIRE"};
// Spell.dbc Attributes bits 0x80 and 0x100: the 1.12 combat log's own
// formatters drop these spells too (benilla-re objectlayer/combatlogmessages).
constexpr uint32_t kHiddenAttributes = 0x180;
constexpr uint32_t kRangedAttributeEx3 = 0x8000;
constexpr int kSchoolCount = 7;
// COMBATLOG_OBJECT_* — 4.3.4's GetCombatLogObjectFlags.
constexpr uint32_t AFFILIATION_MINE = 0x1;
constexpr uint32_t AFFILIATION_PARTY = 0x2;
constexpr uint32_t AFFILIATION_RAID = 0x4;
constexpr uint32_t AFFILIATION_OUTSIDER = 0x8;
constexpr uint32_t AFFILIATION_MASK = 0xF;
constexpr uint32_t REACTION_FRIENDLY = 0x10;
constexpr uint32_t REACTION_NEUTRAL = 0x20;
constexpr uint32_t REACTION_HOSTILE = 0x40;
constexpr uint32_t REACTION_MASK = 0xF0;
constexpr uint32_t CONTROL_PLAYER = 0x100;
constexpr uint32_t CONTROL_NPC = 0x200;
constexpr uint32_t TYPE_PLAYER = 0x400;
constexpr uint32_t TYPE_NPC = 0x800;
constexpr uint32_t TYPE_PET = 0x1000;
constexpr uint32_t TYPE_GUARDIAN = 0x2000;
constexpr uint32_t TYPE_OBJECT = 0x4000;
constexpr uint32_t TARGET = 0x10000;
constexpr uint32_t FOCUS = 0x20000;
constexpr uint32_t NONE = 0x80000000;
uint32_t g_startTime = 0;
uint32_t g_startTimeMs = 0;
bool g_started = false;
// The entry being sent: CombatLogGetCurrentEventInfo answers only while
// COMBAT_LOG_EVENT / COMBAT_LOG_EVENT_UNFILTERED run (Classic Era returns
// nothing outside them).
const Entry *g_sending = nullptr;
uint32_t High(uint64_t guid) { return static_cast<uint32_t>(guid >> 32); }
// 4.3.4's player test on a GUID: high nibble 0 and not the empty GUID.
bool IsPlayerGuid(uint64_t guid) {
const uint32_t high = High(guid);
return (high & 0xF0000000) == 0 &&
((high & 0xF07FFFFF) != 0 || static_cast<uint32_t>(guid) != 0);
}
const uint8_t *Fields(const void *object) {
return object == nullptr ? nullptr
: Game::Read<const uint8_t *>(object, Offsets::OFF_UNIT_DESCRIPTOR);
}
uint64_t ReadGuidField(const void *object, int offset) {
const uint8_t *fields = Fields(object);
return fields == nullptr ? 0 : Game::Read<uint64_t>(fields, offset);
}
bool InParty(uint64_t guid) {
for (int i = 0; i < Offsets::PARTY_MAX_SLOTS; ++i) {
const uint64_t member =
Game::Read<uint64_t>(Offsets::VAR_PARTY_GUIDS + i * sizeof(uint64_t));
if (member != 0 && member == guid)
return true;
}
return false;
}
bool HasParty() {
for (int i = 0; i < Offsets::PARTY_MAX_SLOTS; ++i) {
if (Game::Read<uint64_t>(Offsets::VAR_PARTY_GUIDS + i * sizeof(uint64_t)) != 0)
return true;
}
return false;
}
bool InRaid(uint64_t guid) {
for (int i = 0; i < Offsets::RAID_MAX_SLOTS; ++i) {
const auto *member =
Game::Read<const uint8_t *>(Offsets::VAR_RAID_MEMBER_PTRS + i * sizeof(void *));
if (member != nullptr && Game::Read<uint64_t>(member, 0) == guid)
return true;
}
return false;
}
int Reaction(void *unit, void *other) {
using Reaction_t = int(__thiscall *)(void *unit, void *other);
return reinterpret_cast<Reaction_t>(static_cast<uintptr_t>(Offsets::FUN_UNIT_REACTION))(
unit, other);
}
uint32_t ObjectFlags(uint64_t guid) {
if (guid == 0)
return NONE;
const bool inRaid = Game::Read<int>(Offsets::VAR_RAID_MEMBER_COUNT) != 0;
const bool inGroup = inRaid || HasParty();
const uint64_t player = Unit::Identity::PlayerGuid();
uint64_t owner = guid;
void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid);
if (unit != nullptr) {
const uint64_t controller = Unit::Identity::OwnerGuid(unit);
if (controller != 0)
owner = controller;
} else if (void *object = Object::ByGuid(Offsets::TYPEMASK_GAMEOBJECT, guid)) {
owner = ReadGuidField(object, Offsets::OFF_GAMEOBJECT_FIELD_CREATED_BY);
}
// A minion's minion answers to its master's controller (a totem's totem).
if (owner != guid && !IsPlayerGuid(owner)) {
void *ownerUnit = Object::ByGuid(Offsets::TYPEMASK_UNIT, owner);
const uint64_t controller = Unit::Identity::OwnerGuid(ownerUnit);
if (controller != 0)
owner = controller;
}
// 4.3.4's tests read `(high & 0xF0F00000) == 0xF1300000` and the like,
// which no GUID can pass; the types are the GUID prefixes (Guid::Classify).
const bool isPlayer = IsPlayerGuid(guid);
const bool isPet = Guid::IsPet(guid);
uint32_t flags;
if (isPlayer || isPet || Guid::IsCreature(guid)) {
if (owner == guid) {
flags = isPlayer ? TYPE_PLAYER : (isPet ? TYPE_PET : TYPE_NPC);
} else {
uint64_t master = ReadGuidField(unit, Offsets::OFF_UNIT_FIELD_CHARMEDBY);
if (master == 0)
master = ReadGuidField(unit, Offsets::OFF_UNIT_FIELD_SUMMONEDBY);
flags = master != 0 ? TYPE_PET : TYPE_GUARDIAN;
}
} else {
flags = TYPE_OBJECT;
}
flags |= IsPlayerGuid(owner) ? CONTROL_PLAYER : CONTROL_NPC;
if (owner == player) {
flags |= AFFILIATION_MINE | REACTION_FRIENDLY;
} else {
bool hostile = false;
void *playerUnit = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (unit != nullptr && playerUnit != nullptr) {
const int reaction = Reaction(unit, playerUnit);
if (reaction >= 4) {
flags |= REACTION_FRIENDLY;
} else if (reaction <= 1) {
flags |= REACTION_HOSTILE;
hostile = true;
}
}
if (!hostile && inGroup) {
if (InParty(owner))
flags |= AFFILIATION_PARTY | REACTION_FRIENDLY;
else if (inRaid && InRaid(owner))
flags |= AFFILIATION_RAID | REACTION_FRIENDLY;
}
if ((flags & AFFILIATION_MASK) == 0)
flags |= AFFILIATION_OUTSIDER;
if ((flags & REACTION_MASK) == 0)
flags |= REACTION_NEUTRAL;
}
const uint64_t target = Game::Read<uint64_t>(Offsets::VAR_CURRENT_SELECTION_GUID_LO);
if (target != 0 && guid == target)
flags |= TARGET;
const uint64_t focus = Unit::Focus::Get();
if (focus != 0 && guid == focus)
flags |= FOCUS;
return flags;
}
// COMBATLOG_OBJECT_RAIDTARGET1..8: the mark the object wears, in a group.
uint32_t RaidFlags(uint64_t guid) {
if (guid == 0)
return NONE;
if (Game::Read<int>(Offsets::VAR_RAID_MEMBER_COUNT) == 0 && !HasParty())
return 0;
for (int i = 0; i < 8; ++i) {
if (Game::Read<uint64_t>(Offsets::VAR_RAID_TARGET_GUIDS + i * sizeof(uint64_t)) == guid)
return 1u << i;
}
return 0;
}
using GetText_t = const char *(__fastcall *)(const char *name, int edx, int gender);
using CacheRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t id, const uint64_t *guid,
void *callback, void *userData, char dedup);
using NameCache_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo, uint32_t hi,
const uint64_t *guid, void *callback,
void *userData, char dedup);
using ObjectName_t = const char *(__thiscall *)(void *object, int *outFlags);
using GameObjectName_t = const char *(__fastcall *)(void *object);
void *QueryDone() { return reinterpret_cast<void *>(Offsets::FUN_COMBAT_LOG_NAME_QUERY_DONE); }
// An item's name from the item cache, asked so a miss sends the query.
const char *ItemName(uint32_t itemId) {
const uint64_t none = 0;
const uint8_t *rec = reinterpret_cast<CacheRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD)(
reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE), itemId, &none, nullptr, nullptr, 0);
return rec != nullptr ? Game::Read<const char *>(rec, Offsets::OFF_ITEMSTATS_NAME) : nullptr;
}
// The combat log namer's steps (0x006264E0): the object's own name (a unit,
// a game object, an item's record), else a player's from the name cache or a
// creature's from the creature cache, each asked so a miss sends the query.
// The creature's entry is the GUID's (Guid::CreatureEntry): the namer takes
// the high dword's low word, which is not the entry on this server's GUIDs.
const char *ObjectName(uint64_t guid) {
if (void *object = Object::ByGuid(Offsets::TYPEMASK_OBJECT, guid)) {
const auto *fields = Game::Read<const uint8_t *>(object, Offsets::OFF_UNIT_GUID_PTR);
const uint32_t type =
fields != nullptr ? Game::Read<uint32_t>(fields, Offsets::OFF_OBJECT_FIELD_TYPE) : 0;
if (type & Offsets::TYPEMASK_UNIT)
return reinterpret_cast<ObjectName_t>(Offsets::FUN_OBJECT_GET_NAME)(object, nullptr);
if (type & Offsets::TYPEMASK_GAMEOBJECT)
return reinterpret_cast<GameObjectName_t>(Offsets::FUN_GAMEOBJECT_GET_NAME)(object);
if (type & Offsets::TYPEMASK_ITEM)
return ItemName(Game::Read<uint32_t>(fields, Offsets::OFF_OBJECT_FIELD_ENTRY));
return nullptr;
}
if (Guid::IsPlayer(guid)) {
const uint8_t *rec = reinterpret_cast<NameCache_t>(Offsets::FUN_PLAYER_INFO_LOOKUP)(
reinterpret_cast<void *>(Offsets::VAR_PLAYER_NAME_CACHE), static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), &guid, QueryDone(), nullptr, 0);
return rec != nullptr
? reinterpret_cast<const char *>(rec + Offsets::OFF_PLAYER_INFO_NAME)
: nullptr;
}
const uint32_t entry = Guid::CreatureEntry(guid);
if (entry != 0) {
const uint8_t *rec = reinterpret_cast<CacheRecord_t>(Offsets::FUN_CREATURE_GET_RECORD)(
reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE), entry, &guid, QueryDone(),
nullptr, 0);
return rec != nullptr ? Game::Read<const char *>(rec, 0) : nullptr;
}
return nullptr;
}
void SetName(uint64_t guid, bool &named, std::string &name) {
const char *n = ObjectName(guid);
if (n == nullptr || *n == '\0') {
n = reinterpret_cast<GetText_t>(Offsets::FUN_FRAMESCRIPT_GET_TEXT)("UNKNOWNOBJECT", -1, 0);
if (n == nullptr || *n == '\0')
n = "UKNOWNOBJECT"; // the namer's own last resort, spelling and all
}
named = true;
name = n;
}
void PushNumberOrNil(void *L, int value) {
if (value != 0)
Game::Lua::PushNumber(L, value);
else
Game::Lua::PushNil(L);
}
void PushFlag(void *L, bool value) {
if (value)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
}
void PushName(void *L, bool named, const std::string &name) {
if (named)
Game::Lua::PushString(L, name.c_str());
else
Game::Lua::PushNil(L);
}
// CombatLogEntry::PushEvent, the blocks 1.12 fills.
int Push(void *L, const Entry &e) {
using namespace Game::Lua;
char guid[Guid::STRING_SIZE];
const double timestamp =
static_cast<double>(e.timeMs - g_startTimeMs) * 0.001 + static_cast<double>(g_startTime);
PushNumber(L, timestamp);
PushString(L, Name(e.event));
PushBool(L, e.hideCaster);
PushString(L, Guid::FormatAsString(e.source, guid, sizeof guid));
PushName(L, e.sourceNamed, e.sourceName);
PushNumber(L, e.sourceFlags);
PushNumber(L, e.sourceRaidFlags);
PushString(L, Guid::FormatAsString(e.dest, guid, sizeof guid));
PushName(L, e.destNamed, e.destName);
PushNumber(L, e.destFlags);
PushNumber(L, e.destRaidFlags);
int count = 11;
if (e.spellPrefix) {
const uint8_t *spell = SpellRecord(e.spellId);
PushNumber(L, e.spellId);
const char *name = SpellName(spell);
if (name != nullptr)
PushString(L, name);
else
PushNil(L);
PushNumber(L, spell != nullptr
? SchoolMask(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_SCHOOL))
: e.spellSchool);
count += 3;
}
if (e.info & INFO_COUNT) {
PushNumber(L, e.v[0]);
++count;
}
if (e.info & INFO_TEXT) {
PushString(L, e.text.c_str());
++count;
}
if (e.info & INFO_MISS) {
const int miss = e.v[0];
PushString(L, (miss >= 0 && miss < MISS_COUNT) ? kMissNames[miss] : kMissNames[0]);
PushFlag(L, e.offHand);
count += 2;
}
if (e.info & INFO_ENVIRONMENTAL) {
const int type = e.v[0];
PushString(L, (type >= 0 && type < 6) ? kEnvironmentalNames[type] : kEnvironmentalNames[0]);
++count;
}
if (e.info & INFO_DAMAGE) {
PushNumber(L, e.v[1]);
PushNumber(L, e.v[2]);
PushNumber(L, e.v[3]);
PushNumberOrNil(L, e.v[5]);
PushNumberOrNil(L, e.v[6]);
PushNumberOrNil(L, e.v[4]);
PushFlag(L, e.critical);
PushFlag(L, e.glancing);
PushFlag(L, e.crushing);
PushFlag(L, e.offHand);
count += 10;
}
if (e.info & INFO_MISS_ABSORBED) {
PushNumber(L, e.v[4]);
++count;
}
if (e.info & INFO_MISS_RESISTED) {
PushNumber(L, e.v[5]);
++count;
}
if (e.info & INFO_MISS_BLOCKED) {
PushNumber(L, e.v[6]);
++count;
}
if (e.info & INFO_HEAL) {
PushNumber(L, e.v[0]);
PushNumber(L, e.v[1]);
PushNumber(L, e.v[2]);
PushFlag(L, e.critical);
count += 4;
}
if (e.info & INFO_ENERGIZE) {
PushNumber(L, e.v[0]);
PushNumber(L, e.v[1]);
if (e.v[2] < 0)
PushNil(L);
else
PushNumber(L, e.v[2]);
count += 3;
}
if (e.info & INFO_DRAIN) {
PushNumber(L, e.v[0]);
PushNumber(L, e.v[1]);
PushNumberOrNil(L, e.v[2]);
if (e.v[3] < 0)
PushNil(L);
else
PushNumber(L, e.v[3]);
count += 4;
}
if (e.info & INFO_EXTRA_SPELL) {
const uint8_t *extra = SpellRecord(e.v[0]);
PushNumber(L, e.v[0]);
const char *name = SpellName(extra);
if (name != nullptr)
PushString(L, name);
else
PushNil(L);
PushNumber(L, extra != nullptr ? SchoolMask(Game::Read<uint32_t>(
extra, Offsets::OFF_SPELL_RECORD_SCHOOL))
: 0);
count += 3;
}
if (e.info & INFO_ITEM) {
PushNumber(L, e.v[0]);
PushName(L, e.itemNamed, e.itemName);
count += 2;
}
if (e.info & INFO_AURA_TYPE) {
PushString(L, e.harmful ? "DEBUFF" : "BUFF");
++count;
}
if (e.info & INFO_AURA_AMOUNT) {
PushNumber(L, e.v[2]);
++count;
}
return count;
}
const Game::Doc::Field kEventPayload[] = {
Game::Doc::Req("timestamp", "number", "The Unix time of the event, in seconds."),
Game::Doc::Req("subevent", "string", "What happened, for example SPELL_DAMAGE."),
Game::Doc::Req("hideCaster", "bool", "True when the event has no source."),
Game::Doc::Req("sourceGUID", "WOWGUID"),
Game::Doc::Opt("sourceName", "string"),
Game::Doc::Req("sourceFlags", "number", "COMBATLOG_OBJECT_* bits of the source."),
Game::Doc::Req("sourceRaidFlags", "number", "COMBATLOG_OBJECT_RAIDTARGET* bit of the source."),
Game::Doc::Req("destGUID", "WOWGUID"),
Game::Doc::Opt("destName", "string"),
Game::Doc::Req("destFlags", "number", "COMBATLOG_OBJECT_* bits of the target."),
Game::Doc::Req("destRaidFlags", "number", "COMBATLOG_OBJECT_RAIDTARGET* bit of the target."),
Game::Doc::Vararg("any", "The subevent's own values."),
};
const Game::Doc::Event kFilteredDoc{
"CombatLog", "Fires for each combat log event that matches the combat log filter.",
kEventPayload};
const Game::Doc::Event kUnfilteredDoc{"CombatLog", "Fires for each combat log event.",
kEventPayload};
const Event::Custom::AutoReserve _filtered{"COMBAT_LOG_EVENT", &kFilteredDoc};
const Event::Custom::AutoReserve _unfiltered{"COMBAT_LOG_EVENT_UNFILTERED", &kUnfilteredDoc};
void Send(const Entry &e) {
const int filtered = _filtered.Slot();
const int unfiltered = _unfiltered.Slot();
const bool toFiltered = Event::Custom::HasListeners(filtered);
const bool toUnfiltered = Event::Custom::HasListeners(unfiltered);
if (!toFiltered && !toUnfiltered)
return;
void *L = Game::Lua::State();
if (L == nullptr || Game::Lua::CheckStack(L, MAX_EVENT_VALUES) == 0)
return;
const int top = Game::Lua::GetTop(L);
const int count = Push(L, e);
const Entry *outer = g_sending; // a handler's own action can log again
g_sending = &e;
if (toFiltered && History::MatchesFilter(e))
Event::Custom::FireValues(filtered, L, top + 1, count);
if (toUnfiltered)
Event::Custom::FireValues(unfiltered, L, top + 1, count);
g_sending = outer;
Game::Lua::SetTop(L, top);
}
int __fastcall Script_CombatLogGetCurrentEventInfo(void *L) {
if (g_sending == nullptr || Game::Lua::CheckStack(L, MAX_EVENT_VALUES) == 0)
return 0;
return Push(L, *g_sending);
}
const Game::Doc::Field kInfoReturns[] = {
Game::Doc::Vararg("CombatLogEventInfo",
"timestamp, subevent, hideCaster, sourceGUID, sourceName, sourceFlags, "
"sourceRaidFlags, destGUID, destName, destFlags, destRaidFlags, then the "
"subevent's own values."),
};
const Game::Doc::Function kInfoDoc{"Returns the values of the combat log event that is being sent.",
{}, kInfoReturns, "CombatLogGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("CombatLogGetCurrentEventInfo",
&Script_CombatLogGetCurrentEventInfo, &kInfoDoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
const char *Name(int event) {
return (event >= 0 && event < EVENT_COUNT) ? kEventNames[event] : "";
}
int PushEvent(void *L, const Entry &entry) { return Push(L, entry); }
const uint8_t *SpellRecord(int spellId) { return Spell::Lookup::RecordForID(spellId); }
const char *SpellName(const uint8_t *spell) {
if (spell == nullptr)
return nullptr;
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
const char *n = Game::Read<const char *>(spell, Offsets::OFF_SPELL_NAMES + locale * 4);
return (n == nullptr || *n == '\0') ? nullptr : n;
}
bool Hidden(const uint8_t *spell) {
return spell == nullptr ||
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) & kHiddenAttributes) !=
0 ||
SpellName(spell) == nullptr;
}
bool Ranged(const uint8_t *spell) {
return spell != nullptr &&
(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3) &
kRangedAttributeEx3) != 0;
}
uint32_t SchoolMask(uint32_t school) { return school < kSchoolCount ? 1u << school : 0; }
bool HasEffect(const uint8_t *spell, int effect) {
if (spell == nullptr)
return false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (Game::Read<int>(spell, Offsets::OFF_SPELL_RECORD_EFFECT + i * 4) == effect)
return true;
}
return false;
}
Entry Begin(uint64_t source, uint64_t dest, int event, const uint8_t *spell) {
Entry e;
e.event = event;
if (source != 0) {
e.source = source;
SetName(source, e.sourceNamed, e.sourceName);
e.sourceFlags = ObjectFlags(source);
e.sourceRaidFlags = RaidFlags(source);
e.hideCaster = false;
}
if (dest != 0) {
e.dest = dest;
SetName(dest, e.destNamed, e.destName);
e.destFlags = ObjectFlags(dest);
e.destRaidFlags = RaidFlags(dest);
}
if (spell != nullptr) {
e.spellPrefix = true;
e.spellId = Game::Read<int>(spell, 0);
}
return e;
}
void SetMissInfo(Entry &e, int missType, int amount, bool offHand) {
e.info |= INFO_MISS;
e.offHand = offHand;
e.v[0] = missType;
switch (missType) {
case MISS_RESIST:
e.info |= INFO_MISS_RESISTED;
e.v[5] = amount;
break;
case MISS_BLOCK:
e.info |= INFO_MISS_BLOCKED;
e.v[6] = amount;
break;
case MISS_ABSORB:
e.info |= INFO_MISS_ABSORBED;
e.v[4] = amount;
break;
default:
break;
}
}
void SetItem(Entry &e, int itemId) {
e.info |= INFO_ITEM;
e.v[0] = itemId;
const char *name = ItemName(static_cast<uint32_t>(itemId));
if (name != nullptr && *name != '\0') {
e.itemNamed = true;
e.itemName = name;
}
}
int DisplayAmount(int amount, int powerType) {
using Divisor_t = int(__fastcall *)(int powerType);
const int divisor = reinterpret_cast<Divisor_t>(
static_cast<uintptr_t>(Offsets::FUN_POWER_DISPLAY_DIVISOR))(powerType);
return divisor != 0 ? amount / divisor : amount;
}
void PowerDrain(uint64_t source, uint64_t dest, int event, const uint8_t *spell, int amount,
int powerType, int extra) {
Entry e = Begin(source, dest, event, spell);
e.info |= INFO_DRAIN;
e.v[0] = DisplayAmount(amount, powerType);
e.v[1] = powerType;
e.v[2] = DisplayAmount(extra, powerType);
e.v[3] = -1; // alternate power: none in 1.12
Queue(e);
}
void Queue(Entry &e) {
e.timeMs = Time::Clock::NowMs();
if (!g_started) {
g_started = true;
g_startTime = static_cast<uint32_t>(std::time(nullptr));
g_startTimeMs = e.timeMs;
}
History::Record(e);
Send(e);
}
} // namespace CombatLog
+192
View File
@@ -0,0 +1,192 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// 4.3.4's combat log entry (UnitCombatLog_C.cpp `CombatLogEntry`): who did
// what to whom, built where the engine learns of it and sent as
// COMBAT_LOG_EVENT / COMBAT_LOG_EVENT_UNFILTERED in 4.3.4's argument layout.
// The modules that learn of events (the other files in combatlog/) build an
// entry with `Begin`, fill its suffix, and `Queue` it.
#include <cstdint>
#include <string>
namespace CombatLog {
// 4.3.4's event index (s_combatLogEventNames); `Name` gives the string.
enum Subevent : int {
ENVIRONMENTAL_DAMAGE = 0,
SWING_DAMAGE = 1,
SWING_MISSED = 2,
RANGE_DAMAGE = 3,
RANGE_MISSED = 4,
SPELL_CAST_START = 5,
SPELL_CAST_SUCCESS = 6,
SPELL_CAST_FAILED = 7,
SPELL_MISSED = 8,
SPELL_DAMAGE = 9,
SPELL_HEAL = 10,
SPELL_ENERGIZE = 11,
SPELL_DRAIN = 12,
SPELL_LEECH = 13,
SPELL_INSTAKILL = 14,
SPELL_INTERRUPT = 17,
SPELL_EXTRA_ATTACKS = 18,
SPELL_DURABILITY_DAMAGE = 19,
SPELL_DURABILITY_DAMAGE_ALL = 20,
SPELL_AURA_APPLIED = 21,
SPELL_AURA_APPLIED_DOSE = 22,
SPELL_AURA_REMOVED_DOSE = 23,
SPELL_AURA_REMOVED = 24,
SPELL_DISPEL = 26,
SPELL_PERIODIC_MISSED = 33,
SPELL_PERIODIC_DAMAGE = 34,
SPELL_PERIODIC_HEAL = 35,
SPELL_PERIODIC_ENERGIZE = 36,
SPELL_PERIODIC_DRAIN = 37,
SPELL_PERIODIC_LEECH = 38,
SPELL_DISPEL_FAILED = 39,
DAMAGE_SHIELD = 40,
DAMAGE_SHIELD_MISSED = 41,
DAMAGE_SPLIT = 42,
PARTY_KILL = 43,
UNIT_DIED = 44,
UNIT_DESTROYED = 45,
SPELL_RESURRECT = 46,
EVENT_COUNT = 50,
};
const char *Name(int event);
// 4.3.4's m_infoFlags: which suffix blocks the event carries, pushed in
// this order after the spell prefix.
enum Info : uint32_t {
INFO_COUNT = 0x1, // amount (extra attacks)
INFO_TEXT = 0x2, // failedType
INFO_MISS = 0x4, // missType, isOffHand
INFO_ENVIRONMENTAL = 0x8, // environmentalType
INFO_DAMAGE = 0x10, // amount … isOffHand (10 values)
INFO_MISS_ABSORBED = 0x2000,
INFO_MISS_RESISTED = 0x4000,
INFO_MISS_BLOCKED = 0x8000,
INFO_HEAL = 0x20, // amount, overhealing, absorbed, critical
INFO_ENERGIZE = 0x40, // amount, powerType, alternatePowerType
INFO_DRAIN = 0x80, // amount, powerType, extraAmount, alternatePowerType
INFO_EXTRA_SPELL = 0x100, // extraSpellId, extraSpellName, extraSchool
INFO_ITEM = 0x200, // itemId, itemName
INFO_AURA_TYPE = 0x400, // auraType
INFO_AURA_AMOUNT = 0x800, // amount (stacks)
};
// 4.3.4's miss types (g_spellMissStrings), in the server's numbering.
enum MissType : int {
MISS_NONE = 0,
MISS_MISS = 1,
MISS_RESIST = 2,
MISS_DODGE = 3,
MISS_PARRY = 4,
MISS_BLOCK = 5,
MISS_EVADE = 6,
MISS_IMMUNE = 7,
MISS_DEFLECT = 9,
MISS_ABSORB = 10,
MISS_REFLECT = 11,
MISS_COUNT = 12,
};
struct Entry {
uint32_t timeMs = 0;
int event = -1;
bool hideCaster = true;
uint64_t source = 0;
bool sourceNamed = false;
std::string sourceName;
uint32_t sourceFlags = 0x80000000;
uint32_t sourceRaidFlags = 0x80000000;
uint64_t dest = 0;
bool destNamed = false;
std::string destName;
uint32_t destFlags = 0x80000000;
uint32_t destRaidFlags = 0x80000000;
// The spell prefix (spellId, spellName, spellSchool). 4.3.4 pushes it
// when the entry has a spell; `spellPrefix` also keeps it, with spellId 0
// and a nil name, for an event 1.12 sends without its spell (a damage
// shield), so the suffix stays in place. `spellSchool` is used only then.
bool spellPrefix = false;
int spellId = 0;
uint32_t spellSchool = 0;
uint32_t info = 0;
int v[7] = {}; // 4.3.4's m_info.m_values, same slots
std::string text; // INFO_TEXT
bool itemNamed = false; // INFO_ITEM: the item's name, when the cache has it
std::string itemName;
bool harmful = false; // INFO_AURA_TYPE: DEBUFF, else BUFF
bool critical = false;
bool glancing = false;
bool crushing = false;
bool offHand = false;
};
// The Spell.dbc record of `spellId`, or nullptr.
const uint8_t *SpellRecord(int spellId);
// The spell's name in the client's locale, or nullptr when it has none.
const char *SpellName(const uint8_t *spell);
// 4.3.4's rule for a spell that writes nothing: no record, Attributes 0x80 or
// 0x100, or no name in the client's locale.
bool Hidden(const uint8_t *spell);
// AttributesEx3 0x8000: the spell is a ranged attack (Auto Shot, Shoot,
// Throw), logged as RANGE_* instead of SPELL_*.
bool Ranged(const uint8_t *spell);
// A school index (Spell.dbc School, the packets' school) as a school mask.
uint32_t SchoolMask(uint32_t school);
// Whether one of the spell's three effects is `effect`.
bool HasEffect(const uint8_t *spell, int effect);
// GetCombatLogEntry: an entry for `event` from `source` to `dest` (either may
// be 0), with the spell prefix when `spell` is a record.
Entry Begin(uint64_t source, uint64_t dest, int event, const uint8_t *spell);
// CombatLogEntry::SetMissInfo: the miss suffix, with the amount in the slot
// its type uses.
void SetMissInfo(Entry &entry, int missType, int amount, bool offHand);
// CombatLogEntry::SetExecuteItem: the item suffix, named from the item cache
// (a miss asks the server, and the name is nil this time).
void SetItem(Entry &entry, int itemId);
// A power amount in the units the client shows (Spell_C_GetPowerDisplayMod).
int DisplayAmount(int amount, int powerType);
// AddPowerDrainEntry: a drain or leech of `amount` (and `extra` to the
// caster), shown in the power's display units.
void PowerDrain(uint64_t source, uint64_t dest, int event, const uint8_t *spell, int amount,
int powerType, int extra);
// The most values PushEvent makes: 11 + 3 + 10 + 3 (a damage event).
constexpr int MAX_EVENT_VALUES = 32;
// CombatLogEntry::PushEvent: pushes the entry's values in 4.3.4's layout and
// returns their count. The stack must have room for MAX_EVENT_VALUES.
int PushEvent(void *L, const Entry &entry);
// QueueCombatLogEntry + SendCombatLogEvents: stamps the entry, keeps it and
// sends it.
void Queue(Entry &entry);
} // namespace CombatLog
+250
View File
@@ -0,0 +1,250 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Spell effects — 4.3.4's CombatLog::HandleSpellLogExecute and
// Packet_SMSG_SPELLINSTAKILLLOG, read from 1.12's bodies of the same messages
// through Net::PacketDispatch.
//
// 0x24c SPELLLOGEXECUTE (handler 0x005E7F90): packed caster, spell, effect
// count; per effect its id and a target count, then per target what that
// effect carries. 1.12's reader is the layout, whatever the server meant:
// 8 (power drain) u64 target, amount, power, float multiplier
// 19 (extra attacks) u64 target, count
// 24 (create item) item
// 68 (interrupt) u64 target, interrupted spell
// 101 (feed pet) item
// 111 (durability) u64 target, item, slot (both -1: every item)
// 1, 18, 33, 38, 59, 63, 69, 79, 91, 102, 108, 113, 114, 116, 125, 126
// u64 target
// any other nothing
// 4.3.4 logs drains and leeches, resurrections (18, 113), extra attacks,
// interrupts and durability losses from these. Its summons have no target
// here, so SPELL_SUMMON and SPELL_CREATE do not occur.
//
// 0x32f SPELLINSTAKILLLOG (0x005E85A0): the victim (u64) and the spell. 1.12
// names no caster, so the entry has no source.
#include "combatlog/Entry.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include <cstdint>
namespace CombatLog::Execute {
namespace {
using Net::CDataStore;
using Net::Read;
using Net::ReadPackedGuid;
constexpr uint32_t SMSG_SPELLLOGEXECUTE = 0x24c;
constexpr uint32_t SMSG_SPELLINSTAKILLLOG = 0x32f;
enum Effect : uint32_t {
EFFECT_INSTAKILL = 1,
EFFECT_POWER_DRAIN = 8,
EFFECT_RESURRECT = 18,
EFFECT_ADD_EXTRA_ATTACKS = 19,
EFFECT_CREATE_ITEM = 24,
EFFECT_OPEN_LOCK = 33,
EFFECT_DISPEL = 38,
EFFECT_OPEN_LOCK_ITEM = 59,
EFFECT_THREAT = 63,
EFFECT_INTERRUPT_CAST = 68,
EFFECT_DISTRACT = 69,
EFFECT_SANCTUARY = 79,
EFFECT_THREAT_ALL = 91,
EFFECT_FEED_PET = 101,
EFFECT_DISMISS_PET = 102,
EFFECT_DISPEL_MECHANIC = 108,
EFFECT_DURABILITY_DAMAGE = 111,
EFFECT_RESURRECT_NEW = 113,
EFFECT_ATTACK_ME = 114,
EFFECT_SKIN_PLAYER_CORPSE = 116,
EFFECT_MODIFY_THREAT_PERCENT = 125,
EFFECT_126 = 126,
};
// AttributesEx3 0x2000000: a drain 4.3.4 logs as periodic.
constexpr uint32_t ATTR_EX3_TREAT_AS_PERIODIC = 0x2000000;
bool Overrun(const CDataStore *p) { return p->m_read > p->m_size; }
// UnitCombatLogPowerDrain.
void PowerDrainEffect(uint64_t caster, uint64_t target, const uint8_t *spell, int amount,
int powerType, float multiplier) {
if (amount == 0 || Hidden(spell))
return;
const bool periodic = (Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3) &
ATTR_EX3_TREAT_AS_PERIODIC) != 0;
// A drain (effect 8) gives the caster `multiplier` of it: a leech.
const int extra = static_cast<int>(static_cast<float>(amount) * multiplier);
if (extra != 0)
PowerDrain(caster, target, periodic ? SPELL_PERIODIC_LEECH : SPELL_LEECH, spell, amount,
powerType, extra);
else
PowerDrain(caster, target, periodic ? SPELL_PERIODIC_DRAIN : SPELL_DRAIN, spell, amount,
powerType, 0);
}
void Simple(uint64_t caster, uint64_t target, int event, const uint8_t *spell) {
if (Hidden(spell))
return;
Entry e = Begin(caster, target, event, spell);
Queue(e);
}
// UnitCombatLogExtraAttacks.
void ExtraAttacks(uint64_t caster, uint64_t target, const uint8_t *spell, int count) {
if (count == 0 || Hidden(spell))
return;
Entry e = Begin(caster, target, SPELL_EXTRA_ATTACKS, spell);
e.info |= INFO_COUNT;
e.v[0] = count;
Queue(e);
}
// UnitCombatLogInterrupt.
void Interrupt(uint64_t caster, uint64_t target, const uint8_t *spell, int interruptedId) {
const uint8_t *interrupted = SpellRecord(interruptedId);
if (Hidden(spell) || Hidden(interrupted))
return;
Entry e = Begin(caster, target, SPELL_INTERRUPT, spell);
e.info |= INFO_EXTRA_SPELL;
e.v[0] = interruptedId;
Queue(e);
}
// UnitCombatLogDurabilityDamage / DurabilityDamageAll.
void Durability(uint64_t caster, uint64_t target, const uint8_t *spell, int itemId, int slot) {
if (Hidden(spell))
return;
if (itemId == -1 && slot == -1) {
Entry e = Begin(caster, target, SPELL_DURABILITY_DAMAGE_ALL, spell);
Queue(e);
return;
}
Entry e = Begin(caster, target, SPELL_DURABILITY_DAMAGE, spell);
SetItem(e, itemId);
Queue(e);
}
// One target of one effect: reads what 1.12 reads, logs what 4.3.4 logs.
// False for an effect that carries nothing, whose other targets read nothing
// either.
bool Target(CDataStore *p, uint32_t effect, uint64_t caster, const uint8_t *spell) {
switch (effect) {
case EFFECT_POWER_DRAIN: {
const uint64_t target = Read<uint64_t>(p);
const int amount = Read<int32_t>(p);
const int powerType = Read<int32_t>(p);
const float multiplier = Read<float>(p);
if (!Overrun(p))
PowerDrainEffect(caster, target, spell, amount, powerType, multiplier);
break;
}
case EFFECT_ADD_EXTRA_ATTACKS: {
const uint64_t target = Read<uint64_t>(p);
const int count = Read<int32_t>(p);
if (!Overrun(p))
ExtraAttacks(caster, target, spell, count);
break;
}
case EFFECT_INTERRUPT_CAST: {
const uint64_t target = Read<uint64_t>(p);
const int interrupted = Read<int32_t>(p);
if (!Overrun(p))
Interrupt(caster, target, spell, interrupted);
break;
}
case EFFECT_DURABILITY_DAMAGE: {
const uint64_t target = Read<uint64_t>(p);
const int itemId = Read<int32_t>(p);
const int slot = Read<int32_t>(p);
if (!Overrun(p))
Durability(caster, target, spell, itemId, slot);
break;
}
case EFFECT_RESURRECT:
case EFFECT_RESURRECT_NEW: {
const uint64_t target = Read<uint64_t>(p);
if (!Overrun(p))
Simple(caster, target, SPELL_RESURRECT, spell);
break;
}
case EFFECT_CREATE_ITEM:
case EFFECT_FEED_PET:
Read<uint32_t>(p);
break;
case EFFECT_INSTAKILL:
case EFFECT_OPEN_LOCK:
case EFFECT_DISPEL:
case EFFECT_OPEN_LOCK_ITEM:
case EFFECT_THREAT:
case EFFECT_DISTRACT:
case EFFECT_SANCTUARY:
case EFFECT_THREAT_ALL:
case EFFECT_DISMISS_PET:
case EFFECT_DISPEL_MECHANIC:
case EFFECT_ATTACK_ME:
case EFFECT_SKIN_PLAYER_CORPSE:
case EFFECT_MODIFY_THREAT_PERCENT:
case EFFECT_126:
Read<uint64_t>(p);
break;
default:
return false;
}
return true;
}
void SpellLogExecute(CDataStore *p) {
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const uint32_t effects = Read<uint32_t>(p);
const uint8_t *spell = SpellRecord(spellId);
if (Overrun(p) || spell == nullptr)
return;
for (uint32_t i = 0; i < effects && !Overrun(p); ++i) {
const uint32_t effect = Read<uint32_t>(p);
const uint32_t targets = Read<uint32_t>(p);
for (uint32_t j = 0; j < targets && !Overrun(p); ++j) {
if (!Target(p, effect, caster, spell))
break;
}
}
}
// SMSG_SPELLINSTAKILLLOG → UnitCombatLogInstakill.
void InstakillLog(CDataStore *p) {
const uint64_t victim = Read<uint64_t>(p);
const int spellId = Read<int32_t>(p);
if (!Overrun(p))
Simple(0, victim, SPELL_INSTAKILL, SpellRecord(spellId));
}
void OnPacket(uint32_t opcode, CDataStore *p) {
if (opcode == SMSG_SPELLLOGEXECUTE)
SpellLogExecute(p);
else if (opcode == SMSG_SPELLINSTAKILLLOG)
InstakillLog(p);
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace CombatLog::Execute
+477
View File
@@ -0,0 +1,477 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Kept entries and filters — 4.3.4's s_entries, s_filters, s_currentEntry,
// CombatLogFilter and the Lua surface of UnitCombatLog_C_Scripts.cpp.
//
// Every entry is kept, oldest first. A new one lets go of the oldest when it
// is older than combatLogRetentionTime seconds (GetCombatLogEntry's recycle,
// one per new entry). 4.3.4 also holds entries whose names are still being
// asked for (s_pendingEntries), which the stepping functions stop before;
// here every entry is sent at once, so none is held.
//
// A filter is an OR term: an entry matches when any filter matches, or when
// there is none. Within a filter the events, source, target and spell must
// all match. A unit is matched by GUID, or by its flags against a mask that
// must name an affiliation, a reaction, a controller and a type, unless it
// names a special bit (CombatLogFlagsMatch). 4.3.4 compares spell names with
// SStrCmpUTF8I; 1.12 has only SStrCmpI, which folds ASCII letters.
//
// The log ends with the world (UnitCombatLogShutdown, from
// UnitCombatClientShutdown): leaving for the login screen clears both lists.
#include "combatlog/History.h"
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
#include <algorithm>
#include <cstdint>
#include <deque>
#include <string>
#include <vector>
namespace CombatLog::History {
namespace {
// The mask CombatLog_Object_IsA and the filters treat as complete: any
// special bit, or one bit from each of the four groups.
constexpr uint32_t SPECIAL_MASK = 0xFFFF0000;
constexpr uint32_t AFFILIATION_MASK = 0xF;
constexpr uint32_t REACTION_MASK = 0xF0;
constexpr uint32_t CONTROL_MASK = 0x300;
constexpr uint32_t TYPE_MASK = 0xFC00;
constexpr int kMaxToken = 128;
constexpr int kStormMaxStr = 0x7FFFFFFF;
struct Filter {
uint64_t events = ~0ull; // bit per Subevent
uint64_t sourceGuid = 0;
uint64_t destGuid = 0;
uint32_t sourceFlags = 0;
uint32_t sourceRaidFlags = 0;
uint32_t destFlags = 0;
uint32_t destRaidFlags = 0;
int spellId = 0;
bool spellNamed = false;
std::string spellName;
};
std::deque<Entry> g_entries;
int g_current = -1; // s_currentEntry, as an index into g_entries
std::vector<Filter> g_filters;
CVar::Factory::Handle g_retention = nullptr;
constexpr int kDefaultRetention = 300;
// The longest retention, in seconds, that the wrap-safe age test can measure.
constexpr int kMaxRetention = 0x7FFFFFFF / 1000;
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
using Tokenize_t = void(__stdcall *)(char **cursor, char *out, unsigned outSize,
const char *delims, int *outQuoted);
using LooseBool_t = int(__fastcall *)(void *L, int idx, int dflt);
int StrCmpI(const char *a, const char *b) {
return reinterpret_cast<SStrCmpI_t>(static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I))(
a, b, kStormMaxStr);
}
bool ArgFlag(void *L, int idx) {
return reinterpret_cast<LooseBool_t>(
static_cast<uintptr_t>(Offsets::FUN_LUA_TO_BOOLEAN_LOOSE))(L, idx, 0) != 0;
}
bool Complete(uint32_t matched) {
return (matched & AFFILIATION_MASK) && (matched & REACTION_MASK) && (matched & CONTROL_MASK) &&
(matched & TYPE_MASK);
}
// CombatLogFlagsMatch.
bool FlagsMatch(uint32_t flags, uint32_t mask, uint32_t raidFlags, uint32_t raidMask) {
const uint32_t matched = flags & mask;
return (matched & SPECIAL_MASK) || (raidFlags & raidMask) || Complete(matched);
}
// 4.3.4's StringToGUID: an optional 0x, then up to 16 hex digits, stopping
// at the first other character.
uint64_t StringToGuid(const char *s) {
if (s == nullptr)
return 0;
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X'))
s += 2;
uint64_t value = 0;
for (int i = 0; i < 16; ++i) {
const char c = *s++;
int digit;
if (c >= '0' && c <= '9')
digit = c - '0';
else if (c >= 'a' && c <= 'f')
digit = c - 'a' + 10;
else if (c >= 'A' && c <= 'F')
digit = c - 'A' + 10;
else
break;
value = (value << 4) | static_cast<uint64_t>(digit);
}
return value;
}
// CombatLogFilter::SetFilterData's event list: names split on spaces and
// commas, matched without case; no list is every event.
uint64_t EventBits(const char *events) {
if (events == nullptr)
return ~0ull;
std::string buffer = events;
char *cursor = buffer.data();
uint64_t bits = 0;
char token[kMaxToken];
do {
token[0] = '\0';
reinterpret_cast<Tokenize_t>(static_cast<uintptr_t>(Offsets::FUN_STORM_STR_TOKENIZE))(
&cursor, token, sizeof token, " ,", nullptr);
if (token[0] == '\0')
break;
for (int i = 0; i < EVENT_COUNT; ++i) {
if (StrCmpI(token, Name(i)) == 0) {
bits |= 1ull << i;
break;
}
}
} while (*cursor != '\0');
return bits;
}
// CombatLogFilter::MatchesFilter.
bool Matches(const Filter &f, const Entry &e) {
if (e.event < 0 || e.event >= EVENT_COUNT || (f.events & (1ull << e.event)) == 0)
return false;
if (f.sourceGuid != 0) {
if (e.source != f.sourceGuid)
return false;
} else if (!FlagsMatch(e.sourceFlags, f.sourceFlags, e.sourceRaidFlags, f.sourceRaidFlags)) {
return false;
}
if (f.destGuid != 0) {
if (e.dest != f.destGuid)
return false;
} else if (!FlagsMatch(e.destFlags, f.destFlags, e.destRaidFlags, f.destRaidFlags)) {
return false;
}
if (f.spellId != 0)
return e.spellId == f.spellId;
if (!f.spellNamed)
return true;
const char *name = e.spellPrefix ? SpellName(SpellRecord(e.spellId)) : nullptr;
return name != nullptr && StrCmpI(name, f.spellName.c_str()) == 0;
}
bool Matches(const Entry &e, bool all) { return all || MatchesFilter(e); }
int Retention() { return CVar::Factory::GetInt(g_retention, kDefaultRetention); }
// RecycleCombatLogEntry on the oldest entry: the current entry moves on to
// the next one.
void DropOldest() {
g_entries.pop_front();
if (g_current > 0)
--g_current;
else if (g_current == 0 && g_entries.empty())
g_current = -1;
}
void PushCurrent(void *L) {
if (g_current >= 0)
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
}
int Count() { return static_cast<int>(g_entries.size()); }
int __fastcall Script_CombatLogResetFilter(void *) {
g_filters.clear();
return 0;
}
int __fastcall Script_CombatLogAddFilter(void *L) {
using namespace Game::Lua;
Filter f;
f.events = EventBits(ToString(L, 1));
// The source (2) and target (3): flags, a GUID, or anything else for all.
for (int idx = 2; idx <= 3; ++idx) {
uint32_t flags = 0;
uint64_t guid = 0;
switch (Type(L, idx)) {
case TYPE_NUMBER:
flags = static_cast<uint32_t>(static_cast<int64_t>(ToNumber(L, idx)));
if ((flags & SPECIAL_MASK) == 0 && !Complete(flags)) {
Error(L, idx == 2 ? "CombatLogAddFilter(): incomplete filter for srcMask"
: "CombatLogAddFilter(): incomplete filter for dstMask");
return 0;
}
break;
case TYPE_STRING:
guid = StringToGuid(ToString(L, idx));
break;
default:
flags = 0xFFFFFFFF;
break;
}
(idx == 2 ? f.sourceGuid : f.destGuid) = guid;
(idx == 2 ? f.sourceFlags : f.destFlags) = guid != 0 ? 0 : flags;
}
switch (Type(L, 4)) {
case TYPE_NUMBER:
f.spellId = static_cast<int>(static_cast<int64_t>(ToNumber(L, 4)));
break;
case TYPE_STRING:
if (const char *name = ToString(L, 4)) {
f.spellNamed = true;
f.spellName = name;
}
break;
default:
break;
}
if (f.spellId != 0) {
f.spellNamed = false;
f.spellName.clear();
}
g_filters.push_back(std::move(f));
return 0;
}
int __fastcall Script_CombatLogGetRetentionTime(void *L) {
Game::Lua::PushNumber(L, Retention());
return 1;
}
int __fastcall Script_CombatLogSetRetentionTime(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CombatLogSetRetentionTime(seconds)");
return 0;
}
CVar::Factory::SetInt(g_retention, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_CombatLogGetNumEntries(void *L) {
const bool all = ArgFlag(L, 1);
int count = 0;
for (const Entry &e : g_entries) {
if (Matches(e, all))
++count;
}
Game::Lua::PushNumber(L, count);
return 1;
}
int __fastcall Script_CombatLogSetCurrentEntry(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CombatLogSetCurrentEntry(index [,ignoreFilter])");
return 0;
}
g_current = -1;
int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
const bool all = ArgFlag(L, 2);
if (index <= 0) {
// 0 is the newest entry, -1 the one before it.
for (int i = Count() - 1; i >= 0; --i) {
if (Matches(g_entries[i], all) && index++ == 0) {
g_current = i;
break;
}
}
} else {
for (int i = 0; i < Count(); ++i) {
if (Matches(g_entries[i], all) && --index == 0) {
g_current = i;
break;
}
}
}
PushCurrent(L);
return 1;
}
int __fastcall Script_CombatLogGetCurrentEntry(void *L) {
if (g_current < 0 || g_current >= Count() ||
Game::Lua::CheckStack(L, MAX_EVENT_VALUES) == 0)
return 0;
return PushEvent(L, g_entries[g_current]);
}
int __fastcall Script_CombatLogAdvanceEntry(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CombatLogAdvanceEntry(count [,ignoreFilter])");
return 0;
}
int count = static_cast<int>(Game::Lua::ToNumber(L, 1));
const bool all = ArgFlag(L, 2);
if (count != 0) {
// The walk starts at the current entry, which counts when it matches.
int result = -1;
if (count < 0) {
for (int i = g_current; i >= 0; --i) {
if (Matches(g_entries[i], all) && count++ == 0) {
result = i;
break;
}
}
} else if (g_current >= 0) {
for (int i = g_current; i < Count(); ++i) {
if (Matches(g_entries[i], all) && count-- == 0) {
result = i;
break;
}
}
}
g_current = result;
}
PushCurrent(L);
return 1;
}
int __fastcall Script_CombatLogClearEntries(void *) {
g_entries.clear();
g_current = -1;
return 0;
}
int __fastcall Script_CombatLog_Object_IsA(void *L) {
const uint32_t flags =
static_cast<uint32_t>(static_cast<int64_t>(Game::Lua::ToNumber(L, 1))) &
static_cast<uint32_t>(static_cast<int64_t>(Game::Lua::ToNumber(L, 2)));
if ((flags & SPECIAL_MASK) != 0 || Complete(flags))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
using Game::Doc::Opt;
using Game::Doc::Req;
using Game::Doc::Vararg;
const Game::Doc::Field kAddFilterArgs[] = {
Opt("events", "string", nullptr,
"Subevent names, divided by spaces or commas. With no list, every subevent matches."),
Opt("source", "number", nullptr,
"A COMBATLOG_OBJECT_* mask, or a GUID string. With nothing, every source matches."),
Opt("dest", "number", nullptr,
"A COMBATLOG_OBJECT_* mask, or a GUID string. With nothing, every target matches."),
Opt("spell", "number", nullptr, "A spell ID, or a spell name."),
};
const Game::Doc::Field kIgnoreFilterArg[] = {
Opt("ignoreFilter", "bool", "false"),
};
const Game::Doc::Field kSetCurrentArgs[] = {
Req("index", "number",
"From 1, counted from the oldest entry. From 0 down, counted back from the newest."),
Opt("ignoreFilter", "bool", "false"),
};
const Game::Doc::Field kAdvanceArgs[] = {
Req("count", "number", "The number of entries to move. A negative count moves back."),
Opt("ignoreFilter", "bool", "false"),
};
const Game::Doc::Field kSecondsArg[] = {Req("seconds", "number")};
const Game::Doc::Field kSecondsRet[] = {Req("seconds", "number")};
const Game::Doc::Field kNumRet[] = {Req("numEntries", "number")};
const Game::Doc::Field kFoundRet[] = {Opt("found", "number", nullptr, "1, or nil.")};
const Game::Doc::Field kEntryRets[] = {
Vararg("CombatLogEventInfo", "The same values as CombatLogGetCurrentEventInfo."),
};
const Game::Doc::Field kIsAArgs[] = {
Req("flags", "number", "The COMBATLOG_OBJECT_* flags of a unit."),
Req("mask", "number", "A COMBATLOG_OBJECT_* mask."),
};
const Game::Doc::Field kIsARet[] = {Opt("isA", "number", nullptr, "1, or nil.")};
constexpr const char *kSystem = "CombatLogGlobals";
const Game::Doc::Function kResetFilterDoc{"Removes all combat log filters.", {}, {}, kSystem};
const Game::Doc::Function kAddFilterDoc{"Adds a filter to the combat log.", kAddFilterArgs, {},
kSystem};
const Game::Doc::Function kGetRetentionDoc{
"Returns how long the combat log keeps its entries.", {}, kSecondsRet, kSystem};
const Game::Doc::Function kSetRetentionDoc{"Sets how long the combat log keeps its entries.",
kSecondsArg, {}, kSystem};
const Game::Doc::Function kNumEntriesDoc{"Returns the number of kept entries that match the filter.",
kIgnoreFilterArg, kNumRet, kSystem};
const Game::Doc::Function kSetCurrentDoc{"Selects a kept combat log entry.", kSetCurrentArgs,
kFoundRet, kSystem};
const Game::Doc::Function kGetCurrentDoc{"Returns the values of the selected combat log entry.",
{}, kEntryRets, kSystem};
const Game::Doc::Function kAdvanceDoc{"Moves the selection to a later or earlier entry.",
kAdvanceArgs, kFoundRet, kSystem};
const Game::Doc::Function kClearEntriesDoc{"Removes all kept combat log entries.", {}, {},
kSystem};
const Game::Doc::Function kIsADoc{"Returns whether a unit's flags match a mask.", kIsAArgs,
kIsARet, kSystem};
void RegisterLuaFunctions() {
g_retention = CVar::Factory::Register("combatLogRetentionTime", "The maximum duration in seconds to retain combat log entries", Offsets::CVAR_CATEGORY_GAME, "300");
using Game::Lua::RegisterGlobalFunction;
RegisterGlobalFunction("CombatLogResetFilter", &Script_CombatLogResetFilter, &kResetFilterDoc);
RegisterGlobalFunction("CombatLogAddFilter", &Script_CombatLogAddFilter, &kAddFilterDoc);
RegisterGlobalFunction("CombatLogSetRetentionTime", &Script_CombatLogSetRetentionTime,
&kSetRetentionDoc);
RegisterGlobalFunction("CombatLogGetRetentionTime", &Script_CombatLogGetRetentionTime,
&kGetRetentionDoc);
RegisterGlobalFunction("CombatLogGetNumEntries", &Script_CombatLogGetNumEntries,
&kNumEntriesDoc);
RegisterGlobalFunction("CombatLogSetCurrentEntry", &Script_CombatLogSetCurrentEntry,
&kSetCurrentDoc);
RegisterGlobalFunction("CombatLogGetCurrentEntry", &Script_CombatLogGetCurrentEntry,
&kGetCurrentDoc);
RegisterGlobalFunction("CombatLogAdvanceEntry", &Script_CombatLogAdvanceEntry, &kAdvanceDoc);
RegisterGlobalFunction("CombatLogClearEntries", &Script_CombatLogClearEntries,
&kClearEntriesDoc);
RegisterGlobalFunction("CombatLog_Object_IsA", &Script_CombatLog_Object_IsA, &kIsADoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
// UnitCombatLogReset: the glue pass runs on each return to the login screen.
void Reset() {
g_entries.clear();
g_current = -1;
g_filters.clear();
}
const Game::GlueModuleAutoRegister _gluereg{&Reset};
} // namespace
void Record(const Entry &entry) {
const auto window = 1000u * static_cast<uint32_t>(std::clamp(Retention(), 0, kMaxRetention));
if (!g_entries.empty() &&
static_cast<int32_t>(entry.timeMs - window - g_entries.front().timeMs) >= 0)
DropOldest();
g_entries.push_back(entry);
}
bool MatchesFilter(const Entry &entry) {
if (g_filters.empty())
return true;
for (const Filter &f : g_filters) {
if (Matches(f, entry))
return true;
}
return false;
}
} // namespace CombatLog::History
+30
View File
@@ -0,0 +1,30 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// The combat log's kept entries and filters — 4.3.4's s_entries / s_filters
// and the Lua functions that read them (UnitCombatLog_C_Scripts.cpp).
#include "combatlog/Entry.h"
namespace CombatLog::History {
// QueueCombatLogEntry's link: keeps a copy of the entry, first letting go of
// the oldest one when it is older than the retention time.
void Record(const Entry &entry);
// CombatLogEntry::MatchesFilter: true when no filter is set or one matches.
bool MatchesFilter(const Entry &entry);
} // namespace CombatLog::History
+64
View File
@@ -0,0 +1,64 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Spell misses — 4.3.4's UnitCombatLogSpellMissed.
//
// 1.12 writes every spell miss through one function, FUN_COMBAT_LOG_SPELL_MISS:
// the SMSG_SPELL_GO miss list for an instant spell, a missile's arrival for
// one that travels, and SMSG_SPELLLOGMISS, which passes its damage-shield
// flag. These are the moments 4.3.4 logs a miss too (Spell_C for a spell with
// no speed, Missile_C on arrival, HandleSpellLogMiss with damageShield), so the
// entry is built at the start of that function, before its range test. The
// function drops the spells 4.3.4 drops (missType 0, Attributes 0x180, no
// name); the entry tests the same.
#include "combatlog/Entry.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace CombatLog::Miss {
namespace {
using SpellMiss_t = void(__fastcall *)(int missType, int spellId, uint32_t casterLo,
uint32_t casterHi, uint32_t targetLo, uint32_t targetHi,
uint32_t damageShield);
SpellMiss_t SpellMiss_o = nullptr;
uint64_t Guid(uint32_t lo, uint32_t hi) { return (static_cast<uint64_t>(hi) << 32) | lo; }
void __fastcall SpellMiss_h(int missType, int spellId, uint32_t casterLo, uint32_t casterHi,
uint32_t targetLo, uint32_t targetHi, uint32_t damageShield) {
const uint8_t *spell = SpellRecord(spellId);
if (missType != MISS_NONE && !Hidden(spell)) {
const bool shield = (damageShield & 0xFF) != 0;
const int event =
shield ? DAMAGE_SHIELD_MISSED : (Ranged(spell) ? RANGE_MISSED : SPELL_MISSED);
Entry e = Begin(Guid(casterLo, casterHi), Guid(targetLo, targetHi), event, spell);
SetMissInfo(e, missType, 0, false);
Queue(e);
}
SpellMiss_o(missType, spellId, casterLo, casterHi, targetLo, targetHi, damageShield);
}
const Game::HookAutoRegister _hook{Offsets::FUN_COMBAT_LOG_SPELL_MISS,
reinterpret_cast<void *>(&SpellMiss_h),
reinterpret_cast<void **>(&SpellMiss_o)};
} // namespace
} // namespace CombatLog::Miss
+479
View File
@@ -0,0 +1,479 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The combat log messages — 4.3.4's CombatLog::Handle* parsers and the
// UnitCombatLog* builders they call, read from 1.12's bodies of the same
// messages through Net::PacketDispatch (no hook on the handlers, which
// nampower detours).
//
// 1.12's bodies (each handler disassembled; benilla-re objectlayer/
// playercombat, unitcombat):
// 0x14a ATTACKERSTATEUPDATE hit info, packed attacker, packed victim,
// total damage, u8 n, n × {school, float,
// damage, absorbed, resisted}, victim state,
// u32, melee spell, blocked
// 0x250 SPELLNONMELEEDAMAGELOG packed target, packed caster, spell,
// damage, u8 school, absorbed, resisted,
// u8 periodic, u8, blocked, hit flags, u8 debug
// 0x150 SPELLHEALLOG packed target, packed caster, spell, amount,
// u8 critical
// 0x151 SPELLENERGIZELOG packed target, packed caster, spell, power,
// amount
// 0x24e PERIODICAURALOG packed target, packed caster, spell, count,
// count × {aura type, values by type}
// 0x24f SPELLDAMAGESHIELD shield owner, victim (u64), damage, school
// 0x1fc ENVIRONMENTALDAMAGELOG victim (u64), u8 type, damage, absorbed,
// resisted
// 0x260 PROCRESIST caster, target (u64), spell, u8 debug (two
// floats follow when it is set)
// What 1.12 does not send is filled as 4.3.4 fills "none": overkill and
// overhealing 0, healing absorbed 0, no periodic critical. Schools are
// indexes here and masks in the event. A damage shield names no spell, so
// its spell prefix is spellId 0 with the damage school.
//
// Spell misses do not come from here: they reach the log from three places
// (the SPELL_GO miss list, a missile's arrival, SPELLLOGMISS), all through
// one function — see combatlog/Miss.cpp.
#include "combatlog/Entry.h"
#include "Game.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include <cstdint>
namespace CombatLog::Packets {
namespace {
using Net::CDataStore;
using Net::Read;
using Net::ReadPackedGuid;
constexpr uint32_t SMSG_SPELLHEALLOG = 0x150;
constexpr uint32_t SMSG_SPELLENERGIZELOG = 0x151;
constexpr uint32_t SMSG_ATTACKERSTATEUPDATE = 0x14a;
constexpr uint32_t SMSG_ENVIRONMENTALDAMAGELOG = 0x1fc;
constexpr uint32_t SMSG_PROCRESIST = 0x260;
constexpr uint32_t SMSG_PERIODICAURALOG = 0x24e;
constexpr uint32_t SMSG_SPELLDAMAGESHIELD = 0x24f;
constexpr uint32_t SMSG_SPELLNONMELEEDAMAGELOG = 0x250;
// 1.12 melee hit info (the swing line's own tests, 0x00629B60 / 0x00629D30).
constexpr uint32_t HITINFO_LEFTSWING = 0x4;
constexpr uint32_t HITINFO_MISS = 0x10;
constexpr uint32_t HITINFO_ABSORB = 0x20;
constexpr uint32_t HITINFO_RESIST = 0x40;
constexpr uint32_t HITINFO_CRITICAL = 0x80;
constexpr uint32_t HITINFO_GLANCING = 0x4000;
constexpr uint32_t HITINFO_CRUSHING = 0x8000;
// SPELLNONMELEEDAMAGELOG hit flags (0x005E85E0).
constexpr uint32_t SPELL_HIT_CRITICAL = 0x2;
constexpr uint32_t SPELL_HIT_SPLIT = 0x8;
enum VictimState : uint32_t {
VICTIMSTATE_NORMAL = 1,
VICTIMSTATE_DODGE = 2,
VICTIMSTATE_PARRY = 3,
VICTIMSTATE_BLOCKS = 5,
VICTIMSTATE_EVADES = 6,
VICTIMSTATE_IMMUNE = 7,
VICTIMSTATE_DEFLECTS = 8,
};
// Periodic aura types (SpellAuraNames) the log reads.
constexpr uint32_t AURA_PERIODIC_DAMAGE = 3;
constexpr uint32_t AURA_PERIODIC_HEAL = 8;
constexpr uint32_t AURA_OBS_MOD_HEALTH = 20;
constexpr uint32_t AURA_OBS_MOD_MANA = 21;
constexpr uint32_t AURA_PERIODIC_ENERGIZE = 24;
constexpr uint32_t AURA_PERIODIC_MANA_LEECH = 64;
constexpr uint32_t AURA_PERIODIC_DAMAGE_PERCENT = 89;
constexpr int kMaxDamageParts = 5;
bool Overrun(const CDataStore *p) { return p->m_read > p->m_size; }
void *Unit(uint64_t guid) { return Object::ByGuid(Offsets::TYPEMASK_UNIT, guid); }
bool PlayerControlled(const void *unit) {
const auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return fields != nullptr && (Game::Read<uint32_t>(fields, Offsets::OFF_UNIT_FIELD_FLAGS) &
Offsets::UNIT_FLAG_PLAYER_CONTROLLED) != 0;
}
// SendDamageEvent.
void SendDamage(uint64_t source, uint64_t dest, int event, const uint8_t *spell, int damage,
uint32_t schoolMask, int resisted, int blocked, int absorbed, bool critical,
bool glancing, bool crushing, bool offHand) {
Entry e = Begin(source, dest, event, spell);
e.info |= INFO_DAMAGE;
e.v[1] = damage;
e.v[2] = 0; // overkill: not sent
e.v[3] = static_cast<int>(schoolMask);
e.v[4] = absorbed;
e.v[5] = resisted;
e.v[6] = blocked;
e.critical = critical;
e.glancing = glancing;
e.crushing = crushing;
e.offHand = offHand;
Queue(e);
}
void SendMiss(uint64_t source, uint64_t dest, int event, const uint8_t *spell, int missType,
int amount, bool offHand) {
Entry e = Begin(source, dest, event, spell);
SetMissInfo(e, missType, amount, offHand);
Queue(e);
}
// UnitCombatLogSpellDamage.
void SpellDamage(uint64_t source, uint64_t dest, const uint8_t *spell, int damage,
uint32_t school, int absorbed, int resisted, int blocked, bool critical,
bool periodic) {
if (Hidden(spell))
return;
if (damage != 0) {
const int event = periodic ? SPELL_PERIODIC_DAMAGE
: (Ranged(spell) ? RANGE_DAMAGE : SPELL_DAMAGE);
SendDamage(source, dest, event, spell, damage, SchoolMask(school), resisted, blocked,
absorbed, critical, false, false, false);
return;
}
const int event =
periodic ? SPELL_PERIODIC_MISSED : (Ranged(spell) ? RANGE_MISSED : SPELL_MISSED);
if (absorbed != 0)
SendMiss(source, dest, event, spell, MISS_ABSORB, absorbed, false);
else if (blocked != 0)
SendMiss(source, dest, event, spell, MISS_BLOCK, blocked, false);
else if (resisted != 0)
SendMiss(source, dest, event, spell, MISS_RESIST, resisted, false);
}
// UnitCombatLogHeal.
void Heal(uint64_t caster, uint64_t target, const uint8_t *spell, int amount, bool critical,
bool periodic) {
if (amount == 0 || Hidden(spell))
return;
Entry e = Begin(caster, target, periodic ? SPELL_PERIODIC_HEAL : SPELL_HEAL, spell);
e.info |= INFO_HEAL;
e.v[0] = amount;
e.v[1] = 0; // overhealing: not sent
e.v[2] = 0; // absorbed: 1.12 has no healing absorbs
e.critical = critical;
Queue(e);
}
// UnitCombatLogEnergize + SendEnergizeEvent.
void Energize(uint64_t caster, uint64_t target, const uint8_t *spell, int powerType, int amount,
bool periodic) {
if (amount == 0 || Hidden(spell))
return;
Entry e = Begin(caster, target, periodic ? SPELL_PERIODIC_ENERGIZE : SPELL_ENERGIZE, spell);
e.info |= INFO_ENERGIZE;
e.v[0] = DisplayAmount(amount, powerType);
e.v[1] = powerType;
e.v[2] = -1; // alternate power: none in 1.12
Queue(e);
}
// The periodic mana leech's AddPowerDrainEntry.
void Drain(uint64_t caster, uint64_t target, const uint8_t *spell, int powerType, int amount,
float multiplier) {
if (amount == 0 || Hidden(spell))
return;
const int extra = static_cast<int>(static_cast<float>(amount) * multiplier);
PowerDrain(caster, target, extra != 0 ? SPELL_PERIODIC_LEECH : SPELL_PERIODIC_DRAIN, spell,
amount, powerType, extra);
}
// SMSG_ATTACKERSTATEUPDATE → UnitCombatLog(ATTACKROUNDINFO).
void AttackerState(CDataStore *p) {
const uint32_t hitInfo = Read<uint32_t>(p);
const uint64_t attacker = ReadPackedGuid(p);
const uint64_t victim = ReadPackedGuid(p);
const int damage = Read<int32_t>(p);
const int parts = Read<uint8_t>(p);
uint32_t school = 0;
int absorbed = 0;
int resisted = 0;
for (int i = 0; i < parts; ++i) {
const uint32_t partSchool = Read<uint32_t>(p);
Read<float>(p);
Read<int32_t>(p); // the part's damage; the total is above
absorbed += Read<int32_t>(p);
resisted += Read<int32_t>(p);
if (i == 0)
school = partSchool;
}
uint32_t state = Read<uint32_t>(p);
Read<uint32_t>(p);
const uint32_t meleeSpell = Read<uint32_t>(p);
const int blocked = Read<int32_t>(p);
if (Overrun(p) || parts > kMaxDamageParts)
return;
// ReadAttackerState (0x00625E20): a swing with no damage and a blocked
// amount on a victim the client knows is a block.
void *victimUnit = Unit(victim);
if (victimUnit != nullptr && damage == 0 && blocked != 0)
state = VICTIMSTATE_BLOCKS;
// ShouldShowFeedback, and who 4.3.4's SMSG_ATTACKER_STATE_UPDATE logs
// for: a known attacker, or a player-controlled victim.
if (meleeSpell != 0 && state != VICTIMSTATE_NORMAL)
return;
if (Unit(attacker) == nullptr && (victimUnit == nullptr || !PlayerControlled(victimUnit)))
return;
const bool offHand = (hitInfo & HITINFO_LEFTSWING) != 0;
if ((state == VICTIMSTATE_NORMAL || state == VICTIMSTATE_BLOCKS) && damage != 0) {
SendDamage(attacker, victim, SWING_DAMAGE, nullptr, damage, SchoolMask(school), resisted,
blocked, absorbed, (hitInfo & HITINFO_CRITICAL) != 0,
(hitInfo & HITINFO_GLANCING) != 0, (hitInfo & HITINFO_CRUSHING) != 0, offHand);
return;
}
if (hitInfo & HITINFO_MISS) {
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_MISS, 0, offHand);
return;
}
if (hitInfo & HITINFO_ABSORB) {
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_ABSORB, absorbed, offHand);
return;
}
if (hitInfo & HITINFO_RESIST) {
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_RESIST, resisted, offHand);
return;
}
switch (state) {
case VICTIMSTATE_DODGE:
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_DODGE, 0, offHand);
break;
case VICTIMSTATE_PARRY:
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_PARRY, 0, offHand);
break;
case VICTIMSTATE_BLOCKS:
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_BLOCK, blocked, offHand);
break;
case VICTIMSTATE_EVADES:
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_EVADE, 0, offHand);
break;
case VICTIMSTATE_IMMUNE:
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_IMMUNE, 0, offHand);
break;
case VICTIMSTATE_DEFLECTS:
SendMiss(attacker, victim, SWING_MISSED, nullptr, MISS_DEFLECT, 0, offHand);
break;
default:
break;
}
}
// SMSG_SPELLNONMELEEDAMAGELOG → HandleSpellNonMeleeDamageLog.
void NonMeleeDamage(CDataStore *p) {
const uint64_t target = ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const int damage = Read<int32_t>(p);
const uint32_t school = Read<uint8_t>(p);
const int absorbed = Read<int32_t>(p);
const int resisted = Read<int32_t>(p);
const bool periodic = Read<uint8_t>(p) != 0;
Read<uint8_t>(p);
const int blocked = Read<int32_t>(p);
const uint32_t hitFlags = Read<uint32_t>(p);
if (Overrun(p))
return;
const uint8_t *spell = SpellRecord(spellId);
const bool critical = (hitFlags & SPELL_HIT_CRITICAL) != 0;
if (hitFlags & SPELL_HIT_SPLIT) {
// UnitCombatLogSplitDamage.
if (!Hidden(spell))
SendDamage(caster, target, DAMAGE_SPLIT, spell, damage, SchoolMask(school), resisted,
blocked, absorbed, critical, false, false, false);
return;
}
SpellDamage(caster, target, spell, damage, school, absorbed, resisted, blocked, critical,
periodic);
}
// SMSG_SPELLHEALLOG → HandleSpellHealLog.
void HealLog(CDataStore *p) {
const uint64_t target = ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const int amount = Read<int32_t>(p);
const bool critical = Read<uint8_t>(p) != 0;
if (Overrun(p))
return;
Heal(caster, target, SpellRecord(spellId), amount, critical, false);
}
// SMSG_SPELLENERGIZELOG → HandleSpellLogEnergizeLog.
void EnergizeLog(CDataStore *p) {
const uint64_t target = ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const int powerType = Read<int32_t>(p);
const int amount = Read<int32_t>(p);
if (Overrun(p))
return;
Energize(caster, target, SpellRecord(spellId), powerType, amount, false);
}
// SMSG_PERIODICAURALOG → HandlePeriodicAuraLog.
void PeriodicAuraLog(CDataStore *p) {
const uint64_t target = ReadPackedGuid(p);
const uint64_t caster = ReadPackedGuid(p);
const int spellId = Read<int32_t>(p);
const uint32_t count = Read<uint32_t>(p);
const uint8_t *spell = SpellRecord(spellId);
if (Overrun(p) || spell == nullptr || Unit(target) == nullptr)
return;
for (uint32_t i = 0; i < count; ++i) {
const uint32_t auraType = Read<uint32_t>(p);
switch (auraType) {
case AURA_PERIODIC_DAMAGE:
case AURA_PERIODIC_DAMAGE_PERCENT: {
const int damage = Read<int32_t>(p);
const uint32_t school = Read<uint32_t>(p);
const int absorbed = Read<int32_t>(p);
const int resisted = Read<int32_t>(p);
if (Overrun(p))
return;
SpellDamage(caster, target, spell, damage, school, absorbed, resisted, 0, false, true);
break;
}
case AURA_PERIODIC_HEAL:
case AURA_OBS_MOD_HEALTH: {
const int amount = Read<int32_t>(p);
if (Overrun(p))
return;
Heal(caster, target, spell, amount, false, true);
break;
}
case AURA_OBS_MOD_MANA:
case AURA_PERIODIC_ENERGIZE: {
const int powerType = Read<int32_t>(p);
const int amount = Read<int32_t>(p);
if (Overrun(p))
return;
Energize(caster, target, spell, powerType, amount, true);
break;
}
case AURA_PERIODIC_MANA_LEECH: {
const int powerType = Read<int32_t>(p);
const int amount = Read<int32_t>(p);
const float multiplier = Read<float>(p);
if (Overrun(p))
return;
Drain(caster, target, spell, powerType, amount, multiplier);
break;
}
default:
return; // an unknown type ends the message, as in both clients
}
}
}
// SMSG_SPELLDAMAGESHIELD → UnitCombatLogDamageShield.
void DamageShield(CDataStore *p) {
const uint64_t owner = Read<uint64_t>(p);
const uint64_t victim = Read<uint64_t>(p);
const int damage = Read<int32_t>(p);
const uint32_t school = Read<uint32_t>(p);
if (Overrun(p))
return;
Entry e = Begin(owner, victim, DAMAGE_SHIELD, nullptr);
e.spellPrefix = true;
e.spellSchool = SchoolMask(school);
e.info |= INFO_DAMAGE;
e.v[1] = damage;
e.v[3] = static_cast<int>(SchoolMask(school));
Queue(e);
}
// SMSG_ENVIRONMENTALDAMAGELOG → CGUnit_C::HandleEnvironmentDamage →
// UnitCombatLog(guid, type, damage, absorbed, resisted).
void EnvironmentalDamage(CDataStore *p) {
// s_environmentalDamageSchools: physical ×3, fire, nature, fire.
static constexpr uint32_t kSchools[] = {0, 0, 0, 2, 3, 2};
const uint64_t victim = Read<uint64_t>(p);
const uint32_t type = Read<uint8_t>(p);
const int damage = Read<int32_t>(p);
const int absorbed = Read<int32_t>(p);
const int resisted = Read<int32_t>(p);
if (Overrun(p) || Unit(victim) == nullptr || (damage == 0 && absorbed == 0 && resisted == 0))
return;
Entry e = Begin(0, victim, ENVIRONMENTAL_DAMAGE, nullptr);
e.info |= INFO_ENVIRONMENTAL | INFO_DAMAGE;
e.v[0] = static_cast<int>(type);
e.v[1] = damage;
e.v[3] = static_cast<int>(SchoolMask(type < 6 ? kSchools[type] : 0));
e.v[4] = absorbed;
e.v[5] = resisted;
Queue(e);
}
// SMSG_PROCRESIST → Packet_SMSG_PROC_RESIST: a resisted proc is a spell miss.
void ProcResist(CDataStore *p) {
const uint64_t caster = Read<uint64_t>(p);
const uint64_t target = Read<uint64_t>(p);
const int spellId = Read<int32_t>(p);
if (Overrun(p))
return;
const uint8_t *spell = SpellRecord(spellId);
if (Hidden(spell))
return;
SendMiss(caster, target, SPELL_MISSED, spell, MISS_RESIST, 0, false);
}
void OnPacket(uint32_t opcode, CDataStore *p) {
switch (opcode) {
case SMSG_ATTACKERSTATEUPDATE:
AttackerState(p);
break;
case SMSG_SPELLNONMELEEDAMAGELOG:
NonMeleeDamage(p);
break;
case SMSG_SPELLHEALLOG:
HealLog(p);
break;
case SMSG_SPELLENERGIZELOG:
EnergizeLog(p);
break;
case SMSG_PERIODICAURALOG:
PeriodicAuraLog(p);
break;
case SMSG_SPELLDAMAGESHIELD:
DamageShield(p);
break;
case SMSG_ENVIRONMENTALDAMAGELOG:
EnvironmentalDamage(p);
break;
case SMSG_PROCRESIST:
ProcResist(p);
break;
default:
break;
}
}
const Net::PacketDispatch::AutoSubscribe _sub{&OnPacket};
} // namespace
} // namespace CombatLog::Packets
+58 -37
View File
@@ -13,12 +13,44 @@
#include "CommandLine.h"
#include "Common.h"
#include "Offsets.h"
#include <windows.h>
namespace Config::CommandLine {
namespace {
// Storm's `_ARGLIST` record.
struct ArgList {
uint32_t flags;
uint32_t id;
const char *name;
void *callback;
};
constexpr uint32_t SCMD_TYPE_STRING = 0x20000;
constexpr ArgList kSwitches[] = {
{SCMD_TYPE_STRING, static_cast<uint32_t>(Switch::Config), "config", nullptr},
{SCMD_TYPE_STRING, static_cast<uint32_t>(Switch::GlueScript), "gluescript", nullptr},
{SCMD_TYPE_STRING, static_cast<uint32_t>(Switch::GlueScriptFile), "gluescriptFile", nullptr},
{SCMD_TYPE_STRING, static_cast<uint32_t>(Switch::GameScript), "gamescript", nullptr},
{SCMD_TYPE_STRING, static_cast<uint32_t>(Switch::GameScriptFile), "gamescriptFile", nullptr},
};
using RegisterArgList_t = int(__stdcall *)(const ArgList *list, uint32_t count);
using GetString_t = int(__stdcall *)(uint32_t id, char *buffer, uint32_t size);
// Replaces the boot call to the client-switch registrar, which also runs the
// parse. Ours go on the list first, then the engine does what it always did.
void __cdecl RegisterThenParse() {
reinterpret_cast<RegisterArgList_t>(Offsets::FUN_SCMD_REGISTER_ARG_LIST)(
kSwitches, static_cast<uint32_t>(sizeof kSwitches / sizeof kSwitches[0]));
reinterpret_cast<void(__cdecl *)()>(Offsets::FUN_REGISTER_CLIENT_SWITCHES)();
}
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
char Lower(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c; }
@@ -49,17 +81,24 @@ const char *FindValue(const char *name) {
if (nameLen == 0)
return nullptr;
bool quoted = false;
for (const char *p = cmdLine; *p != '\0'; ++p) {
if (*p != '-' && *p != '/')
if (*p == '"') {
quoted = !quoted;
continue;
}
if (quoted || (*p != '-' && *p != '/'))
continue;
// Only at a token boundary, so a path containing the name cannot match.
// A closing quote is a boundary too: the engine's tokenizer ends a
// token there.
if (p != cmdLine && !IsSpace(p[-1]) && p[-1] != '"')
continue;
if (!EqualsIgnoreCase(p + 1, name, nameLen))
continue;
const char *after = p + 1 + nameLen;
if (*after == '=') {
if (*after == '=' || *after == ':') {
++after;
} else if (IsSpace(*after)) {
while (IsSpace(*after))
@@ -72,52 +111,34 @@ const char *FindValue(const char *name) {
return nullptr;
}
// Walks the token at `p`, honoring double quotes so a value containing spaces
// arrives whole. `emit` takes each character and returns whether it was kept;
// the walk stops as a failure the moment one is not.
template <typename Emit> bool ReadToken(const char *p, Emit emit) {
const bool quoted = (*p == '"');
if (quoted)
++p;
size_t n = 0;
while (*p != '\0' && (quoted ? (*p != '"') : !IsSpace(*p))) {
if (!emit(*p++))
return false;
++n;
}
return n > 0;
}
} // namespace
bool Value(const char *name, char *out, size_t outSize) {
void Install() {
Common::RedirectCall(Offsets::CALL_INIT_REGISTER_CLIENT_SWITCHES,
Offsets::FUN_REGISTER_CLIENT_SWITCHES, &RegisterThenParse);
}
bool Value(Switch id, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
out[0] = '\0';
const char *p = FindValue(name);
if (p == nullptr)
return false;
size_t n = 0;
const bool ok = ReadToken(p, [&](char c) {
if (n + 1 >= outSize)
return false;
out[n++] = c;
return true;
});
out[n] = '\0';
return ok;
reinterpret_cast<GetString_t>(Offsets::FUN_SCMD_GET_STRING)(
static_cast<uint32_t>(id), out, static_cast<uint32_t>(outSize));
return out[0] != '\0';
}
bool Value(const char *name, std::string &out) {
bool RawValue(const char *name, std::string &out) {
out.clear();
const char *p = FindValue(name);
if (p == nullptr)
return false;
return ReadToken(p, [&](char c) {
out.push_back(c);
return true;
});
// Honor double quotes so a value containing spaces arrives whole.
const bool quoted = (*p == '"');
if (quoted)
++p;
while (*p != '\0' && (quoted ? (*p != '"') : !IsSpace(*p)))
out.push_back(*p++);
return !out.empty();
}
} // namespace Config::CommandLine

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