Compare commits

..
90 Commits
Author SHA1 Message Date
Brues d7103de564 feat(spell): count set bonuses in GetSpellBonusHealing
Set-bonus spells are applied server-side as auras the client never sees
as buffs or known spells, so the derived healing sum missed them —
Lawbringer (3) is "Increase Healing 31" (9317), Judgement (3) is 57
(18037). Add a pass that counts the equipped members of each set from
the item record's set id (OFF_ITEMSTATS_ITEM_SET), reads ItemSet.dbc's
bonus spells and member thresholds, and runs every met bonus through
the same aura decoder — the rule the item tooltip's set-bonus lines use
at 0x52DFB9.

The Ironclad (aura 199) armor conversion stays on
UNIT_FIELD_RESISTANCES[0]: the server's SpellBaseHealingBonusDone uses
GetArmor(), the full total, even though the talent text says "Armor
from items".

Refs #135
2026-11-09 11:28:51 -06:00
Brues c00eb24a8d fix(event): refuse glue's event table and re-validate cached claim slots
CGGameUI::Initialize (0x0048FE00) calls RegisterScriptFunctions — where
our LoadScriptFunctions hook enables claim writes — and only then
SetScriptEventTable(0xBE1198, 0x225), which builds the in-game table.
Between the two the live table at [0xCEEF68] is still glue's 26-entry
one. A third-party DLL that registers a frame from its own
LoadScriptFunctions hook fires our Frame::RegisterEvent (0x00702140)
hook in that window: Grow enlarged the glue table (26 -> 142), the
reservations claimed slots 26..125 in it, the engine freed that buffer
and built the real table, and every cached slot stayed non-negative —
never retried, invisible to IsEventValid, and a Fire would have
signalled whatever engine event sat at that id.

Two guards, both read off the engine's own build:

- GameTableInstalled: the in-game build always passes 0x225 names and
  the other DLLs' SetEventCount clamps only enlarge, so a table smaller
  than EVENT_NAME_TABLE_STATIC_COUNT is glue's. Claim and Grow refuse it.
- Stale: each reservation keeps the SStrDup'd pointer it wrote; when the
  live entry at its slot no longer holds that pointer (or name), the
  table was rebuilt behind us and every slot is forgotten and re-claimed
  before the original RegisterEvent populates any chain.

Fixes #134
2026-11-09 11:05:32 -06:00
Brues b65c9e0319 fix(spell): never auto-unshift out of Spirit of Redemption
3.3.5's and 4.4.2's SpellShapeshiftForm.dbc give Spirit of Redemption
(form 32) flags 0x4100, which include SHAPESHIFT_FORM_FLAG_NO_AUTO_UNSHIFT
(0x100). So the later clients' CGUnit_C::CanAutoUnshift refuses it, and no
cast, interaction or item use cancels it. 1.12's row has flags 0, so with
autoUnshift on, our CanAutoUnshift allowed it. A priest in Spirit of
Redemption who cast a NOT_SHAPESHIFT spell, right-clicked an NPC or object,
used an item or opened the flight map sent CMSG_CANCEL_AURA for it.

The later-client flags that 1.12's rows lack now sit in one table:
CAN_INTERACT on the druid forms and Ghost Wolf (read only while
autoUnshift is on, as before), and NO_AUTO_UNSHIFT on form 32, which
CanAutoUnshift reads unconditionally, as 4.3.4 does. With it, every
autoUnshift site leaves Spirit of Redemption on and keeps 1.12's own
refusal.

The comment on FormCanInteract also records why a locked object still
unshifts. It opens by a spell (Opening 6478 for chests and barrels), and
the server's CheckCast form test refuses that spell on 1.12's spell data,
which keeps SPELL_ATTR_NOT_SHAPESHIFT (3.3.5 dropped it).
2026-11-08 11:29:09 -06:00
Brues de033896b1 feat(spell): let autoUnshift talk and use objects in the druid forms
1.12's SpellShapeshiftForm.dbc gives SHAPESHIFT_FORM_FLAG_CAN_INTERACT
(0x8) only to Shadowform. 3.3.5 and 4.4.2 also give it to Cat, Travel,
Aquatic, Bear, Dire Bear and Ghost Wolf, and Turtle's id 11 (Swift
Travel Form) has it there too. So in the later clients, these forms pass
the NPC right-click gate (CGPlayer_C::HandleOnRightClick, 0x005F0130)
and the game-object use check (0x005F3130) as they are. The form cancel
that comes before the range check almost never runs.

In 1.12, autoUnshift cancelled every druid form at that gate, even when
the NPC was out of interact range. With autoUnshift on, the redirected
IsShapeShifted calls at both gates and at CanInteractNow (0x00606880)
now read a form in that set as able to interact. They report "not
shifted", so the engine's own 0x8 test that follows does not refuse it,
and the cursor shows the NPC's service icon. The cancel stays for a form
that cannot interact in either client.

Neither vmangos nor tortoise-wow tests the form on an NPC or object
interaction. CanInteractWithNPC and the use handlers have no form check;
the only one is item use. So the client gates are the only refusal.
2026-11-07 20:46:48 -06:00
MrbcatandBrues dc8d4caf12 fix(loot): skip corpses that stay lootable after LootAllCorpses (#133)
* fix(loot): skip corpses that stay lootable after LootAllCorpses

A corpse whose remaining loot this player can never take keeps
UNIT_DYNFLAG_LOOTABLE set: quest items for quests they don't have,
condition-gated loot, and the like. The per-corpse re-check still
passes, so a caller that runs C_Loot.LootAllCorpses from a timer
re-opens the same corpse on every walk and re-sends the refused
CMSG_LOOT_ITEM, which never clears.

Loot mode now remembers every corpse it opened and released. Later
walks skip a remembered corpse while it is still visible and
lootable; the entry is dropped once the corpse stops being lootable or
leaves the object manager. A corpse that merely left render range is
dropped too, so it gets one retry when the player comes back.
ScanNearbyLoot stays read-only and keeps reporting the corpse.

1.12 has no per-slot "cannot loot" flag (GetLootSlotInfo has no
`locked` return), so one refused request per corpse is unavoidable;
this only stops it from repeating on every walk.

* docs(loot): describe the LootAllCorpses corpse skip

Explain why a corpse that stays lootable after the walk is skipped by
later walks, and when the skip entry is dropped.

* fix(loot): skip only corpses that open empty in LootAllCorpses

The previous change remembered every corpse the loot walk opened. The
most common reason a corpse stays UNIT_DYNFLAG_LOOTABLE is full bags,
so those corpses were never looted again by the walk, even after the
player freed bag space. Rolls and master-loot assignments in progress
had the same problem.

The server never sends a player a loot slot that player cannot take:
another member's quest item and condition-gated loot are left out of
SMSG_LOOT_RESPONSE, while the corpse stays lootable for as long as any
loot remains. So the stuck corpse opens to an EMPTY window, with no
coin and no populated VAR_LOOT_SLOTS entry. Loot mode now remembers a
corpse only when the scraped window is empty. A window with anything
in it (full bags, a roll, a master-loot slot) is retried.

An entry is forgotten:
- on the first world tick where its unit is gone or no longer
  lootable. This replaces the prune at walk start, which missed a
  corpse that left view and came back, and a creature that respawned
  with the same GUID and died again, between two walks;
- all at once on QUEST_LOG_UPDATE (event 0x134, observed through the
  shared FUN_FIRE_EVENT_NO_ARGS interceptor and never swallowed),
  because quest items are per-player loot and a quest change can put
  an item into a window that was empty.

The memory moves out of the per-walk ScanCtx into its own vector. The
API reference now describes the skip as a contract only.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-06 23:25:23 -06:00
Brues 20069f3815 docs(api): sort the reference's sections alphabetically
Seven sections were out of place in the body: ChatBubbles (after Mount),
Bindings (after Focus), APIDocumentation, CVar, MerchantFrame, NamePlate
and PvP. The contents list shared the last four. Both are now in the same
case-insensitive order, with one blank line between contents groups
(DeathInfo and Sound had none). The ClassicAPI namespace section had no
contents entry; it has one now.

The PVP_TIMER_UPDATE entry moves from the PvP section to Events, between
PLAYER_SWING_RANGE_UPDATE and QUEST_ACCEPTED. It now names the PvP timer
and links the two timer functions, and the PvP section links to it.
2026-11-06 17:35:16 -06:00
Brues f2fa112f0a feat(mount): add C_MountJournal.GetMountFromSpell and GetMountInfoExtraByID
1.12.1 has no mount table, so a mount's ID is the ID of the spell that
summons it: GetMountFromSpell(458) returns 458. A spell is a mount when
one of its effects applies SPELL_AURA_MOUNTED (78); Spell.dbc is read
directly, so mounts the player has not learned resolve too. On Turtle
every mount item carries spell 46499 "Add Mount to Collection" instead of
its mount spell (the server maps item to spell from its own table), so
only the mount spell gives a mount ID.

GetMountInfoExtraByID returns the nine retail values. The display ID is
the one the server mounts the player on: the mount aura's misc value is a
creature entry, and the client's creature cache holds that creature's
display (SMSG_CREATURE_QUERY_RESPONSE carries the template's first
display, and every mount creature has exactly one). An uncached creature
returns nil and starts the cache load through the new
Creature::Info::RequestLoad, which C_CreatureInfo.RequestLoadCreatureByID
now uses too; CREATURE_DATA_LOAD_RESULT fires when the data lands.
Verified in game: 23219 (Swift Mistsaber) returns 14332.

Mount type and model scene default to 230 and 4. Seven mounts take
retail's values instead (the six Qiraji battle tanks use scene 44, the
Riding Turtle type 231), each checked by name against Turtle's
Spell.dbc. The seat animation is 91 (Mount), the animation 1.12.1's
world mount plays on the rider.
2026-11-06 17:29:11 -06:00
Brues fca967c827 refactor(spell): test a spell's auras through one HasAuraEffect
Six places asked the same question of a Spell.dbc record: does one of its
three effects apply aura N (EffectApplyAuraName, +0x16C, int32[3])? Each
had its own loop: the combat log's damage-shield lookup, crowd-control
classification and its snare test, tracking-spell detection,
C_SpellBook.GetPlayerSpellsByAura, and the aura-source periodic-damage
gate (which also hard-coded 3 for the effect count).

Spell::Lookup now has FindAuraEffect (the index of the first effect that
applies the aura, or -1, for callers that read that effect's other
columns) and HasAuraEffect on top of it, next to the existing HasEffect.
All six call it. Code that pairs an aura with the same effect's misc
value, amount or effect code (Cancel, Interruptible, BonusDamage,
StatAccum, EffectInfo) keeps its own per-effect reads.
2026-11-06 17:29:02 -06:00
Brues 24404b5a23 fix(swing): report only a held main-hand reset at attack start
SMSG_ATTACKSTART re-fired PLAYER_SWING(MainHand) whenever the main-hand
swing was still running. That was meant for a cast reset recorded before
the attack (held back because no swing was coming yet), but the server
also sends SMSG_ATTACKSTART on a target switch mid-attack. Unit::Attack
then resets only the off-hand timer; the main-hand swing keeps going.
The event carried just the time left in that swing, so listeners read a
nearly finished swing as a fresh, short one.

Each swing type now carries a held flag, set only by a reset recorded
without notify and cleared when that type's PLAYER_SWING fires. The
attack-start path reports the main hand only while it is held.

Fixes brues-code/SuperCleveRoidMacros#11
2026-11-06 12:58:12 -06:00
Brues a106d86969 fix(unit): consult the name cache only when no unit object is held
UnitClassBase and UnitRaceBase now follow stock Script_UnitClass
(0x00518350) and Script_UnitRace (0x00518200) branch for branch.
"player" reads the selected-character record; any other token goes
through GetGUIDFromName + ClntObjMgrObjectPtr(TYPEMASK_UNIT) and reads
the held object's descriptor byte (+0x79 class, +0x78 race). The
NameCache record (+0x140 / +0x138, null callback) is peeked only when
no object is held.

Before, the NameCache was also peeked when a held object's byte was 0.
That never changed a result (creatures are not in the NameCache) but
resolved the token twice and did not match the engine's branch.

The comments and API.md also claimed creatures return nil, that class
IDs run 1-9, and that UnitClass has a third return. None of that is
true: creatures go through the same ChrClasses/ChrRaces lookup as in
UnitClass/UnitRace, Druid is 11, and stock returns exactly two values.
2026-11-06 11:52:58 -06:00
Brues fac3be3a0b refactor(lua): take integer enum and constants entries as arrays
RegisterIntegerEnum and RegisterConstants took their entries with a
count beside them, written as sizeof(t) / sizeof(t[0]), std::size, or
a literal. They now take Game::ArrayRef<EnumIntegerEntry>, as
RegisterFrameMethods does, so the count comes from the array's type.
The documentation recorders RecordEnum and RecordConstants take the
same view.

All 34 call sites pass only the array. The six literal counts were
checked against their arrays first: each was the full length, so every
Enum.* and Constants.* table gets the same entries as before. Five
files that included <iterator> only for std::size drop it.
2026-11-06 11:34:27 -06:00
Brues b49c107778 refactor(lua): take frame-method tables as arrays, not pointer and count
Every RegisterFrameMethods call passed its method table and doc table
with a count beside each: sizeof(t) / sizeof(t[0]), std::size, or a
literal. The literals had to be kept in step with the tables by hand.

Game::ArrayRef<T> is a view over a file-scope array that takes the
count from the array's type, the way Doc::FieldList already did for a
function's arguments and returns. FieldList is now ArrayRef<Field>,
Doc::MethodList is ArrayRef<Method>, and RegisterFrameMethods takes
(context, methods, docs). The table and count still go to the engine's
frame-method iterator (FUN_REGISTER_FRAME_METHODS) unchanged, and to
the documentation recorder.

All 84 call sites pass only the arrays. The 16 literal counts were
checked against their tables first: each was the full length, so every
registry gets the same methods as before.
2026-11-06 11:27:22 -06:00
Brues 53b7f79440 fix(model): release dress builders and stopped effects with their frame
/reload crashed with an access violation in the model release
(0x007103A3, inside FUN_MODEL_INSTANCE_RELEASE 0x007103A0) when
SetDisplayInfo had dressed a few panes.

1.12.1 reloads in two steps. CGGameUI::Shutdown (0x00490BD0) deletes
the CSimpleTop, which destroys every frame. A Model frame's teardown,
DestroyModelFrame (0x0076CA50, from the CSimpleModel and
CGCharacterModelBase destructors), releases the frame's M2 scene at
+0x314, and the scene frees every model in it, even one that something
else still holds a reference on. Only then does CGGameUI::Initialize
(0x0048FBF0) reset the Lua state, which runs FrameScript_Initialize
and our reload cleanups. DisplayInfo's cleanup destroyed each kept
compositor there (FUN_CHARCOMP_DESTROY 0x00476000), and the compositor
released its reference to a model that was already freed. VisualKit's
cleanup detached and released stopped one-shot effects that live in
the same freed scene.

Model::Teardown hooks DestroyModelFrame and runs its subscribers
before the original, while the frame's scene and models are intact,
which is where the engine frees what its own frames hold. DisplayInfo
drops the frame's dress job there, and VisualKit records each stopped
effect's pane (in the done callback's user data) and discards that
pane's effects there. Their reload cleanups keep only the
pointer-keyed state.

The ReloadAutoRegister doc now says that the cleanups run after the
frames are gone, so they may forget pointers but never release an
engine object tied to a frame.
2026-11-06 11:16:47 -06:00
Brues 7b21453469 refactor(model): share the engine model calls across the model modules
The model modules each declared their own pointers to the same 1.12.1
engine functions: the instance gates FUN_MODEL_INSTANCE_LOADED
(0x007103D0) and FUN_MODEL_INSTANCE_READY, AddRef / Release
(0x00710390 / 0x007103A0), FUN_M2SCENE_CREATE_MODEL,
FUN_M2MODEL_PLAY_ANIMATION, the done callback, the item visuals, the
render callback, the M2 data lookup FUN_M2_LOOKUP_INDEX (0x00710310),
and the pane's model, scene, SetModelInstance virtual (vtable +0x90)
and SetDisplay. model/M2.h now holds one typed wrapper for each, and
every module calls through it.

- Game::Lua::ResolvePlayerModel / ResolveDressUpModel join the
  per-type resolvers, replacing four ResolvePane copies and five
  inline ResolveTypedObject calls. Game::Lua::TYPE_NONE replaces the
  local kTypeNone in Rotation and frame/CreateFrame.
- Offsets.h has the full INVTYPE list, 0..28, named by the engine's
  own key table at 0x0083DDB0. It replaces the four scattered
  INVTYPE_* entries and the local copies in DressUp and ItemDisplay,
  and the try-on result switch reads by name.
- DisplayInfo keeps its dress jobs in a map by frame, like the other
  modules, in place of the fixed 8-slot pool and its eviction.
- DressUp names the held lane's bytes (item class, inventory type,
  sheathe type) and detaches a lane in one place.
- ItemDisplay::OwnVisual is the one test for a display's own
  ItemVisuals row (GetItemDescVisual 0x0062EC70), used by SetItem and
  by TryOn's enchant glow. Both own-record panes share
  Pane::DisplayRecord, and the SetDisplayInfo records are cleared on
  /reload as the SetItem records are.
2026-11-06 11:11:18 -06:00
shikuljaandBrues ebfd9d034d feat(localization): complete and correct the Russian locale (#130)
* feat(localization): complete and correct the Russian locale

The Russian locale was 99 keys behind enUS, so a Russian client kept the
English strings for ClassicAPI's own options: the AddOn Options title and
its game-menu button, every page name, header, control label and tooltip,
the AoE Loot binding, and the generic strings the options pages gained
(FOCUS_TOKEN_NOT_FOUND, LUA_ERROR, RELOADUI, PLAYER_LIST_DELIMITER,
SYMBOLIC_NEGATIVE_NUMBER, SYMBOLIC_POSITIVE_NUMBER and the money texture
formats). All 327 keys are now present, in enUS order, so the two files
diff key by key.

Focus follows the client's modern terms — «Взять в фокус», «Сбросить
фокус», «Выбрать фокус» and «Взять цель в фокус» — matching the existing
/фокус and /сбросфокуса aliases. The rest uses the 1.12 Russian client's
vocabulary where it has one (модификация, цель, панель команд, время
восстановления, табличка с именем) and «динамическое выделение» /
«мягкая цель» for action targeting. The money strings keep their |4
plural forms, which GetCoinText expects.

Fixes:

- LARGE_NUMBER_SEPERATOR was "", which turned digit grouping off. It is
  now a space, as in the tooltip's 1 234 567.
- FIRST/SECOND_NUMBER_CAP and their _NO_SPACE twins carry the leading
  space, because Abbreviate concatenates the number and the suffix
  directly.
- EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP printed a slot number where
  the missing count belongs; it now takes the missing count.
- SETTINGS_BIND_KEY_TO_COMMAND_OR_CANCEL was missing a space before
  «или нажмите».
- SET_FRIENDNOTE_LABEL had a |3-5 declension token, which 1.12 renders
  literally.
- ITEM_SLOTS_IGNORED and ITEMS_NOT_IN_INVENTORY had wrong third plural
  forms, ITEM_MOD_HIT_MELEE_RATING_SHORT had a double space, and the flat
  stat labels (Защита, Уклонение, Парирование, Блок) read wrong after a
  signed value, so they use the same «к …» form as the other ITEM_MOD
  labels.

The TOC gains ## Notes-ruRU next to ## Notes.

* fix(localization): restore retail ruRU focus, number and log strings

Follow-up to the Russian locale completion, which had changed several
strings that the retail ruRU client already translates. Each is now
the retail text, or a correction where the new wording was wrong:

- Focus: the FOCUSTARGET / TARGETFOCUS binding labels were swapped
  (FOCUSTARGET runs FocusUnit("target"), TARGETFOCUS runs
  TargetUnit("focus")). All focus strings use retail ruRU's terms
  again: Запомнить цель, Восстановить цель, Отменить запоминание цели,
  Запомненная цель, <фокус отсутствует>.
- EVENTTRACE_LOG_DISCARD is the message logged after the log is
  discarded; it had become an option-style label. Retail: Журнал
  сброшен. EVENTTRACE_BUTTON_DISCARD_FILTER read "cancel all"; retail:
  Сбросить все.
- FIRST/SECOND_NUMBER_CAP and their _NO_SPACE variants go back to
  retail Т / М. AbbreviateNumbers appends the _NO_SPACE suffix
  directly by design ("1.2K"), so " тыс." widened compact numbers.
- LARGE_NUMBER_SEPERATOR goes back to retail "" (as frFR). A plain
  space also let a number wrap across lines. The separate-large-numbers
  tooltip drops its "1 234 567" example, which no longer applies.
- The loose-target option read "whom the soft target follows". The
  normal target is the one that follows, so it is now "обычная цель",
  and the choices name the soft enemy / soft friend.

* fix(localization): pick language rules by locale name, not 4.3.4's index

1.12.1's locale index (VAR_LOCALE_INDEX, 0xC0E080) is the Wow.ini
Language+Country tag's position in the eight-entry name table at
0x8558A4 (enUS koKR frFR deDE zhCN zhTW esES xxYY), stored once at
0x402562 and 0 when nothing matches. It is never 8. The Russian branches
ported from 4.3.4 switched on 4.3.4's own slot numbers (esMX 7, ruRU 8),
so they could never run: a client whose GetLocale() is "ruRU" fell
through to the default plural rule ("5 предмета") and got no ё/е folding
in bag search.

Localization::Locale::Client() reads the same entry Script_GetLocale
(0x48D8B0) pushes, table[index], and Is(code) compares against it. The
language-rule sites now go through it:

- Text::Grammar's plural index (|4 expansion) keys koKR/frFR/zhCN/zhTW
  and ruRU by name.
- Container::Search picks its accent-folding set by name, as a Folding
  value instead of an index switch.
- Localization::Numbers' ClientIsAsian reuses IsAsianLocale on the name.
- AddOns::TocRewrite drops its private copy of the reader, and the
  slot-8 clamp, which guarded an index that cannot occur.

The DBC column reads keep using the index, which is what it is for.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-06 10:25:00 -06:00
Brues d825bcbdda fix(spell): stand, dismount and unshift only when the cast is sent
autoStand / autoDismount / autoUnshift ran their actions as soon as the
caster-state check (CheckCasterStateForSpell, 0x006094F0) passed. In the
1.12.1 cast request (TryCastSpell, 0x006E4B60) that check comes before
the target arm (0x006E5250), so a cast that then found no target still
dismounted the player and showed "You have no target" (Charge with
nothing targeted). A targeting cursor the player cancelled did the same.

The caster check now only keeps what its rungs noted, and the actions run
at the send. SendCast (0x006E54F0) builds the cast packet; its five
callers all pass the global pending cast: the arm's two immediate sends
and the object / location / trade-slot binders, which the targeting
cursor's click also runs. The arm's only caller is the cast request,
after the caster check, so every send follows a check of the same cast.
Each action runs only if its state still holds, since the player can
stand, dismount or leave the form while the cursor is up. The send
redirects must all apply before the rungs are patched.

Item use had the same ordering gap. Its form refusal (0x005D914D) comes
before the toggle test, which turns off a use aura that is already up
instead of casting (0x005D9157..0x005D9237). Re-using such an item while
shapeshifted dropped the form as well. The refusal now only notes the
unshift; it runs at the bind-on-use test (0x005D91D3), which every path
past the toggle reaches, and the toggle's cancel call clears it.

Fixes #132
2026-11-06 09:56:14 -06:00
Brues 6b36bb10e6 fix(spell): report GetSpellPowerCost in displayed power units
The engine's cost helper (FUN_006E31B0) answers in stored units, and
1.12.1 stores rage in tenths, so Shield Slam's 20 rage came back as
cost = 200. Divide by the engine's own display divisor
(GetPowerDisplayDivisor, 0x006E7130: [0x0086F978 + 4*type], 1 for a
negative type; table {1, 10, 1, 1, 1000}) before pushing, the same way
the engine and our UnitPower / GetSpellInfo / combat-log paths do.

Every power amount surfaced to Lua now goes through one helper,
Unit::Power::DisplayDivisor, which calls that engine function. That
retires the duplicate FUN_GET_POWER_DISPLAY_DIVISOR offset, the two
local typedef copies, and UnitPower's direct read of the divisor table
(VAR_UNIT_POWER_DIVISOR_TABLE, now removed).

Fixes #129
2026-11-05 08:51:59 -06:00
Brues 6628dcca19 fix(loot): keep LootAllCorpses walking past refused and stale corpses
The walk ignored the server's refusal. A failed CMSG_LOOT ("too far
away", not ours, ...) arrives as SMSG_LOOT_RESPONSE with loot type 0;
CGPlayer_C::ReadLootResponse (0x005EB900) shows the error and clears the
held loot target (+0x1D28) without opening a window, and the release
response (0x005EC090) clears it the same way. The walk only advanced on
the loot controller hook or a 180-frame timeout, so each refusal stalled
it for seconds. It now advances as soon as the held target is no longer
the corpse it sent for.

A send the engine refused dropped the whole queue. LootUnit (0x005DF2A0)
sends nothing while the mover flags & 0x20FF are set (moving, turning,
falling), and the walk treated each refusal as "skip this corpse", so
moving during a walk skipped every corpse left. A refused send now stays
the current corpse and is retried each tick; after 3 s the walk ends.

The queue's range check went stale. Range was tested once when the walk
began, but each corpse costs a round trip and the player can move in
between. With CanAutoInteract set, CanLootNow (0x005EC110) skips its own
reach test, so the request went out and the server refused it. Each
corpse is now checked for existence, DYNFLAG_LOOTABLE and reach right
before its send, and skipped when it fails.

The reach test is CanLootNow's own: both units' +0x1F0 reach plus the
engine's leeway, at least its minimum, with both constants read live
(VAR_MELEE_REACH_LEEWAY / VAR_MELEE_REACH_MIN) instead of local copies.
It and the lootable test move to Loot::Request, shared by
GetNearbyLootableUnits and the walk. The step timeout uses Time::Clock
instead of a frame count.
2026-11-04 20:04:35 -06:00
Brues 55d94c80d6 feat(spell): apply autoStand/autoDismount/autoUnshift to interaction, loot, items, attacks and taxis
The three settings only covered casts. Each 1.12.1 gate that refuses an
action for the player's stand state, mount or form now passes when the
matching setting allows it, and the engine's own action (the stand setter
FUN_005ED430, or CMSG_CANCEL_AURA for the mount / form aura) runs just
before the action is sent. With the settings at 0 nothing changes.

- NPC right-click (0x005F0130): the IsShapeShifted call at 0x005F0150
  cancels a form without SpellShapeshiftForm flag 0x8 instead of showing
  ERR_CANT_INTERACT_SHAPESHIFTED. CanInteractNow (0x00606880), whose only
  caller is the hover cursor, passes the same form, so the NPC's service
  cursor shows.
- Game objects: the use check (vtable slot 6, 0x005F3130), also the hover
  cursor's query, passes the mount (0x005F31A8) and the form (0x005F31E1)
  for an object with no Lock row. The default use's no-spell branch
  (0x005F35DF, before CMSG_GAMEOBJ_USE) and the mailbox and text use slots
  dismount (except at a mailbox, FUN_0047CFF0) and cancel the form. A
  locked object opens by a spell, which the cast path already handles.
- Loot: the GetStandState calls of the unit (0x0060BFB7) and corpse
  (0x005D6C3B) right-clicks stand the player up instead of showing
  ERR_LOOT_NOTSTANDING. The unit loot step's mount read (0x0060BF98)
  passes, and LootUnit dismounts before CMSG_LOOT (0x005DF3B3).
- Item use (0x005D8D00): the ERR_NO_ITEMS_WHILE_SHAPESHIFTED refusal at
  0x005D914D cancels the form and continues. Turtle's exe already skips
  that test; the stock exe reaches it.
- Attack: the mount reads in the attack cursor (0x00482659), the
  right-click attack (0x0060C1B4), StartAttack (0x005ECC06) and both tests
  of the attack resolver (0x00613039, ERR_ATTACK_MOUNTED; 0x00613142)
  pass. StartAttack, after all its tests (0x005ECC56), dismounts when the
  target is in melee reach (Combat::Swing::InMeleeRange). Otherwise it
  returns without attacking and shows ERR_OUT_OF_RANGE, except while
  click-to-move (FUN_0060F900) walks the player there.
- Taxi: after the taxi map opener's TAXIMAP_OPENED tutorial call
  (0x004DBAA2), a map with more than one node stands the player up,
  dismounts and cancels the form, each when its setting is on.

Every site is patched only when it holds the expected stock bytes, which
both the Octo and stock 1.12.1 exes do.
2026-11-04 16:44:44 -06:00
Brues c0b403ba05 feat(model): add PlayerModel:ApplySpellVisualKit
PlayerModel:ApplySpellVisualKit(spellVisualKitID [, oneShot]) shows a
SpellVisualKit's effects on the pane's model.

In the world, 1.12.1 plays a kit on a unit (0x0060EDF0), through effect
objects, spell durations and the unit's own animation, and a pane has
none of these. The engine's own kit on a bare model is the
char-select ghost (0x004727F0), and this is that routine for any kit on
the pane's model:
- Each of the nine effect columns (+0x0C) is created as a model from its
  SpellVisualEffectName file, in the pane's scene. It is attached at
  the column's attachment (20, 34, 19, 21, 22, 17, 23, 24, 25) when the
  model has that attachment (0x00712CB0, 0x00712F70), as in 0x00472780.
  The effects get the frame's light through LightChildren.
- Procedure kind 1 sets the tint (an 0xRRGGBB colour stored as a float,
  scaled by 1/255) and kind 14 the alpha, clamped to 0..1. Both are
  plain stores (0x00710CF0, 0x00710CB0), as the ghost kit makes them.
The kit's animation and sound do not play, which matches retail.

An effect model's sequence plays one time. Without oneShot, each effect
gets the world's loop callback (KitEffectLoopCallback, 0x0060ED00): a
clean end (reason 0) plays the animation again. With oneShot, each
effect gets EffectDoneCallback's test (0x005FBF50) and CEffect::Stop's
model steps (0x006203E0): clear the callback and hide the model, so
its emitters stop and the particles already out finish. A FrameTick
drain, which runs after the strata walk, then mirrors
DrainDyingEffects (0x0061F680). It detaches a stopped effect (0x00713020)
once HasLiveParticles (0x007135C0) is false, or at once when its
model has already dropped it. A model's destructor detaches its
children first (0x0070E3F5), so the drain never reaches a freed parent.

The effects are children of the pane's model, so SetUnit, SetItem or
a hide and show clears them.
2026-11-04 15:32:23 -06:00
Brues f6b98bc3ec fix(dbc): reject negative IDs in DBC::Record
DBC::Record checked the ID against the store's count as a signed int.
A DBC column uses -1 for "none", and -1 passed that check, so the
helper returned records[0xFFFFFFFF]. On 32-bit that wraps to
records[-1], the heap word in front of the index array. A caller that
passed an unchecked column read a garbage "record": an empty
SpellVisualKit effect column gave the pointer 0xF and crashed.

The engine rejects these IDs before it compares with the count: the
char-select ghost kit's effect step tests the ID with a signed jl at
0x00472797, then a jg against the count. Record now compares
unsigned, so it rejects 0, every negative ID, an ID above the count,
and an unloaded store, whose count is -1.

PvP::Arena had its own copy of the lookup with the same signed
compare. It now calls DBC::Record.
2026-11-04 15:32:22 -06:00
Brues 8f4f574174 feat(model): add PlayerModel:SetItem
PlayerModel:SetItem(itemID [, appearanceModID, itemVisualID]) shows one
item's own model in the pane: a weapon, shield, helm, shoulder, quiver or
ammo, with its skin texture and its glow.

A 1.12.1 pane shows whatever its display record names (+0x3F0).
SetDisplay (0x00505CB0) stores the record and builds it when the pane is
visible, the show path (0x00505D49) builds it again, and the SetUnit
helper (0x00505B50) clears it. SetItem gives the pane a record of its
own and passes it to SetDisplay. The AttachDisplayModel (0x00505A70)
co-hook in Model::DisplayInfo then builds the item instead of a
creature. So hide, show, SetUnit, SetDisplayInfo and SetKeepModelOnHide
handle an item with no new hooks.

The model is built from 1.12.1's own steps:
- the item folder by inventory type, from the engine's own
  Item\ObjectComponents\ path strings (0x00838CD8 on);
- model name 0, else name 1, with the texture of the same index;
- for a head item, the helm builder's (0x004799A0) path, with the
  player's race prefix (ChrRaces +0x18) and sex letter (0x00838AB4);
- AttachItemModelFile's (0x004798C0) steps: create the model in the
  pane's scene, load the skin with the item-model status (0x00B424F0)
  and filter 3, bind it as texture type 2 (0x00710EC0), and attach the
  item visuals (0x00479700);
- AttachDisplayModel's install: SetModelInstance (vtable +0x90), the
  pane's done callback (0x005062A0 through 0x00711BB0), Stand, and the
  release of the creation reference.

The display's own ItemVisuals row wins over itemVisualID, the rule
GetItemDescVisual (0x0062EC70) applies to enchants. The visual's child
models get the frame's light and fog callback, now a shared
LightChildren helper, so they are not drawn black. An uncached item is
queued through Item::Data::WarmCache, and each visible pane that still
shows it is built again when the item loads.
2026-11-04 14:21:58 -06:00
Brues 43a989dd56 feat(model): add PlayerModel:FreezeAnimation
FreezeAnimation(animation, variation, frame) holds the model in one
pose of an animation until the pane loads a new model, as SetAnimation
does. frame is the time into the animation in milliseconds, the unit
of M2 sequence times.

The engine's sequence time is start + (ftol(elapsed * speed) + offset)
mod length. So the pane's arm plays the animation through
PlayAnimation (0x007121A0) at speed 0 and writes frame to bone 0's
slot offset (+0xB8, bone states 0x118 bytes each at [model+0x90]).
Speed 0 also puts the end stamp on the start stamp. Update
(0x00719370) would then queue the done callback on the next frame, and
the pane's done callback (0x005062A0) would arm Stand. The arm
therefore sets the slot's done latch (+0xC0), the flag Update itself
sets once it has reported done. A held animation never ends.

SetAnimation and FreezeAnimation now also mark the pane for one re-arm.
A call with the animation that already plays then still starts or ends
a freeze, where the per-paint compare alone would skip it.
2026-11-04 13:52:02 -06:00
Brues 0caddb8fe6 feat(model): add PlayerModel animation, keep-on-hide and unit methods
Adds SetAnimation, SetDoBlend/GetDoBlend, HasAnimation,
SetKeepModelOnHide/GetKeepModelOnHide, CanSetUnit and GetDisplayInfo
to PlayerModel (and so to DressUpModel and TabardModel). SetUnit now
returns whether the unit exists, and SetCreature takes an optional
display ID.

Animation. A 1.12.1 pane rests on Stand: its per-paint UpdateModel
(0x00505C50) arms Stand (PlayAnimation 0x007121A0 with id 0) whenever
key bone -1 plays anything else and no turn is pending. A deadline of 0
counts as passed, so any other animation lasted one frame. SetAnimation
gives the pane a resting animation and variation of its own. With one
set, UpdateModel is hooked to arm that animation in Stand's place, using
the pane's blend setting. Every other pane runs the stock function. The
pane's done callback (0x005062A0) arms Stand when the animation ends,
so the next paint starts it again and it loops. CSimpleModelFFX's
SetModelInstance (0x0076CD30), which every pane model build and hide
goes through, clears it, so a new model rests on Stand again.
SetRotation turns without the shuffle while a resting animation other
than Stand is set. HasAnimation is PlayAnimation's own test on the raw
id: the lookup at shared data +0x24 must give an index below the
sequence count at +0x1C. It is false until the model has loaded.

Keep on hide. The pane's Hide (0x00505CE0) drops its model through
slot 36, and its Show (0x00505D00) builds a new one from the unit or
display record. With keep on, Hide runs only the base hide (0x0076AD50)
and Show runs only the base show (0x0076AE10) while the pane still
holds its model. The model then comes back with its try-ons and
animation. The SetUnit helper (0x00505B50, behind SetUnit, RefreshUnit
and Dress) and SetDisplay (0x00505CB0) only store the new subject while
the pane is hidden, so they drop a kept model first. The next Show then
builds the new one. Off is the default and matches the stock client.

Units and displays. SetUnit runs the engine's method (0x00505D70,
which Turtle patches) unchanged. It then reports whether the GUID it
stored resolves to a unit (type mask 8), the same test the helper uses
before it builds. CanSetUnit is that resolve without the set.
GetDisplayInfo reads the display record's ID, or the unit's
UNIT_FIELD_DISPLAYID (+0x1F4, field 0x7D). SetCreature with a display
ID shows that display through the SetDisplayInfo record.
2026-11-04 13:11:45 -06:00
Brues 8e603a7a0b fix(player): make IsLoggedIn false until PLAYER_LOGIN fires
IsLoggedIn returned whether the player GUID was set. That GUID falls
back to the char-select GUID before the world loads, and on a /reload
the live GUID is already set while the addons load. So IsLoggedIn was
true while every addon's files ran, and EventUtil.ContinueOnPlayerLogin
ran its callback at once instead of waiting for PLAYER_LOGIN. An addon
that hooked another addon this way hooked it before that addon had set
itself up.

1.12.1 fires PLAYER_LOGIN from one place: the enter cascade
(0x004908C0) calls FUN_FIRE_EVENT_NO_ARGS with 0x10E at 0x00490959,
once per interface initializer run (login and reload). Its arm byte
0x00B4E260 cannot answer the question directly: the initializer sets
it after the addons load (0x0049011D) and the cascade clears it after
the fire (0x0049095E). That call is now redirected to set a flag just
before the event goes out, and the FrameScript_Initialize cleanup,
which runs before every /reload and /logout, clears it.

IsLoggedIn is now false on glue and during addon load, and true from
the PLAYER_LOGIN handlers on, through world transfers.
UnitGUID("player") keeps its char-select fallback.
2026-11-04 13:02:26 -06:00
Brues 4726fd7c85 feat(model): add DressUpModel try-on hand, enchant glow, sheathing and auto-dress
TryOn now takes retail's (item [, handSlotName, spellEnchantID]) and
returns an Enum.ItemTryOnReason. The pane gains IsGeoReady, SetSheathed /
GetSheathed and SetAutoDress / GetAutoDress.

Hand: TryOnItem (0x00504540) picks a lane at one site, 0x0050492D
(mov edx,[edi+0x2C]; cmp edx,0x0D). A 6-byte call there jumps a named hand
to the worker's own main-hand (0x0050495F) or off-hand (0x005049B1) path,
so its CanHoldBoth (0x00504BC0) eviction, lane store and attach all still
run; with no hand it replays the two instructions and the jnz reads their
flags. The name is matched like GetInventorySlotInfo matches it: SStrCmpI
over the PaperDollItemFrame rows, slot 16 or 17.

Enchant glow: the player's AttachEnchantVisual step (0x005EED50) on the
attachment AttachItemModel (0x0047A0C0) returns: FindChild (0x00712F00),
then AttachItemVisuals (0x00479700) with SpellItemEnchantment +0x58,
skipped when the display's own ItemVisuals row exists (GetItemDescVisual's
rule, 0x0062EC70). The enchant is the argument, else the link's enchant
field, which Item::Arg now parses.

Sheathing re-runs the unit's AttachHandItem step on the pane's lanes with
the sheathed flag. The lanes keep no display row, so the pane records one:
the unit's vtable +0x9C row when SetModelInstance (slot 0x00808438) clones
it, then whatever the worker's attach (0x00504AE6) installs; its two
evictions (0x00504A0C, 0x00504A55) drop theirs. Each lane keeps its glow
too, a player's read through GetVisibleItem + GetItemDescVisual, so a
re-hung weapon keeps it. Auto-dress off runs ClearModel (0x00504490) after
SetModelInstance unless Dress() is the one rebuilding.

TryOn's result reads the gates the worker passes: an uncached record is
DataPending, a held type installs, armor needs the component and the
ready byte (+0x40C).

DressUpItemLink now passes the whole link to DressUpItem, so a
Control-click shows the link's enchant, and skips a cached item that
IsDressableItem rejects. DressUpItem stays, since Blizzard_AuctionUI and
Turtle both replace it.
2026-11-04 10:16:07 -06:00
Brues 3e0c40184b feat(cursor): add expanded cursors for NPCs and the fishing bobber
Adds the expandedCursors cvar (default 0; AddOn Options > ClassicAPI >
Interface > Cursor). At 1 the hover cursor shows what an NPC offers: an
anvil for a repairer, a hearthstone for an innkeeper, a horseshoe for a
stable master, and a quest giver's ! or ? (a blue ? for a repeatable
reward). The player's fishing bobber shows a fish hook in place of the
Interact gear. Each has a gray form beyond reach or when not usable.

NPCs: ClassifyHoveredUnit (0x00482200) reaches its NPC flag chain at
0x00482336 for a unit the player can interact with, the unit in ESI and
the beyond-reach flag in EBX. The chain's `mov ecx, [esi+0x110]` becomes
a call to a thunk. At 0 it replays the instruction and the stock chain
runs. At 1 it picks from the quest giver status first (CGUnit_C +0xCB8,
read by the meaning of the client's status-to-marker table at
0x0080C454), then the NPC flags in service order: repair, innkeeper,
flight master, trainer, spirit healer, banker, registrar/tabard/
battlemaster, auctioneer, stable master, vendor, then gossip or a quest
giver IsQuestGiverToTalk (0x005DF490) accepts. It leaves through the
chain's own epilogue (0x00482356).

Bobber: the fishing node handler's cursor slot (vtable 0x0080BC80, slot
4; GetCursor 0x005F8760 is its one reader) points at a wrapper that runs
BaseHandlerGetCursor (0x005F3070) and maps Interact/UnableInteract to
the hook. The engine still decides whether the bobber is interactable
and usable.

Showing a mode past 41: SetCursorMode indexes a 42-slot image table, so
its existing hook now sends the new modes to Cursor::Extended::Show. It
calls SetCustomCursor (0x00523C40) with the mode's path, so a real file
wins, and otherwise does what that function does after its load: copy
the 32x32 image into slot 41 (0x00C0BD80), store mode 41, send it
(SendCursorImage 0x00523790) and keep the name. The hook also records
the new modes while Hover::Mode computes, so the soft-interact pick, its
icon and SetUnitCursorTexture follow; a quest cursor has no icon image.

Images: the 14 BLPs ship inside the DLL (src/embed/Textures, a new
TEXTURES_ROOT pack). Texture::Builtin decodes each once with the
engine's BLP reader from memory: BlpParse (0x005A83B0) is BlpOpen
without the file read, and BlpReset never frees the file buffer. The
reader moves to Texture::BlpReader, shared with the map overlays.
Texture loads of the same paths, such as the option label's inline
icons, get the same images.

The reader refuses a DXT5 file. BlpDecodeLevel (0x005A7F00) converts a
DXT level through the matrix InitConvertMatrix (0x005A4FC0) fills, and
that matrix has DXT1 and DXT3 converters to BGRA8888 but none for DXT5:
BlpDecodeLevel would return 1 with its SMemAlloc buffer unwritten. No
shipped cursor is DXT5; the check keeps a later embedded DXT5 file, or a
DXT5 map overlay, from being read as heap memory.

Game paths now compare through one rule, Common::SamePath (any case,
either slash), and the embedded manifests through Embed::FindFile. The
embedded addons, the bindings splice and sound muting each had their own
copy of the rule; they and Texture::Builtin now share it.

The option's header, label and tooltip are translated for every client
locale, in Blizzard's own terms for each (NSC, PNJ, 物体, 物件, ...).

Release Win32 build is clean. In game, only the soft-interact quest
icon was checked so far.
2026-11-04 02:36:18 -06:00
MrbcatandBrues b3e8d92b03 feat(localization): translate the addon options panel into zhCN (#126)
* feat(localization): translate the addon options panel into zhCN

A Chinese client kept the English strings for ClassicAPI's own options:
the AddOn Options window title and its game-menu button, every page
name, header, control label and tooltip, and the AoE Loot binding name,
which zhCN only had as a commented-out line. The generic strings enUS
gained with those pages were missing from zhCN too: FOCUS_TOKEN_NOT_FOUND,
LUA_ERROR, RELOADUI, PLAYER_LIST_DELIMITER, SYMBOLIC_NEGATIVE_NUMBER,
SYMBOLIC_POSITIVE_NUMBER and the money texture formats.

The locale files load enUS first and the file that matches GetLocale()
overrides it, so English clients are unchanged and only zhCN is filled
in. Terminology follows the client's own: 插件, 界面, 姓名板, 动作条 and
动作瞄准 for action targeting.

The Release Win32 build embeds the new zhCN.lua in the !!!ClassicAPI
pack (the packed strings were checked). Not tested in-game.

* fix(localization): use Blizzard's zhCN terms in the options panel

The backpack options said 背包, which zhCN uses for bags in general,
so "ignore backpack" read as "ignore bags" and its tooltip contradicted
itself. Use 行囊 for bag 0, as BACKPACK_TOOLTIP and
BINDING_NAME_TOGGLEBACKPACK do. Junk is 垃圾 (BAG_FILTER_JUNK), and
helpful spells are 有益法术, as in the self-cast option tooltips.

Drop the coin-texture and signed-number format strings: they were
byte-for-byte copies of enUS.lua, which always loads first.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-03 21:29:40 -06:00
Brues 0a3f8324ea fix(frame): stop a re-anchor during layout from freeing the point being read
Crash #132 at 0x00768323 (FUN_REGION_GET_RECT reading [5+0x3C]): Show lays
a frame out before its children leave their layout deferral, so its
OnSizeChanged ran; the ScrollBox handler read a region's size, and laying
that region out reached an anchor point on a still-deferred child. The
point getter (0x007A2F90 / 0x007A3070) ends the relative's deferral with
FUN_REGION_LAYOUT_REALIZE(0), which ran the child's OnSizeChanged; that
script re-anchored the region, freeing the point, and the getter then read
the point again (0x007A2FC5) from freed heap.

The getters now end the deferral themselves with the point marked busy.
The point's deleting destructor (0x00767D80, the only free path) flags a
busy point; a flagged getter returns the layout's undefined value without
touching the point, and the point walk (0x007671A0 / 0x007671F0) is run
again with the current anchors, up to ten times. Otherwise the engine's
own getter reads the value, with the deferred bit hidden so a script that
hid the relative again cannot start a second resolve.

No hooks: the three getters/destructor are patched in the relative-point
vtable (0x0081C44C, their only reference), and the six edge resolvers'
calls to the walks are redirected. Adds Common::RedirectSlot for the
vtable entries.
2026-11-03 20:47:46 -06:00
6f3a8ffc1d feat(texture): add static creature portraits by display ID (#123)
* feat(texture): add static creature portraits by display ID

* docs(api): document static creature portrait functions

* Update API.md

* refactor(model): bake display-ID portraits on the engine's own path

SetPortraitTextureFromCreatureDisplayID now replays 1.12.1's unit
portrait bake, BakePortrait (0x00524F60), call for call on a display
model of its own, instead of driving a hidden PlayerModel through Lua.

The display model is built the way AttachDisplayModel (0x00505A70)
builds one, without a frame: M2CreateScene (0x00706E10), then
CreateModelFromPath (0x00707350) on the CreatureModelData path, then
ApplyCreatureDisplay (0x004797B0). A character display is dressed
through the engine compositor with the same code that dresses
PlayerModel:SetDisplayInfo, now shared as Model::DisplayInfo::
CreateDresser / PumpDresser / DestroyDresser.

The bake's own gate, IsReady(model, 0, 1) (0x00710450), only passes for
a model that something has drawn: a BLP texture gets its device texture
only when GetTextureDeviceObject (0x0044ACF0) is asked with wait set.
The shared M2 still loads on its own, so the gate here is
IsLoaded(model, 0, 1) (0x007103D0) plus the dress, and the bake's
IsReady(copy, 1, 1) makes the device textures, as it does for the copy
of a unit's model.

The bake itself is the engine's sequence: copy into a fresh scene,
DetachAllItemModels, camera 0, PlayAnimation, save projection, view,
viewport and fog (GxMasterEnable 0x0058A210 / 0x0058A1E0), the 64x64
DDC box in the bake's own float order, CameraSetDeviceTransforms, clear
colour 0, ClearDevice(3), PortraitLighting, both scene passes, read back
through 0x00589CF0, clear, cut the alpha mask in (0x005258A0), restore.
It runs at the head of the UI render callback, the same point as an
OnUpdate script that calls SetPortraitTexture, behind the bake's device
gate (0x0058A260 window active, 0x0058A280 device alive).

Each display ID owns one callback texture (TextureCreateWithCallback,
which makes its device texture at creation). It is bound with SetImage
(0x00770300) at call time and filled in place with GxTexMarkRectDirty
(0x0058ACD0), the bake's own path for re-baking a cached portrait. A
region the caller re-points before the bake finishes keeps what it was
given, so no region is tracked: the request map, the region-destruction
subscription and ClassicAPI_CancelPortraitTextureRequest are gone. The
function returns nothing, as retail's does.

Portraits are kept until logout, the lifetime of the engine's display-ID
portrait cache (cleared in ShutdownGame). A display with no portrait
camera is done once and stays transparent.

Offsets.h: the portrait block is renamed to engine names with calling
conventions and sources; the duplicates of FUN_M2SCENE_ANIMATE,
FUN_M2MODEL_SET_ANIMATED and GetCameraById are removed, as are the
made-up draw-flag and scene-count fields and the raw device-vtable
slots. Model::Camera::CameraById is exposed instead of being copied.

---------

Co-authored-by: z1xel <ramirezthiago@71gmail.com>
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-03 20:00:22 -06:00
Brues 3862408e1a docs(readme): list the new base-library functions
The Lua row gains debuglocals, debugstack's coroutine argument,
difftime, gmatch, math.cosh/sinh/tanh, newproxy, scrub, string.byte and
strbyte ranges, string.format's two-digit %n$, %0 in string.gsub and
table.removemulti. The Lua errors section says the window shows each
error's local variables.
2026-11-03 15:36:32 -06:00
Brues 12a81f7f89 feat(baselib): debugstack takes a coroutine
debugstack([thread,] [start [, count1 [, count2]]]) returns the stack
of a coroutine when one is passed first, with start defaulting to 0
(the coroutine's innermost level), as 4.3.4's Script_debugstack does.

1.12.1's debugstack (0x703760) has no thread argument. A thread fails
its lua_isnumber and lua_isstring checks, so it returns the thread
itself, and msg .. debugstack(co) raises "attempt to concatenate a
thread value" in the coroutine error paths of WeakAuras, Questie's
ThreadLib, MRT and Zygor.

The engine function still builds the text, so its line format and its
12/10-level head and tail windows are unchanged, which keeps addons
that parse debugstack() for their folder working. It reads levels
through six calls on the state it runs on: lua_getstack (0x6FBAA0) at
0x70383F, 0x70386E, 0x703896, 0x7038AD and 0x703998, and
lua_getinfo (0x6FBC70) at 0x7038CD. Those calls are redirected to
read from the state the new global names for the call: the thread, or
the calling state. The engine function has no callers of its own, so
the global is its only way in and sets that state for every call; an
error part way cannot leave a thread behind for the next call.

The global removes the thread argument, inserts 0 as start when none
is given, and calls the engine function directly in its own frame, so
levels without a thread count exactly as before.
2026-11-03 15:33:30 -06:00
Brues 05fa5845b7 feat(baselib): debuglocals, and the error window shows locals
debuglocals([level [, skipFunctionsAndUserdata]]) returns the locals and
then the upvalues of a call level as text, one name=value line each.

It runs only while an error handler runs, as in 4.3.4. 1.12.1's error
function sets no flag for that, but while a handler runs that function
(FUN_FRAMESCRIPT_CALL_ERROR_HANDLER) is a C closure level on the stack;
ErrorCallstack's search for it moves into a header so both modules use
it. level counts as lua_getstack and debugstack do.

Each local comes from lua_getlocal (0x6FBB20, fastcall L/ar + n), then
each upvalue of the level's function (lua_getinfo "f") from
lua_getupvalue (0x6F4660, fastcall L/funcindex + n). Values are
formatted as 4.3.4's FrameScript_PackVariable does, with retail's
spacing (probed: n=7, s="hi", t=<table>{ / k=1 / }): a frame table shows
its name, read from t[0] as FrameScript_GetObject does; a table's fields
show one space in at the top level only; a function shows where it is
defined (lua_getinfo ">nS"). The text stops at 4095 bytes. The second
argument, which retail's error handler passes as true, leaves out
function and userdata values.

Where 4.3.4 would fail inside the handler, this does not: a number key
outside int range is formatted from a copy, not converted in place
(which breaks lua_next), and a key that is not a number or string prints
as its type.

Retail also lists (*temporary) lines, 5.1's names for a level's stack
slots past its locals, including a C level's arguments. 1.12.1's
lua_getlocal is 5.0's and names declared locals only; those slots are
compiler registers that 5.0 and 5.1 allocate differently, so they are
not imitated.

The error window takes the locals as retail's GetErrorData does,
debuglocals(level, true) at the stack's level, and shows them under
Locals ("<none>" when there are none).

LuaCAPI.md had lua_pushupvalues, lua_getupvalue and aux_upvalue one row
off; corrected.
2026-11-03 15:04:55 -06:00
Brues 6ed0edc947 feat(baselib): gmatch, table.removemulti, math hyperbolics, difftime, newproxy, scrub
The base-library names that 1.12.1's Lua 5.0 and its compat.lua do not
have:

- gmatch: the global alias of string.gmatch (5.0's str_gfind,
  0x7FCFA0), set by value like strmatch and strrev.
- table.removemulti(t [, pos [, count]]): removes count elements from
  pos, shifts the rest down with lua_rawgeti/lua_rawseti and returns the
  removed elements. 5.0 keeps the length apart from the keys, so like
  the engine's table.remove (0x7FB770) it records the new length with
  luaL_setn (0x6F4EA0).
- math.cosh, math.sinh, math.tanh: luaL_checknumber (0x6F4C80) and the
  C function.
- difftime(t2 [, t1]): C difftime over the two times, next to the
  engine's date and time globals.
- newproxy([false | true | proxy]): an empty userdata from
  lua_newuserdata. The closure's upvalue is a weak table (its own
  metatable, __mode "kv") that marks the metatables newproxy(true)
  makes, so only those can be shared.
- scrub(...): replaces each argument that is not a boolean, number or
  string with nil, in place with lua_replace (0x6F32B0).

All are registered on both Lua states.

Game::Lua gains RawSetI (lua_rawseti, 0x6F3F60), Replace, CheckLString
(luaL_checklstring, 0x6F4BE0), CheckNumber and OptNumber (luaL_optnumber,
0x6F4CD0); string.byte now uses them instead of local typedefs.

issecure, issecurevariable and forceinsecure are left out. 1.12.1 has
no taint, so they could only return constants, and a constant
"secure" from issecurevariable makes AceHook-3.0 refuse every plain
hook.
2026-11-03 14:23:25 -06:00
Brues 1645e6b43c feat(baselib): string.byte returns a range of byte codes
1.12.1's str_byte (0x7FC210) is Lua 5.0's: it reads only the start
position and pushes one code, so string.byte(s, 1, -1) silently returns
the first byte alone.

string.byte and the strbyte global now take (s [, i [, j]]) and push the
codes of bytes i..j, with j defaulting to i. Negative positions count
from the end and the range is clamped to the string. The arguments are
read with the engine's luaL_checklstring (0x6F4BE0) and luaL_optnumber
(0x6F4CD0), and positions truncate toward zero as the engine's str_byte
and str_sub do, so a call without j returns exactly what it did before.

Each code takes a stack slot, reserved with lua_checkstack (0x6F2F30).
The engine refuses more than 2048 slots, so a range longer than about
2000 bytes raises "stack overflow (string slice too long)".

compat.lua set strbyte from the old string.byte when the state opened,
so strbyte is re-aliased to the new table entry.
2026-11-03 14:00:10 -06:00
Brues efcdc54a50 feat(baselib): string.gsub takes 5.1 string and table replacements
1.12.1's str_gsub (0x7FD0E0) is Lua 5.0's. Its replacement argument
differs from 5.1 in four ways: a table is rejected ("string or function
expected"); in a replacement string, %0, and %1 when the pattern has no
captures, raise "invalid capture index" (add_s checks every %n with
check_capture, 0x7FCE20); a function that returns nil or false deletes
the match instead of keeping it; and one that returns a table deletes
it too instead of raising an error.

The first two only ever raised errors, so the 5.1 behavior cannot break
an existing addon. The function rules can: SuperAPI's
CombatText_AddMessage returns nil to delete the player's own " [0x...]"
GUID from heal text. So function replacements keep 1.12.1's behavior,
and only string and table replacements change. A scan of every gsub
call in the installed addons and the stock FrameXML found no other code
that depends on the old string or function results.

str_gsub's match loop, anchor, count and buffer stay the engine's. Two
parts are replaced:

- The 60-byte replacement-type gate at 0x7FD15E (lua_gettop / isstring /
  lua_type == function, then luaL_argerror) becomes a call to a
  __fastcall check with ECX = L that accepts a number, string, function
  or table and raises "string/function/table expected" otherwise.
- The one call to add_s (0x7FD203 -> 0x7FD2A0, __fastcall ms/b + s/e on
  the stack, ret 8) goes to a function with the same signature. A
  function replacement calls the engine's add_s unchanged. A string or
  number gets 5.1's add_s, and a table gets 5.1's lookup: the first
  capture or whole match as the key, a nil or false value keeps the
  match, and any other non-string value raises "invalid replacement
  value". Both use the engine's push_onecapture (0x7FCF40, plus 5.1's
  whole-match case for index 0 with no captures), luaL_addlstring
  (0x6F51F0) and luaL_addvalue (0x6F5270).

Both patches check the original bytes or call target and are skipped
when they differ.

The table-replacement shim in StringLib is removed. It wrapped the
pattern in an outer capture so a closure could return the whole match,
and deleted the match for a missing key when the pattern had a
back-reference. string.gsub and gsub are the engine's function again.
2026-11-03 14:00:04 -06:00
Brues 6a24271cd1 feat(baselib): two-digit %n$ argument numbers in string.format
1.12.1's str_format (0x7FD3D0) only reads a one-digit argument number:
isdigit(p[0]) && p[1] == '$'. In "%10$s" the "10" is read as a width and
the '$' as the conversion, which raises "invalid option in `format'".
Combat log strings such as ACTION_SPELL_PERIODIC_LEECH_RESULT,
"(%10$s %8$s Gained)", cannot be formatted.

Replace the engine's 18-byte one-digit branch at 0x7FD4AC with a call to
a naked thunk that sets the argument number ([ebp-8]) and the flags scan
start (EBX) for both "n$" and "nn$". EAX/ECX/EDX are dead at the site.
The engine still tests the first digit with its own isdigit, increments
the argument after the site, and does every conversion, so string.format,
format and the C callers of str_format all get the change. The patch
checks the original bytes and is skipped when they differ.
2026-11-03 13:07:32 -06:00
MrBCatandBrues a7ce7555f0 fix(softinteract): anyinteract yields when the target is out of reach (#121)
* fix(softinteract): anyinteract yields when the target is out of reach

GetAny treated Reach::Out — the cursor's Unable form — as a usable
target, so with a target too far away the Interact Key pressed the inert
target instead of the nearest object it could interact with.

Prefer the target only while the cursor says it can be used now
(Reach::Now), and fall back to softinteract otherwise. A target behind
the camera still wins while it is in reach: the cursor verdict does not
look at facing.

* docs(api): anyinteract needs the target to be in reach now

The token section said a target that is too far away still counts, which
the implementation no longer does.

* fix(softinteract): anyinteract keeps an out-of-reach target when nothing is picked

Retail 12.1.5, targeting an NPC that is out of reach: facing away from
everything (softinteract nil), anyinteract is the target; facing a nearer
NPC, it is that NPC. The previous change returned 0 in the first case,
so the Interact Key lost the engine's own out-of-reach handling on the
target (the "need to be closer" error, or the click-to-move walk).

GetAny now reads the target's cursor verdict once: in reach wins, then
softinteract, then the target while it is interactable but out of reach.

* feat(interact): honor looseTargeting in C_PlayerInteractionManager.InteractUnit

On retail the Interact Key on a nearer NPC opens its dialog while a
farther unit stays targeted; with no target it targets the NPC. The key
calls InteractUnit("anyinteract") with looseTargeting at its default,
true, which ClassicAPI ignored, so the right-click's select replaced the
target.

FUN_ON_SPRITE_RIGHT_CLICK (0x00492820) is the active-player gate, the
select (FUN_SET_SELECTION_BY_GUID, 0x00493540), then the object's
right-click virtual (vtable +0x60, call at 0x00492881) with
FUN_GAMEUI_IS_KEY_DOWN(0). The select stores only units, so only a unit
other than the current target would move it. With looseTargeting and a
target, that one case runs the gate and the virtual without the select;
every other case still calls the engine function, which nampower also
detours for its right-click guards. The global InteractUnit keeps
targeting.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-11-03 12:39:54 -06:00
Brues 04790cb6c1 feat(options): name the page scroll bars for skins
Each options page's MinimalScrollBar is now ClassicAPIOptionsScrollBarN,
numbered like the ClassicAPIOptionsControlN controls, so a skin can find
the scroll bars the same way it finds the controls.
2026-11-03 00:54:42 -06:00
Brues e7ecae56da feat(options): key binding rows for ClassicAPI's bindings
The Interact Key page gets rows for Interact With Target and Interact
With Mouseover, and Bags and Items gets AoE Loot under a Loot header.
A row is retail's settings-page binding row: the binding's name, then a
button for each of its two keys. Left click a button and press a key,
mouse button 3-5 or the wheel to bind it; Escape cancels; right click
unbinds that key. The tooltip on a bound key and the page's output line
use retail's strings, added to every locale with Blizzard's translations.

The binding is 1.12's Key Bindings window's (Blizzard_BindingUI): the
same ALT-CTRL-SHIFT chord order, the same skipped modifier keys, the same
KEY_UNBOUND_ERROR check for an action that loses its last key, and the
same put-back when SetBinding refuses a key. A change saves at once with
SaveBindings(GetCurrentBindingSet()), so it lands in the account or
character set in use, as every other control on these pages writes at
once.

While a button listens, a full-screen button on FULLSCREEN_DIALOG takes
keys, clicks and the wheel, as retail's KeybindListener and its input
blocker do. A left or right click on it cancels, and so does hiding the
page.
2026-11-03 00:54:35 -06:00
Brues 65c6b0b271 feat(action): spell search, slot item and #showtooltip action queries
C_ActionBar.FindSpellActionButtons, HasSpellActionButtons and
IsOnBarOrSpecialBar find a spell on the bars. A slot matches on the
spell ID stored in VAR_ACTION_TABLE under the spell tag, the same test
4.3.4's PutActionInSlot makes, so a macro or item that casts the spell
does not count and a rank matches only itself. A name resolves through
the engine's spellbook resolver. IsOnBarOrSpecialBar also reads the
shapeshift bar's spell array (pointer at 0x00B71108, count at
0x00B71104, as Script_GetShapeshiftFormInfo 0x004B45C0 reads them) and
the pet bar's spell entries (types 1-5, spell ID in the low 16 bits, as
Script_GetPetActionInfo 0x004BDC50 reads them, gated on the pet GUID).
Enum.ActionBarSet is registered; all 120 slots are its Mkb set.

IsStackableAction (global, 1/nil) and C_ActionBar.IsStackableAction
(bool) answer 4.3.4's IsStackableAucItem: the slot's item has a max stack
above 1. C_ActionBar.GetItemActionOnEquipSpellID returns the item's
first spell with trigger 1 (on equip). The item is the item-by-ID
action's, or the one a macro's #showtooltip / #show resolved to, as
4.3.4's CGActionBar::GetItem returns it. Both peek the item cache and
never query it.

C_ActionBar.IsMacroActionWithShowTooltip parses the macro's body with
the parse Macro::ShowTooltip hands the tooltip over on, so it answers
while that module yields or a publisher owns the macro.
2026-11-03 00:37:16 -06:00
Brues 5f94fb98bd feat(action): C_ActionBar.GetSpell
C_ActionBar.GetSpell(actionID) returns the spell an action-bar slot
casts, or 0. It is the engine's own slot resolver (0x004E5A50), which
the cooldown, usable and range readers already go through: a spell
action is its spell, a macro is its cached primary spell (+0x564, the
one #showtooltip sets), and an item-by-ID action is the first on-use
spell in its item cache record. An empty slot, a slot outside 1..120,
an equipment set or a macro whose macro is gone gives 0. The spellbook
flag the resolver also hands back is not part of the answer.
2026-11-03 00:16:02 -06:00
Brues 426f6b6ec9 fix(container): free slots count the bank and use the engine's counter
GetContainerNumFreeSlots and GetContainerFreeSlots now take the main bank
(-1) and the bank bags (5..10) while a banker is open. The main bank is
gated on the banker GUID (0x00BDD038); bank bags get the gate from the
engine's container-GUID getter (0x004F93E0), which answers 0 for them
while the bank is closed.

A bag's empty slots are counted by the engine's own CountFreeSlots
(0x00622910, __thiscall on the bag's inventory list), and its slot count
comes from that live list instead of the item record. The backpack and
main bank are ranges of the player's list, walked slot by slot. Nothing
walks PackBagSlot per slot any more, so the Lua stack is left alone.

A container that is not there (keyring, empty bag slot, closed bank) now
returns only 0, leaving bagFamily nil.

The bag view SortBags kept for itself moves to Container::Bag so both
modules share it, and Item::Location::EquippedBagInventory becomes
BagInventory (1..10) beside a new BagContainer.

C_Item.GetItemData's bagFamily now goes through BitmaskForRecord, so a
bag whose family field is empty reports the same family there as it does
from GetItemFamily (a Soul Pouch was 0 there, 0x4 here).
2026-11-02 21:03:01 -06:00
Brues 3a4d291748 fix(link): split links with string.split captured at load
LinkUtil.SplitLinkData and SplitLinkOptions looked up string.split on
every call. BigWigs (pepopo978 fork) defines a table-returning
string:split in Plugins/SpellRequests.lua, which replaces the global, so
with it loaded every custom link type resolved to a table, no handler
matched, and SetItemRef rejected the link as unknown.

string.split is registered in C by the DLL before any addon loads, so a
file-local captured when LinkUtil.lua loads always holds the real one.

Fixes brues-code/pfUI#73
2026-11-02 16:23:36 -06:00
Brues 1cdd46958f feat(combatlog): 4.3.4 combat log lines in retail-named log files
While combat logging is on, each combat log entry is also written as one
CSV line in 4.3.4's PackEvent layout: the subevent, source and dest
(GUID, quoted name or nil, flags as hex), the spell prefix, then the
suffix blocks the entry carries. Fields are written as 1 or nil, damage
values as numbers, and there is no hideCaster, isOffHand or alternate
power type. The line is written from Queue before the events fire, so
it is written even when no frame listens.

The switch is LoggingCombat's own flag (0x00B4FDB8), so /combatlog and
LoggingCombat() control both files. Each time the flag turns on, a new
file starts, named as retail names it:
Logs\WoWCombatLog-MMDDYY_HHMMSS.txt, the local time logging started.
The file is a Storm log opened as LoggingCombat opens its own:
SLogCreate (0x0065A0C0) with flag 4 (append). Lines go through SLogWrite
(0x0065ACE0), which puts the "M/D HH:MM:SS.mmm  " stamp in front.

The flag is read once a frame on FrameTick and before each line. When it
turns off, SLogClose (0x00659EC0) writes the buffered lines and closes
the file. This also covers logout: the chat teardown in
CGGameUI::ShutdownGame (0x00498700, its only caller) clears the flag,
and FrameTick runs on the glue screen too. Storm closes what is still
open at exit (0x0065A4B0). LoggingCombat itself is not hooked: it is a
jmp into the enable code the chat log shares, and other DLLs add their
own logs to it.

1.12's Logs\WoWCombatLog.txt keeps its chat-text lines, written by
0x0049B1E8, for the log readers that parse them.
2026-11-02 15:44:06 -06:00
Brues 72f0e5d98f docs(action): range and usable events move to the Events section 2026-11-02 14:06:41 -06:00
Brues ceb6aaf46b feat(action): action range and usable events
ACTION_RANGE_CHECK_UPDATE(slot, isInRange, checksRange) and
C_ActionBar.EnableActionRangeCheck(slot, enable). Enabled slots are
checked on each world tick while a frame is registered for the event,
with IsActionInRange's own answer (Action::Range::Answer, the macro
unit redirect included), and fire on change. Enabling is counted per
slot, and each enable makes the slot's next check fire, because retail
buttons take their first state from the event. A new Lua state starts
with no slot enabled.

ACTION_USABLE_CHANGED(changes) carries { slot, usable, noMana } for
each slot that changed. The engine's bulk refresh 0x004E5C00 already
compares each slot's pair against the caches IsUsableAction reads
(0x00BC6B60 / 0x00BC67A0). Its per-slot call at 0x004E5C15 is
redirected to record the slots whose pair differs, and its
ACTIONBAR_UPDATE_USABLE signal at 0x004E5C53 to send that event
unchanged and then the new one. No hook.
2026-11-02 14:03:27 -06:00
Brues 84c936faa4 fix(compat): hooks wrap the engine's body when wow_turbo got there first
wow_turbo detours about 380 engine functions to its own reimplementations
from its DllMain. It yields any function whose prologue another mod has
already patched, but ClassicAPI installs from the Load export, which
VanillaFixes calls after every DllMain. So wow_turbo always patches first,
and MinHook copies its jmp into our trampoline: the hook then calls the
reimplementation, not the engine's body. Everything that lives in that body
was lost. The script runners (0x00704D50, 0x00704F10) never reached the
pcall redirects at 0x00704E79 / 0x00705199, so every handler saw self ==
nil. The text builder (0x005CDC20) calls its own emitter, so the inline
texture emitter hook (0x005CCBE0) and the word-wrap redirects at
0x005CDDD3 / 0x005CDE23 never ran.

Before each HookAutoRegister hook is created, a prologue that is a jmp
rel32 into wow_turbo.dll gets its five stock bytes back. The bytes come from
WoW.exe on disk, found through the loaded image's section table. Then the
hook wraps the engine's body. wow_turbo's own rule produces the same state
when it loads after a mod that hooked first. It keeps its original in
place, logs the overwrite and does not re-patch these entries. Release
builds reclaim six: the two runners, the text builder and emitter,
GxSetViewport (0x0058AF60) and the selection-circle update (0x00614A90).
Nothing changes when wow_turbo is not loaded.
2026-11-02 13:00:50 -06:00
Brues 957d66af49 fix(addons): memory usage walks the live objects, as luaC_sizeall does
UpdateAddOnMemoryUsage summed Gc::Tag::SizeOf over the keys of the tag
map. That is only safe while every free reaches luaC_freeobj (0x006F7260),
whose co-hook erases the key. wow_turbo replaces luaC_collectgarbage
(0x006F7340) with a parallel sweep that frees strings, tables, closures,
userdata and upvalues itself and sends only threads and protos through
freeobj. Dead keys stayed in the map, and the recount read freed blocks:
crash 132.

The recount now walks the lists the engine sweeps: rootgc (G+0x10),
rootudata (G+0x14) and every string-table bucket (G+0x04, size G+0x0C),
and looks up the tag of each live object. 3.3.5's luaC_sizeall walks the
same lists. A create with no context word now erases its address's entry,
so a reused address never carries a dead object's tag, whoever freed it.
Dead entries a foreign sweep leaves are never read, and the pool's address
reuse bounds them. _classicapi_LuaHeapAudit uses the same walk.

Fixes #120
2026-11-02 13:00:50 -06:00
Brues 3849b7513b feat(statusbar): the standard fill moves the bar texture's rect
1.12's CSimpleStatusBar::OnFrameUpdate (0x007833C0) draws the fill by
cropping. InstallBarTexture (0x007831F0) sets the texture's
SetTexCoordModifiesRect flag (+0x124), and the update writes the filled
part into the coordinates. The crop shrinks the drawn quad but leaves the
texture's rect at the full bar, so a region anchored to
GetStatusBarTexture() stays on the bar's far edge. Reverse, center,
rotated and tiled bars already moved the texture's points.

The OnFrameUpdate hook now replaces the engine's update for every bar. It
runs the base frame update (0x0076B2C0), clears the crop flag, and moves
the texture's four points in from the bar's edges with CLayoutFrame
SetPoint and doResize, as 4.3.4's OnLayerUpdate does. The fraction comes
from the bar's fill-fraction virtual (+0x98), so SetValue, the ease and
SetTimerDuration all move the rect. doResize queues the texture for the
layout pass before the draw, so anchored regions follow in the same frame.
An empty fill keeps a zero-width rect on the start edge (CalculateRect
0x767A20 has no size test), so its anchors still resolve.

The crop path and the code that handed a bar back to it are removed. A
standard vertical bar now shows the bottom part of its image.
2026-11-02 11:25:46 -06:00
Brues 1cdd443e53 refactor(text): inline icons take their line's fade when its colour changes
The icon textures have no frame, so the frame alpha fold (0x0077FAC0)
skips them and their only alpha is the one copied from their fontstring's
colour (colors[0], OFF_FONTSTRING_COLOR_ARRAY). The tick read that colour
for every fontstring with icons, every frame, and re-coloured the icons
when it changed.

Every change of that colour ends in the fontstring's region slot 8,
CSimpleFontString::OnColorChanged (0x00772180, `ret 4`): SetVertexColor
(0x0077F750) calls it after storing a colour, which is how the chat fader
and SetTextColor reach it, and the frame alpha setter (0x0076A690) calls it
on each region of the frame. It folds the colour (0x0077FAC0) before it
hands it to the built string, so the value is final when it returns.

A co-hook on it now copies the colour onto the line's icons in the same
call. No other loaded DLL refers to that address. The batch reads a
texture's colour through a pointer, so no layer rebuild is needed. The glue
value-follow (FadedColor) is unchanged. The tick only deletes orphaned
textures now; the per-fontstring applied-colour stamp is gone.
2026-11-02 10:26:26 -06:00
Brues ada33825ad refactor(text): inline icons are placed at build and at move only
Inline icons were placed on three paths: the RebuildString refresh, the
paint flush (FUN_TEXT_PAINT co-hook), which recomputed every icon line's
placements after every paint and queued any change, and the tick, which
applied the queue. Measured over normal play, the paint flush never
supplied an icon the rebuild had missed. All it did was re-place icons
that had moved, 456 times: almost all by float noise (1e-5 px), each one
re-anchoring the icon and rebuilding the fontstring's layer; the rest by up
to 0.9 px.

That 0.9 px is a move. A fontstring whose size does not change keeps its
built string: the rect callback (0x00772D40), SetTextOffset (0x00772100)
and the animation translation only move it, through TextBlockSetStringPos
(0x0044D600) -> CGxString::SetPosition (0x005CCB10), which re-finalizes the
node origin (0x005CDF70) before it returns. The origin is floored to whole
render-target pixels, so an icon anchored to the unrounded fontstring rect
drifts off its glyphs by up to a pixel.

The icons are now placed when the string is built (the RebuildString
refresh) and after each TextBlockSetStringPos, from a co-hook on it. No
other loaded DLL refers to that address. The move lays the node out first
(FUN_TEXT_ENSURE_BUILT, as the rebuild does): a colour change can leave the
node unbuilt until the next paint, and with no built lines (+0x9C) the
origin finalize returns early with the raw normalized position in the
origin, which placed those icons at the bottom-left of the screen. The
placement now refuses an unbuilt node. Only nodes with icon records are
laid out early.

Removed: the paint flush's placement queue, the pool's queue and dirty
flag, and the tick's apply. The pool has one entry point, Apply, which
skips an icon set equal to the one applied. The paint flush keeps its
other jobs (the stuck-blockless re-dirty, the stale-record guard and the
baked-alpha mirror); the tick keeps the orphan deletes and the fade mirror.
2026-11-02 10:15:54 -06:00
Brues dd584703a2 refactor(text): place inline icons once, at scale 1
An inline icon texture was given its fontstring's layout scale (layout
slot 0x10, as AddRegionToLayer gives a region its frame's), its anchor
offsets were divided by that same scale, and every tick read each drawn
icon's resolved width back and folded the error into a per-icon
correction, re-placing the icon until it settled within 0.5%.

The textures have no frame, so that machinery has nothing to correct. The
layout constructor (0x00767680) sets the scale (layout +0x58, region +0x7C)
to 1.0 and nothing else changes it without a parent. RelativePointGetX/Y
(0x007A2F90 / 0x007A3070) place a point at the positioned region's own
scale times the stored offset plus the relative edge, so with scale 1 the
fontstring-relative edges land exactly. The scale copy and the divide
cancelled, and the correction stayed at 1.

The icon is now placed with two SetPoints at the raw offsets and the
layout realize (0x00768060, SetDeferResize(0), which resizes at once when
a resize is pending). Removed: the scale copy, the correction and the
per-tick rect readback, and VTBL_LAYOUT_SET_LAYOUT_SCALE, which named the
same slot as VTBL_LAYOUT_SET_SCALE.
2026-11-02 10:04:13 -06:00
Brues 5911298acd fix(text): inline icons are drawn by their line, as 4.3.4 does
Inline icons were CSimpleTextures registered on the fontstring's parent
frame, with their visibility copied from the line once per tick, after the
draw. Any line that hid kept its icons for one more frame; once chat lines
moved with their text, a line scrolled off the top flashed its icons above
the chat frame.

4.3.4's CSimpleEmbeddedTexture has no frame, and the fontstring draws it:
CSimpleFontString::Draw queues the string, then QueueTextures. 1.12's
equivalent is CSimpleFontString::Emit (0x00772E50), the only caller of
AppendToBatch (0x00773080). The icon textures are now made with no frame
(SimpleRegionConstruct skips SetRegionParent for a null parent; the
frame-alpha fold 0x0077FAC0 tests the parent), anchored to the fontstring
and given its scale through layout slot 0x10, as AddRegionToLayer does. A
hook on Emit adds a line's icons to the layer batch right after its text,
and only when the text went in (the batch count at +0x4 grew), so an icon
draws exactly when its line does, in the same frame.

The batch copies each texture's handle, blend and colour count when built
(0x00772FD0) and reads vertices, texcoords and colours through pointers, so
a change to a line's icon set marks the fontstring's layer
(NotifyLayerChanged 0x0077FDF0), and moves and fades need no rebuild.

4.3.4 sets the textures up in UpdateString. RebuildString (0x007724A0) now
does the same: when its gate passes (layout flags +0x60 bit 1, `test byte
[esi+0x60],1`) it replaces the built string, so the line's icons are
dropped, the new node is laid out (FUN_TEXT_ENSURE_BUILT) and its
placements applied at once. The paint flush stays the backstop.

Removed: the per-tick visibility mirror, the freshness expiry, region
Show/Hide and re-parenting, and the SMF early apply with its
_classicapi_InlineTexEarly toggle; the rebuild refresh covers what it did,
and it rebuilt every icon line on every layout. The textures are deleted
through their vtable slot 0 (as the frame destructor deletes regions) one
tick after their fontstring dies or a reload, once the batches holding them
were rebuilt.
2026-11-02 00:53:10 -06:00
Brues a6fa06faa0 perf(smf): lines a layout keeps on screen keep their font strings
The line layout (0x00788750) shows record (position - i) in line i and sets
each line's text through ShowLine (0x00788AF0). When the position moves, by
a message at the bottom or a scroll, every line got the text of its
neighbour: a copy, a freed built string, a new measure and a new shaping.

Before a layout of a shown frame, the line entries are now turned by the
distance the position moves (the layout's record minus the scroll record
+0x348, the record line 0 shows), so each line that stays on screen is the
font string that already holds its text. The layout itself runs unchanged.
SetText (0x00771D80) returns at its SStrCmp when the text is the one the
line holds, so the line keeps its built string and measured height;
SetVertexColor (0x0077F750) skips a repeated colour; the forced Resize only
moves the built string, since the rect callback 0x00772D40 rebuilds only on
a size change. Only lines that come round get new text. Hyperlink buttons
are rebuilt from the lines after every layout, and inline textures are
anchored to their line, so both follow.

The 0x10-byte entries move whole, as the engine's own array grow 0x00789060
moves them with SMemReAlloc. Two lines get new anchors: the new line 0 goes
on the frame, then the old line 0 on the line below it, in that order so no
line is anchored to one that depends on it. Both are anchored as the layout
anchors a new line, in the frame's insert mode (InsertMode::AnchorLine, now
shared). A frame whose last layout placed no line (+0x34C = 0, a hidden
frame's layout) is laid out as before, as is a distance as large as the
lines. A wrong distance costs only the reuse: the layout still sets every
text it shows.

The layout's before-listener now receives the record it runs from.
2026-11-02 00:24:28 -06:00
Brues 045ab77508 perf(smf): one line layout per update for messages added at the bottom
The ring writer 0x00788150, AddMessage's only callee, lays the lines out
(0x00788750) after every message at the bottom, and the layout sets the text
of every line it shows. k messages in one update cost k layouts of every
shown line, and only the last is drawn.

The writer's layout call (0x00788234) is redirected. The layout is put off
when its result is known apart from line heights: the frame is shown, it had
a message before this one, and its top flag +0x350 is clear (the lines do
not reach the oldest message, and a newer message cannot make them). The
layout's first store, the scroll record +0x348 = the newest record, is made
at once, so GetCurrentScroll and the writer's next check read as before.

The put-off layout runs at the first of:
- the frame's update, between its OnUpdate script and its fader (the call
  to 0x0076B2C0 at 0x00788471). The fader, RearmLines and the link-button
  rebuild read each line's record through node+0xC, and the writer may have
  reused those records.
- the end of the top's update pass, before the second layout-queue drain
  (0x00768ED0 at 0x00765799) and the draw, so a message added by any
  OnUpdate draws in the same frame.
- RearmLines 0x00788B80 (hooked), which ScrollDown, ScrollToBottom and
  ScrollToOffset run at the bottom instead of a layout.
- GetNumLinesDisplayed (0x00793590, reads +0x34C), overridden on the method
  registry to lay out first.
- any other layout of the frame, which replaces it. Layout gains a
  before-listener for this, since a layout can run Lua.

OnMessageScrollChanged now runs once for the messages a layout covers: the
put-off layout gets the scroll record from before the first of them. The
writer is put off only when both flush redirects apply.
2026-11-02 00:14:18 -06:00
Brues ac5df0fab8 perf(smf): BackFillMessage shifts in place and skips layouts it cannot change
BackFillMessage made every older message move up one record. It copied the
record span into a vector and back, built a new Lua table for the extra
arguments, and laid the lines out again on every call. pfUI's combat-log
refilter calls it once per frame for up to 300 lines, and its chat history
up to 30 times per frame at login.

The records now rotate in place (std::rotate over the 0x18-byte records),
and the extra-argument tables follow each cycle of the rotation in the same
table, so a call makes no garbage. RemoveMessagesByPredicate shares the
rotation.

A shown frame whose lines do not reach the oldest message (+0x350, the top
flag the layout 0x00788750 sets) cannot show the new oldest one. The layout
would place the same lines again, so it is skipped. The one thing it would
change, each laid-out node's record pointer (node+0xC, written by ShowLine
0x00788AF0), moves one record up with its message. The fader 0x00788460,
RearmLines 0x00788B80 and RebuildLinkButtons 0x00788C00 read records only
through that pointer, for nodes 0..+0x34C-1. Clear leaves +0x350 at 0 on an
empty frame, so the skip also needs existing messages. An empty or hidden
frame is laid out as before.
2026-11-01 23:52:22 -06:00
Brues 48822978c6 feat(statusbar): ExponentialEaseOut interpolation
bar:SetValue(value, interpolation) and SetMinMaxValues(min, max,
interpolation) take Enum.StatusBarInterpolation. With
ExponentialEaseOut the fill eases from what it showed to the new value;
GetInterpolatedValue, IsInterpolating and SetToTargetValue read and end
the ease, and SetTimerDuration's interpolation argument now chases the
timer the same way. The curve was measured in a retail 12.1.5 client
at 77 and 30 frames per second and fits to under 1%: each frame closes
the gap in the fill FRACTION by exp(-18 * elapsed), whatever the range,
and the frame whose gap before its step is under 0.001 snaps to the
target. The same probe on 1.12 matches: k = 18.0, a full-bar ease done
in 0.38 s against retail's 0.39.

SetValue stores the target at once (GetValue returns it, OnValueChanged
runs once, with it); a new eased value goes on from the shown fill, and
an Immediate one, or SetToTargetValue, snaps. SetMinMaxValues with the
ease keeps the fraction shown and eases to the new one. A hidden bar
does not ease. Only a target that held still since the last frame may
snap: an eased timer starts at a near-zero gap, and retail chases it
until the timer ends.

The ease lives where the timer does, so StatusBar::Timer becomes
StatusBar::Display: one state per bar holding the timer, the ease and
the fraction drawn. The co-hook on the bar's GetFillFraction virtual
(0x783380) answers that fraction, so the ease keeps 1.12's texture crop
and StatusBar::Fill's styles. Advance runs at the head of
CSimpleStatusBar::OnFrameUpdate (0x7833C0) for shown bars and now takes
the pass's elapsed, which is the step retail takes.

SetValue and SetMinMaxValues stay the engine's Lua methods (0x78F690,
0x78F4B0, read from the method table at 0x87B010), registered over by
wrappers that read the interpolation argument and the fraction shown,
call the engine method for its checks, errors and setter, then start or
stop the ease. Both engine methods read no argument past their own.
2026-11-01 23:34:36 -06:00
Brues f061179646 refactor(region): one region destruction signal; inline icons drop the vtable guess
Frame::RegionDestruction co-hooks SimpleRegionDestruct (0x77F6F0), the
destructor body every region ends in, and runs its listeners first. It
has exactly three callers: the base region's deleting destructor
(0x77F6C6), ~CSimpleTexture (0x76FD40, a tail JMP; its only caller is
the deleting destructor 0x76FD10) and ~CSimpleFontString (0x770E50, a
tail JMP; called by its deleting destructor 0x770E20 and by a
subclass's, 0x789320). The texture and font string vtables (0x81C718,
0x81C778) are written only by their constructor and destructor, so no
inlined destructor skips the hook. It replaces FontString::Destruction,
the font-string-only hook from the previous commit; the duration text
binding now listens here.

InlineTexturePool kept icon state keyed by font string pointer and
judged a key alive by its first word matching a vtable it had seen
painted. That guess had a hole: a destroyed font string's block goes
back to the region pool and can come back as another font string,
vtable and all, and the dead key's icons then served the new one. It
also read possibly-freed memory on every tick. Now a destroyed key
takes its entry with it at once, so the address starts clean; its icon
regions live on with their parent frame and are hidden on the next
tick, never inside the destructor that reported the death. A destroyed
icon region is dropped wherever it is held, and a live key that loses
one is placed again. The learned-vtable sets, IsLiveFontString and
IsLiveRegion are gone. The SEH latch stays, for the engine region calls
the tick makes from paint-time data.
2026-11-01 23:03:11 -06:00
Brues f8a796ae04 feat(duration): duration text bindings, Format methods, GetTotemDuration
A font string can now count down by itself. A binding from
C_DurationUtil.CreateDurationTextBinding() takes a font string
(SetFontString), a duration object (SetDuration) and a format: either
SetFormatter(formatter), which writes a NumericRuleFormatter's text for
the remaining seconds, or SetTextFormat(formatString, components), where
each "{}" takes one component's property (Enum.DurationTextBindingProperty)
through its own formatter. SetExpiredText and SetZeroDurationText give
fixed texts, SetUpdateInterval spaces the updates out, and SetEnabled /
Enable / Disable start and stop it. The getters, Copy, Assign,
SetToDefaults, UpdateFontString, GetFormattedText and the Can* checks
are there too; the color curves are not. Duration objects gain
FormatElapsedDuration, FormatRemainingDuration and FormatTotalDuration
on the same formatter. Each answer was probed in a retail 12.1.5 client,
and the same probe matches on 1.12.

A binding is a userdata over a reference-counted native object, like the
duration object and the formatter, and it keeps both of those by
reference, so a change to the duration shows at the next frame. An
enabled binding takes a reference of its own, because it must go on
updating with no Lua object left; a Game::ReloadAutoRegister disables
every binding before the Lua state goes. The text is set through
CSimpleFontString::SetText (0x771D80), only when it differs from the
font string's own text buffer (+0xF0).

The updates run at the head of the UI render root (0x764330), before
the frame pass runs any OnUpdate or draws: Tick::FrameTick gains
AutoSubscribeBefore for this. At the tail, where FrameTick and
WorldTick subscribers run, the text would draw a frame after a timer
bar's fill, which the same pass computes; the probe caught that as a
"3.0" where retail showed "2.9". Nothing is mid-draw at the head, so the
formatter's call into str_format is safe there.

A binding keeps a font string pointer, and 1.12 hands a destroyed font
string's block back to the region pool, where it can come back as
another font string. The new FontString::Destruction co-hooks
CSimpleFontString::~CSimpleFontString (0x770E50, decompiled; its
deleting destructor 0x770E20 calls it, then frees the block) and runs
its listeners first, like Frame::Destruction does for frames. The
binding drops the pointer there and writes nothing after.

GetTotemDuration(slot) now returns a duration object, as retail's does:
the totem's span from its spawn stamp to its expiry, in exact
GetTime() seconds rather than GetTotemInfo's whole seconds, or a zero
span for an empty slot, since retail's return is never nil. Each call
returns a new object.
2026-11-01 22:48:32 -06:00
Brues aff52e0ef6 feat(statusbar): timer bars, duration objects and Unit*Duration
A status bar can now fill by itself from a duration, with no Lua on each
frame. bar:SetTimerDuration(duration [, interpolation [, direction]])
takes a duration object from C_DurationUtil.CreateDuration(), and
Enum.StatusBarTimerDirection picks ElapsedTime (fills) or RemainingTime
(empties). GetTimerDuration() returns it. UnitCastingDuration(unit) and
UnitChannelDuration(unit) return the time span of a unit's cast or
channel as a duration object.

The duration object is a userdata like the NumericRuleFormatter: a
pointer to a reference-counted span (start, total, mod rate) that every
Lua object and every bar holding it own a reference to. Times are
GetTime() seconds read through Time::Clock::NowSeconds, so they agree
with GetTime() exactly. Its methods set the span from a start, an end
or both, and read the start, end, total, elapsed and remaining time,
the two percents and the started / expired / active state, in real or
base time (Enum.DurationTimeModifier). The answers were probed in a
retail 12.1.5 client and the same probe matches on 1.12.

1.12 already draws a bar's fill from a virtual, not from its stored
value: CSimpleStatusBar::OnFrameUpdate (0x7833C0) crops the bar texture
to the fraction its vtable slot +0x98 returns (0x783380,
(value - min) / (max - min), or 0 for an empty range; disassembled).
The timer co-hooks that function, so a timed bar keeps 1.12's texture
crop. StatusBar::Fill's reversed, centered, turned and tiled layouts
now take their fraction from the same virtual, so they follow a timer
too. Fill's OnFrameUpdate co-hook calls Timer::Advance first, which
marks the fill changed when the share moved and caches the share it
drew. Shown bars update on every frame; hidden bars do nothing and are
right on their first frame shown.

As in retail, the timer stores 0 for the value and the range (written
directly, so no OnValueChanged runs), SetValue leaves it running, and
SetMinMaxValues ends it: Frame::MinMaxChanged's co-hook on the range
setter (0x783270) clears the timer first. The bar keeps the duration
object, not a copy, so a change to the object shows on the next frame.
ExponentialEaseOut is accepted and draws as Immediate for now.

UnitCastingDuration and UnitChannelDuration share one per-unit lookup
with C_Spell.UnitCastingInfo / UnitChannelInfo, so they take the same
unit tokens and have the same limits. Each call returns a new snapshot;
it does not follow pushback or the end of the cast.
2026-11-01 22:28:01 -06:00
Brues aef68d5ea4 fix(nameplate): fire REMOVED and ADDED when a unit moves to another plate
The name-query callback (0x5FB86E) and the creature-cache reply (0x6046C1)
call CGUnit_C::ReleaseNamePlate (0x608A10) so the plate is rebuilt with the
name, and the next tick takes a plate again through AllocNamePlate
(0x6087F0). The free list is FIFO: release links the frame in at the tail
and alloc takes s_freeNamePlates[2], the head. So unless the list was empty,
the unit gets a different CGNamePlateFrame. The per-tick diff compared GUIDs
only. The GUID was in both snapshots, so no event fired, and an addon that
keys its state by frame kept the old, hidden frame.

A binding is now the (GUID, frame) pair. A unit on a new frame gets
NAME_PLATE_UNIT_REMOVED for the old binding, then NAME_PLATE_UNIT_ADDED for
the new one. The REMOVED pass now runs before the ADDED pass, so the old
binding's slot is free before the new one is assigned. A re-acquire that
hands back the same frame still fires nothing.

During REMOVED, GetNamePlateForUnit / GetNamePlateForGUID now return the
frame being removed before they read unit+0xE60. After a rebind, that field
already names the new frame, and the getters would have given the handler
the wrong plate.
2026-11-01 21:46:34 -06:00
Brues cc1e70a641 feat(debugtools): modern /etrace
/etrace and /eventtrace now open a backport of retail's
Blizzard_EventTrace, the load-on-demand addon ClassicAPI_EventTrace
embedded in the DLL. It replaces the old event trace frame in
ClassicAPI_DebugTools, and DebugToolsSavedVars with it; the panel saves
EventTraceSavedVars.

What differs on 1.12.1:

- RegisterAllEvents adds the frame to every event slot, so it opens the
  listener gate of each ClassicAPI event that builds its arguments only
  for a listener (UNIT_AURA_DELTA, the combat log and cast events). The
  panel registers all events only while it is shown or logging while
  hidden; otherwise it keeps PLAYER_LOGOUT, which saves its variables,
  since this client has no ADDONS_UNLOADING.
- An engine event signalled by a number with no name arrives with a nil
  event and is not logged.
- An event's table arguments are copied when logged, and the tooltip
  lists their contents. UNIT_AURA_DELTA refills the same tables on every
  fire, so a kept reference shows only the latest delta.
- No tool windows, ImGui debug tools or secret values.

The window's templates are new shared templates in !!!ClassicAPI:
ButtonFrameTemplate and PortraitFrameBaseTemplate (with
PortraitFrameMixin / TitledPanelMixin), UIPanelCloseButtonNoScripts,
PanelResizeButtonTemplate, InputBoxInstructionsTemplate,
SharedTooltipTemplate, VisibleWhilePlayingAnimGroupTemplate, and
GameTooltip_AddHighlightLine / GameTooltip_AddColoredDoubleLine. Art
this client lacks is drawn with its own textures: tooltip border
chrome, the world map magnifier, the cancel button, a turned chat
expand arrow for the resize grip, and the panel button in three slices.
The scroll bars are the 1.12 bar, whose art is wider than its frame,
so the lists leave room for it.

Retail's search box is ClassicAPISearchBoxTemplate. Turtle's FrameXML
registers its own SearchBoxTemplate first, and 1.12 keeps the first
template of a name, so the retail one takes that name only through a
TOC line marked [ExcludeLoadGameType turtle].

The EVENTTRACE_* strings and SLASH_EVENTTRACE1-4 come from Classic
Era's GlobalStrings for each client locale but koKR.
2026-11-01 21:29:44 -06:00
Brues f1843afc0f feat(addons): ExcludeLoadGameType and the turtle game type in TOC lines
A TOC file line can now end in [ExcludeLoadGameType ...] as well as
[AllowLoadGameType ...]. An unknown condition drops its line, so a
line excluded for some other game type used to be dropped here too.

Both lists may name "turtle", a ClassicAPI extension like the
_Turtle.toc flavor: it matches when Turtle::Detected() (the engine's
TURTLE_WOW_VERSION or the glue TURTLE_* globals), and "vanilla" matches
on every client. So [ExcludeLoadGameType turtle] loads a line
everywhere but Turtle.

The TOCs of the addons embedded in the DLL now go through the same
rewrite. The FUN_FILE_READ hook serves an embedded addon's file from
disk or from the DLL as before, now in ReadAddonFile, and passes a read
that succeeded to TocRewrite::Transform, which rewrites only TOCs.
2026-11-01 21:25:47 -06:00
Brues f8304c06f7 fix(xml): frames loaded from XML lay out after OnLoad
When a frame finishes loading from XML, CSimpleFrame::PostLoadXML
(0x0076A2F0) clears the loading flag and, for a visible frame, ends the
layout deferral of the frame, its title region and its registered
regions with 0x00768060(0). That call resolves a pending layout at once
(0x007680E0 with force 1), so OnSizeChanged runs before the frame's
OnLoad, and before its children, built next, run theirs. Building the
children first does not help: resolving a child ends the deferral of the
frame it anchors to and lays that frame out too (0x007A2F90).

Retail lays these frames out later, from its end-of-frame resize list.
Its EventFrame, ScrollBox and scroll bars trigger callbacks from
OnSizeChanged on the registry their OnLoad creates, so a retail window
that loads visible raised "executingEvents (a nil value)" from each of
them.

The three calls (0x0076A310, 0x0076A324, 0x0076A35B) now go to
EndLoadDeferral: it clears the deferred bit (0x02 of the layout flags
at +0x3C) the same way, and sends a pending layout (0x00768310) to the
engine's layout queue with 0x007680E0(0), the path SetPoint takes. The
queue drains on the top's update and in the interface build, after
every OnLoad of the load. A script that reads a size or an edge still
lays its frame out at once. The minimap and tooltip finalizers call the
base first and read no rect after it. Each redirect checks the call's
target first, so a site another DLL has patched keeps its code.
2026-11-01 21:25:33 -06:00
Brues 2396d44f10 fix(frame): GetWidth / GetHeight / GetSize keep 1.12's scaled size
For a region with no set size, 1.12's Script_GetWidth (0x007A1E00) and
Script_GetHeight (0x007A2030) return the rect span without dividing by
the region's layout scale. The value is the size times the effective
scale: a 160-high track at a UI scale of 0.91 answers 145.6.

dceec8b replaced both getters with the divided form. That broke vanilla
addons tuned to the scaled value. BetterCharacterPanel sets its paperdoll
column from PaperDollFrame:GetHeight() with padding tuned at one UI
scale, so the resist icons and the stat list left the frame.

- GetWidth / GetHeight / GetSize call the engine getters again. With
  ignoreRect, a region with no set size still answers 0.
- New region methods GetUnscaledWidth / GetUnscaledHeight /
  GetUnscaledSize give the size in GetTop / GetBottom units: the explicit
  size, else the rect span over OFF_REGION_EFFECTIVE_SCALE after a
  pending resize. GetRect and GetScaledRect keep using these.
- The ported Lua in !!!ClassicAPI (ScrollBox, Menu, LayoutFrame, Util)
  calls the GetUnscaled* names wherever it measures a region.

Fixes #118
2026-11-01 19:12:04 -06:00
Brues 1ee80b74e9 docs(readme): texture wrap modes, tiling and the element XML attributes
The Texture row lists the SetTexture wrap modes and the tile methods. The
XML attributes row now names the element attributes that ClassicAPI reads,
for frames, buttons, textures, font strings, status bars and edit boxes,
and intrinsic frame types.
2026-11-01 18:35:11 -06:00
Brues 932e6ed82f feat(texture): wrap modes and tiling
texture:SetTexture(file, wrapModeHorizontal, wrapModeVertical), the XML
hWrapMode / vWrapMode attributes, texture:SetHorizTile / GetHorizTile /
SetVertTile / GetVertTile and the XML horizTile / vertTile attributes.

Wrap. 1.12 fixes a texture's wrap when its file loads.
CSimpleTexture::SetTexture (0x00770200) builds the flags word with
CGxTexFlags (0x0058A980) from one wrap argument for both axes, and
TextureCreate (0x00449D90) caches the handle under the name and that
word, so a repeating copy of a file is its own shared texture. The device
reads one bit per axis (3 for U, 4 for V), repeat or clamp, so the mirror
and border modes clamp. A texture with a mode or a tile flag loads
through a co-hook on that SetTexture: the same body, with a repeat per
axis. The engine's same-file early-out (0x0044AC70) ignores flags, so the
co-hook also compares the held image's wrap (its device texture's flags
at +0x3C, or a loading BLP handle's at +0x150), and for the same file it
swaps in the other copy and keeps the pixel shader. Changing a mode or a
tile flag reloads the held file the same way. The Lua method is
registered again so it stores the wrap arguments before the engine
handler (0x0079BB40) runs: true or "REPEAT" repeats, anything else clamps.

Tiling. A tiled axis repeats, and each non-zero coordinate on it becomes
the texture's length in script units over the image's size in texels.
The script units are the engine's own coordinate push: DivideByScreenScaleX
(0x0041AE40) of aspect (0x0041AD70) x 1024 x length, over the effective
scale. The texels are the device texture's width and height (+0x24 /
+0x28): the file's size, halved only past the GPU limit and untouched by
BaseMip. The layer batch keeps a pointer to the eight coordinates
(0x00772FD0), so the count is written whenever an input changes: after
each corner store, after both native SetTexCoord forms (0x00770410,
0x007704C0), on a tile change, and after CSimpleTexture::Emit
(0x007706E0), whose device lookup loads the image. A tile change counts
against the held image before the repeating copy of the same file
replaces it, since the copy has no size until it is drawn.

A texture showing an atlas does not tile (the atlases are parts of their
files), and neither does one with SetTexCoordModifiesRect on. SetAtlas
clears modes and tiling. A tiled status bar texture takes
StatusBar::Fill's anchored layout instead of 1.12's crop, which would size
it by the count, and its fill sets no coordinates. An XML <TexCoords> that
moves a tiled axis off 0..1 logs "Invalid TexCoords value (horizTile is
on)" at error level (new Xml::Error) and falls back to the whole image.

Also corrects the AtlasData.h note on the atlas tile flags: 4 is
horizontal and 2 is vertical.
2026-11-01 18:34:36 -06:00
Brues b032dc09ac refactor(texture): shared hooks for the texture constructor and corner store
MinHook takes one hook per address. Texture::Mask hooked the
CSimpleTexture constructor (0x0076FC40) to drop its state when pool memory
is reused, and Texture::Transform hooked the corner store (0x007705B0) to
turn the quad again after each layout. Texture wrap modes and tiling need
both hooks too, so each hook now has an owner that fans out to listeners:

- Texture::Construct runs its listeners before the constructor, on the
  memory it is about to build. Mask listens.
- Texture::CornerStore runs its listeners after the store. Transform
  listens.

The store writes only the corners. The layer batch reads them through a
pointer on every draw, so nothing marks the layer changed.

The XML number parse through SStrToFloat (0x0064AAA0) moves into
Xml::FloatAttr, which NativeSetters and Transform's rotation attributes
now share.
2026-11-01 18:30:32 -06:00
Brues eb0b70bc5e feat(xml): rotation, degrees, registerForDrag and group parentArray
Four more later-client XML attributes, each applied only when present and
non-empty, after the engine has loaded the node.

- Texture `rotation` (degrees) and FontString `degrees` / `radians`
  (`radians` wins when both are given), parsed with SStrToFloat
  (FUN_SSTR_TO_FLOAT): Texture::Transform's rotation about the default
  pivot. The bodies of texture:SetRotation and fontstring:SetRotation
  move into SetTextureRotation / SetFontStringRotation, which the Lua
  methods and the Xml::TypeLoad subscribers both call.
- Frame `registerForDrag`: the names, split on spaces and commas with
  Storm's tokenizer (FUN_STORM_STR_TOKENIZE) as registerForClicks is,
  passed to Frame:RegisterForDrag itself (FUN_SCRIPT_FRAME_REGISTERFORDRAG
  0x00776D60) as its own Lua call. That function ORs LeftButton 1,
  MiddleButton 2 and RightButton 4 and stores the mask at frame+0xFC;
  there is no setter to call instead.
- AnimationGroup `parentArray`: the group is appended to region[key].
  The append moves out of Xml::ParentKey into Xml::AppendToArray, which
  frames, regions and groups now share.

Tested in game with an XML test addon: rotation="90" and degrees="90"
read back as pi/2, two groups land in parentArray in load order, and a
frame with registerForDrag="LeftButton" drags.
2026-11-01 17:25:28 -06:00
Brues 238e35ae53 feat(animation): from/to alpha, setToFinalAlpha, target keys, scale from/to
Later clients' animation attributes and the methods behind them, on the
animation port's own objects.

Alpha fromAlpha / toAlpha (SetFromAlpha / SetToAlpha / GetFromAlpha /
GetToAlpha): an absolute alpha. Each update the target's alpha becomes
from + (to - from) x progress, through Region::SetAnimAlpha, which takes
AddAnimAlpha's target rule but sets instead of adding: a frame's own
alpha through its vtable setter, a texture's or font string's vertex
alpha through FUN_REGION_GET_COLOR / FUN_FONTSTRING_SET_COLOR. Earlier
orders apply at full progress before the current order updates, so the
last write wins. Setting either end turns the mode on, an unset end is 0,
and SetChange returns to the relative change. Absolute values and the 0
default follow Blizzard's own code: it starts fades with
SetFromAlpha(region:GetAlpha()) and fades in with toAlpha="1" alone.

AnimationGroup setToFinalAlpha (SetToFinalAlpha / IsSetToFinalAlpha):
when the group finishes (Finish included), Region::KeepAnimAlpha makes
the alpha each alpha animation's target ended on the stored alpha, so the
end restores that. Stop restores the stored alpha as before.

targetKey / childKey (SetTargetKey / SetChildKey), with SetTarget,
SetTargetName, SetTargetParent and GetTarget: a key path from the
animation, where $parent steps to the group, then its region, then that
region's parent; childKey "X" is "$parent.$parent.X". The steps among the
animation objects are taken by Region::FindRegionByKey, and the rest goes
to the relativeKey resolver in Xml::Anchors, now public with a switch for
its leading-global rule. A name or key is looked up again on Play, as
4.3.4 looks up `target`; the setters also look it up at once and return
whether they found a region. GetTarget returns the group's region when
there is no target.

Scale fromScaleX / fromScaleY / toScaleX / toScaleY (SetScaleFrom /
SetScaleTo / GetScaleFrom / GetScaleTo): the scale goes from `from` to
`to`, passed to AddAnimScale as 1 - scale. SetScale sets the end, so
4.3.4's scale is the case from = 1 and its results do not change.

Tested in game with an XML test addon: childKey and targetKey resolve,
the absolute alpha and the last-write-wins rule give each order's end
value, setToFinalAlpha keeps 0.5 after the group finishes, a group without
it restores alpha 1, and the from/to scale values load.
2026-11-01 17:25:18 -06:00
Brues 5b7fe94d4d feat(xml): alpha, scale, desaturated, indented and other XML attributes
Later clients read these attributes in their XML loaders; 1.12's do not.
Each is now read after the engine has loaded the node (Xml::FrameLoad for
frames, Xml::TypeLoad for the rest) and goes through the setter the
matching Lua method uses. Each applies only when present and non-empty,
so an element's own value overrides its template's.

Frames:
- scale: Frame:SetScale's path (0x00775140). A negative scale is refused,
  anything else goes to SetFrameScale (FUN_FRAME_SET_SCALE 0x0076AC10,
  __thiscall(frame, float, force), ret 8) without force. A Model keeps
  `scale` as its model scale, which its own loader reads.
- dontSavePosition, propagateKeyboardInput: the existing frame flags,
  through FUN_FRAME_SET_FRAME_FLAG.

Textures and font strings:
- alpha: parsed as the frame loader parses its own `alpha` (SStrToFloat,
  clamped to 0..1, truncated to a byte), then set as Texture:SetAlpha and
  FontString:SetAlpha set it (0x0079B580, 0x0079CB70): the region color
  from FUN_REGION_GET_COLOR with its alpha byte replaced, through
  FUN_FONTSTRING_SET_COLOR. It applies after the node's <Color>.
- desaturated (textures): Texture::Desaturation's full amount, the same
  as SetDesaturated. It applies whenever present; an empty value is false.
- indented (font strings): the style bit SetIndentedWordWrap sets.

Status bars:
- reverseFill, rotatesTexture, fillStyle: StatusBar::Fill's modes, read
  after the status bar loader has set the BarTexture. An unknown
  fillStyle name changes nothing.

Tested in game with an XML test addon: every attribute set its value, and
parentKey, wordwrap and countInvisibleLetters still load.
2026-11-01 16:45:11 -06:00
Brues 43594398f0 refactor(xml): one observer owns the per-type XML loader hooks
MinHook allows one hook per address, so the texture, font string and
button loader hooks lived in Xml::ParentKey, which then read every other
module's attribute for it (mask, atlas, wordwrap, registerForClicks), and
CountInvisibleLetters hooked the EditBox loader on its own.

Xml::TypeLoad now owns the hooks on the engine's per-type node loaders,
the way Xml::FrameLoad owns FUN_XML_FRAME_LOAD's, and fans out to
subscribers after the engine has loaded each node:

- texture        FUN_XML_TEXTURE_LOAD    0x0076FE20
- font string    FUN_XML_FONTSTRING_LOAD 0x00770F40
- button         FUN_XML_BUTTON_LOAD     0x007788C0 (CheckButton's loader
                 calls it first)
- edit box       FUN_XML_EDITBOX_LOAD    0x00779FB0
- status bar     FUN_XML_STATUSBAR_LOAD  0x00782EF0 (new; calls
                 FUN_XML_FRAME_LOAD first at 0x00782F06, ret 8)

All five share the sub-object convention __thiscall(loader, node,
status), ret 8, object = loader - OFF_XML_LOADER_SUBOBJECT. An inherited
template goes through the same loader first, so a node's subscribers run
after its templates'. The texture and font string loads keep running in
the node's mixin scope (Xml::Mixin::Begin / End), as before.

Each attribute is now read in the module that owns its setter:
Texture::Mask (mask), Texture::Atlas (atlas, useAtlasSize),
FontString::WordWrap (wordwrap), Frame::ClickMask (registerForClicks) and
EditBox::CountInvisibleLetters (countInvisibleLetters). The XML-only
entry points Atlas::SetFromXml, WordWrap::Set and ClickMask::SetFromXml
become private, and ClickMask.h and WordWrap.h go. ParentKey keeps only
its own binding work. Same hook addresses, same order around the engine
call, no behavior change.
2026-11-01 16:44:59 -06:00
Brues 2eef939b5a feat(xml): registerForClicks and wordwrap attributes
1.12's loaders read neither attribute: the Button loader (0x007788C0)
has no registerForClicks, and the font string loader (0x00770F40) reads
nonspacewrap but not wordwrap. Both were silently ignored, so ported
templates got the default click mask and wrapping text.

Both are read in the loader hooks Xml::ParentKey already owns, after
the engine's loader and before OnLoad, and only when present and
non-empty, so a node's value beats its template's and an OnLoad call
still overrides it. No new hooks.

registerForClicks follows 3.3.5's CSimpleButton::LoadXML. Storm's
tokenizer (0x0064AE50, the one 3.3.5 calls) splits the value on spaces
and commas. Each name ORs in its bits through
Frame::MouseRegistration::MaskForName, the RegisterForMouse name table
(3.3.5's MouseButtonStringToBitmask, AnyUp / AnyDown included). The
result replaces the mask through the engine's own setter 0x00779730
(__thiscall(button, mask), ret 4, writes only +0x330).

wordwrap goes through FontString::WordWrap::Set, the body of
fontstring:SetWordWrap: it clears the font's override of the no-wrap
style bit and lays the string out again when the value changes.

Tested in game: WowStyle1DropdownTemplate's Text (wordwrap="false")
now reports CanWordWrap() = nil.
2026-11-01 16:03:33 -06:00
Brues 36f3661b98 feat(lua): GetCallstackHeight, GetErrorCallstackHeight, SetErrorCallstackHeight
Adds the three call stack heights an error handler uses to start its
stack at the code that failed:
    debugstack(GetCallstackHeight() - (GetErrorCallstackHeight() - 1))
A height counts levels as lua_getstack (0x006FBAA0) does, and so as
debugstack does: C functions and lost tail calls count, and level 1 is
the caller.

1.12.1 records no error height, but every error report runs one C
function, FrameScriptCallErrorHandler (0x00703B40, __fastcall(L)). It
pushes the seterrorhandler function (registry ref 0x008722CC), moves it
under the message and lua_calls it. Nothing calls it directly: state
open pushes it as a C closure and refs it (0x008722C8). Every script
pcall passes that closure as the errfunc, so Lua calls it at the point
of the error before the stack unwinds; a chunk that fails to compile is
reported by a pcall of it (0x00704AE0). ClassicAPI's securecall,
hooksecurefunc and script runners pass the same ref.

So while any handler runs, that closure's level sits directly above the
code that failed. GetErrorCallstackHeight walks the levels with
lua_getstack and lua_getinfo "f", finds the innermost one whose function
is 0x00703B40 (lua_tocfunction, 0x006F3720) and answers the height below
it, or nil when no error is being reported. There is no hook, and
nothing is recorded, so a handler that fails part way leaves nothing
behind.

SetErrorCallstackHeight sets the height for a handler called with no
error raised. It applies only while no 0x00703B40 level is on the stack,
so a value left set by a failed handler cannot move a later error's
stack. A new Lua state starts with no value set.

Users:
- The Lua error window takes its stack this way, so it starts at the
  failing code even when another addon wraps the handler.
- CallErrorHandler and assertsafe set the height around their direct
  handler call, so they report from their caller.
- Game::Lua gains GetStack, GetInfo and ToCFunction; SourceLocation uses
  them in place of its own copies.

Docs: the xpcall and coroutine notes said error() returns without
raising. Stock error (0x00702970) ends in lua_error (0x006F4440) and
raises. pfUI replaces the global error with one that prints and
returns, which is what those notes described.

Tested in game: with seterrorhandler wrapping the old handler, a nil
index in a local function f showed a stack that starts at f.
2026-11-01 15:37:11 -06:00
Brues 4f3ee595e7 feat(ui): Lua error window replaces the stock error box
The Lua error window (/luaerrors) moves from the load-on-demand
ClassicAPI_DebugTools into !!!ClassicAPI and becomes the client's error
display. Each error opens it with the message, time, count and stack, and
the arrows page through every error so far.

1.12.1's error handler is FrameXML's _ERRORMESSAGE (BasicControls.xml). It
reports an error as ScriptErrors_Message:SetText(message) and then
ScriptErrors:Show(), and only while the stock box is hidden. The window's
OnLoad takes those two slots, the same two !BugGrabber 2.1.1 takes: SetText
records the error and Show does nothing, so the stock box stays hidden and
every error arrives. Errors still pass through the handler first, so:
- !BugGrabber loads after !!!ClassicAPI and takes the slots back. BugSack
  gets the errors and the window does not open.
- An addon that calls seterrorhandler (pfUI) replaces _ERRORMESSAGE, so
  neither shows. One that wraps the old handler still ends here.

The engine's error function (0x00703B40) is C. It runs at the point of the
error, before the stack unwinds, and calls the registered handler. From the
SetText slot, level 1 is the slot, 2 is _ERRORMESSAGE, 3 is that C function
and 4 is the code that failed, so the stack starts at debugstack(4), the
level !BugGrabber's GrabError reads from the same slot.

The text is ClassicAPI's ScrollingEditBoxTemplate with a MinimalScrollBar.
The bottom row has Reload UI, arrows that wrap around the index label, and
Close. /luaerrors opens or closes the window and /luaerrors clear empties
it; the old on/off handler install is gone.

Not tested in game yet.
2026-11-01 15:15:52 -06:00
Brues 99f72aa631 feat(ui): UI_SCALE_CHANGED event
Fires (no payload) each time the game applies the interface scale to
UIParent.

In 1.12 every scale change goes through one function, SetUiParentScale
(0x00494550, void __stdcall(float), ret 4). It raises the value to 0.64,
calls SetScale with force (0x0076AC10) and drains the layout queue
(0x00768ED0), and does nothing while UIParent (0x00B4B44C) is null. It
has six callers:
- the uiScale cvar callback (0x00490770, two sites)
- the useUiScale cvar callback (0x004907F0, two sites)
- OnDisplaySizeChanged (0x00492E90) when useUiScale is on
- the automatic scale ApplyAutoUiScale (0x00492F70)

One MinHook on it fires the custom event after the original returns,
only while UIParent is set. The game UI's Initialize stores UIParent
(0x00490077) and its Shutdown clears it (0x00490CA4), so the event never
fires on the glue screen. Each path calls the setter once, so one change
fires one event; a resize fires it before DISPLAY_SIZE_CHANGED.

Tested in game: useUiScale on, uiScale 0.8 and useUiScale off each fired
one event with the new scale (0.91, 0.8, the automatic 0.9).
2026-11-01 14:38:33 -06:00
Brues 9e5c0c558a feat(autocomplete): C_AutoComplete name completion (API only)
Adds C_AutoComplete.GetAutoCompleteResults, C_AutoComplete.IsRecognizedName,
Enum.AutoCompleteEntryFlag and Enum.AutoCompletePriority, and the cvars
autoCompleteResortNamesOnRecency, autoCompleteWhenEditingFromCenter and
autoCompleteUseContext. ClassicAPI only answers the queries; an addon draws
the list of names.

1.12.1 keeps no autocomplete list, but it keeps every list the names come
from, so each query reads them where the client keeps them:
- party: the four GUIDs at VAR_PARTY_GUIDS (0x00BC6F48), names from the
  name cache, online from the member stats block
- raid: the VAR_RAID_MEMBER_PTRS slots (0x00B712A8) and their online bit
- guild: the GuildMemberInfo roster at 0x00B72704, GUID at +0x00 and the
  online word at +0x48 (stored at 0x004D0C22, tested by GetGuildRosterInfo
  at 0x004D13C5). Read only while the player's guild key is set, the test
  IsInGuild (0x00516DE0) makes, because the client keeps the old roster
  after a /gquit
- friends: the 50 slots of the social singleton

Whisper partners are the one list the client does not keep. The chat
dispatch hook (FUN_0049A870) reports each whisper and whisper inform
(types 6 and 7, not the addon language -1) with the sender GUID it already
resolves. Logging out clears the list.

Names match case-insensitively by UTF-8 code point; Text::Utf8::CompareI
now takes a character limit. Results come most recent whisper first, then
by name. A result's priority is its closest tie: friend, guild, group,
whisper.

GetAutoCompletePresenceID and GetAutoCompleteRealms are constant answers in
Compat.lua. The three cvars are on a new Social page of the options panel.
2026-11-01 14:15:25 -06:00
Brues b3b950f2d8 feat(spell): spell alerts and proc glows (API only)
Adds C_SpellActivationOverlay.IsSpellOverlayed (and the global
IsSpellOverlayed), Enum.ScreenLocationType, and the four
SPELL_ACTIVATION_OVERLAY_* events. Nothing is drawn: an action-bar or
UI addon draws the alerts and the glows from these.

Glows come from the engine's own reactive-spell list. AddSpellToBook
puts every learned spell whose record has AttributesEx4 0x200 on the
list at 0x00B71150 (count 0x00B7114C), one 8-byte {spellID, usable}
entry per name. FireUsableEvents (0x004B31C0) retests the list with
the engine's usable check, stores each answer, then signals
SPELL_UPDATE_USABLE (0x107). The module subscribes to that signal
through Event::SignalHook and reads the stored answers, so the verdict
is the engine's (rage, target health, aura states, and the hidden
combo point behind Overpower) and no new hook is needed. A usable entry
glows every spellbook spell with the same name, which is the list's own
identity rule, so a bar slot with any rank glows. On stock 1.12.1 this
is Execute, Overpower, Revenge, Riposte, Counterattack, Mongoose Bite
and Hammer of Wrath; Turtle's Spell.dbc changes the set by itself.

Alerts come from two Blizzard rows whose aura exists unchanged in 1.12:
Shadow Trance (Nightfall art, glows Shadow Bolt through its class mask
via Spell::Lookup::IsFitToFamily) and druid Clearcasting
(Natures_Grace art). An Aura::Source observer reconciles on the
player's aura add/remove, rescanning the descriptor so counts can't
drift. The texture is a path; 1.12 has no file data IDs.

On world entry, HIDE fires with no spell ID and every active alert
fires SHOW again, so a /reload redraws them. Glows already lit before a
/reload do not re-fire; a button reads IsSpellOverlayed when it draws.
Logging out clears the state without events.

Event::EnteringWorld gains an observer list, because it claims
PLAYER_ENTERING_WORLD in the signal chain and later subscribers never
see it. Event::Custom's reservation cap goes from 96 to 128: the four
new events filled the old cap exactly, and a name past the cap is
dropped silently.
2026-11-01 13:35:12 -06:00
Brues 5dcf10a9fd fix(player): name cache keeps one copy of each player and honors renames
The persistent name cache copied every name reply into its own map, so
each player the engine named this session was held twice. The copy also
went stale. On a rename the server sends SMSG_INVALIDATE_PLAYER (0x31C)
and HandleNameInvalidate 0x00555600 frees the engine's record, but our
copy kept the old name, and GetPlayerInfoByGUID / UnitNameFromGUID fell
through to it.

The store now holds only the players the engine's name cache
(0x00C0E228) does not:

- A Net::PacketDispatch subscriber reads SMSG_NAME_QUERY_RESPONSE (0x51)
  in the same order as HandleNameQueryResponse 0x005551A0 and drops our
  entry just before the engine stores its record. This replaces the hook
  on NameCacheStore 0x0055F310. The backstop save runs before the drop,
  so no save misses the player.
- The same subscriber drops our entry on SMSG_INVALIDATE_PLAYER.
- The engine empties its cache only at world entry: NameCacheClear
  0x00560400, called from CGGameUI::InitializeGame through 0x00555740.
  A hook there copies the engine's records into the store first, while
  the store still holds the realm they came from.
- At exit the engine frees the cache from its atexit handler
  (NameCacheSave 0x0055EC70). Storm's exit handler 0x0064CC30 runs every
  atexit handler before ExitProcess, so DLL_PROCESS_DETACH was too late
  to read it. At the first world entry the clear hook registers our save
  with the exe's own _atexit (0x00409AEF). Handlers run newest first, so
  ours runs before the engine's. Detach now calls Shutdown, which never
  reads the engine's cache.
- A save writes our store, then the engine's records, so a load applies
  the engine's data over ours.
- C_PlayerCache.RememberPlayer leaves alone a player the engine holds.

One hook removed (0x0055F310), one added (0x00560400).
2026-11-01 13:16:56 -06:00
Brues 3ef6c4ffed fix(player): name cache keeps each player's realm and whole UTF-8 names
The persistent name cache cut names at 16 bytes. A Chinese character is
3 bytes in UTF-8, so a 6-character name lost part of its last
character. The limit is now the engine's own: the name cache store
(0x0055F310) copies the name with SStrCopy(dst, src, 0x30) and the
realm with SStrCopy(dst, src, 0x100), now PLAYER_INFO_NAME_SIZE and
PLAYER_INFO_REALM_SIZE in Offsets.h. A longer name from Lua is cut on
a UTF-8 lead byte, never inside a character.

Entries are now keyed by GUID, the engine name cache's own key, and
keep the record's server name (+0x30, empty for a player on this
realm). The by-name index uses the form the engine itself shows for a
player from another realm: ClientChatHandler (0x0049D560) builds the
sender as name, "-", and the realm with spaces and tabs removed. A
bare name stands for this realm. Player::FullName builds and parses
that form. Two players with one name from two realms no longer replace
each other, C_PlayerCache.GetPlayerInfoByName takes "Name-Realm" (a
chat sender name as it comes), and the persistent fallbacks of
GetPlayerInfoByGUID and UnitNameFromGUID return the stored realm. A
"Name-Realm" skips the by-name search and the friends list, which keep
no realm. The engine name cache walk moved from Unit::Identity into
Player::Info, its one caller.

The cache is one flat file per realm,
WTF\ClassicAPI\NameCache-<realm>@<realmList>.txt, in a v5 format split
on tabs (a realm can hold spaces): guid, name, realm, class, race, sex.
The v4 file under WTF\ClassicAPI\<realmList>\<realm>\ is migrated on
first load and deleted, with its emptied folders, once v5 is saved.
The old import from the per-account path is gone.

The store loaded once per process and saved to whatever realm was
current, so logging out into another realm wrote the first realm's
players into the second realm's file. It now records the file it
holds: every access compares that with the current realm's file, and
on a change saves the old realm's entries to their own file before
loading the new one. A failed rename leaves the cache dirty for the
next flush.

Verified on Octo: all 63,178 N'Zoth v4 entries migrated unchanged,
13 of 13 C'Thun entries migrated, the v4 folders were removed, and a
N'Zoth to C'Thun switch in one session kept the two files apart.

Fixes #119
2026-11-01 12:34:31 -06:00
Brues 1ef8c5597a fix(player): UnitNameFromGUID returns the name cache's realm
CGUnit_C::GetUnitName (0x00609210) takes a realm out-pointer as its
stack argument, not the flags word Offsets.h called it. For a player
whose name-cache record has a non-empty server name (record +0x30) it
stores a pointer to that string, and otherwise leaves the pointer alone
(0x0060926B-0x00609283). Vanilla's Script_UnitName (0x00517020) passes
&realm there and returns it as UnitName's second value. The record's
server name is the realm string SMSG_NAME_QUERY_RESPONSE carries:
Tortoise sends it empty, a cross-realm server fills it.

UnitNameFromGUID pushed a hard-coded "" instead. NameFromGuid now passes
the out-pointer through, as Script_UnitName does, and gains a step
between the friends list and the persistent cache: the engine name
cache record itself (FUN_PLAYER_INFO_LOOKUP with no callback, a passive
peek), which also gives the realm for players out of range. Before, a
player who whispered from far away resolved only with the persistent
cache on. UnitSpellTargetName shares NameFromGuid and finds those
players too. The realm is "" when the record has none, or when the name
came from the friends list or the persistent cache, which stores no
realm. Retail documents both returns as non-nil strings.

GetNameAndServerNameFromGUID in the addon's PlayerUtil returned
GetRealmName(), the local realm, for every GUID. It now returns
GetPlayerInfoByGUID's realm, as retail's UnitUtil does, so a player on
the local realm gets "" and code that appends "-" .. realm only when it
is non-empty works.

Seven files declared their own function-pointer type for 0x00609210,
all with the wrong int *outFlags argument. Unit::Identity::NameForUnit
is now the one wrapper. The other six callers pass no realm and behave
as before.

Offsets.h gives the getter's real signature (RET 4) and what it stores.
API.md's GetPlayerInfoByGUID and UnitNameFromGUID entries describe the
realm and the four sources.
2026-11-01 11:05:24 -06:00
Brues 9b0aa46ff0 fix(tts): a stopped utterance no longer finishes the next one
StopSpeakingText purges SAPI's queue, but the purge still queues
START/END events for the streams it cut off, and SAPI numbers streams
from 1 again afterwards. The next SpeakText then reused stream 1, so
the stale events were read as the new utterance's:
VOICE_CHAT_TTS_PLAYBACK_STARTED and _FINISHED fired for it before it
played, and its real end fired a FINISHED with utteranceID 0.

The purge is synchronous and has queued all of those events by the
time it returns, so StopAll now drains and discards them right after
it. That covers stop and speak in the same Lua call too, where the
stale START is still queued and a "require START first" check on the
END handler would not have been enough. An END for a stream we do not
track no longer fires FINISHED with utteranceID 0. Purged utterances
now signal nothing, which is retail's contract.

Verified with a standalone SAPI probe: speak/stop/speak, with and
without a message pump between the calls.
2026-11-01 00:00:27 -05:00
Brues 84171bef2d fix(unit): mirror timers clear on leaving the world, empty slots say UNKNOWN
The engine's handler for the three mirror-timer SMSGs (0x005E7990) only
reads each packet and signals MIRROR_TIMER_START / _PAUSE / _STOP, so
GetMirrorTimerInfo and GetMirrorTimerProgress read a table of our own.
A MinHook on that handler fed it by reading the packet and putting the
cursor back. The table now reads START (0x1D9) and STOP (0x1DB) as a
Net::PacketDispatch subscriber, and the hook is gone.

CGPlayer_C::Release (0x005E9AA0) runs StopMirrorTimers (0x005E9B90) for
the active player: MIRROR_TIMER_STOP for all three types, with no
packet. The table never saw it, so a breath timer stayed after a logout
or a loading screen. Release then calls LeaveWorldUI (0x00490A80),
which fires PLAYER_LEAVING_WORLD through the no-arg dispatcher. An
Event::SignalHook subscriber clears the table on that event.

What the functions return:

- GetMirrorTimerInfo returns an empty slot as "UNKNOWN" with zeros, the
  name helper 0x005E7AE0 gives for no type, instead of nothing. Code
  that tests for "UNKNOWN" on PLAYER_ENTERING_WORLD called its setup
  with nil before. paused is a number, as the event's %d is, and
  spellID is a seventh value.
- A PAUSE packet no longer changes the table. Tortoise never sends one:
  it resends START with the paused byte, which the table keeps.
- GetMirrorTimerProgress is value + scale * elapsed with no clamp. A
  timer started paused holds its value. Names match through SStrCmpI,
  and a name that is not a timer type returns nil.
- The time comes from Time::Clock. 0x0042B790 truncates the tick
  itself and returns it in EAX, so the GetTickCount workaround for an
  FPU return had no cause.

Offsets.h drops the CDataStore readers, the handler and the PAUSE
opcode, which nothing reads now, and its comment gives the verified
signatures and packet layout. Both functions get API descriptors under
the MirrorTimer system. API.md's section is rewritten to match.
2026-10-31 23:34:39 -05:00
Brues f19f13d44c feat(debugtools): fold pfDebug in as /profiler
pfDebug was a separate addon that profiled through ClassicAPI's own
APIs: GetAddOnMemoryUsage and GetAddOnCPUUsage, GetFrameCPUUsage,
GetFunctionCPUUsage, GetEventCPUUsage, and the intrinsic precall and
postcall script hooks. Its code now lives in ClassicAPI_DebugTools
under Profiler/, with its MIT notice (Copyright (c) 2018 Shagu). The
analyzer tabs (Addons, Frames, Events, Globals) and the widget are
pfDebug's, with the frame and template names renamed to
ClassicAPIProfiler*.

/profiler and /prof load DebugTools on demand through the same stub as
/dump, then toggle the analyzer. The subcommands widget, reset and
profile on|off are pfDebug's.

What changed in the move:

- The sampler starts only when the widget or the analyzer first opens,
  so a load for /dump costs nothing. It first takes every counter as it
  is, so the first CPU/s and KB/s cover one second, not the session.
- Whether profiling runs is probed, not read from the cvar: a function
  is called once and GetFunctionCPUUsage must count that call. The
  scriptProfile cvar can already hold the next session's value.
- A frame record starts at the frame's current CPU time and call count,
  and opening the analyzer takes every frame and event counter again,
  because the frame totals and the deep hooks keep counting while it is
  closed. pfDebug started records at 0, so the first scan and each
  reopen showed a frame's whole session as one second.
- The /fstack lines (owner, OnUpdate and OnEvent owner and CPU, all
  scripts, deep allocations) are added at the end of
  FrameStackTooltip_OnTooltipSetFrameStack, not by hooking
  OnTooltipSetFrameStack.
- No pfUI skin and no SavedVariables. The plain backdrop is pfDebug's
  own fallback. The widget no longer opens at login, because that would
  load DebugTools at login.

README's Debug tools table gains /profiler and the new /fstack lines.
2026-10-31 23:16:01 -05:00
Brues 4509da0966 build: pack each embedded addon into one gzip stream
Each embedded file was its own gzipped RCDATA resource, 175 in all.
Every stream started with an empty deflate window, so no file could
match text it shares with its neighbours, and 97 of the files are under
4 KB, where that costs the most. Each resource also cost about 50 bytes
of resource directory and padding.

cmake/pack_files.cmake, which replaces gzip_file.cmake, now joins each
addon's files at build time into one gzip stream. It holds an index,
each file's size as a decimal line and then an empty line, followed by
the files' bytes in path order. `cmake -E cat` joins them byte for
byte, in batches of 100 so the Windows command-line limit cannot fail
the build. The index goes through file(CONFIGURE NEWLINE_STYLE UNIX)
because file(WRITE) writes \r\n on Windows. The resources are now three:
Bindings.xml as a pack of one, !!!ClassicAPI, and ClassicAPI_DebugTools.

The manifest entries become {path, resourceId, index}, and
Embed::Resource(id) becomes Embed::File(id, index). On first use it
inflates the whole pack with the engine's zlib, splits it into views,
and keeps it for the life of the DLL, as before. A pack whose sizes do
not cover its bytes exactly is rejected. The load-on-demand DebugTools
pack is still inflated only when it loads.

Embedded data 323,947 -> 272,825 bytes; .rsrc 334,336 -> 274,432;
DLL 1,632,768 -> 1,573,888 bytes.
2026-10-31 23:06:40 -05:00
Brues 38d5c6c7b6 fix(unit): ClassicAPI's unit events reach markN and the soft tokens
ClassicAPI fires its own unit events (UNIT_AURA_DELTA, the remote
UNIT_SPELLCAST_*, the power, group-member, distance, incoming-resurrection
and targetable events) once per token through
Unit::Identity::TokensForGUID. That took the engine's tokens from its
reverse map FUN_UNIT_TOKENS_FROM_GUID (0x00515C50) and then added its own
short list: focus and nameplateN. The engine's unit events get their
ClassicAPI tokens from a second list in Unit::TokenEvents, which also has
markN, softenemy and softfriend. So a soft enemy got UNIT_HEALTH but never
UNIT_SPELLCAST_START.

That list is now the public Unit::TokenEvents::TokensOf, capped at the
caller's room and empty before the player object exists, as before.
TokensForGUID appends it to the engine's tokens, so both kinds of event
reach the same tokens, and a new token goes in one place. nameplateN now
comes before focus, the order TokensOf already used.

API.md's soft enemy and soft friend section now says that their tokens
fire unit events, as the focus, nameplateN and markN sections do.
2026-10-31 22:46:51 -05:00
Brues be5ebd4017 docs(unit): name softenemy and softfriend in the token comments
44f9bcd added softenemy and softfriend to Unit::TokenEvents, but the
comments that list its tokens still named only focus, nameplateN and
markN. They now name all five. The call-site mismatch log no longer
lists them at all: it says ClassicAPI tokens get no engine unit events.

The token resolver's header in TokenExtensions.cpp had drifted too. It
said markN unit events go through TokenObserver (TokenEvents fires them
now), spoke of "both" families where there are six, and gave a
resolution order of focus then nameplate. The order in the hook is raw
GUID, focus, the soft targets, nameplateN, markN. UnitCreatureID's
comment now points at that header instead of keeping its own list.

UnitTokenFromGUID's comment called the soft targets' place in retail's
order unverified. Retail's list puts them last, as the code does, and
has no markN, so the comment now says markN is never returned.
2026-10-31 22:37:48 -05:00
Brues 44f9bcd9af feat(unit): softenemy and softfriend fire unit events
Unit::TokenEvents already redirects the 38 broadcast and 4 combat
feedback call sites so a unit holding focus / nameplateN / markN gets
every engine unit event under that token. softenemy and softfriend join
that token list, read through SoftUnit::Current (the last pick, no scan)
because TokensOf runs on every unit event.

The registration bit (UNIT_SCRIPT_REG_TOKENS) is what makes the engine
watch a unit, so SoftUnit::Refresh re-runs FUN_UNIT_SCRIPT_RECOMPUTE
through TokenEvents::Refresh for the old and new guid before the
PLAYER_SOFT_*_CHANGED event fires. The frame tick now picks every frame
rather than only while that event has a listener: a UNIT_HEALTH listener
never registers for it, and the pick is cached per scene drain anyway.
2026-10-31 22:25:18 -05:00
Brues f9ec92014c feat(target): show softenemy and softfriend names as the target's
Retail shows a soft unit's overhead name the way it shows the target's:
a soft enemy past nameplate range keeps its name, whatever the UnitName
cvars say.

1.12 decides an overhead name in CGUnit_C::ShouldShowName (0x6070A0),
which its only caller, the name draw PLAYERNAMEDESC::UpdatePlate
(0x6C6EE2), asks every frame. In order: no name when CanSelect (vtable
+0x58) refuses the unit, or when it has a nameplate (+0xE60) or a chat
bubble (+0xE64); the active player by UnitNameOwn; the target always;
then the UnitName mask. A hook gives a soft unit the target's branch:
the first three tests, then 1. The player is never a soft unit, so the
own-unit test does not apply. In nameplate range the plate shows and
the name does not, as for the target.

The name draw runs inside the scene (the model draw callback 0x6C7780),
so the hook reads the soft GUIDs cached on the UI frame tick, as the
selection circle does, and never picks there. Nothing invalidates: the
verdict is asked fresh each draw.
2026-10-31 20:38:51 -05:00
373 changed files with 23853 additions and 6565 deletions
+1
View File
@@ -10,3 +10,4 @@
*.png binary
*.jpg binary
*.ico binary
*.blp binary
+10 -8
View File
@@ -76,20 +76,22 @@ endif()
add_library(${PROJECT_NAME} SHARED
${SOURCES} ${HEADERS} ${TINYCBOR_SOURCES})
# Embed the addons under `src/embed/AddOns/` and `src/bindings/Bindings.xml` as RCDATA
# resources. The file-read hook in `src/addons/Embedded.cpp` serves the
# addon files when the engine asks for paths under
# `Interface\AddOns\<addon>\`, so the addons load even when the user
# doesn't have them on disk. `src/bindings/Inject.cpp` splices the bindings
# XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time.
# Embed the addons under `src/embed/AddOns/`, `src/bindings/Bindings.xml` and
# the textures under `src/embed/Textures/` as RCDATA resources. The file-read
# hook in `src/addons/Embedded.cpp` serves the addon files when the engine
# asks for paths under `Interface\AddOns\<addon>\`, so the addons load even
# when the user doesn't have them on disk. `src/bindings/Inject.cpp` splices
# the bindings XML (our TARGETING-group additions) into the engine's
# `Interface\FrameXML\Bindings.xml` at load time. `src/texture/Builtin.cpp`
# serves each texture at its game path when no file has it.
include(cmake/embed_resources.cmake)
embed_resources(
TARGET ${PROJECT_NAME}
ADDONS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/src/embed/AddOns"
ADDONS "!!!ClassicAPI" "ClassicAPI_DebugTools"
ADDONS "!!!ClassicAPI" "ClassicAPI_DebugTools" "ClassicAPI_EventTrace"
ADDON_VERSION "${CLASSICAPI_TOC_VERSION}"
BINDINGS_XML "${CMAKE_CURRENT_SOURCE_DIR}/src/bindings/Bindings.xml"
TEXTURES_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/src/embed/Textures"
OUT_DIR "${CMAKE_BINARY_DIR}/generated")
target_compile_definitions(
+61 -26
View File
@@ -23,7 +23,7 @@ reference is in **[docs/API.md](docs/API.md)**.
| [Soft targeting](docs/API.md#soft-interact-softinteract--anyinteract) | The Interact Key and soft targets. Bind **Interact With Target** to a key to interact with the nearest NPC, corpse, mailbox, chest or gathering node in front of you. The [`softenemy` and `softfriend`](docs/API.md#soft-enemy-and-soft-friend-softenemy--softfriend--anyenemy--anyfriend) tokens point at the nearest enemy and friendly unit without a change of target. With no target, harmful spells go to `softenemy` and helpful spells go to `softfriend`. `PLAYER_SOFT_*_CHANGED` events fire when the units change. |
| [Animations](docs/API.md#animation) | Animation groups on frames, textures and font strings, made in Lua or XML: `CreateAnimationGroup`, looping, smoothing, scripts and templates, with `Alpha`, `Translation`, `Scale`, `Rotation` and `Path` applied. |
| Tooltip line cap | Removes `GameTooltip`'s 30-line limit for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). A tooltip adds a new line pair when it needs one, so long tooltips and comparison blocks (for example, pfUI's eqcompare) keep all their lines. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, `<OnLoad method="Name"/>` uses the frame's own function, `mixin="A, B"` copies mixins onto the object, `<KeyValues>` sets fields on the object, a button's `<NormalFont style="..."/>` sets its font, and `inherits="A, B"` applies more than one template. `parentArray="Name"` adds the object to a list on its parent. An `<Anchor>` takes `x` and `y` offsets and `relativeKey="$parent.Key"`. `<OnEnter inherit="prepend">` runs the template's handler and then its own, and `inherit="append"` runs them in the other order. |
| [XML attributes](docs/API.md#parentkey-in-xml) | More XML attributes work: `parentKey="Name"` makes `parent.Name` point to a frame, texture or font string, `<OnLoad function="Name"/>` uses a global function as a script handler, `<OnLoad method="Name"/>` uses the frame's own function, `mixin="A, B"` copies mixins onto the object, `<KeyValues>` sets fields on the object, a button's `<NormalFont style="..."/>` sets its font, and `inherits="A, B"` applies more than one template. `parentArray="Name"` adds the object to a list on its parent. An `<Anchor>` takes `x` and `y` offsets and `relativeKey="$parent.Key"`. `<OnEnter inherit="prepend">` runs the template's handler and then its own, and `inherit="append"` runs them in the other order. A frame with `intrinsic="true"` makes a new frame type. Frames take `scale`, `registerForDrag`, `clipChildren`, `useParentLevel`, `dontSavePosition`, `propagateKeyboardInput`, `propagateMouseInput`, `enableMouseClicks`, `enableMouseMotion` and `motionScriptsWhileDisabled`, and buttons take `registerForClicks`. Textures take `alpha`, `atlas`, `useAtlasSize`, `mask`, `desaturated`, `rotation`, `horizTile`, `vertTile`, `hWrapMode` and `vWrapMode`. Font strings take `alpha`, `wordwrap`, `indented`, `degrees` and `radians`. Status bars take `reverseFill`, `rotatesTexture` and `fillStyle`, and edit boxes take `countInvisibleLetters`. |
| [Macro conditions](docs/API.md#more-commands-with-conditions) | `[conditions]` and `@unit` work on 38 slash commands, from `/cast` and `/use` to `/target`, `/follow`, `/cancelaura` and the `/pet` family. With `/cast [@mouseover,harm][] Fireball`, the spell goes to the mouseover unit. With no mouseover unit, it goes to your target. `@cursor` places a ground-target spell or item under the mouse and `@player` places it at your feet. A character name works where a unit token does (`[target=Feral]`). Addons read the same rules through `SecureCmdOptionParse`. |
| [`#showtooltip`](docs/API.md#showtooltip-and-show) | A macro button shows the spell or item that its directive resolves to, conditions included: icon, tooltip, cooldown, range, usable state and auto-repeat glow. Action-bar addons get this with no code of their own. Addons with their own macro parser can publish through `C_Macro.SetMacroDisplay`. |
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
@@ -51,11 +51,12 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `HasBonusActionBar` |
| [Action](docs/API.md#action) | `GetActionInfo`, `IsItemAction`, `IsStackableAction`, `HasBonusActionBar`, `C_ActionBar.EnableActionRangeCheck`, `C_ActionBar.GetSpell`, `C_ActionBar.GetItemActionOnEquipSpellID`, `C_ActionBar.IsStackableAction`, `C_ActionBar.IsMacroActionWithShowTooltip`, `C_ActionBar.FindSpellActionButtons`, `C_ActionBar.HasSpellActionButtons`, `C_ActionBar.IsOnBarOrSpecialBar` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnInterfaceVersion`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
| [APIDocumentation](docs/API.md#apidocumentation) | `/classicapi` in-game browser, `C_APIDocumentation.GetSystem`, `C_APIDocumentation.GetSystems` |
| [Animation](docs/API.md#animation) | `region:CreateAnimationGroup`, `region:GetAnimationGroups`, `region:StopAnimating`, `SetMaxAnimFramerate`, `GetMaxAnimFramerate`, `AnimationGroup` and `Animation` / `Alpha` / `Translation` / `Scale` / `Rotation` / `Path` / `ControlPoint` methods |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem`, `CanCancelAuction` |
| [AutoComplete](docs/API.md#autocomplete) | `C_AutoComplete.GetAutoCompleteResults`, `C_AutoComplete.IsRecognizedName`, `C_AutoComplete.GetAutoCompletePresenceID`, `C_AutoComplete.GetAutoCompleteRealms` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings`, `GetBindingAction` (`checkOverride`), `GetBindingByKey`, `IsModifiedClick`, `GetModifiedClick`, `SetModifiedClick`, `GetNumModifiedClickActions`, `GetModifiedClickAction` |
| [Chat](docs/API.md#chat) | `GetCurrentChatEvent`, `GetCurrentChatGUID`, `IsChatAFK`, `IsChatDND`, `SendSystemMessage` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
@@ -72,18 +73,19 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Currency](docs/API.md#currency) | `GetCoinText`, `C_CurrencyInfo.GetCoinText`, `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString`, `Constants.MoneyFormattingConstants` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [DeathInfo](docs/API.md#deathinfo) | `C_DeathInfo.GetSelfResurrectOptions`, `C_DeathInfo.UseSelfResurrectOption` |
| [DurationUtil](docs/API.md#durationutil) | `C_DurationUtil.CreateDuration`, `C_DurationUtil.CreateDurationTextBinding` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.PickupEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetAccountExpansionLevel`, `GetClassicExpansionLevel`, `GetExpansionLevel`, `GetMaxPlayerLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `CollapseAllFactionHeaders`, `ExpandAllFactionHeaders`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame: Region](docs/API.md#regionsetpointpoint-one-argument-form) | `:SetPoint("point")` (one-arg form), `:SetSize` / `:GetSize`, `ignoreRect` on `:GetSize` / `:GetWidth` / `:GetHeight`, `:GetRect`, `:GetScaledRect`, `:IsRectValid`, `:GetPointByName`, `:ClearPoint`, `:GetScale` / `:GetEffectiveScale` (on textures and font strings), `:GetDebugName`, `:IsMouseOver`, `:IsDragging`, `:IsProtected` / `:CanChangeProtectedState` |
| [Frame: Region](docs/API.md#regionsetpointpoint-one-argument-form) | `:SetPoint("point")` (one-arg form), `:SetSize` / `:GetSize`, `ignoreRect` on `:GetSize` / `:GetWidth` / `:GetHeight`, `:GetUnscaledWidth` / `:GetUnscaledHeight` / `:GetUnscaledSize`, `:GetRect`, `:GetScaledRect`, `:IsRectValid`, `:GetPointByName`, `:ClearPoint`, `:GetScale` / `:GetEffectiveScale` (on textures and font strings), `:GetDebugName`, `:IsMouseOver`, `:IsDragging`, `:IsProtected` / `:CanChangeProtectedState` |
| [Frame: Frame](docs/API.md#createframeframetype--name-parent-template-id) | `CreateFrame` template lists and `id`, `GetMouseFoci`, `:SetShown`, `:SetClampRectInsets` / `:GetClampRectInsets`, `:SetResizeBounds`, `:SetClipsChildren` / `:DoesClipChildren`, `:GetBoundsRect`, `:GetEffectiveAlpha`, `:SetAlphaGradient` / `:ClearAlphaGradient` / `:HasAlphaGradient`, `:DesaturateHierarchy`, `:HookScript`, `:IsEventRegistered`, `:RegisterUnitEvent`, `:SetPropagateKeyboardInput` / `:GetPropagateKeyboardInput`, `:SetDontSavePosition` / `:GetDontSavePosition`, `:SetAttribute`, `:SetAttributeNoHandler`, `:ClearAttribute`, `:GetAttribute`, `:CanChangeAttribute`, `:AllowAttributeChanges` |
| [Frame: Button](docs/API.md#buttonsetnormalfontobjectfont--buttongetnormalfontobject) | `:SetEnabled`, `:SetFormattedText`, `:SetNormalFontObject` / `:GetNormalFontObject`, `:SetMotionScriptsWhileDisabled` / `:GetMotionScriptsWhileDisabled`, `:RegisterForClicks("AnyUp"/"AnyDown")`, `:RegisterForMouse`, `GetClickFrame` |
| [Frame: FontString](docs/API.md#fontstringgetstringheight) | `:GetStringHeight`, `:GetUnboundedStringWidth`, `:GetWrappedWidth`, `:GetNumLines`, `:GetLineHeight`, `:GetFontHeight` / `:SetFontHeight` (also on fonts), `:GetFieldSize`, `:IsTruncated`, `:SetMaxLines` / `:GetMaxLines`, `:SetWordWrap` / `:CanWordWrap`, `:SetIndentedWordWrap` / `:GetIndentedWordWrap` (also on fonts, edit boxes, message frames, and SimpleHTML), `:SetFormattedText`, `:SetRotation` / `:GetRotation` |
| [Frame: Texture](docs/API.md#texturesetrotationangle--cx-cy) | `:SetRotation` / `:GetRotation`, `:SetVertexOffset` / `:GetVertexOffset`, `:SetColorTexture`, `:SetAtlas` / `:GetAtlas`, `:ResetTexCoord`, `:SetSpriteSheetCell`, `:SetDesaturation` / `:GetDesaturation`, `IsDesaturateSupported`, `:SetMask`, `frame:CreateMaskTexture`, `:AddMaskTexture` / `:RemoveMaskTexture`, `:GetNumMaskTextures`, `:GetMaskTexture` |
| [Frame: Texture](docs/API.md#texturesetrotationangle--cx-cy) | `:SetRotation` / `:GetRotation`, `:SetVertexOffset` / `:GetVertexOffset`, `:SetColorTexture`, `:SetAtlas` / `:GetAtlas`, `:ResetTexCoord`, `:SetSpriteSheetCell`, `:SetTexture` wrap modes, `:SetHorizTile` / `:GetHorizTile`, `:SetVertTile` / `:GetVertTile`, `: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: StatusBar](docs/API.md#statusbarsetreversefillisreversefill--statusbargetreversefill) | `:SetReverseFill` / `:GetReverseFill`, `:SetRotatesTexture` / `:GetRotatesTexture`, `:SetFillStyle` / `:GetFillStyle`, `:SetColorFill`, `:SetStatusBarDesaturated` / `:IsStatusBarDesaturated`, `:SetStatusBarDesaturation` / `:GetStatusBarDesaturation`, `:SetTimerDuration` / `:GetTimerDuration`, `:SetValue` / `:SetMinMaxValues` with `interpolation`, `:GetInterpolatedValue`, `:IsInterpolating`, `:SetToTargetValue` |
| [Frame: Slider](docs/API.md#sliderenable--sliderdisable--sliderisenabled) | `:Enable` / `:Disable` / `:IsEnabled` |
| [Frame: Cooldown](docs/API.md#createframecooldown--name-parent-template) | `CreateFrame("Cooldown")`, `:SetCooldown`, `:SetCooldownDuration`, `:SetCooldownFromExpirationTime`, `:Clear`, `:GetCooldownTimes`, `:GetCooldownDuration`, `:GetCooldownDisplayDuration`, `:Pause` / `:Resume`, `:SetPaused` / `:IsPaused`, `:SetReverse` / `:GetReverse`, `:SetDrawEdge` / `:GetDrawEdge`, `:SetDrawSwipe` / `:GetDrawSwipe`, `:SetDrawBling` / `:GetDrawBling`, `:SetSwipeColor`, `:SetEdgeColor`, `:SetSwipeTexture`, `:SetEdgeTexture`, `:SetBlingTexture`, `:SetRotation` / `:GetRotation`, `:SetHideCountdownNumbers` / `:GetHideCountdownNumbers`, `:GetCountdownFontString`, `:SetCountdownAbbrevThreshold` / `:GetCountdownAbbrevThreshold`, `:SetCountdownMillisecondsThreshold` / `:GetCountdownMillisecondsThreshold`, `:SetMinimumCountdownDuration` / `:GetMinimumCountdownDuration`, `OnCooldownDone`, `CooldownFrame_Set`, `CooldownFrame_Clear`, `CooldownFrame_SetDisplayAsPercentage` |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled`, `:GetMessageInfo` (with the extra `:AddMessage` values), `:BackFillMessage` (adds a message as the oldest), `:RemoveMessagesByPredicate`, `:SetScrollOffset` / `:GetScrollOffset`, `:GetMaxScrollRange`, `:ScrollByAmount`, `:SetInsertMode` / `:GetInsertMode`, `:AddOnDisplayRefreshedCallback` |
@@ -104,13 +106,13 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [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`, `strcmputf8i`, `strconcat`, `string.concat`, `string.gmatch`, `string.gsub` (table replacement), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strlenutf8`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strsplittable`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args), `wipe`, `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`, `debuglocals`, `debugstack` (coroutine argument), `difftime`, `GetCallstackHeight`, `GetErrorCallstackHeight`, `gmatch`, `math.cosh`, `math.fmod`, `math.huge`, `math.modf`, `math.sinh`, `math.tanh`, `Mixin`, `newproxy`, `scrub`, `select`, `SetErrorCallstackHeight`, `strbyte` (range args), `strcmputf8i`, `strconcat`, `string.byte` (range args), `string.concat`, `string.format` (two-digit `%n$`), `string.gmatch`, `string.gsub` (table replacement, `%0`), `string.join`, `string.match`, `string.reverse`, `string.split`, `string.trim`, `strjoin`, `strlenutf8`, `strmatch`, `strreplace`, `strrev`, `strsplit`, `strsplittable`, `strtrim`, `table.count`, `table.maxn`, `table.removemulti`, `table.wipe`, `unpack` (range args), `wipe`, `xpcall` (argument forwarding) |
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `C_Macro.SetMacroDisplay`, `DeleteMacro` / `EditMacro` / `GetMacroInfo` / `PickupMacro` by name, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroBody`, `GetMacroIcons`, `GetMacroItem`, `GetMacroItemIcons`, `GetMacroSpell`, `GetRunningMacro`, `GetRunningMacroButton`, `RunMacroText`, `SetMacroItem`, `SetMacroSpell`, `StopMacro` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredAreaIDsAtPosition`, `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems`, `GetBuybackItemLink` |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names), `DressUpModel:UndressSlot`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature`, `PlayerModel:SetDisplayInfo`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`) |
| [Model](docs/API.md#model) | `DressUpModel:TryOn` (links, recipes, names, hand, enchant glow, result), `DressUpModel:UndressSlot`, `DressUpModel:IsGeoReady`, `DressUpModel:SetSheathed` / `GetSheathed`, `DressUpModel:SetAutoDress` / `GetAutoDress`, `Model:SetGlow`, `PlayerModel:RefreshCamera`, `PlayerModel:SetCamDistanceScale`, `PlayerModel:SetCreature` (`displayID`), `PlayerModel:SetDisplayInfo`, `PlayerModel:SetItem`, `PlayerModel:SetPortraitZoom`, `PlayerModel:SetRotation` (`animate`), `PlayerModel:SetUnit` (result), `PlayerModel:CanSetUnit`, `PlayerModel:GetDisplayInfo`, `PlayerModel:SetAnimation`, `PlayerModel:FreezeAnimation`, `PlayerModel:HasAnimation`, `PlayerModel:SetDoBlend` / `GetDoBlend`, `PlayerModel:SetKeepModelOnHide` / `GetKeepModelOnHide`, `PlayerModel:ApplySpellVisualKit` |
| [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.RememberPlayer`, `C_PlayerCache.SetEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
@@ -120,7 +122,8 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Profiling](docs/API.md#profiling) | `GetAddOnCPUUsage`, `GetAddOnMemoryUsage`, `GetEventCPUUsage`, `GetFrameCPUUsage`, `GetFunctionCPUUsage`, `GetScriptCPUUsage`, `ResetCPUUsage`, `UpdateAddOnCPUUsage`, `UpdateAddOnMemoryUsage` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetInfo`, `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestLogTitle`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogCompletionText`, `GetQuestLogLeaderBoardID` (Turtle WoW only: `C_QuestLog.GetAllCompletedQuestIDs`, `C_QuestLog.IsQuestFlaggedCompleted`, `GetQuestsCompleted`, `QueryQuestsCompleted`) |
| [Sound](docs/API.md#sound) | `C_Sound.GetRecentSoundFiles`, `C_Sound.GetSoundScaledVolume`, `C_Sound.IsPlaying`, `C_Sound.PlayItemSound`, `C_Sound.PlaySound`, `C_Sound.PlaySoundWithOptions`, `C_Sound.PlayVocalErrorSound`, `MuteSoundFile`, `PlaySound`, `PlaySoundFile`, `StopSound`, `UnmuteSoundFile` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CancelUnitBuff`, `CastSpellByID`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetCritChance`, `GetRangedCritChance`, `GetSpellBaseCooldown`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsAttackSpell`, `IsAutoRepeatSpell`, `IsConsumableSpell`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCastCount`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectInfo`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellLossOfControlCooldown`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CancelUnitBuff`, `CastSpellByID`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetCritChance`, `GetRangedCritChance`, `GetSpellBaseCooldown`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsAttackSpell`, `IsAutoRepeatSpell`, `IsConsumableSpell`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange`, `UnitCastingDuration`, `UnitChannelDuration` |
| [SpellActivationOverlay](docs/API.md#spellactivationoverlay) | `C_SpellActivationOverlay.IsSpellOverlayed`, `IsSpellOverlayed` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.ContainsAnyDisenchantSpell`, `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetNumSpellBookSkillLines`, `C_SpellBook.GetPlayerSpellsByAura`, `C_SpellBook.GetSkillLineIndexByID`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemCastCount`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownDuration`, `C_SpellBook.GetSpellBookItemLossOfControlCooldownInfo`, `C_SpellBook.GetSpellBookItemSkillLineIndex`, `C_SpellBook.GetSpellBookSkillLineInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsClassTalentSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftForm`, `GetShapeshiftFormID`, `GetSheathState`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsLoggingOut`, `IsMounted`, `IsOutdoors`, `IsPlayerInWorld`, `IsStealthed`, `IsSwimming` |
| [SwingTimer](docs/API.md#swingtimer) | `C_SwingTimer.EnableRangeCheck`, `C_SwingTimer.IsTargetWithinSwingRange` |
@@ -128,7 +131,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetDirectionFinished`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer`, `InteractUnit`, `C_PlayerInteractionManager.InteractUnit`, `IsTargetLoose` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas`, `SetPortraitTextureFromCreatureDisplayID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTickTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
@@ -208,21 +211,25 @@ for what each command does, and
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
these settings and the player cache in the game, open the game menu and
click AddOn Options, then ClassicAPI.
these settings, the player cache and the ClassicAPI key bindings 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. |
| [Combat log](docs/API.md#kept-entries) | `combatLogRetentionTime` | `300` | The number of seconds that the combat log keeps an entry. |
| [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. |
| [Stand, dismount and form](docs/API.md#stand-dismount-and-form-settings-autostand-autodismount-autounshift) | `autoStand` | `0` | `1` makes you stand up when you cast, loot or open the flight map while you sit. |
| | `autoDismount` | `0` | `1` makes you dismount when you cast, use an object, loot, attack or open the flight map while you are mounted. |
| | `autoUnshift` | `0` | `1` makes you leave your form when you cast a spell that cannot be cast in it, use an item, or open the flight map. It also lets you talk to NPCs and use objects in druid forms and Ghost Wolf. |
| [Bag sorting](docs/API.md#c_containergetsortbagsrighttoleft--c_containersetsortbagsrighttoleftenable) | `sortBagsRightToLeft` | `0` | `1` makes a sort fill your bags from the last slot of the last bag. |
| | `backpackAutosortDisabled` | `0` | `1` keeps the items in your backpack in place when you sort your bags. |
| | `bankAutosortDisabled` | `0` | `1` keeps the items in the main bank slots in place when you sort your bank. |
| [Expanded cursors](docs/API.md#expanded-cursors-expandedcursors) | `expandedCursors` | `0` | `1` makes the cursor show what you can do with an NPC or an object: an anvil for repairs, a hearthstone for an inn, a horseshoe for a stable, `!` or `?` for quests, and a fish hook for your fishing bobber. |
| [Equipment sets](docs/API.md#equipment-sets-on-item-tooltips) | `dontShowEquipmentSetsOnItems` | `0` | `1` removes the Equipment Sets line from item tooltips. |
| [Name completion](docs/API.md#name-completion-settings) | `autoCompleteResortNamesOnRecency` | `1` | `1` puts the players that you whispered first in `C_AutoComplete` results, the most recent first. `0` uses alphabetical order. |
| | `autoCompleteWhenEditingFromCenter` | `1` | `1` also matches the text after the cursor when you type inside a name. |
| | `autoCompleteUseContext` | `1` | `0` makes `C_AutoComplete` ignore `includeFlags` and `excludeFlags`. |
| [Profiling](docs/API.md#scriptprofile) | `scriptProfile` | `0` | `1` turns on CPU profiling for the `*CPUUsage` functions. The change starts after `/reload`. |
| [Soft interact](docs/API.md#soft-interact-softinteract--anyinteract) | `SoftTargetInteract` | `3` | Turns `softinteract` on: `2` keyboard and mouse, `3` always, `0` / `1` off. |
| | `SoftTargetInteractArc` | `0` | Where the object can be: `0` straight ahead of the camera, `1` within 30° of it, `2` any direction. |
@@ -274,6 +281,8 @@ when launching with `-console`), not as Lua functions. See the
| Event | Payload |
|-------|---------|
| `ACTION_RANGE_CHECK_UPDATE` | `slot, isInRange, checksRange` (slots with `C_ActionBar.EnableActionRangeCheck` on) |
| `ACTION_USABLE_CHANGED` | `changes` (a `{ slot, usable, noMana }` table for each slot that changed) |
| `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)) |
@@ -325,6 +334,11 @@ when launching with `-console`), not as Lua functions. See the
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `SELF_RES_SPELL_CHANGED` | *(none)* |
| `SOUNDKIT_FINISHED` | `soundHandle` (only for sounds played with `runFinishCallback`) |
| `SPELL_ACTIVATION_OVERLAY_SHOW` | `spellID, texture, locationType, scale, r, g, b` ([details](docs/API.md#spell_activation_overlay_show--spell_activation_overlay_hide-events)) |
| `SPELL_ACTIVATION_OVERLAY_HIDE` | `spellID` (`nil` hides every alert) |
| `SPELL_ACTIVATION_OVERLAY_GLOW_SHOW` | `spellID` ([details](docs/API.md#spell_activation_overlay_glow_show--spell_activation_overlay_glow_hide-events)) |
| `SPELL_ACTIVATION_OVERLAY_GLOW_HIDE` | `spellID` |
| `UI_SCALE_CHANGED` | *(none)* |
| `UNIT_AREA_CHANGED` | `unitTarget` |
| `UNIT_AURA_DELTA` | `unitTarget, updateInfo` (the instance IDs of the auras added, updated, and removed) |
| `UNIT_CONNECTION` | `unitTarget, isConnected` |
@@ -381,7 +395,10 @@ when launching with `-console`), not as Lua functions. See the
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
| Player swing type | `Enum.PlayerSwingType.{MainHand,OffHand,Ranged}` (0–2) |
| Screen location type | `Enum.ScreenLocationType.{Center,Left,Right,Top,Bottom,TopLeft,TopRight,LeftOutside,RightOutside,LeftRight,TopBottom,LeftRightOutside}` (0–11) |
| Self-resurrect option type | `Enum.SelfResurrectOptionType.{Spell,Item}` (0–1) |
| Autocomplete entry flag | `Enum.AutoCompleteEntryFlag.{InGroup,InGuild,Friend,Bnet,InteractedWith,Online,InAOI,AccountCharacter,RecentPlayer}` (bits 1–256) |
| Autocomplete priority | `Enum.AutoCompletePriority.{Other,Interacted,InGroup,Guild,Friend,AccountCharacter,AccountCharacterSameRealm}` (0–6) |
</details>
@@ -437,10 +454,10 @@ Download the prebuilt `ClassicAPI.dll` from the
3. Add `ClassicAPI.dll` to `dlls.txt`.
4. Launch the game with `VanillaFixes.exe`.
The `!!!ClassicAPI` and `ClassicAPI_DebugTools` addons are *inside* the
DLL. You do not download or install them. The `src/embed/AddOns/` folder
in this repository is source code for the DLL. Do not copy it into your
game.
The `!!!ClassicAPI`, `ClassicAPI_DebugTools` and `ClassicAPI_EventTrace`
addons are *inside* the DLL. You do not download or install them. The
`src/embed/AddOns/` folder in this repository is source code for the DLL.
Do not copy it into your game.
## Bundled addon: !!!ClassicAPI
@@ -485,22 +502,40 @@ without a DLL rebuild. Without this file, the game uses the copy with the
newer `## Version:`. The copy is hidden and always enabled, the same as
the embedded addon.
## Lua errors
ClassicAPI replaces the game's Lua error box with a Lua error window. The
window opens when an error occurs. It shows the message, the time, the count,
the stack and the local variables of each error. You can select and copy the
text. The arrow buttons show the previous and the next error. The
**Reload UI** button reloads the interface.
If you use `!BugGrabber` (with BugSack), BugGrabber gets the errors and the
window does not open. If an addon sets its own error handler, the window also
does not open.
| Command | Purpose |
|---------|---------|
| `/luaerrors` (or `/scripterrors`) | Opens or closes the Lua error window. |
| `/luaerrors clear` | Removes all errors from the window. |
## Debug tools
ClassicAPI contains a backport of Blizzard's `Blizzard_DebugTools` addon,
with the name `ClassicAPI_DebugTools`. The DLL contains this addon, so you do
not install it. The addon is not in the AddOns list, and you cannot disable it.
ClassicAPI contains backports of two Blizzard addons: `Blizzard_DebugTools`,
with the name `ClassicAPI_DebugTools`, and `Blizzard_EventTrace`, with the
name `ClassicAPI_EventTrace`. The DLL contains these addons, so you do not
install them. They are not in the AddOns list, and you cannot disable them.
The slash commands are always available. The tools load the first time
that you use one of these commands:
The slash commands are always available. Each addon loads the first time
that you use one of its commands:
| Command | Purpose |
|---------|---------|
| `/dump <expr>` | Shows a Lua value in the chat frame. It shows tables and all return values. Use it to examine the return values of `GetSpellInfo`, `C_Item.GetItemInfoInstant`, and other functions. |
| `/etrace` | Opens the event trace window. Other forms: `/etrace start`, `/etrace stop`, `/etrace add EVENT`, and more. |
| `/framestack [showHidden [showRegions]]` (or `/fstack`) | Shows a tooltip with a list of the frames under the cursor. A `-->` mark shows one frame, with its anchors and the file and line that made it. Left Alt and Right Alt move the mark. Ctrl opens the marked frame in the table inspector. Shift shows its texture files. |
| `/etrace` (or `/eventtrace`) | Opens or closes the event trace window. The window logs each event with its time and its arguments. Move the mouse over an event to see its arguments, and the contents of each table argument. You can search the log, hide events with the filter, and pause the log. `/etrace mark <text>` adds a line of text to the log. |
| `/framestack [showHidden [showRegions]]` (or `/fstack`) | Shows a tooltip with a list of the frames under the cursor. A `-->` mark shows one frame, with its anchors and the file and line that made it. Left Alt and Right Alt move the mark. Ctrl opens the marked frame in the table inspector. Shift shows its texture files. The tooltip also shows the addon that made the marked frame. When `scriptProfile` is `1`, it shows the CPU time of the frame scripts. |
| `/tinspect [expr]` (or `/tins`) | Opens a table inspector on the result of `expr`. With no table, it opens on the frame under the cursor. The window shows the anchors and the fields of the table, grouped by type. You can edit numbers, strings and booleans, filter the lines, and open a child table or frame. |
| `/luaerrors` (or `/scripterrors`) | Opens the Lua error window. |
| `/profiler` (or `/prof`) | Opens the profiler. The profiler shows the Lua memory, the allocations and the CPU time of each addon, frame, event and global function. `/profiler widget` shows a small window with the Lua memory, the frame rate and the addons that use the most. `/profiler reset` sets the counters to zero. CPU times need `scriptProfile` `1`. `/profiler profile on` sets it and reloads the UI. |
## Building
+111 -54
View File
@@ -1,10 +1,10 @@
# embed_resources.cmake — embed the addons and the bindings XML as Win32
# RCDATA resources.
# embed_resources.cmake — embed the addons, the bindings XML and the
# built-in textures as Win32 RCDATA resources.
#
# Runs at configure time. It writes two files into OUT_DIR:
#
# embedded.rc one `<id> RCDATA "<path>"` line per file; rc.exe
# reads each file's gzipped copy from disk.
# embedded.rc one `<id> RCDATA "<path>"` line per pack; rc.exe
# reads each gzipped pack from disk.
# embedded_resources.h the IDs the DLL looks up at runtime through
# src/embed/Resource.h:
#
@@ -12,14 +12,31 @@
# namespace Addons::Embedded::Manifest {
# constexpr const char *kAddons[] = { ... }; // in ADDONS order
# constexpr size_t kAddonCount = ...;
# struct File { const char *path; int resourceId; };
# struct File { const char *path; int resourceId; int index; };
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
# namespace Texture::Builtin::Manifest {
# struct File { const char *path; int resourceId; int index; };
# constexpr File kFiles[] = { ... };
# constexpr size_t kFileCount = ...;
# }
#
# File paths are relative to ADDONS_ROOT, in backslash form
# Each resource is a pack: one gzip stream of several files, which
# pack_files.cmake joins at build time into `OUT_DIR/embed/<name>.pack.gz`.
# Resource 1 is the bindings XML, a pack of one; then each addon is one
# pack, in ADDONS order; then the textures, one pack. A pack compresses each
# file against the files before it, so the addon text packs to about 22% of
# its size, against 27% for one gzip stream per file. The DLL inflates a
# whole pack with the engine's zlib on first use (src/embed/Resource.cpp),
# so the load-on-demand addon costs nothing until it loads.
#
# Addon file paths are relative to ADDONS_ROOT, in backslash form
# (`!!!ClassicAPI\Util\Print.lua`) — the suffix the engine builds after
# `Interface\AddOns\`. The DLL compares them ignoring case.
# `Interface\AddOns\`. Texture paths are relative to TEXTURES_ROOT, where
# each file sits at its game path (`Interface\Cursor\Quest.blp`), for
# src/texture/Builtin.cpp. The DLL compares both ignoring case. A file's
# index is its place in its pack, which is in path order.
#
# Each addon's `.toc` is the one file that is not embedded verbatim: its
# `## Version:` line is stamped with ADDON_VERSION into a copy under
@@ -28,30 +45,38 @@
# never embedded: it must reflect on-disk presence only (see
# DiskHasDevMarker in src/addons/Embedded.cpp).
#
# Every resource is gzipped at build time (gzip_file.cmake) into
# `OUT_DIR/embed/<path>.gz`, and the DLL inflates it with the engine's zlib on
# first use (src/embed/Resource.cpp). The addon text compresses to about a
# quarter of its size.
#
# Rebuild triggers: adding or removing a file reconfigures through the
# CONFIGURE_DEPENDS glob; editing a toc reconfigures through
# CMAKE_CONFIGURE_DEPENDS; editing any other file re-gzips it through its
# custom command, which recompiles the .rc through its OBJECT_DEPENDS.
# CMAKE_CONFIGURE_DEPENDS; editing any other file re-packs its addon through
# the pack's custom command, which recompiles the .rc through its
# OBJECT_DEPENDS.
#
# Usage:
# embed_resources(TARGET <t> ADDONS_ROOT <dir> ADDONS <folder names...>
# ADDON_VERSION <v> BINDINGS_XML <file> OUT_DIR <dir>)
# ADDON_VERSION <v> BINDINGS_XML <file>
# TEXTURES_ROOT <dir> OUT_DIR <dir>)
set(_EMBED_GZIP_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/gzip_file.cmake")
set(_EMBED_PACK_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/pack_files.cmake")
# Add the build-time command that gzips `src` into `gz`.
function(_embed_gzip src gz)
# Add the build-time command that packs `ARGN` (absolute paths, in pack
# order) into `<out_dir>/embed/<name>.pack.gz`, and set `gz` in the caller
# to that path. The paths reach the script through a generated list file:
# an addon's paths are too long for one command line.
function(_embed_pack out_dir name)
set(pack "${out_dir}/embed/${name}.pack")
set(list "set(FILES\n")
foreach(path ${ARGN})
string(APPEND list " [==[${path}]==]\n")
endforeach()
string(APPEND list ")\n")
_embed_write_if_changed("${pack}.cmake" "${list}")
add_custom_command(
OUTPUT "${gz}"
COMMAND "${CMAKE_COMMAND}" "-DIN=${src}" "-DOUT=${gz}"
-P "${_EMBED_GZIP_SCRIPT}"
DEPENDS "${src}" "${_EMBED_GZIP_SCRIPT}"
OUTPUT "${pack}.gz"
COMMAND "${CMAKE_COMMAND}" "-DLIST=${pack}.cmake" "-DOUT=${pack}.gz"
-P "${_EMBED_PACK_SCRIPT}"
DEPENDS ${ARGN} "${pack}.cmake" "${_EMBED_PACK_SCRIPT}"
VERBATIM)
set(gz "${pack}.gz" PARENT_SCOPE)
endfunction()
# Write `content` to `path` only when it differs, so an unchanged
@@ -64,63 +89,79 @@ function(_embed_write_if_changed path content)
endfunction()
function(embed_resources)
cmake_parse_arguments(E "" "TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;OUT_DIR"
cmake_parse_arguments(E ""
"TARGET;ADDONS_ROOT;ADDON_VERSION;BINDINGS_XML;TEXTURES_ROOT;OUT_DIR"
"ADDONS" ${ARGN})
set(rc "// Generated by cmake/embed_resources.cmake - do not edit.\n\n")
set(addon_names "")
set(manifest "")
set(inputs "")
set(id 1)
set(gz "${E_OUT_DIR}/embed/Bindings.xml.gz")
_embed_gzip("${E_BINDINGS_XML}" "${gz}")
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
set(bindings_id 1)
_embed_pack("${E_OUT_DIR}" "Bindings.xml" "${E_BINDINGS_XML}")
string(APPEND rc "${bindings_id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
set(bindings_id ${id})
math(EXPR id "${id} + 1")
set(files "")
set(id 2)
foreach(addon ${E_ADDONS})
string(APPEND addon_names " \"${addon}\",\n")
file(GLOB_RECURSE addon_files CONFIGURE_DEPENDS RELATIVE
file(GLOB_RECURSE files CONFIGURE_DEPENDS RELATIVE
"${E_ADDONS_ROOT}" "${E_ADDONS_ROOT}/${addon}/*")
list(FILTER addon_files EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(APPEND files ${addon_files})
endforeach()
list(SORT files) # deterministic IDs
foreach(rel ${files})
set(abs "${E_ADDONS_ROOT}/${rel}")
if(rel MATCHES "\\.toc$")
file(READ "${abs}" toc)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${E_ADDON_VERSION}" toc "${toc}")
set(stamped "${E_OUT_DIR}/embed/${rel}")
_embed_write_if_changed("${stamped}" "${toc}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${abs}")
set(abs "${stamped}")
endif()
set(gz "${E_OUT_DIR}/embed/${rel}.gz")
_embed_gzip("${abs}" "${gz}")
list(FILTER files EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
list(SORT files) # deterministic indexes
set(paths "")
set(index 0)
foreach(rel ${files})
set(abs "${E_ADDONS_ROOT}/${rel}")
if(rel MATCHES "\\.toc$")
file(READ "${abs}" toc)
string(REGEX REPLACE "## Version:[^\n\r]*"
"## Version: ${E_ADDON_VERSION}" toc "${toc}")
set(stamped "${E_OUT_DIR}/embed/${rel}")
_embed_write_if_changed("${stamped}" "${toc}")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${abs}")
set(abs "${stamped}")
endif()
list(APPEND paths "${abs}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND manifest " { \"${rel_bs}\", ${id}, ${index} },\n")
math(EXPR index "${index} + 1")
endforeach()
_embed_pack("${E_OUT_DIR}" "${addon}" ${paths})
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND manifest " { \"${rel_bs}\", ${id} },\n")
math(EXPR id "${id} + 1")
endforeach()
file(GLOB_RECURSE textures CONFIGURE_DEPENDS RELATIVE
"${E_TEXTURES_ROOT}" "${E_TEXTURES_ROOT}/*")
list(SORT textures) # deterministic indexes
set(texture_paths "")
set(texture_manifest "")
set(index 0)
foreach(rel ${textures})
list(APPEND texture_paths "${E_TEXTURES_ROOT}/${rel}")
string(REPLACE "/" "\\\\" rel_bs "${rel}")
string(APPEND texture_manifest " { \"${rel_bs}\", ${id}, ${index} },\n")
math(EXPR index "${index} + 1")
endforeach()
_embed_pack("${E_OUT_DIR}" "Textures" ${texture_paths})
string(APPEND rc "${id} RCDATA \"${gz}\"\n")
list(APPEND inputs "${gz}")
_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.
// Resource IDs for the RCDATA packs in embedded.rc, and each embedded file's
// place in them. Load them through src/embed/Resource.h.
#pragma once
#include <cstddef>
namespace Embed::Ids {
constexpr int kBindingsXml = ${bindings_id};
constexpr int kBindingsXml = ${bindings_id}; // a pack of the one file
} // namespace Embed::Ids
namespace Addons::Embedded::Manifest {
@@ -133,7 +174,8 @@ constexpr size_t kAddonCount = sizeof(kAddons) / sizeof(kAddons[0]);
struct File {
const char *path;
int resourceId;
int resourceId; // its addon's pack
int index; // its place in the pack
};
constexpr File kFiles[] = {
@@ -142,6 +184,21 @@ ${manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Addons::Embedded::Manifest
namespace Texture::Builtin::Manifest {
struct File {
const char *path; // the game path
int resourceId; // the textures' pack
int index; // its place in the pack
};
constexpr File kFiles[] = {
${texture_manifest}};
constexpr size_t kFileCount = sizeof(kFiles) / sizeof(kFiles[0]);
} // namespace Texture::Builtin::Manifest
")
add_custom_target(${E_TARGET}_embed DEPENDS ${inputs})
-8
View File
@@ -1,8 +0,0 @@
# gzip_file.cmake — gzip one file at build time, for embed_resources.cmake.
#
# cmake -DIN=<file> -DOUT=<file.gz> -P gzip_file.cmake
#
# The raw format holds the one file's bytes with no archive entry around
# them, so OUT is a plain gzip stream of IN.
file(ARCHIVE_CREATE OUTPUT "${OUT}" PATHS "${IN}" FORMAT raw
COMPRESSION GZip COMPRESSION_LEVEL 9)
+50
View File
@@ -0,0 +1,50 @@
# pack_files.cmake — join files into one gzipped pack at build time, for
# embed_resources.cmake.
#
# cmake -DLIST=<list.cmake> -DOUT=<pack.gz> -P pack_files.cmake
#
# LIST sets FILES to the paths to join, in order. The pack is one gzip
# stream, so each file compresses against the files before it: the addon
# text packs about 16% smaller than when each file is gzipped alone. Inside
# the stream is an index, each file's size as a decimal line and then an
# empty line, followed by the files' bytes in FILES order. The DLL splits
# it in src/embed/Resource.cpp.
include("${LIST}")
set(index "")
foreach(file IN LISTS FILES)
file(SIZE "${file}" size)
string(APPEND index "${size}\n")
endforeach()
# Not file(WRITE): on Windows it writes each \n as \r\n.
file(CONFIGURE OUTPUT "${OUT}.index" CONTENT "${index}\n" @ONLY NEWLINE_STYLE UNIX)
# Join `ARGN` byte for byte into `out`.
function(cat out)
execute_process(COMMAND "${CMAKE_COMMAND}" -E cat ${ARGN}
OUTPUT_FILE "${out}" RESULT_VARIABLE rc)
if(NOT rc EQUAL 0)
message(FATAL_ERROR "pack_files.cmake: cannot join into ${out}: ${rc}")
endif()
endfunction()
# `cmake -E cat` takes its files on the command line, which Windows limits
# to 32767 characters: join in batches of 100, then join the batches.
set(parts "${OUT}.index")
list(LENGTH FILES count)
set(first 0)
while(first LESS count)
list(SUBLIST FILES ${first} 100 batch)
set(part "${OUT}.part${first}")
cat("${part}" ${batch})
list(APPEND parts "${part}")
math(EXPR first "${first} + 100")
endwhile()
cat("${OUT}.raw" ${parts})
# The raw format holds the one file's bytes with no archive entry around
# them, so OUT is a plain gzip stream.
file(ARCHIVE_CREATE OUTPUT "${OUT}" PATHS "${OUT}.raw" FORMAT raw
COMPRESSION GZip COMPRESSION_LEVEL 9)
file(REMOVE ${parts} "${OUT}.raw")
+2859 -1132
View File
File diff suppressed because it is too large Load Diff
+18 -22
View File
@@ -27,24 +27,22 @@ The DLL hooks the engine's own text pipeline — no companion addon:
pen is lazy and omits the last glyph's own advance, so a coin sat on its
digits; the emitter now adds the true terminal advance from the engine's own
glyph-advance helpers (`550cf5e`).
- **Rendering** — the paint-pass co-hook `FUN_005c8fe0` walks the icon records
and queues each to `Text::InlineTexturePool`, which places the region on the
next frame tick (never mid-render).
- **Chat has no one-frame lag** — a ScrollingMessageFrame re-`SetText`s every
visible line on each new message, and the engine builds each line lazily at
paint, so a line's icons would land one frame after its glyphs (glyphs draw
synchronously; the icon regions are separate objects whose placement is only
known once the line builds mid-paint, after the frame's texture pass already
drew). A co-hook on the SMF display refresh (`FUN_00788750`, which runs
pre-render from the frame's own methods) forces each icon-bearing line to
resolve+build and applies its icon placement synchronously — before the frame
draws — so icons move in lockstep with their text. It reuses the engine's own
`Realize(0)` → `RebuildString` → ensure-built path and the same placement math
the paint flush uses (`ComputePlacementsForNode`), so the later flush dedups to
a no-op. Additive and best-effort: any line whose rect isn't resolvable in time
falls back to the paint + tick pipeline (correct, one-frame lagged). A pure
scroll or drag never lagged — the icons are anchored to the line and the engine
moves them for free; only content changes on a reused line needed this.
- **Rendering** — 4.3.4's `CSimpleEmbeddedTexture` model. Each icon is a
pooled `CSimpleTexture` with no frame, anchored to its fontstring: it is in
no draw layer, so the fontstring draws it. The fontstring's batch call
(`FUN_FONTSTRING_EMIT` `0x00772E50`, 4.3.4's `CSimpleFontString::Draw` →
`QueueTextures`) adds the icons right after its text, and only when the text
went in, so an icon shows exactly when its line does — hidden, faded out, off
screen, or scrolled off the top of a chat frame — in the same frame.
- **No one-frame lag** — 4.3.4 sets the embedded textures up in `UpdateString`,
right after it builds the string. The `RebuildString` co-hook does the same:
a rebuild drops the fontstring's icons, lays the new node out now (the
ensure-built call the paint makes) and applies the placements at once, so a
string's icons draw with its text wherever it was rebuilt (a chat line laid
out by its frame, a tooltip line, a rect change). The paint-pass co-hook
`FUN_005c8fe0` still walks the icon records and queues them to
`Text::InlineTexturePool` for the next frame tick: the backstop for a string
whose placement can't be computed at rebuild time.
- **Placement coordinates** — text pen space IS render-target pixels. The
engine stores node positions NORMALIZED (fs-local anchor ÷
`[0x832A44]`/`[0x832A48]` per axis, `FUN_0041ade0`), and the origin finalize
@@ -107,10 +105,8 @@ Every fs-level measure path now counts icons: width (`FUN_00772890`), height
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of a
wrap-enabled line through it, so icon-bearing wrapped tooltip lines
undersized their tooltip until it was hooked too). The Lua control surface is
two toggles: `_classicapi_InlineTexEnable` (the whole-feature kill switch; the
SEH latch trips it on a flush fault) and `_classicapi_InlineTexEarly` (the
SMF-refresh early apply above — on by default, toggle off to A/B the chat lag
against the baseline paint+tick pipeline, which keeps running either way). The
one toggle: `_classicapi_InlineTexEnable` (the whole-feature kill switch; the
SEH latch trips it on a fault). The
bring-up tune / stat / probe functions were removed (`5e5677b`, `6800a9a`).
## REMOVED (bring-up record) — the raw-GxU-quad rendering primitive
+3 -3
View File
@@ -178,9 +178,9 @@ behaviour the comment describes (`push 0; mov edx, 1; mov ecx, L`) is
| `0x006F4450` | `lua_next` | fastcall | calls `luaH_next` at `0x006FA2A0` | ★ |
| `0x006F44E0` | `lua_concat` | fastcall | calls `luaV_concat` at `0x006F84D0` | ★ |
| `0x006F4560` | `lua_newuserdata` | fastcall | tag 7; calls `luaS_newudata` at `0x006F9E30` | ★ |
| `0x006F45B0` | `lua_getupvalue` | fastcall | reads upvalue from C closure | ◦ |
| `0x006F4660` | `lua_setupvalue` | fastcall | | ◦ |
| `0x006F46E0` | *internal upvalue helper*| | shared by get/set above | ◦ |
| `0x006F45B0` | `lua_pushupvalues` | fastcall | pushes the running C closure's upvalues, returns how many | ★ |
| `0x006F4660` | `lua_getupvalue` | fastcall | `(L, funcindex, n)`: pushes upvalue n, returns its name or null | ★ |
| `0x006F46E0` | `aux_upvalue` | | finds upvalue n of a function, returns its name | ★ |
## Auxiliary library (`luaL_*`)
+27
View File
@@ -64,6 +64,17 @@ bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target) {
return PatchBytes(reinterpret_cast<uint8_t *>(site) + 2, &rel, sizeof rel);
}
bool RedirectSlot(uintptr_t slot, uintptr_t expected, const void *target) {
uintptr_t current = 0;
std::memcpy(&current, reinterpret_cast<const void *>(slot), sizeof current);
const uintptr_t to = reinterpret_cast<uintptr_t>(target);
if (current == to)
return true;
if (current != expected)
return false;
return PatchBytes(reinterpret_cast<void *>(slot), &to, sizeof to);
}
bool ReplaceWithCall(uintptr_t site, const uint8_t *original, size_t len, const void *target) {
uint8_t code[64];
if (len < 5 || len > sizeof code)
@@ -102,4 +113,20 @@ void EnsureParentDirs(const char *relPath) {
}
}
char FoldPathChar(char c) {
if (c == '/')
return '\\';
if (c >= 'A' && c <= 'Z')
return static_cast<char>(c + ('a' - 'A'));
return c;
}
bool SamePath(const char *a, const char *b) {
for (; FoldPathChar(*a) == FoldPathChar(*b); ++a, ++b) {
if (*a == '\0')
return true;
}
return false;
}
} // namespace Common
+11
View File
@@ -28,6 +28,10 @@ bool RedirectCall(uintptr_t site, uintptr_t expected, const void *target);
// at `site` to `target` when it still reaches `expected`. Same contract.
bool RedirectJcc(uintptr_t site, uintptr_t expected, const void *target);
// RedirectCall for a function-pointer slot (a vtable entry): points the slot
// at `slot` to `target` when it still holds `expected`. Same contract.
bool RedirectSlot(uintptr_t slot, uintptr_t expected, const void *target);
// Writes `call target` plus NOP padding over the `len` bytes (5 to 64) of
// code at `site` when they still hold `original`; true when the site
// now calls `target`. A site holding anything else (another DLL's patch, a
@@ -45,4 +49,11 @@ void BoundedCopy(char *dst, const char *src, size_t n);
// directory. Directories that already exist are a no-op.
void EnsureParentDirs(const char *relPath);
// `c` as game paths compare it: '/' as '\', and A-Z as a-z.
char FoldPathChar(char c);
// Whether `a` and `b` are the same game path: equal after FoldPathChar, so
// any case and either slash.
bool SamePath(const char *a, const char *b);
} // namespace Common
+19 -13
View File
@@ -80,10 +80,13 @@ static void __stdcall LoadGlueScriptFunctions_h() {
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
// for any custom event still waiting. By this point the engine's table
// is fully populated and SuperWoWhook / other DLLs have done their
// post-rebuild writes, so the table state is settled and our walk from the
// low end finds genuinely free slots.
// for any custom event still waiting. By the first FrameXML registration
// the engine's table is fully populated and SuperWoWhook / other DLLs have
// done their post-rebuild writes, so the walk from the low end finds
// genuinely free slots. A registration that arrives EARLIER (another DLL's
// frame, between LoadScriptFunctions and the table build — issue #134)
// still sees glue's 26-entry table; `RetryClaims` refuses that table and
// re-validates its cached slots against the live one on every call.
static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
const char *eventName) {
Event::Custom::RetryClaims();
@@ -234,16 +237,19 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
CloseHandle(worker);
}
} else if (reason == DLL_PROCESS_DETACH) {
// Clean /quit path — only tear down if we actually initialized.
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
// directly (no MinHook involvement), so either order works in
// practice, but flushing first lets us bail early if the hook
// teardown ever grows side effects. Hard process termination (task
// manager kill) bypasses this path; that's an inherent OS
// limitation, and the 5-minute backstop flush in Remember() covers
// the worst case there.
// Process exit — only tear down if we actually initialized. A
// clean /quit already saved the name cache from its atexit handler,
// which runs before this; this save covers an exit that skips the
// atexit handlers (Storm's fatal exit 0x0064CC80 calls ExitProcess
// itself) and a save that failed there. Save before
// MH_Uninitialize — the cache's file I/O uses Win32 directly (no
// MinHook involvement), so either order works in practice, but
// saving first lets us bail early if the hook teardown ever grows
// side effects. Hard process termination (task manager kill)
// bypasses this path; that's an inherent OS limitation, and the
// 5-minute backstop flush covers the worst case there.
if (g_mhInitialized) {
Player::NameCache::Flush();
Player::NameCache::Shutdown();
MH_Uninitialize();
}
}
+27 -11
View File
@@ -15,6 +15,7 @@
#include "MinHook.h"
#include "Offsets.h"
#include "api/Documentation.h"
#include "compat/WowTurbo.h"
namespace Game {
@@ -72,12 +73,14 @@ namespace Lua {
F(GetTable, lua_gettable, LUA_GET_TABLE) \
F(RawGet, lua_rawget, LUA_RAW_GET) \
F(RawGetI, lua_rawgeti, LUA_RAWGETI) \
F(RawSetI, lua_rawseti, LUA_RAW_SET_I) \
F(SetTable, lua_settable, LUA_SET_TABLE) \
F(RawSet, lua_rawset, LUA_RAW_SET) \
F(GetMetatable,lua_getmetatable,LUA_GET_METATABLE)\
F(SetMetatable,lua_setmetatable,LUA_SET_METATABLE)\
F(Insert, lua_insert, LUA_INSERT) \
F(Remove, lua_remove, LUA_REMOVE) \
F(Replace, lua_replace, LUA_REPLACE) \
F(GetTop, lua_gettop, LUA_GET_TOP) \
F(SetTop, lua_settop, LUA_SET_TOP) \
F(Call, lua_call, LUA_CALL) \
@@ -89,11 +92,17 @@ namespace Lua {
F(ToPointer, lua_topointer, LUA_TO_POINTER) \
F(ToThread, lua_tothread, LUA_TO_THREAD) \
F(IsCFunction, lua_iscfunction, LUA_IS_C_FUNCTION)\
F(ToCFunction, lua_tocfunction, LUA_TO_C_FUNCTION)\
F(GetStack, lua_getstack, LUA_GET_STACK) \
F(GetInfo, lua_getinfo, LUA_GET_INFO) \
F(XMove, lua_xmove, LUA_X_MOVE) \
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(CheckLString,luaL_checklstring,LUAL_CHECK_LSTRING)\
F(CheckNumber, luaL_checknumber,LUAL_CHECK_NUMBER)\
F(OptNumber, luaL_optnumber, LUAL_OPT_NUMBER) \
F(SetN, luaL_setn, LUAL_SETN) \
F(Ref, luaL_ref, LUA_REF_REF) \
F(Unref, luaL_unref, LUA_REF_UNREF) \
@@ -243,6 +252,12 @@ void *ResolveEditBox(void *L, int idx, bool raiseError) {
void *ResolveModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_MODEL_LUA_TYPE_ID, raiseError);
}
void *ResolvePlayerModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_PLAYERMODEL_LUA_TYPE_ID, raiseError);
}
void *ResolveDressUpModel(void *L, int idx, bool raiseError) {
return ResolveTypedObject(L, idx, Offsets::VAR_DRESSUPMODEL_LUA_TYPE_ID, raiseError);
}
// Looks up `_G[name]`. If absent, creates a fresh table and binds it.
// Leaves the resulting table on top of the stack.
@@ -352,11 +367,11 @@ void RegisterGlueFunction(const char *name, CFunction func, const Doc::Function
Api::Documentation::RecordGlobal(name, doc);
}
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs, int docCount) {
void RegisterFrameMethods(void *context, ArrayRef<FrameMethodEntry> methods,
Doc::MethodList docs) {
auto fn = reinterpret_cast<RegisterFrameMethods_t>(Offsets::FUN_REGISTER_FRAME_METHODS);
fn(table, count, context);
Api::Documentation::RecordFrameMethods(context, table, count, docs, docCount);
fn(methods.data, methods.count, context);
Api::Documentation::RecordFrameMethods(context, methods, docs);
}
// Registers `func` at `_G[tableName][methodName]`. If the namespace
@@ -422,17 +437,16 @@ static bool SetIntegerTable(const char *parent, const char *sub,
return true;
}
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
void RegisterIntegerEnum(const char *parent, const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem) {
if (SetIntegerTable(parent, sub, entries, count))
Api::Documentation::RecordEnum(parent, sub, entries, count, docSystem);
if (SetIntegerTable(parent, sub, entries.data, entries.count))
Api::Documentation::RecordEnum(parent, sub, entries, docSystem);
}
void RegisterConstants(const char *sub, const EnumIntegerEntry *entries, int count,
void RegisterConstants(const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem) {
if (SetIntegerTable("Constants", sub, entries, count))
Api::Documentation::RecordConstants(sub, entries, count, docSystem);
if (SetIntegerTable("Constants", sub, entries.data, entries.count))
Api::Documentation::RecordConstants(sub, entries, docSystem);
}
void SetFieldNumber(void *L, const char *key, double value) {
@@ -583,6 +597,8 @@ HookAutoRegister::HookAutoRegister(uintptr_t target, void *hook, void **original
bool RunHookRegistrations() {
for (auto *node = g_hookHead; node != nullptr; node = node->next) {
auto *targetPtr = reinterpret_cast<LPVOID>(node->target);
// A hook must wrap the engine's body, not wow_turbo's reimplementation.
Compat::WowTurbo::Reclaim(node->target);
if (MH_CreateHook(targetPtr, node->hook,
reinterpret_cast<LPVOID *>(node->original)) != MH_OK)
return false;
+55 -16
View File
@@ -147,6 +147,18 @@ int StrCmpI(const char *a, const char *b, int n = 0x7FFFFFFF);
// literals + file-scope arrays): no heap, no dynamic init.
// - Summaries are user-facing text: one sentence, plain words, present
// tense, no patch versions.
// A view over a file-scope `const T[]` that takes its count from the array's
// type, so no call site passes one; `{}` means none.
template <typename T>
struct ArrayRef {
const T *data = nullptr;
int count = 0;
constexpr ArrayRef() = default;
template <size_t N>
constexpr ArrayRef(const T (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
namespace Doc {
struct Field {
@@ -172,14 +184,7 @@ constexpr Field Vararg(const char *type, const char *doc = nullptr) {
return {"...", type, true, nullptr, doc};
}
// A view over a file-scope `const Field[]`; `{}` means none.
struct FieldList {
const Field *data = nullptr;
int count = 0;
constexpr FieldList() = default;
template <size_t N>
constexpr FieldList(const Field (&a)[N]) : data(a), count(static_cast<int>(N)) {}
};
using FieldList = ArrayRef<Field>;
// A registered function. Positional init: `{summary, args, rets}` for a
// table function, `{summary, args, rets, "XGlobals"}` for a global, and
@@ -200,6 +205,7 @@ struct Method {
const char *name;
const Function *doc;
};
using MethodList = ArrayRef<Method>;
// A table shape a function returns or accepts → `Tables{Type="Structure"}`
// in `system`. Self-registering: nothing binds a structure to Lua, so it
@@ -240,7 +246,9 @@ constexpr int UpvalueIndex(int i) { return GLOBALS_INDEX - i; }
// `lua_call` / `lua_pcall` nresults sentinel meaning "all".
constexpr int MULTRET = -1;
// Type tag values returned by `Type()` (lua_type).
// Type tag values returned by `Type()` (lua_type). TYPE_NONE is an absent
// argument (past the top of the stack).
constexpr int TYPE_NONE = -1;
constexpr int TYPE_NIL = 0;
constexpr int TYPE_BOOLEAN = 1;
constexpr int TYPE_LIGHTUSERDATA = 2;
@@ -270,6 +278,8 @@ using lua_rawget_t = void(__fastcall *)(void *L, int idx);
// Pushes t[n] for the table at idx, raw (no metamethods). With idx =
// REGISTRY_INDEX it pushes a value stored under an integer ref.
using lua_rawgeti_t = void(__fastcall *)(void *L, int idx, int n);
// Pops the top value into t[n] for the table at idx, raw.
using lua_rawseti_t = void(__fastcall *)(void *L, int idx, int n);
using lua_settable_t = void(__fastcall *)(void *L, int idx);
using lua_rawset_t = void(__fastcall *)(void *L, int idx);
// Pushes the metatable of the value at idx and returns 1, or pushes
@@ -280,6 +290,8 @@ using lua_getmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_setmetatable_t = int(__fastcall *)(void *L, int idx);
using lua_insert_t = void(__fastcall *)(void *L, int idx);
using lua_remove_t = void(__fastcall *)(void *L, int idx);
// Pops the top value into slot idx.
using lua_replace_t = void(__fastcall *)(void *L, int idx);
using lua_gettop_t = int(__fastcall *)(void *L);
using lua_settop_t = void(__fastcall *)(void *L, int idx);
using lua_call_t = void(__fastcall *)(void *L, int nargs, int nresults);
@@ -298,6 +310,11 @@ using lua_error_t = void(__cdecl *)(void *L, const char *fmt, ...);
using lua_topointer_t = const void *(__fastcall *)(void *L, int idx);
using lua_tothread_t = void *(__fastcall *)(void *L, int idx);
using lua_iscfunction_t = int(__fastcall *)(void *L, int idx);
using lua_tocfunction_t = CFunction(__fastcall *)(void *L, int idx);
// Fill a lua_Debug (Offsets::SIZEOF_LUA_DEBUG bytes) for a call level /
// with the fields `what` names. Level 0 is the running C function.
using lua_getstack_t = int(__fastcall *)(void *L, int level, void *ar);
using lua_getinfo_t = int(__fastcall *)(void *L, const char *what, void *ar);
using lua_xmove_t = void(__fastcall *)(void *from, void *to, int n);
// `lua_newthread` returns the new `lua_State *` in eax, but Ghidra
// infers `void` because the inner call's value just flows through.
@@ -308,6 +325,11 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
// The auxlib argument readers: each raises the engine's own "bad argument"
// error. CheckLString's `len` may be null.
using luaL_checklstring_t = const char *(__fastcall *)(void *L, int narg, unsigned int *len);
using luaL_checknumber_t = double(__fastcall *)(void *L, int narg);
using luaL_optnumber_t = double(__fastcall *)(void *L, int narg, double def);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
// Pops the top value into the table at `t` under a fresh integer key and
// returns the key (LUA_REFNIL, -1, for a nil). With REGISTRY_INDEX it anchors
@@ -353,12 +375,14 @@ extern const lua_newtable_t NewTable;
extern const lua_gettable_t GetTable;
extern const lua_rawget_t RawGet;
extern const lua_rawgeti_t RawGetI;
extern const lua_rawseti_t RawSetI;
extern const lua_settable_t SetTable;
extern const lua_rawset_t RawSet;
extern const lua_getmetatable_t GetMetatable;
extern const lua_setmetatable_t SetMetatable;
extern const lua_insert_t Insert;
extern const lua_remove_t Remove;
extern const lua_replace_t Replace;
extern const lua_gettop_t GetTop;
extern const lua_settop_t SetTop;
extern const lua_call_t Call;
@@ -370,11 +394,17 @@ extern const lua_error_t Error;
extern const lua_topointer_t ToPointer;
extern const lua_tothread_t ToThread;
extern const lua_iscfunction_t IsCFunction;
extern const lua_tocfunction_t ToCFunction;
extern const lua_getstack_t GetStack;
extern const lua_getinfo_t GetInfo;
extern const lua_xmove_t XMove;
extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_checklstring_t CheckLString;
extern const luaL_checknumber_t CheckNumber;
extern const luaL_optnumber_t OptNumber;
extern const luaL_setn_t SetN;
extern const luaL_ref_t Ref;
extern const luaL_unref_t Unref;
@@ -457,6 +487,8 @@ void *ResolveTexture(void *L, int idx = 1, bool raiseError = true);
void *ResolveFontString(void *L, int idx = 1, bool raiseError = true);
void *ResolveEditBox(void *L, int idx = 1, bool raiseError = true);
void *ResolveModel(void *L, int idx = 1, bool raiseError = true);
void *ResolvePlayerModel(void *L, int idx = 1, bool raiseError = true);
void *ResolveDressUpModel(void *L, int idx = 1, bool raiseError = true);
// Every name these registrars bind is ALSO bound under `_G.ClassicAPI` —
// `ClassicAPI.GetSpellInfo`, `ClassicAPI.C_Item.IsBound` — as an escape
@@ -501,10 +533,11 @@ struct FrameMethodEntry {
// Registers a batch of methods on a per-frame-type registry (e.g.
// GameTooltipMethodRegistry for `tooltip:Foo()` calls). `context` is the
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `docs` pairs
// descriptors to the entries by NAME (any order, any subset).
void RegisterFrameMethods(void *context, const FrameMethodEntry *table, int count,
const Doc::Method *docs = nullptr, int docCount = 0);
// registry address — see Offsets::VAR_*_METHOD_REGISTRY. `methods` and
// `docs` are file-scope arrays; `docs` pairs descriptors to the entries by
// NAME (any order, any subset).
void RegisterFrameMethods(void *context, ArrayRef<FrameMethodEntry> methods,
Doc::MethodList docs = {});
// Registers `func` at `_G[tableName][methodName]`, creating the namespace
// table if it doesn't already exist. This is how modern WoW C_*-style APIs
@@ -535,14 +568,14 @@ struct EnumIntegerEntry {
// `Enum.AddOnSecurityStatus = { Secure=0, Insecure=1, ... }` shapes.
// `docSystem` is the documentation System the enumeration belongs to
// ("SpellBook", "Item", …); the entries themselves are the documentation.
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
// `entries` is a file-scope array.
void RegisterIntegerEnum(const char *parent, const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem = nullptr);
// Registers `Constants[sub] = { entries }`, Blizzard's engine constant
// tables (`Constants.MoneyFormattingConstants`). Documented as a "Constants"
// table of `docSystem`.
void RegisterConstants(const char *sub, const EnumIntegerEntry *entries, int count,
void RegisterConstants(const char *sub, ArrayRef<EnumIntegerEntry> entries,
const char *docSystem);
// Set `t[key] = value` on the table currently at stack[-1] (the most
@@ -755,6 +788,12 @@ bool RunHookRegistrations();
// issue #33, Frame::Attributes). A pure-C++ cache keyed by spellID/itemID is
// NOT fragile and must not register.
//
// It runs AFTER the frames are gone: CGGameUI::Shutdown destroys every frame
// (and each Model frame's scene with every model in it) before
// CGGameUI::Initialize resets the Lua state. So a cleanup here only FORGETS
// pointers; it never releases an engine object tied to a frame. Release those
// when the frame dies (Model::Teardown for Model frames).
//
// Same lifetime + ordering rules as ModuleAutoRegister: static-init chaining,
// undefined cross-TU order — fine, because each callback only clears its OWN
// state. This is the "clear stale state" category only; a save-before-teardown
+1047 -126
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
// 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/>.
// Where a spell sits on the bars.
//
// FindSpellActionButtons(spell [, set]) the 1-based slots holding the
// spell as a spell action, or nothing when none does.
// HasSpellActionButtons(spell [, set]) whether any slot does.
// IsOnBarOrSpecialBar(spell [, set]) the same, or the spell is on the
// shapeshift bar or the pet bar.
//
// A slot matches on the spell ID it stores, as 4.3.4's PutActionInSlot
// compares (IsSpell && GetSpell == spellId): a macro or an item that casts
// the spell is not the spell, and a rank matches only itself. The special
// bars are the engine's own lists — the shapeshift bar's spell array that
// GetShapeshiftFormInfo reads, and the pet bar's spell entries
// (GetPetActionInfo's types 1-5) while a pet bar is up.
//
// `set` is Enum.ActionBarSet, a mask whose Mkb bit is the one set of slots
// this client has; Gamepad alone finds no slot.
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include <cstdint>
namespace Action::Find {
namespace {
constexpr int ACTION_BAR_SET_MKB = 1;
constexpr int ACTION_BAR_SET_GAMEPAD = 2;
constexpr int ACTION_BAR_SET_ALL = ACTION_BAR_SET_MKB | ACTION_BAR_SET_GAMEPAD;
// The spell at stack[1] and whether the set at stack[2] (default All)
// covers this client's slots. 0 when the spell does not resolve.
int Args(void *L, bool *searchSlots) {
const int set = Game::Lua::IsNumber(L, 2) ? static_cast<int>(Game::Lua::ToNumber(L, 2))
: ACTION_BAR_SET_ALL;
*searchSlots = (set & ACTION_BAR_SET_MKB) != 0;
return Spell::Arg::ResolveSpellID(L, 1);
}
bool SlotHolds(int slot0, int spellID) {
const uint32_t entry = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE))[slot0];
return (entry & Offsets::ACTION_TYPE_MASK) == Offsets::ACTION_TYPE_SPELL &&
entry == static_cast<uint32_t>(spellID);
}
bool AnySlotHolds(int spellID) {
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
if (SlotHolds(slot0, spellID))
return true;
}
return false;
}
bool OnShapeshiftBar(int spellID) {
const int count = Game::Read<int>(Offsets::VAR_SHAPESHIFT_BAR_SPELL_COUNT);
const auto *spells = Game::Read<const uint32_t *>(Offsets::VAR_SHAPESHIFT_BAR_SPELLS);
if (spells == nullptr)
return false;
for (int i = 0; i < count; ++i) {
if (spells[i] == static_cast<uint32_t>(spellID))
return true;
}
return false;
}
bool OnPetBar(int spellID) {
if (Game::Read<uint64_t>(Offsets::VAR_PET_GUID) == 0)
return false;
const auto *slots = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PET_ACTION_SLOTS));
for (int i = 0; i < Offsets::PET_ACTION_SLOT_COUNT; ++i) {
const uint32_t type = (slots[i] >> 24) & 0x3F;
if (type >= Offsets::PET_ACTION_TYPE_SPELL_FIRST &&
type <= Offsets::PET_ACTION_TYPE_SPELL_LAST &&
(slots[i] & 0xFFFF) == static_cast<uint32_t>(spellID))
return true;
}
return false;
}
int __fastcall Script_FindSpellActionButtons(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
if (spellID <= 0 || !searchSlots || !AnySlotHolds(spellID))
return 0;
Game::Lua::NewTable(L);
int index = 0;
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
if (!SlotHolds(slot0, spellID))
continue;
Game::Lua::PushNumber(L, static_cast<double>(++index));
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
Game::Lua::RawSet(L, -3);
}
return 1;
}
int __fastcall Script_HasSpellActionButtons(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
Game::Lua::PushBool(L, spellID > 0 && searchSlots && AnySlotHolds(spellID));
return 1;
}
int __fastcall Script_IsOnBarOrSpecialBar(void *L) {
bool searchSlots;
const int spellID = Args(L, &searchSlots);
Game::Lua::PushBool(L, spellID > 0 && ((searchSlots && AnySlotHolds(spellID)) ||
OnShapeshiftBar(spellID) || OnPetBar(spellID)));
return 1;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("spellID", "SpellIdentifier", "A spell ID, spell link, or spell name."),
Game::Doc::Opt("setToSearch", "ActionBarSet", "All"),
};
const Game::Doc::Field kFindRets[] = {
Game::Doc::Req("slots", "table", "The 1-based action slots; nothing when no slot "
"holds the spell."),
};
const Game::Doc::Function kFindSpellActionButtons{
"The action slots that hold the spell as a spell action.",
kArgs, kFindRets};
const Game::Doc::Field kHasRets[] = {
Game::Doc::Req("hasSpellActionButtons", "bool"),
};
const Game::Doc::Function kHasSpellActionButtons{
"Whether any action slot holds the spell as a spell action.",
kArgs, kHasRets};
const Game::Doc::Field kOnBarRets[] = {
Game::Doc::Req("isOnBarOrSpecialBar", "bool"),
};
const Game::Doc::Function kIsOnBarOrSpecialBar{
"Whether the spell is in an action slot, on the shapeshift bar, or on the pet bar.",
kArgs, kOnBarRets};
const Game::Lua::EnumIntegerEntry kActionBarSet[] = {
{"None", 0},
{"Mkb", ACTION_BAR_SET_MKB},
{"Gamepad", ACTION_BAR_SET_GAMEPAD},
{"All", ACTION_BAR_SET_ALL},
};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ActionBar", "FindSpellActionButtons",
&Script_FindSpellActionButtons, &kFindSpellActionButtons);
Game::Lua::RegisterTableFunction("C_ActionBar", "HasSpellActionButtons",
&Script_HasSpellActionButtons, &kHasSpellActionButtons);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsOnBarOrSpecialBar",
&Script_IsOnBarOrSpecialBar, &kIsOnBarOrSpecialBar);
Game::Lua::RegisterIntegerEnum("Enum", "ActionBarSet", kActionBarSet, "ActionBar");
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::Find
+61
View File
@@ -38,11 +38,24 @@
// 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.
//
// `C_ActionBar.GetSpell(slot)` — the spell the slot stands for, 0 for none.
// It is the engine's own resolver (FUN_ACTION_SLOT_TO_SPELL), which every
// action reader (cooldown, usable, range, current) goes through: a spell
// action is its spell, a macro is its cached primary spell, and an item is
// its first on-use spell — retail's ActionButton.lua calls GetSpell for
// exactly that item case. The spellbook flag it also hands back is not part
// of the answer.
//
// `C_ActionBar.IsMacroActionWithShowTooltip(slot)` — the slot holds a macro
// whose body carries `#showtooltip`, by the parse `Macro::ShowTooltip` hands
// the tooltip over on. `#show` is not it.
#include "Game.h"
#include "Offsets.h"
#include "action/Slot.h"
#include "equipmentset/Data.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
@@ -126,10 +139,58 @@ int __fastcall Script_GetActionInfo(void *L) {
return 0;
}
int __fastcall Script_GetSpell(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.GetSpell(actionID)");
return 0;
}
// The resolver bounds-checks the slot itself and answers 0 outside it.
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
uint32_t petFlag = 0;
const uint32_t spellID = reinterpret_cast<SlotToSpell_t>(
static_cast<uintptr_t>(Offsets::FUN_ACTION_SLOT_TO_SPELL))(
static_cast<uint32_t>(slot0), &petFlag);
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
int __fastcall Script_IsMacroActionWithShowTooltip(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.IsMacroActionWithShowTooltip(actionID)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
const uint32_t macroID = Action::Slot::MacroIDForSlot(slot0);
Game::Lua::PushBool(L, macroID != 0 && Macro::ShowTooltip::HasShowTooltipDirective(macroID));
return 1;
}
const Game::Doc::Field kGetSpellArgs[] = {
Game::Doc::Req("actionID", "luaIndex"),
};
const Game::Doc::Field kGetSpellRets[] = {
Game::Doc::Req("spellID", "number"),
};
const Game::Doc::Function kGetSpellDoc{
"The spell an action stands for: a spell's own ID, a macro's spell, or an item's "
"on-use spell; 0 when there is none.",
kGetSpellArgs, kGetSpellRets};
const Game::Doc::Field kIsMacroActionWithShowTooltipRets[] = {
Game::Doc::Req("isMacroActionWithShowTooltip", "bool"),
};
const Game::Doc::Function kIsMacroActionWithShowTooltipDoc{
"Whether the action is a macro with an explicit #showtooltip line.",
kGetSpellArgs, kIsMacroActionWithShowTooltipRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetActionInfo", &Script_GetActionInfo);
Game::Lua::RegisterTableFunction("C_ActionBar", "GetSpell", &Script_GetSpell, &kGetSpellDoc);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsMacroActionWithShowTooltip",
&Script_IsMacroActionWithShowTooltip,
&kIsMacroActionWithShowTooltipDoc);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+4
View File
@@ -28,6 +28,8 @@
// The function's one caller, Script_IsActionInRange, is redirected rather than
// the function hooked.
#include "action/Range.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
@@ -107,4 +109,6 @@ const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
int Answer(int slot0, int *inRange) { return SlotRange_h(slot0, inRange); }
} // namespace Action::Range
+24
View File
@@ -0,0 +1,24 @@
// 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 Action::Range {
// IsActionInRange's answer for a slot (0-based, 0..119): 0 when it has none
// (IsActionInRange's nil), else 1 with `*inRange` 0 or 1. A macro aimed at a
// unit is checked against that unit.
int Answer(int slot0, int *inRange);
} // namespace Action::Range
+144
View File
@@ -0,0 +1,144 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
// A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_ActionBar.EnableActionRangeCheck(slot, enable)` and
// `ACTION_RANGE_CHECK_UPDATE(slot, isInRange, checksRange)`.
//
// Nothing in the client reacts to a distance (see Unit::InRangeEvent), so the
// enabled slots are checked every world tick, while a frame is registered for
// the event, and a slot fires when its answer changes. The answer is
// IsActionInRange's (Action::Range::Answer), macro unit included, so a slot
// whose action changes fires when the new action's answer differs.
//
// Enabling is counted per slot, as retail's ActionBarButtonRangeCheckFrame
// expects: it enables once for each button on a slot and disables once as
// each button leaves. Retail's buttons never call IsActionInRange; they take
// their first state from this event. So each enable makes the slot's next
// check fire, changed or not. A new Lua state (`/reload`) starts with no slot
// enabled.
#include "Game.h"
#include "Offsets.h"
#include "action/Range.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace Action::RangeCheck {
namespace {
enum class State : uint8_t { NoCheck, OutOfRange, InRange };
struct Slot {
int refs = 0;
bool pending = false; // fire on the next check, changed or not
State last = State::NoCheck;
};
Slot g_slots[Offsets::ACTION_TABLE_MAX_SLOTS];
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("isInRange", "bool",
"Whether the action's target is in range. Should not be used if checksRange "
"is false."),
Game::Doc::Req("checksRange", "bool",
"False when no range check was made, for example when there is no target."),
};
const Game::Doc::Event kDoc{
"ActionBar",
"Fires when an action that has EnableActionRangeCheck on goes in or out of range of its "
"target.",
kPayload};
const Event::Custom::AutoReserve kEvent{"ACTION_RANGE_CHECK_UPDATE", &kDoc};
State Evaluate(int slot0) {
int inRange = 0;
if (Action::Range::Answer(slot0, &inRange) == 0)
return State::NoCheck;
return inRange != 0 ? State::InRange : State::OutOfRange;
}
void OnWorldTick() {
const int event = kEvent.Slot();
if (!Event::Custom::HasListeners(event))
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
for (int slot0 = 0; slot0 < Offsets::ACTION_TABLE_MAX_SLOTS; ++slot0) {
Slot &slot = g_slots[slot0];
if (slot.refs == 0)
continue;
const State state = Evaluate(slot0);
if (!slot.pending && state == slot.last)
continue;
slot.pending = false;
slot.last = state;
Game::Lua::PushNumber(L, static_cast<double>(slot0 + 1));
Game::Lua::PushBool(L, state == State::InRange);
Game::Lua::PushBool(L, state != State::NoCheck);
Event::Custom::FireValues(event, L, top + 1, 3);
Game::Lua::SetTop(L, top);
}
}
const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// --- C_ActionBar.EnableActionRangeCheck(slot, enable) -----------------------
int __fastcall Script_EnableActionRangeCheck(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_ActionBar.EnableActionRangeCheck(slot, enable)");
return 0;
}
const int slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
Slot &slot = g_slots[slot0];
if (Game::Lua::ToBoolean(L, 2) != 0) {
++slot.refs;
slot.pending = true;
} else if (slot.refs > 0) {
--slot.refs;
}
return 0;
}
const Game::Doc::Field kEnableArgs[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("enable", "bool",
"True if changes in range for the action should dispatch "
"ACTION_RANGE_CHECK_UPDATE. False if the action no longer needs the event."),
};
const Game::Doc::Function kEnableDoc{
"Used with ACTION_RANGE_CHECK_UPDATE to be informed when an action goes in or out of range "
"of its target.",
kEnableArgs};
void RegisterLuaFunctions() {
for (Slot &slot : g_slots)
slot = Slot{};
Game::Lua::RegisterTableFunction("C_ActionBar", "EnableActionRangeCheck",
&Script_EnableActionRangeCheck, &kEnableDoc);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Action::RangeCheck
+148
View File
@@ -0,0 +1,148 @@
// 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 item behind an action slot, 4.3.4's CGActionBar::GetItem: an item-by-ID
// action's item, or the item a macro's `#showtooltip` / `#show` resolved to
// (1.12 keeps no item per macro, so `Macro::ShowTooltip` holds it). Both
// readers below peek the item cache and never query it, as 4.3.4 does.
//
// IsStackableAction the item's max stack is more than 1 —
// 4.3.4's IsStackableAucItem. The global
// answers 1/nil, the C_ActionBar form a bool.
// GetItemActionOnEquipSpellID the item's first on-equip spell (trigger 1),
// nil when it has none.
#include "Game.h"
#include "Offsets.h"
#include "item/Record.h"
#include "macro/ShowTooltip.h"
#include <cstdint>
namespace Action::SlotItem {
namespace {
uint32_t ItemForSlot(int slot0) {
if (slot0 < 0 || slot0 >= Offsets::ACTION_TABLE_MAX_SLOTS)
return 0;
const uint32_t entry = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_ACTION_TABLE))[slot0];
if ((entry & Offsets::ACTION_TYPE_MASK) == Offsets::ACTION_TYPE_ITEM_BY_ID)
return entry & Offsets::ACTION_PAYLOAD_MASK_ITEM_BY_ID;
Macro::ShowTooltip::Info info;
if (Macro::ShowTooltip::ForSlot(slot0, &info) &&
info.target == Macro::ShowTooltip::Target::Item)
return static_cast<uint32_t>(info.itemID);
return 0;
}
// Reads the 1-based slot at stack[1]; false (after the usage error) when it
// is not a number.
bool SlotArg(void *L, const char *usage, int *slot0) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, usage);
return false;
}
*slot0 = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
return true;
}
bool IsStackable(int slot0) {
const uint8_t *record = ::Item::PeekRecord(ItemForSlot(slot0));
return record != nullptr && Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_STACKABLE) > 1;
}
int __fastcall Script_IsStackableAction(void *L) {
int slot0;
if (!SlotArg(L, "Usage: IsStackableAction(slot)", &slot0))
return 0;
if (IsStackable(slot0))
Game::Lua::PushNumber(L, 1.0);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_C_IsStackableAction(void *L) {
int slot0;
if (!SlotArg(L, "Usage: C_ActionBar.IsStackableAction(actionID)", &slot0))
return 0;
Game::Lua::PushBool(L, IsStackable(slot0));
return 1;
}
int __fastcall Script_GetItemActionOnEquipSpellID(void *L) {
int slot0;
if (!SlotArg(L, "Usage: C_ActionBar.GetItemActionOnEquipSpellID(actionID)", &slot0))
return 0;
const uint8_t *record = ::Item::PeekRecord(ItemForSlot(slot0));
if (record == nullptr)
return 0;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
const uint32_t spellID = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_ID + i * 4);
const uint32_t trigger = Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER + i * 4);
if (spellID != 0 && trigger == Offsets::ITEM_SPELLTRIGGER_ON_EQUIP) {
Game::Lua::PushNumber(L, static_cast<double>(spellID));
return 1;
}
}
return 0;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kSlotArgs[] = {
Game::Doc::Req("slot", "number", "The action slot, 1 to 120."),
};
const Game::Doc::Field kIsStackableRets[] = {
Game::Doc::Opt("isStackable", "number", nullptr,
"1 if the slot's item stacks, else nil."),
};
const Game::Doc::Function kIsStackableAction{
"Returns 1 if an action slot holds an item that stacks.",
kSlotArgs, kIsStackableRets, "ActionBar"};
const Game::Doc::Field kActionIDArgs[] = {
Game::Doc::Req("actionID", "luaIndex"),
};
const Game::Doc::Field kCIsStackableRets[] = {
Game::Doc::Req("isStackableAction", "bool"),
};
const Game::Doc::Function kCIsStackableAction{
"Whether the action's item stacks.",
kActionIDArgs, kCIsStackableRets};
const Game::Doc::Field kOnEquipRets[] = {
Game::Doc::Opt("onEquipSpellID", "number", nullptr,
"Nil when the action has no item or its item has no on-equip spell."),
};
const Game::Doc::Function kGetItemActionOnEquipSpellID{
"The spell the action's item grants while equipped.",
kActionIDArgs, kOnEquipRets};
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsStackableAction", &Script_IsStackableAction,
&kIsStackableAction);
Game::Lua::RegisterTableFunction("C_ActionBar", "IsStackableAction",
&Script_C_IsStackableAction, &kCIsStackableAction);
Game::Lua::RegisterTableFunction("C_ActionBar", "GetItemActionOnEquipSpellID",
&Script_GetItemActionOnEquipSpellID,
&kGetItemActionOnEquipSpellID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Action::SlotItem
+125
View File
@@ -0,0 +1,125 @@
// 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/>.
// `ACTION_USABLE_CHANGED(changes)` — the slots whose usable / noMana pair
// changed, as `{ slot, usable, noMana }` entries.
//
// The engine's bulk refresh FUN_ACTION_UPDATE_USABLE already finds them: it
// computes each slot with FUN_ACTION_SLOT_USABLE, compares the pair against
// the caches IsUsableAction reads, stores a changed pair, and signals the
// argument-less ACTIONBAR_UPDATE_USABLE once when any changed. Two of its
// calls are redirected. The per-slot call records the slots whose pair differs
// from the caches, the same compare the engine makes next. The signal sends
// ACTIONBAR_UPDATE_USABLE unchanged, then this event with the recorded slots.
//
// The per-slot refresh in FUN_ACTION_SLOT_CHANGED_NOTIFY stores its pair
// without this event; it signals ACTIONBAR_SLOT_CHANGED for the slot instead.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include <cstdint>
#include <cstring>
namespace Action::UsableChanged {
namespace {
using SlotUsable_t = uint32_t(__fastcall *)(uint32_t slot0, uint32_t *outNoMana);
using FireEvent_t = void(__fastcall *)(int eventID);
struct Change {
uint32_t slot0;
bool usable;
bool noMana;
};
// This refresh's changes. The loop runs slots 0..119 in order, so slot 0
// starts a new list.
Change g_changes[Offsets::ACTION_TABLE_MAX_SLOTS];
int g_count = 0;
const Game::Doc::Field kStateFields[] = {
Game::Doc::Req("slot", "luaIndex"),
Game::Doc::Req("usable", "bool"),
Game::Doc::Req("noMana", "bool"),
};
const Game::Doc::Structure kState{"ActionUsableState", "ActionBar", kStateFields};
const Game::Doc::Field kPayload[] = {
Game::Doc::Req("changes", "table", "An ActionUsableState for each slot that changed."),
};
const Game::Doc::Event kDoc{
"ActionBar", "Fires when the usable or noMana state of one or more action slots changes.",
kPayload};
const Event::Custom::AutoReserve kEvent{"ACTION_USABLE_CHANGED", &kDoc};
uint32_t __fastcall SlotUsable_h(uint32_t slot0, uint32_t *outNoMana) {
const uint32_t usable =
reinterpret_cast<SlotUsable_t>(Offsets::FUN_ACTION_SLOT_USABLE)(slot0, outNoMana);
if (slot0 == 0)
g_count = 0;
const auto *usableCache =
reinterpret_cast<const uint32_t *>(static_cast<uintptr_t>(Offsets::VAR_ACTION_USABLE));
const auto *noManaCache =
reinterpret_cast<const uint32_t *>(static_cast<uintptr_t>(Offsets::VAR_ACTION_NO_MANA));
if (slot0 < static_cast<uint32_t>(Offsets::ACTION_TABLE_MAX_SLOTS) &&
g_count < Offsets::ACTION_TABLE_MAX_SLOTS &&
(usable != usableCache[slot0] || *outNoMana != noManaCache[slot0]))
g_changes[g_count++] = {slot0, usable != 0, *outNoMana != 0};
return usable;
}
void __fastcall FireUsable_h(int eventID) {
// Copied first: the handlers below can run another refresh.
Change changes[Offsets::ACTION_TABLE_MAX_SLOTS];
const int count = g_count;
std::memcpy(changes, g_changes, sizeof(Change) * count);
g_count = 0;
reinterpret_cast<FireEvent_t>(Offsets::FUN_FIRE_EVENT_NO_ARGS)(eventID);
const int event = kEvent.Slot();
void *L = Game::Lua::State();
if (count == 0 || !Event::Custom::HasListeners(event) || L == nullptr)
return;
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L);
for (int i = 0; i < count; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "slot", static_cast<double>(changes[i].slot0 + 1));
Game::Lua::SetFieldBool(L, "usable", changes[i].usable);
Game::Lua::SetFieldBool(L, "noMana", changes[i].noMana);
Game::Lua::RawSet(L, -3);
}
Game::Lua::SetN(L, -1, count);
Event::Custom::FireValues(event, L, top + 1, 1);
Game::Lua::SetTop(L, top);
}
void Register() {
Common::RedirectCall(Offsets::CALL_UPDATE_USABLE_SLOT_USABLE, Offsets::FUN_ACTION_SLOT_USABLE,
reinterpret_cast<const void *>(&SlotUsable_h));
Common::RedirectCall(Offsets::CALL_UPDATE_USABLE_FIRE, Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<const void *>(&FireUsable_h));
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace Action::UsableChanged
+68 -64
View File
@@ -15,18 +15,19 @@
// this module makes the engine think they do — without writing anything
// to the filesystem. The addons are `Manifest::kAddons` (the `ADDONS`
// list in CMakeLists.txt): `!!!ClassicAPI`, which loads at login, and
// `ClassicAPI_DebugTools`, which is `## LoadOnDemand` behind the
// `/dump` / `/fstack` / `/etrace` stubs in `!!!ClassicAPI` — retail's
// chat-frame + Blizzard_DebugTools split. (The `Blizzard_` name is not
// `ClassicAPI_DebugTools` and `ClassicAPI_EventTrace`, which are
// `## LoadOnDemand` behind the `/dump` / `/fstack` / `/etrace` stubs in
// `!!!ClassicAPI` — retail's chat-frame + Blizzard_DebugTools /
// Blizzard_EventTrace split. (The `Blizzard_` name is not
// available to us: the engine moves loose `Interface\AddOns\Blizzard*`
// folders aside at startup, `MoveAsideLooseInterface`, and refuses a
// `Blizzard_*` TOC without a signature.)
//
// How:
//
// 1. The CMake build embeds every file of every listed addon as an
// RCDATA resource, plus a generated `{path, resourceId}` manifest
// (`embedded_resources.h`).
// 1. The CMake build embeds each listed addon as one RCDATA resource,
// a gzipped pack of its files, plus a generated
// `{path, resourceId, index}` 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
@@ -34,6 +35,9 @@
// via the same allocator the engine itself uses, copy the
// embedded content in, and hand it back. The caller's normal
// `SMemFree` reclaims it on completion — no special lifetime.
// A TOC then goes through `AddOns::TocRewrite`, as an installed
// addon's does, so its file lines can carry `[AllowLoadGameType ...]`
// / `[ExcludeLoadGameType ...]` conditions.
//
// 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
@@ -61,6 +65,7 @@
#include "embedded_resources.h"
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "addons/Embedded.h"
@@ -99,26 +104,12 @@ constexpr const char *kDevMarkerFile = ".classicapi-dev";
// paths are case-insensitive.
constexpr const char *kAddOnsPrefix = "Interface\\AddOns\\";
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a && *b) {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
// `path` with the case-insensitive `prefix` stripped, NULL when it does
// not start with it.
const char *StripPrefixCI(const char *path, const char *prefix) {
while (*prefix) {
if (*path == '\0') return nullptr;
if (NormalizeChar(*path) != NormalizeChar(*prefix)) return nullptr;
if (Common::FoldPathChar(*path) != Common::FoldPathChar(*prefix)) return nullptr;
++path; ++prefix;
}
return path;
@@ -126,7 +117,7 @@ const char *StripPrefixCI(const char *path, const char *prefix) {
int IndexOfAddon(const char *name) {
for (size_t i = 0; i < kAddonCount; ++i) {
if (PathEqualsCI(name, kAddons[i]))
if (Common::SamePath(name, kAddons[i]))
return static_cast<int>(i);
}
return -1;
@@ -140,7 +131,7 @@ int MatchAddonPath(const char *path, const char **relPath) {
if (rel == nullptr) return -1;
for (size_t i = 0; i < kAddonCount; ++i) {
const char *rest = StripPrefixCI(rel, kAddons[i]);
if (rest != nullptr && NormalizeChar(*rest) == '\\') {
if (rest != nullptr && Common::FoldPathChar(*rest) == '\\') {
*relPath = rel;
return static_cast<int>(i);
}
@@ -151,11 +142,10 @@ int MatchAddonPath(const char *path, const char **relPath) {
// The embedded bytes for manifest path `relPath`; `data` is NULL when it
// is not embedded.
Embed::Bytes LookupEmbedded(const char *relPath) {
for (size_t i = 0; i < Manifest::kFileCount; ++i) {
if (PathEqualsCI(relPath, Manifest::kFiles[i].path))
return Embed::Resource(Manifest::kFiles[i].resourceId);
}
return {nullptr, 0};
const Manifest::File *file = Embed::FindFile(Manifest::kFiles, relPath);
if (file == nullptr)
return {nullptr, 0};
return Embed::File(file->resourceId, file->index);
}
// Engine file I/O + Storm allocator — see addons/EngineIO.h for the shapes and
@@ -301,6 +291,49 @@ void DecideSource(int addon) {
g_source[addon] = (cmp > 0) ? Source::Embedded : Source::Disk;
}
// Serves one file of embedded addon `addon` from whichever source won
// DecideSource.
int ReadAddonFile(int addon, const char *relPath, int unused, const char *path,
void **outBuf, size_t *outSize, size_t extraBytes, int flag1,
int flag2) {
DecideSource(addon);
if (g_source[addon] == Source::Disk) {
// Disk version is at least as new — serve disk. If disk
// doesn't have this specific file (e.g. embedded has files
// disk doesn't), fall through and try embedded.
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
extraBytes, flag1, flag2);
if (diskResult != 0) return diskResult;
}
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
// Look up the embedded version and synthesize a Storm buffer the
// caller can free normally.
const Embed::Bytes entry = LookupEmbedded(relPath);
if (entry.data == nullptr) {
// Not in our embedded set either — let the engine try disk
// one more time (returns 0 if that also fails). Covers
// pathological cases like an empty disk install + embedded
// missing some file.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
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);
if (extraBytes > 0) {
std::memset(static_cast<uint8_t *>(buf) + entry.size, 0, extraBytes);
}
if (outBuf != nullptr) *outBuf = buf;
if (outSize != nullptr) *outSize = entry.size;
return 1;
}
int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
size_t *outSize, size_t extraBytes,
int flag1, int flag2) {
@@ -344,42 +377,13 @@ int __stdcall FileRead_h(int unused, const char *path, void **outBuf,
return result;
}
DecideSource(addon);
if (g_source[addon] == Source::Disk) {
// Disk version is at least as new — serve disk. If disk
// doesn't have this specific file (e.g. embedded has files
// disk doesn't), fall through and try embedded.
const int diskResult = FileRead_o(unused, path, outBuf, outSize,
extraBytes, flag1, flag2);
if (diskResult != 0) return diskResult;
}
// Source::Embedded path, OR Source::Disk-but-file-missing fallback.
// Look up the embedded version and synthesize a Storm buffer the
// caller can free normally.
const Embed::Bytes entry = LookupEmbedded(relPath);
if (entry.data == nullptr) {
// Not in our embedded set either — let the engine try disk
// one more time (returns 0 if that also fails). Covers
// pathological cases like an empty disk install + embedded
// missing some file.
return FileRead_o(unused, path, outBuf, outSize, extraBytes,
flag1, flag2);
}
auto SMemAlloc = reinterpret_cast<SMemAllocFn>(
Offsets::FUN_STORM_SMEM_ALLOC);
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);
if (extraBytes > 0) {
std::memset(static_cast<uint8_t *>(buf) + entry.size, 0, extraBytes);
}
if (outBuf != nullptr) *outBuf = buf;
if (outSize != nullptr) *outSize = entry.size;
return 1;
// An embedded addon's TOC takes the same per-line directives as an
// installed addon's, whichever source serves it.
const int result = ReadAddonFile(addon, relPath, unused, path, outBuf, outSize,
extraBytes, flag1, flag2);
if (result != 0)
AddOns::TocRewrite::Transform(path, outBuf, outSize);
return result;
}
// The exists-check counterpart of FileRead_h: the same source decision and
+1 -5
View File
@@ -408,11 +408,7 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnInterfaceVersion",
&Script_GetAddOnInterfaceVersion,
&kGetAddOnInterfaceVersion);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
sizeof(kAddOnSecurityStatusEntries) /
sizeof(kAddOnSecurityStatusEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "AddOnSecurityStatus", kAddOnSecurityStatusEntries);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+9 -24
View File
@@ -89,8 +89,8 @@ void PatchLoadWindow() {
reinterpret_cast<const void *>(&LoadWindowCloseThunk));
}
int __fastcall Script_UpdateAddOnMemoryUsage(void * /*L*/) {
Gc::Tag::UpdateUsage();
int __fastcall Script_UpdateAddOnMemoryUsage(void *L) {
Gc::Tag::UpdateUsage(L);
return 0;
}
@@ -107,30 +107,15 @@ int __fastcall Script_GetAddOnMemoryUsage(void *L) {
}
// `_classicapi_LuaHeapAudit()` → objectBytes, nblocks, taggedObjects.
// Walks every collectable object the engine can reach — the main list,
// the userdata list and the string table, the three lists `luaC_sweep`
// sweeps — summing `Gc::Tag::SizeOf`. `objectBytes` should sit just under
// `nblocks` (the engine's own byte count): the gap is what is not a GC
// object (string-table buckets, the main thread and its stack, buffers).
// A gap that grows with the heap means a size formula is wrong.
uint64_t SumList(const uint8_t *object) {
uint64_t total = 0;
while (object != nullptr) {
total += Gc::Tag::SizeOf(object);
object = Game::Read<const uint8_t *>(object, Offsets::OFF_LUA_GCHEADER_NEXT);
}
return total;
}
// Sums `Gc::Tag::SizeOf` over every collectable object the engine can
// reach. `objectBytes` should sit just under `nblocks` (the engine's own
// byte count): the gap is what is not a GC object (string-table buckets,
// the main thread and its stack, buffers). A gap that grows with the heap
// means a size formula is wrong.
int __fastcall Script_LuaHeapAudit(void *L) {
uint64_t bytes = 0;
Gc::Tag::ForEachObject(L, [&bytes](const void *object) { bytes += Gc::Tag::SizeOf(object); });
const uint8_t *g = Game::Read<const uint8_t *>(L, Offsets::OFF_LUA_STATE_G);
uint64_t bytes = SumList(Game::Read<const uint8_t *>(g, Offsets::OFF_LUA_G_ROOTGC)) +
SumList(Game::Read<const uint8_t *>(g, Offsets::OFF_LUA_G_ROOTUDATA));
const auto buckets =
Game::Read<const uint8_t *const *>(g, Offsets::OFF_LUA_G_STRT_HASH);
const int size = Game::Read<int>(g, Offsets::OFF_LUA_G_STRT_SIZE);
for (int i = 0; i < size; ++i)
bytes += SumList(buckets[i]);
Game::Lua::PushNumber(L, static_cast<double>(bytes));
Game::Lua::PushNumber(L, static_cast<double>(Game::Read<uint32_t>(g, Offsets::OFF_LUA_G_NBLOCKS)));
Game::Lua::PushNumber(L, static_cast<double>(Gc::Tag::TaggedCount()));
+16 -14
View File
@@ -14,8 +14,10 @@
#include "addons/EngineIO.h"
#include "addons/TocRewrite.h"
#include "addons/Toc.h"
#include "localization/Locale.h"
#include "Offsets.h"
#include "turtle/Detect.h"
#include <cstddef>
#include <cstdint>
@@ -33,17 +35,7 @@ using AddOns::Toc::Lower;
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
// The client locale code ("enUS", "frFR", …) — the exact string
// GetLocale() returns (Offsets.h VAR_LOCALE_NAME_TABLE derivation).
const char *ClientLocale() {
uint32_t idx = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
if (idx > 7) idx = 0; // slot 8 overreads the string pool — clamp
const char *const *table = reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_NAME_TABLE));
const char *s = table[idx];
return (s != nullptr) ? s : "enUS";
}
using Localization::Locale::Client;
// True iff the comma-separated `list` (spaces allowed) contains `token` (CI).
bool ListContains(const char *list, size_t listLen, const char *token) {
@@ -65,7 +57,7 @@ bool ListContains(const char *list, size_t listLen, const char *token) {
const char *VariableValue(const char *head, size_t headLen) {
if (EqCI(head, headLen, "Family")) return "Classic";
if (EqCI(head, headLen, "Game")) return "Vanilla";
if (EqCI(head, headLen, "TextLocale")) return ClientLocale();
if (EqCI(head, headLen, "TextLocale")) return Client();
return nullptr;
}
@@ -139,15 +131,25 @@ bool TryRewriteInterfaceLine(const char *line, size_t len, std::string &out) {
return true;
}
// True iff the game-type list names a game type this client is: always
// `vanilla`, and `turtle` (a ClassicAPI extension, like the `_Turtle.toc`
// flavor) on a Turtle client.
bool ListContainsGameType(const char *list, size_t listLen) {
return ListContains(list, listLen, "vanilla") ||
(Turtle::Detected() && ListContains(list, listLen, "turtle"));
}
// Evaluate a `[keyword args]` condition — the return is whether the line should
// load. An unrecognized keyword returns false so the caller drops the line (a
// condition we cannot confirm must not load — fail safe).
bool EvalCondition(const char *head, size_t headLen, const char *args,
size_t argsLen) {
if (EqCI(head, headLen, "AllowLoadGameType"))
return ListContains(args, argsLen, "vanilla");
return ListContainsGameType(args, argsLen);
if (EqCI(head, headLen, "ExcludeLoadGameType"))
return !ListContainsGameType(args, argsLen);
if (EqCI(head, headLen, "AllowLoadTextLocale"))
return ListContains(args, argsLen, ClientLocale());
return ListContains(args, argsLen, Client());
if (EqCI(head, headLen, "AllowLoad"))
return ListContains(args, argsLen, "game"); // addon files: in-game only
return false;
+9 -1
View File
@@ -42,7 +42,15 @@ namespace AddOns::TocRewrite {
// below); metadata-line conditions, added in retail 12.0.7, are out of
// scope. What the tokens resolve to on this 1.12 client:
//
// [AllowLoadGameType ...] keep the line iff the list contains `vanilla`
// [AllowLoadGameType ...] keep the line iff the list names a game type
// of this client
// [ExcludeLoadGameType ...] keep the line iff the list names none
// This client is the `vanilla` game type, and
// also `turtle` (a ClassicAPI extension, like
// the `_Turtle.toc` flavor) when
// Turtle::Detected(). So
// `[ExcludeLoadGameType turtle]` loads a line
// everywhere but Turtle.
// [AllowLoadTextLocale ...] keep the line iff the list contains the
// client locale (GetLocale() code, e.g. enUS)
// [AllowLoad ...] addon files only load in-game, so `game`
+216 -16
View File
@@ -15,6 +15,17 @@
// 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.
//
// Later clients' additions, on the same objects:
// - the target setters (SetTarget, SetTargetName, SetTargetKey,
// SetChildKey, SetTargetParent, GetTarget);
// - Alpha's fromAlpha / toAlpha. Blizzard's own code starts a fade from
// the region's current alpha with SetFromAlpha(region:GetAlpha()), and
// its XML fades in with toAlpha="1" alone, so the alpha is absolute (the
// target's alpha becomes from + (to - from) x progress) and an unset
// fromAlpha is 0. SetChange goes back to 4.3.4's relative change;
// - Scale's SetScaleFrom / SetScaleTo. 4.3.4's scale is the case from = 1,
// and SetScale sets the end, so its old results do not change.
#include "animation/Classes.h"
#include "animation/Region.h"
@@ -25,7 +36,9 @@
#include "baselib/Ascii.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
namespace Animation {
@@ -79,9 +92,15 @@ Anim::~Anim() {
// ---- Playback --------------------------------------------------------------------
bool Anim::Play() {
if (targetName[0] != '\0')
void Anim::ResolveTarget() {
if (!targetKey.empty())
target = Region::FindRegionByKey(this, targetKey.c_str());
else if (targetName[0] != '\0')
target = Region::FindRegionByName(targetName);
}
bool Anim::Play() {
ResolveTarget();
if (playState != 1) {
playState = 1;
RunScript(A_ONPLAY, 0);
@@ -204,14 +223,21 @@ void Anim::Apply(float p) {
return;
switch (kind) {
case KIND_ALPHA:
Region::AddAnimAlpha(region, target, static_cast<int16_t>(change * p));
if (alphaFromTo)
Region::SetAnimAlpha(region, target, static_cast<uint8_t>((fromAlpha + (toAlpha - fromAlpha) * p) * 255.0f));
else
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);
case KIND_SCALE: {
// AddAnimScale takes the share of the way to the origin, 1 - scale.
const float x = scaleFromX + (scaleToX - scaleFromX) * p;
const float y = scaleFromY + (scaleToY - scaleFromY) * p;
Region::AddAnimScale(region, target, originPoint, originX, originY, 1.0f - x, 1.0f - y);
break;
}
case KIND_ROTATION:
Region::AddAnimRotation(region, target, originPoint, originX, originY, radians * p);
break;
@@ -473,6 +499,101 @@ int __fastcall Script_GetRegionParent(void *L) {
return 1;
}
// ---- Target (later clients) ----
//
// An animation with no target animates its group's region. Each setter
// replaces the others' target and returns whether it names a region now;
// a name or key is looked up again on Play, as 4.3.4 looks up `target`.
int PushTargetFound(void *L, const Anim *a) {
Game::Lua::PushBool(L, a->target != nullptr);
return 1;
}
void *RegionArg(void *L, int idx) {
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_TABLE)
return nullptr;
void *object = Game::Lua::ResolveObject(L, idx);
if (object == nullptr || IsOurs(object) || !Game::Lua::IsA(object, Offsets::VAR_REGION_LUA_TYPE_ID))
return nullptr;
return object;
}
const char *StringArg(void *L, Anim *a, const char *usage) {
if (Game::Lua::IsString(L, 2))
return Game::Lua::ToString(L, 2);
Game::Lua::Error(L, usage, a->DisplayName());
return nullptr;
}
int __fastcall Script_SetTarget(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
void *region = RegionArg(L, 2);
if (region == nullptr) {
Game::Lua::PushBool(L, false);
return 1;
}
a->targetName[0] = '\0';
a->targetKey.clear();
a->target = region;
return PushTargetFound(L, a);
}
int __fastcall Script_SetTargetName(void *L) {
Anim *a = Self(L);
const char *name = a != nullptr ? StringArg(L, a, "Usage: %s:SetTargetName(name)") : nullptr;
if (name == nullptr)
return 0;
std::snprintf(a->targetName, sizeof(a->targetName), "%s", CreateName(name, a).c_str());
a->targetKey.clear();
a->target = nullptr;
a->ResolveTarget();
return PushTargetFound(L, a);
}
int __fastcall Script_SetTargetKey(void *L) {
Anim *a = Self(L);
const char *key = a != nullptr ? StringArg(L, a, "Usage: %s:SetTargetKey(key)") : nullptr;
if (key == nullptr)
return 0;
a->targetName[0] = '\0';
a->targetKey = key;
a->target = nullptr;
a->ResolveTarget();
return PushTargetFound(L, a);
}
int __fastcall Script_SetChildKey(void *L) {
Anim *a = Self(L);
const char *key = a != nullptr ? StringArg(L, a, "Usage: %s:SetChildKey(childKey)") : nullptr;
if (key == nullptr)
return 0;
a->targetName[0] = '\0';
a->targetKey = std::string("$parent.$parent.") + key;
a->target = nullptr;
a->ResolveTarget();
return PushTargetFound(L, a);
}
int __fastcall Script_SetTargetParent(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
a->targetName[0] = '\0';
a->targetKey.clear();
a->target = a->GetRegionParent();
return PushTargetFound(L, a);
}
int __fastcall Script_GetTarget(void *L) {
Anim *a = Self(L);
if (a == nullptr)
return 0;
void *region = a->target != nullptr ? a->target : a->GetRegionParent();
if (region != nullptr)
Game::Lua::PushObject(L, region);
else
Game::Lua::PushNil(L);
return 1;
}
// ---- Alpha ----
int __fastcall Script_SetChange(void *L) {
@@ -483,6 +604,7 @@ int __fastcall Script_SetChange(void *L) {
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);
a->alphaFromTo = false;
return 0;
}
int __fastcall Script_GetChange(void *L) {
@@ -493,6 +615,39 @@ int __fastcall Script_GetChange(void *L) {
return 1;
}
// Later clients' absolute alpha. Setting either end turns it on; the other
// end keeps its value, 0 until set.
void SetAlphaEnd(void *L, float Anim::*end, const char *usage) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr || !NumberArg(L, 2, a, usage))
return;
const float value = static_cast<float>(Game::Lua::ToNumber(L, 2));
a->*end = value < 0.0f ? 0.0f : (value > 1.0f ? 1.0f : value);
a->alphaFromTo = true;
}
int __fastcall Script_SetFromAlpha(void *L) {
SetAlphaEnd(L, &Anim::fromAlpha, "Usage: %s:SetFromAlpha(alpha)");
return 0;
}
int __fastcall Script_SetToAlpha(void *L) {
SetAlphaEnd(L, &Anim::toAlpha, "Usage: %s:SetToAlpha(alpha)");
return 0;
}
int __fastcall Script_GetFromAlpha(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->fromAlpha);
return 1;
}
int __fastcall Script_GetToAlpha(void *L) {
Anim *a = SelfOf(L, Anim::s_alphaClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->toAlpha);
return 1;
}
// ---- Translation ----
int __fastcall Script_SetOffset(void *L) {
@@ -572,19 +727,57 @@ int __fastcall Script_SetScale(void *L) {
Game::Lua::Error(L, "Usage: %s:SetScale(x, y)", a->DisplayName());
return 0;
}
// CSimpleScaleAnim::SetScale.
// CSimpleScaleAnim::SetScale: the scale it ends on.
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);
a->scaleToX = x >= 0.001f ? x : 0.001f;
a->scaleToY = 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);
Game::Lua::PushNumber(L, a->scaleToX);
Game::Lua::PushNumber(L, a->scaleToY);
return 2;
}
// Later clients' SetScaleFrom / SetScaleTo: the scale the animation starts
// and ends on, as given.
void SetScaleEnd(void *L, float Anim::*x, float Anim::*y, const char *usage) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return;
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, usage, a->DisplayName());
return;
}
a->*x = static_cast<float>(Game::Lua::ToNumber(L, 2));
a->*y = static_cast<float>(Game::Lua::ToNumber(L, 3));
}
int __fastcall Script_SetScaleFrom(void *L) {
SetScaleEnd(L, &Anim::scaleFromX, &Anim::scaleFromY, "Usage: %s:SetScaleFrom(x, y)");
return 0;
}
int __fastcall Script_SetScaleTo(void *L) {
SetScaleEnd(L, &Anim::scaleToX, &Anim::scaleToY, "Usage: %s:SetScaleTo(x, y)");
return 0;
}
int __fastcall Script_GetScaleFrom(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->scaleFromX);
Game::Lua::PushNumber(L, a->scaleFromY);
return 2;
}
int __fastcall Script_GetScaleTo(void *L) {
Anim *a = SelfOf(L, Anim::s_scaleClass);
if (a == nullptr)
return 0;
Game::Lua::PushNumber(L, a->scaleToX);
Game::Lua::PushNumber(L, a->scaleToY);
return 2;
}
@@ -647,24 +840,31 @@ const Game::Lua::FrameMethodEntry kAnimMethods[] = {
{"GetSmoothing", &Script_GetSmoothing},
{"SetParent", &Script_SetParent},
{"GetRegionParent", &Script_GetRegionParent},
{"SetTarget", &Script_SetTarget},
{"SetTargetName", &Script_SetTargetName},
{"SetTargetKey", &Script_SetTargetKey},
{"SetChildKey", &Script_SetChildKey},
{"SetTargetParent", &Script_SetTargetParent},
{"GetTarget", &Script_GetTarget},
{"HasScript", &Script_HasScript},
{"GetScript", &Script_GetScript},
{"SetScript", &Script_SetScript},
{"HookScript", &Script_HookScript},
};
const Game::Lua::FrameMethodEntry kAlphaMethods[] = {
{"SetChange", &Script_SetChange},
{"GetChange", &Script_GetChange},
{"SetChange", &Script_SetChange}, {"GetChange", &Script_GetChange},
{"SetFromAlpha", &Script_SetFromAlpha}, {"GetFromAlpha", &Script_GetFromAlpha},
{"SetToAlpha", &Script_SetToAlpha}, {"GetToAlpha", &Script_GetToAlpha},
};
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},
{"SetOrigin", &Script_SetOrigin}, {"GetOrigin", &Script_GetOrigin},
{"SetScale", &Script_SetScale}, {"GetScale", &Script_GetScale},
{"SetScaleFrom", &Script_SetScaleFrom}, {"GetScaleFrom", &Script_GetScaleFrom},
{"SetScaleTo", &Script_SetScaleTo}, {"GetScaleTo", &Script_GetScaleTo},
};
const Game::Lua::FrameMethodEntry kRotationMethods[] = {
{"SetOrigin", &Script_SetOrigin}, {"GetOrigin", &Script_GetOrigin}, {"SetDegrees", &Script_SetDegrees},
+21 -3
View File
@@ -16,6 +16,7 @@
#include "animation/Object.h"
#include <cstdint>
#include <string>
#include <vector>
// 4.3.4's CSimpleAnimGroup and CSimpleAnim (and its Alpha, Translation,
@@ -62,6 +63,10 @@ class Group : public Object {
bool HasAlphaAnim() const;
bool HasPositionAnim() const;
// Later clients' setToFinalAlpha: when the group finishes, its alpha
// animations' targets keep the alpha they end on (KeepAnimAlpha).
bool setToFinalAlpha = false;
void NotifyAnimAdded(Anim *anim);
void NotifyOrderChanged(Anim *anim, int oldOrder);
void NotifyAnimRemoved(Anim *anim);
@@ -141,17 +146,30 @@ class Anim : public Object {
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
void *target = nullptr; // m_target, resolved from targetName or targetKey on Play
char targetName[128] = {}; // m_targetName
// Later clients' targetKey: a key path from the animation itself, where
// `$parent` steps to the group, then the group's region, then that
// region's parent. childKey "Icon" is "$parent.$parent.Icon".
std::string targetKey;
bool hasSmoother = false;
float smoothIn = 0.0f, smoothOut = 0.0f;
// Alpha: m_change, -255..255.
// Resolves targetName or targetKey into target, when either is set.
void ResolveTarget();
// Alpha: m_change, -255..255, or (alphaFromTo) the absolute fromAlpha /
// toAlpha, 0..1, that later clients replaced it with.
int16_t change = 0;
bool alphaFromTo = false;
float fromAlpha = 0.0f, toAlpha = 0.0f;
// 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
// Scale: from and to, 1 by default. 4.3.4's m_scaleChange (1 - scale)
// is the case from = 1; later clients added the from scale.
float scaleFromX = 1.0f, scaleFromY = 1.0f;
float scaleToX = 1.0f, scaleToY = 1.0f;
float radians = 0.0f;
int originPoint = 4; // FRAMEPOINT_CENTER
float originX = 0.0f, originY = 0.0f;
+24 -1
View File
@@ -12,7 +12,10 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// 4.3.4's CSimpleAnimGroup (wowdecomp15595 CSimpleAnimGroup.cpp and
// CSimpleAnimGroup_Scripts.cpp), on Animation::Object.
// CSimpleAnimGroup_Scripts.cpp), on Animation::Object, with later clients'
// SetToFinalAlpha / IsSetToFinalAlpha: the final alpha is kept when the
// group finishes (Finish included), and Stop still restores the alpha. No
// source on disk says what a later client's Stop does with the flag.
#include "animation/Classes.h"
#include "animation/Region.h"
@@ -343,6 +346,12 @@ bool Group::OnUpdate(float elapsedSec) {
RunScript(G_ONUPDATE, 1);
}
if (done) {
// setToFinalAlpha: the alpha each alpha animation's target finished on
// becomes what the end restores. Stop restores the alpha as before.
if (setToFinalAlpha)
for (Anim *anim : animations)
if (anim->kind == KIND_ALPHA)
Region::KeepAnimAlpha(RegionObject(), anim->target);
Region::NotifyEnd(RegionObject(), this);
const bool requested = (flags & GROUP_PENDING_FINISH) != 0;
loopState = STATE_NONE;
@@ -632,6 +641,18 @@ int __fastcall Script_GetIgnoreFramerateThrottle(void *L) {
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_SetToFinalAlpha(void *L) {
if (Group *g = Self(L))
g->setToFinalAlpha = Game::Lua::ToFlag(L, 2);
return 0;
}
int __fastcall Script_IsSetToFinalAlpha(void *L) {
Group *g = Self(L);
if (g == nullptr)
return 0;
Game::Lua::PushBool(L, g->setToFinalAlpha);
return 1;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"GetObjectType", &Script_GetObjectType},
@@ -662,6 +683,8 @@ const Game::Lua::FrameMethodEntry kMethods[] = {
{"HookScript", &Script_HookScript},
{"SetIgnoreFramerateThrottle", &Script_SetIgnoreFramerateThrottle},
{"GetIgnoreFramerateThrottle", &Script_GetIgnoreFramerateThrottle},
{"SetToFinalAlpha", &Script_SetToFinalAlpha},
{"IsSetToFinalAlpha", &Script_IsSetToFinalAlpha},
};
} // namespace
+51 -3
View File
@@ -64,10 +64,12 @@
#include "frame/Alpha.h"
#include "frame/Destruction.h"
#include "texture/Transform.h"
#include "xml/Anchors.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
@@ -558,9 +560,8 @@ void RegisterLuaFunctions() {
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])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY), kRegionMethods, kRegionDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
@@ -683,6 +684,28 @@ void AddAnimAlpha(void *region, void *target, int16_t alpha) {
SetVertexAlpha(region, static_cast<uint8_t>(value > 255 ? 255 : (value < 0 ? 0 : value)));
}
void SetAnimAlpha(void *region, void *target, uint8_t alpha) {
if (IsFrame(region)) {
if (target != nullptr && target != region) {
ForEachChild(region, [target, alpha](void *child) { SetAnimAlpha(child, target, alpha); });
return;
}
if (alpha != OwnAlpha(region))
SetOwnAlpha(region, alpha);
return;
}
if (target != nullptr && target != region)
return;
SetVertexAlpha(region, alpha);
}
void KeepAnimAlpha(void *region, void *target) {
void *shown = target != nullptr ? target : region;
AnimData *data = DataOf(shown);
if (data != nullptr && data->hasAlpha)
data->alpha = IsFrame(shown) ? OwnAlpha(shown) : static_cast<uint8_t>(VertexColor(shown) >> 24);
}
namespace {
// The corners (TL, BL, TR, BR) as 2D points.
@@ -842,4 +865,29 @@ void *FindRegionByName(const char *name) {
return object;
}
// The leading `$parent` steps that stay among our objects (an animation to
// its group, a group to its region) are taken here; the rest of the path,
// from that region, is the one Xml::Anchors resolves for relativeKey.
void *FindRegionByKey(Object *from, const char *path) {
if (from == nullptr || path == nullptr)
return nullptr;
void *current = from;
const char *p = path;
while (current != nullptr && IsOurs(current)) {
const char *dot = std::strchr(p, '.');
const std::string part = dot != nullptr ? std::string(p, dot) : std::string(p);
if (Game::StrCmpI(part.c_str(), "$parent") != 0)
return nullptr; // a key on a group or animation names no region
current = static_cast<Object *>(current)->parent;
p = dot != nullptr ? dot + 1 : p + part.size();
}
if (current == nullptr)
return nullptr;
if (*p != '\0')
current = ::Xml::Anchors::ResolveKeyPath(current, p, false);
if (current == nullptr || IsOurs(current) || !Game::Lua::IsA(current, Offsets::VAR_REGION_LUA_TYPE_ID))
return nullptr;
return current;
}
} // namespace Animation::Region
+11
View File
@@ -21,6 +21,7 @@
// frames, textures and font strings, which have no room for them.
namespace Animation {
class Group;
class Object;
}
namespace Animation::Region {
@@ -35,6 +36,12 @@ void NotifyEnd(void *region, Group *group);
void PreOnAnimUpdate(void *region);
// CScriptRegion::AddAnimAlpha(target, alpha) on the group's region.
void AddAnimAlpha(void *region, void *target, int16_t alpha);
// Later clients' absolute alpha (Alpha fromAlpha / toAlpha): the same target
// rule as AddAnimAlpha, but the alpha becomes `alpha` instead of growing.
void SetAnimAlpha(void *region, void *target, uint8_t alpha);
// AnimationGroup setToFinalAlpha: the alpha that `target` (the group's
// `region` when null) shows now becomes the one the group's end restores.
void KeepAnimAlpha(void *region, void *target);
// 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);
@@ -58,5 +65,9 @@ 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);
// The region a later-client targetKey path names, read from `from`: each
// dot-separated part is `$parent` (the object's parent) or a key on the
// current object's table. Nullptr unless the path ends on a region.
void *FindRegionByKey(Object *from, const char *path);
} // namespace Animation::Region
+52 -2
View File
@@ -16,6 +16,11 @@
// 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.
//
// Later clients' attributes go to the same fields their methods set:
// setToFinalAlpha and parentArray on a group; targetKey and childKey on any animation;
// fromAlpha / toAlpha on Alpha; fromScaleX / fromScaleY / toScaleX /
// toScaleY on Scale. Each applies only when present and non-empty.
#include "animation/Xml.h"
@@ -70,6 +75,22 @@ void BindParentKey(void *object, Object *child, const char *key) {
Game::Lua::SetTop(L, top);
}
// Later clients' parentArray on a group: appended to object[key], as a
// frame or region is.
void BindParentArray(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)) {
Push(L, child);
::Xml::AppendToArray(L, top + 1, key);
}
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) {
@@ -205,6 +226,7 @@ void LoadGroupNode(Group *group, const void *node, void *status) {
LoadGroupNode(group, base, status);
}
BindParentKey(group->RegionObject(), group, ::Xml::Attr(node, "parentKey"));
BindParentArray(group->RegionObject(), group, ::Xml::Attr(node, "parentArray"));
if (const char *looping = ::Xml::Attr(node, "looping"); looping != nullptr && *looping != '\0') {
LoopType type;
if (StringToLoopType(looping, type))
@@ -212,6 +234,8 @@ void LoadGroupNode(Group *group, const void *node, void *status) {
else
::Xml::Warn(status, "AnimationGroup %s: Invalid looping value: %s", group->DisplayName(), looping);
}
if (const char *keep = ::Xml::Attr(node, "setToFinalAlpha"); keep != nullptr && *keep != '\0')
group->setToFinalAlpha = Game::StringToFlag(keep);
float x = 0.0f, y = 0.0f;
FloatAttr(node, "initialOffsetX", x);
FloatAttr(node, "initialOffsetY", y);
@@ -262,8 +286,13 @@ void LoadAnim(Anim *anim, const void *node, void *status) {
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);
anim->scaleToX = sx >= 0.001f ? sx : 0.001f;
anim->scaleToY = sy >= 0.001f ? sy : 0.001f;
// Later clients' from and to scale, each axis when given.
FloatAttr(node, "fromScaleX", anim->scaleFromX);
FloatAttr(node, "fromScaleY", anim->scaleFromY);
FloatAttr(node, "toScaleX", anim->scaleToX);
FloatAttr(node, "toScaleY", anim->scaleToY);
break;
}
default:
@@ -285,6 +314,16 @@ void LoadAnim(Anim *anim, const void *node, void *status) {
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());
anim->targetKey.clear();
}
// Later clients' key paths, looked up on Play like `target`.
if (const char *key = ::Xml::Attr(node, "targetKey"); key != nullptr && *key != '\0') {
anim->targetName[0] = '\0';
anim->targetKey = key;
}
if (const char *key = ::Xml::Attr(node, "childKey"); key != nullptr && *key != '\0') {
anim->targetName[0] = '\0';
anim->targetKey = std::string("$parent.$parent.") + key;
}
if (FloatAttr(node, "startDelay", x))
anim->startDelay = x < 0.0f ? 0.0f : x;
@@ -319,6 +358,17 @@ void LoadAnim(Anim *anim, const void *node, void *status) {
x = 0.0f;
}
anim->change = static_cast<int16_t>((x < -1.0f ? -1.0f : (x >= 1.0f ? 1.0f : x)) * 255.0f);
if (::Xml::Attr(node, "change") != nullptr)
anim->alphaFromTo = false;
// Later clients' absolute alpha: either end turns it on.
if (FloatAttr(node, "fromAlpha", x)) {
anim->fromAlpha = x < 0.0f ? 0.0f : (x > 1.0f ? 1.0f : x);
anim->alphaFromTo = true;
}
if (FloatAttr(node, "toAlpha", x)) {
anim->toAlpha = x < 0.0f ? 0.0f : (x > 1.0f ? 1.0f : x);
anim->alphaFromTo = true;
}
}
if (anim->kind == KIND_SCALE || anim->kind == KIND_ROTATION)
LoadOrigin(anim, node);
+12 -7
View File
@@ -665,10 +665,15 @@ void RecordTable(const char *table, const char *name, const Function *doc) {
Record(KIND_TABLE, table, name, nullptr, doc);
}
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Method *docs, int docCount) {
void RecordFrameMethods(const void *registry,
Game::ArrayRef<Game::Lua::FrameMethodEntry> methods,
Game::Doc::MethodList docList) {
if (g_pass == ENV_NONE)
return;
const Game::Lua::FrameMethodEntry *table = methods.data;
const int count = methods.count;
const Method *docs = docList.data;
const int docCount = docList.count;
for (int i = 0; i < count; ++i) {
const Function *doc = nullptr;
for (int d = 0; d < docCount; ++d) {
@@ -720,14 +725,14 @@ void RecordValueTable(Kind kind, const char *parent, const char *sub,
} // namespace
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
RecordValueTable(KIND_ENUM, parent, sub, entries, count, system);
void RecordEnum(const char *parent, const char *sub,
Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries, const char *system) {
RecordValueTable(KIND_ENUM, parent, sub, entries.data, entries.count, system);
}
void RecordConstants(const char *sub, const Game::Lua::EnumIntegerEntry *entries, int count,
void RecordConstants(const char *sub, Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries,
const char *system) {
RecordValueTable(KIND_CONSTANTS, "Constants", sub, entries, count, system);
RecordValueTable(KIND_CONSTANTS, "Constants", sub, entries.data, entries.count, system);
}
} // namespace Api::Documentation
+6 -5
View File
@@ -32,14 +32,15 @@ void EndPass();
void RecordGlobal(const char *name, const Game::Doc::Function *doc);
void RecordTable(const char *table, const char *name, const Game::Doc::Function *doc);
void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry *table,
int count, const Game::Doc::Method *docs, int docCount);
void RecordFrameMethods(const void *registry,
Game::ArrayRef<Game::Lua::FrameMethodEntry> methods,
Game::Doc::MethodList docs);
// 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);
void RecordConstants(const char *sub, const Game::Lua::EnumIntegerEntry *entries, int count,
void RecordEnum(const char *parent, const char *sub,
Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries, const char *system);
void RecordConstants(const char *sub, Game::ArrayRef<Game::Lua::EnumIntegerEntry> entries,
const char *system);
} // namespace Api::Documentation
+2 -9
View File
@@ -1023,16 +1023,9 @@ CompressState *FindOrClaimCompressState(uint64_t targetGuid,
// would compress it, this gate excludes it exactly as the server's does.
// (Drain Soul passes via its effect-2 periodic damage: aura=[86,3,0].)
bool HasPeriodicDamageOrLeech(const uint8_t *rec) {
if (rec == nullptr)
return false;
constexpr int32_t kPeriodicDamage = 3, kPeriodicLeech = 53;
for (int i = 0; i < 3; ++i) {
const int32_t aura = *reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4);
if (aura == kPeriodicDamage || aura == kPeriodicLeech)
return true;
}
return false;
return Spell::Lookup::HasAuraEffect(rec, kPeriodicDamage) ||
Spell::Lookup::HasAuraEffect(rec, kPeriodicLeech);
}
// Compression pairs by CASTER, mirroring the server (each warlock's Dark
+468
View File
@@ -0,0 +1,468 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// C_AutoComplete: the player names an edit box can complete.
//
// 4.3.4's CGAutoCompleteName keeps its own list of names, each with flag bits
// that the party, raid, guild, friend list and chat code add and remove as
// those lists change. 1.12 has no such list, but it keeps every list the bits
// come from, so a query reads them where the engine keeps them:
// - party: the four VAR_PARTY_GUIDS, the name from the name cache, online
// from the member stats block (Group::MemberStats) - InGroup;
// - raid: the VAR_RAID_MEMBER_PTRS slots and their online bit - InGroup;
// - guild roster: each GuildMemberInfo's GUID, name and online word, read
// only while the player is in a guild, the test Script_IsInGuild
// (0x00516DE0) makes, because the engine keeps the old roster after a
// /gquit - InGuild;
// - friends list: the social singleton's slots (FriendList.h) - Friend.
// The one list the engine does not keep is ours: the players this character
// whispered or was whispered by (Chat::Dispatch reports each line), in the
// order they were last heard from - InteractedWith. Logging out clears it,
// as 4.3.4's ShutdownGame clears its list.
//
// A player on several lists is one entry with every list's bit. A list that
// holds the player decides Online; a player known only from a whisper is
// Online, as 4.3.4 sets Online together with InteractedWith.
//
// Matching is 4.3.4's NodeMatchesParams (the same in 3.3.5's binary,
// 0x0057ACA0), with two differences:
// - a cursorPosition outside 1..length means the end of the text. Retail's
// tell-target extraction (ChatFrameEditBox.lua) passes 0 and expects a
// whole-text match;
// - the edit-from-center search also tries the last place. The engine's
// loop stops one place early, so it never finds the text after the
// cursor at the end of a name.
// Order is 3.3.5's SortNameNode (0x0057B130): with
// autoCompleteResortNamesOnRecency on, the most recent whisper first, then by
// name. Retail's client cannot be read, so each result's priority is the
// highest Enum.AutoCompletePriority its bits reach, in the enum's own order:
// Friend, Guild, InGroup, Interacted, else Other.
#include "autocomplete/Names.h"
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
#include "friendlist/FriendList.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "text/Utf8.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
namespace AutoComplete::Names {
namespace {
// Enum.AutoCompleteEntryFlag. 1.12 has nothing to fill Bnet, InAOI,
// AccountCharacter or RecentPlayer from.
enum EntryFlag : uint32_t {
FLAG_IN_GROUP = 0x1,
FLAG_IN_GUILD = 0x2,
FLAG_FRIEND = 0x4,
FLAG_BNET = 0x8,
FLAG_INTERACTED_WITH = 0x10,
FLAG_ONLINE = 0x20,
FLAG_IN_AOI = 0x40,
FLAG_ACCOUNT_CHARACTER = 0x80,
FLAG_RECENT_PLAYER = 0x100,
};
// Enum.AutoCompletePriority.
enum Priority {
PRIORITY_OTHER = 0,
PRIORITY_INTERACTED = 1,
PRIORITY_IN_GROUP = 2,
PRIORITY_GUILD = 3,
PRIORITY_FRIEND = 4,
PRIORITY_ACCOUNT_CHARACTER = 5,
PRIORITY_ACCOUNT_CHARACTER_SAME_REALM = 6,
};
constexpr int CHAT_MSG_WHISPER = 6;
constexpr int CHAT_MSG_WHISPER_INFORM = 7;
constexpr int LANGUAGE_ADDON = -1; // the dispatch's addon-message arm
CVar::Factory::Handle g_resortOnRecency = nullptr;
CVar::Factory::Handle g_editFromCenter = nullptr;
CVar::Factory::Handle g_useContext = nullptr;
bool CVarOn(CVar::Factory::Handle cvar) { return CVar::Factory::GetInt(cvar, 1) != 0; }
// A whisper partner. `order` grows with every whisper, so a larger one was
// heard from more recently.
struct Interaction {
uint64_t guid;
std::string name;
uint32_t order;
};
std::vector<Interaction> g_interactions;
uint32_t g_lastOrder = 0;
// One player as one list knows them, or, after Merge, as all lists do.
struct Entry {
uint64_t guid;
const char *name; // the list's own storage, valid until Lua runs
uint32_t flags; // EntryFlag bits, Online excluded
bool listed; // a group, guild or friends list holds the player
bool online; // what those lists say
uint32_t order; // the Interaction's order, 0 for none
};
bool InGuild() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return false;
const auto *info = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
return info != nullptr && Game::Read<uint32_t>(info, Offsets::OFF_PLAYER_INFO_GUILD_KEY) != 0;
}
// Every player whose name `match` accepts, one entry per list that has them.
template <typename Match>
std::vector<Entry> Collect(Match match) {
std::vector<Entry> found;
const uint64_t self = Unit::Identity::PlayerGuid();
const auto add = [&](uint64_t guid, const char *name, uint32_t flag, bool listed, bool online,
uint32_t order) {
if (guid == 0 || guid == self || name == nullptr || *name == '\0' || !match(name))
return;
found.push_back({guid, name, flag, listed, online, order});
};
const auto *party =
reinterpret_cast<const uint64_t *>(static_cast<uintptr_t>(Offsets::VAR_PARTY_GUIDS));
for (int i = 0; i < Offsets::PARTY_MAX_SLOTS; ++i) {
const uint64_t guid = party[i];
if (guid != 0)
add(guid, Unit::Identity::NameForGuid(guid), FLAG_IN_GROUP, true,
Group::MemberStats::Lookup(guid).online, 0);
}
int raidCount = Game::Read<int>(Offsets::VAR_RAID_MEMBER_COUNT);
if (raidCount > Offsets::RAID_MAX_SLOTS)
raidCount = Offsets::RAID_MAX_SLOTS;
const auto *slots = reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_RAID_MEMBER_PTRS));
for (int i = 0; i < raidCount; ++i) {
if (slots[i] == nullptr)
continue;
const uint64_t guid = Game::Read<uint64_t>(slots[i], 0);
const uint32_t flags = Game::Read<uint32_t>(slots[i], Offsets::OFF_RAID_SLOT_FLAGS);
add(guid, Unit::Identity::NameForGuid(guid), FLAG_IN_GROUP, true,
(flags & Offsets::RAID_SLOT_FLAG_ONLINE) != 0, 0);
}
if (InGuild()) {
auto **roster = Game::Read<const uint8_t **>(Offsets::VAR_GUILD_ROSTER_PTR);
const int total = Game::Read<int>(Offsets::VAR_GUILD_ROSTER_TOTAL_COUNT);
for (int i = 0; roster != nullptr && i < total; ++i) {
const uint8_t *member = roster[i];
if (member == nullptr)
continue;
add(Game::Read<uint64_t>(member, Offsets::OFF_GUILD_MEMBER_GUID),
reinterpret_cast<const char *>(member + Offsets::OFF_GUILD_MEMBER_NAME),
FLAG_IN_GUILD, true,
Game::Read<uint32_t>(member, Offsets::OFF_GUILD_MEMBER_ONLINE) != 0, 0);
}
}
const int friends = FriendList::Count();
for (int i = 0; i < friends; ++i) {
const uint8_t *entry = FriendList::EntryByIndex(i);
if (entry == nullptr)
break;
add(Game::Read<uint64_t>(entry, FriendList::OFF_FRIEND_GUID),
Game::Read<const char *>(entry, FriendList::OFF_FRIEND_NAME), FLAG_FRIEND, true,
Game::Read<uint8_t>(entry, FriendList::OFF_FRIEND_CONNECTED) != 0, 0);
}
for (const Interaction &i : g_interactions)
add(i.guid, i.name.c_str(), FLAG_INTERACTED_WITH, false, false, i.order);
return found;
}
// One entry per player, with every list's bits and Online decided; then the
// include / exclude test, which autoCompleteUseContext turns off.
std::vector<Entry> Merge(std::vector<Entry> found, uint32_t include, uint32_t exclude) {
std::sort(found.begin(), found.end(),
[](const Entry &a, const Entry &b) { return a.guid < b.guid; });
std::vector<Entry> merged;
for (const Entry &e : found) {
if (!merged.empty() && merged.back().guid == e.guid) {
Entry &m = merged.back();
m.flags |= e.flags;
m.listed = m.listed || e.listed;
m.online = m.online || e.online;
m.order = std::max(m.order, e.order);
} else {
merged.push_back(e);
}
}
const bool useContext = CVarOn(g_useContext);
std::vector<Entry> kept;
for (Entry &e : merged) {
if (e.listed ? e.online : (e.flags & FLAG_INTERACTED_WITH) != 0)
e.flags |= FLAG_ONLINE;
if (!useContext || ((e.flags & include) != 0 && (e.flags & exclude) == 0))
kept.push_back(e);
}
return kept;
}
int PriorityOf(uint32_t flags) {
if (flags & FLAG_FRIEND)
return PRIORITY_FRIEND;
if (flags & FLAG_IN_GUILD)
return PRIORITY_GUILD;
if (flags & FLAG_IN_GROUP)
return PRIORITY_IN_GROUP;
if (flags & FLAG_INTERACTED_WITH)
return PRIORITY_INTERACTED;
return PRIORITY_OTHER;
}
// Past `chars` code points of `s`, continuation bytes included, stopping at
// the terminator. (Storm's SStrAdvanceCharsUTF8 stops one byte into a
// multi-byte character.)
const char *Advance(const char *s, size_t chars) {
for (; *s != '\0' && chars != 0; --chars) {
++s;
while ((static_cast<uint8_t>(*s) & 0xC0) == 0x80)
++s;
}
return s;
}
// What the edit box holds: the text and the cursor in it, in code points.
struct Typed {
const char *text;
size_t length;
size_t cursor;
bool allowFullMatch;
bool editFromCenter;
};
bool Completes(const Typed &t, const char *name) {
if (!t.allowFullMatch && Text::Utf8::CompareI(t.text, name) == 0)
return false;
if (!t.editFromCenter || t.cursor == t.length)
return Text::Utf8::CompareI(t.text, name, t.length) == 0;
// The text before the cursor starts the name, and the text after it
// follows somewhere later in the name.
if (Text::Utf8::CompareI(t.text, name, t.cursor) != 0)
return false;
const size_t nameLength = Text::Utf8::Length(name);
if (nameLength < t.length)
return false;
const char *tail = Advance(t.text, t.cursor);
const size_t tailLength = t.length - t.cursor;
const char *at = Advance(name, t.cursor);
for (size_t places = nameLength - t.length + 1; places != 0; --places) {
if (Text::Utf8::CompareI(at, tail, tailLength) == 0)
return true;
at = Advance(at, 1);
}
return false;
}
// A flag mask from Lua: bit.bor results above 0x7FFFFFFF arrive negative.
uint32_t FlagArg(void *L, int index) {
return static_cast<uint32_t>(static_cast<int64_t>(Game::Lua::ToNumber(L, index)));
}
// C_AutoComplete.GetAutoCompleteResults(name, numResults, cursorPosition,
// allowFullMatch, includeFlags, excludeFlags) -> results
// A numResults of 0 or less returns every match, as the engine's count test
// (`++count == numReturns`) does.
int __fastcall Script_GetAutoCompleteResults(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
!Game::Lua::IsNumber(L, 5) || !Game::Lua::IsNumber(L, 6)) {
Game::Lua::Error(L, "Usage: C_AutoComplete.GetAutoCompleteResults(name, numResults, "
"cursorPosition, allowFullMatch, includeFlags, excludeFlags)");
return 0;
}
Typed typed;
typed.text = Game::Lua::ToString(L, 1);
typed.length = Text::Utf8::Length(typed.text);
const double cursor = Game::Lua::ToNumber(L, 3);
typed.cursor = cursor >= 1 && cursor <= static_cast<double>(typed.length)
? static_cast<size_t>(cursor)
: typed.length;
typed.allowFullMatch = Game::Lua::ToBoolean(L, 4) != 0;
typed.editFromCenter = CVarOn(g_editFromCenter);
const int numResults = static_cast<int>(Game::Lua::ToNumber(L, 2));
std::vector<Entry> matches =
Merge(Collect([&](const char *name) { return Completes(typed, name); }), FlagArg(L, 5),
FlagArg(L, 6));
const bool recency = CVarOn(g_resortOnRecency);
std::sort(matches.begin(), matches.end(), [recency](const Entry &a, const Entry &b) {
if (recency && a.order != b.order)
return a.order > b.order;
return Text::Utf8::CompareI(a.name, b.name) < 0;
});
if (numResults > 0 && matches.size() > static_cast<size_t>(numResults))
matches.resize(numResults);
Game::Lua::NewTable(L);
for (size_t i = 0; i < matches.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", matches[i].name);
Game::Lua::SetFieldNumber(L, "priority", PriorityOf(matches[i].flags));
Game::Lua::SetFieldNumber(L, "bnetID", 0);
Game::Lua::RawSet(L, -3);
}
return 1;
}
// C_AutoComplete.IsRecognizedName(name, includeFlags, excludeFlags) -> bool
int __fastcall Script_IsRecognizedName(void *L) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L,
"Usage: C_AutoComplete.IsRecognizedName(name, includeFlags, excludeFlags)");
return 0;
}
const char *text = Game::Lua::ToString(L, 1);
const std::vector<Entry> matches =
Merge(Collect([text](const char *name) { return Text::Utf8::CompareI(text, name) == 0; }),
FlagArg(L, 2), FlagArg(L, 3));
Game::Lua::PushBool(L, !matches.empty());
return 1;
}
void ClearOnLogout() {
g_interactions.clear();
g_lastOrder = 0;
}
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kResultFields[] = {
Game::Doc::Req("name", "string"),
Game::Doc::Req("priority", "AutoCompletePriority",
"The closest tie to the player: friend, guild, group, whisper, or none."),
Game::Doc::Req("bnetID", "number", "Always 0."),
};
const Game::Doc::Structure kResult{"AutoCompleteResult", "AutoComplete", kResultFields};
const Game::Doc::Field kResultsArgs[] = {
Game::Doc::Req("name", "string", "The text typed so far."),
Game::Doc::Req("numResults", "number", "The most results to return; 0 returns all."),
Game::Doc::Req("cursorPosition", "number", "The cursor's place in the text, in characters."),
Game::Doc::Req("allowFullMatch", "bool", "Whether a name equal to the text can be a result."),
Game::Doc::Req("includeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; a result has at least one."),
Game::Doc::Req("excludeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; a result has none."),
};
const Game::Doc::Field kResultsRets[] = {
Game::Doc::Req("results", "table", "Array of AutoCompleteResult."),
};
const Game::Doc::Function kGetAutoCompleteResults{
"The known players whose names complete the typed text.", kResultsArgs, kResultsRets};
const Game::Doc::Field kRecognizedArgs[] = {
Game::Doc::Req("name", "string"),
Game::Doc::Req("includeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; the player has at least one."),
Game::Doc::Req("excludeFlags", "number",
"Enum.AutoCompleteEntryFlag bits; the player has none."),
};
const Game::Doc::Field kRecognizedRets[] = {
Game::Doc::Req("isRecognizedName", "bool"),
};
const Game::Doc::Function kIsRecognizedName{"Whether a known player has exactly this name.",
kRecognizedArgs, kRecognizedRets};
const Game::Lua::EnumIntegerEntry kEntryFlagEntries[] = {
{"InGroup", FLAG_IN_GROUP},
{"InGuild", FLAG_IN_GUILD},
{"Friend", FLAG_FRIEND},
{"Bnet", FLAG_BNET},
{"InteractedWith", FLAG_INTERACTED_WITH},
{"Online", FLAG_ONLINE},
{"InAOI", FLAG_IN_AOI},
{"AccountCharacter", FLAG_ACCOUNT_CHARACTER},
{"RecentPlayer", FLAG_RECENT_PLAYER},
};
const Game::Lua::EnumIntegerEntry kPriorityEntries[] = {
{"Other", PRIORITY_OTHER},
{"Interacted", PRIORITY_INTERACTED},
{"InGroup", PRIORITY_IN_GROUP},
{"Guild", PRIORITY_GUILD},
{"Friend", PRIORITY_FRIEND},
{"AccountCharacter", PRIORITY_ACCOUNT_CHARACTER},
{"AccountCharacterSameRealm", PRIORITY_ACCOUNT_CHARACTER_SAME_REALM},
};
} // namespace
void OnChat(int chatType, int languageID, const char *name, uint64_t guid) {
if (chatType != CHAT_MSG_WHISPER && chatType != CHAT_MSG_WHISPER_INFORM)
return;
if (languageID == LANGUAGE_ADDON || guid == 0 || !Guid::IsPlayer(guid) || name == nullptr ||
*name == '\0')
return;
++g_lastOrder;
for (Interaction &i : g_interactions) {
if (i.guid == guid) {
i.name = name;
i.order = g_lastOrder;
return;
}
}
g_interactions.push_back({guid, name, g_lastOrder});
}
static void RegisterLuaFunctions() {
// Retail's help lines and category (Ketho's CVars.lua dump).
g_resortOnRecency = CVar::Factory::Register(
"autoCompleteResortNamesOnRecency",
"Shows people you recently spoke with higher up on the AutoComplete list.",
Offsets::CVAR_CATEGORY_GAME, "1");
g_editFromCenter = CVar::Factory::Register(
"autoCompleteWhenEditingFromCenter",
"If you edit a name by inserting characters into the center, a smarter auto-complete "
"will occur.",
Offsets::CVAR_CATEGORY_GAME, "1");
g_useContext = CVar::Factory::Register(
"autoCompleteUseContext",
"The system will, for example, only show people in your guild when you are typing "
"/gpromote. Names will also never be removed.",
Offsets::CVAR_CATEGORY_GAME, "1");
Game::Lua::RegisterTableFunction("C_AutoComplete", "GetAutoCompleteResults",
&Script_GetAutoCompleteResults, &kGetAutoCompleteResults);
Game::Lua::RegisterTableFunction("C_AutoComplete", "IsRecognizedName", &Script_IsRecognizedName,
&kIsRecognizedName);
Game::Lua::RegisterIntegerEnum(
"Enum", "AutoCompleteEntryFlag", kEntryFlagEntries, "AutoComplete");
Game::Lua::RegisterIntegerEnum(
"Enum", "AutoCompletePriority", kPriorityEntries, "AutoComplete");
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace AutoComplete::Names
+27
View File
@@ -0,0 +1,27 @@
// 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 AutoComplete::Names {
// Chat::Dispatch calls this for every dispatched chat line, before the engine
// fires its CHAT_MSG_* event. A whisper to or from a player (`guid` is the
// other player: Chat::Dispatch's sender) makes that player an InteractedWith
// name, most recent first — 4.3.4's ChatFrame does the same in its message
// handler. Every other line is ignored.
void OnChat(int chatType, int languageID, const char *name, uint64_t guid);
} // namespace AutoComplete::Names
+221
View File
@@ -0,0 +1,221 @@
// 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/>.
// `debuglocals([level [, skipFunctionsAndUserdata]])` — the locals and then
// the upvalues of a call level as text, one `name=value` line each, for an
// error report. 4.3.4's Script_debuglocals and the FrameScript_PackVariable
// it formats with, in retail's spacing (probed: `n=7`, `s="hi"`,
// `t=<table>{`, ` k=1`, `}`; 4.3.4 writes `n = 7` and `t = <table> {`):
// - Only while an error handler runs; otherwise it returns nothing. 4.3.4
// tests a flag its error function sets around the handler call. 1.12's
// error function sets none, but while a handler runs that function's
// level is on the stack (ErrorCallstack::ErrorFunctionLevel).
// - `level` counts as lua_getstack and debugstack do: 1 is the caller.
// Less than 1 is 1.
// - A table prints as `name=<table>{`, or with the frame's name for a
// frame (t[0], as FrameScript_GetObject reads it), then its fields one
// space in at the top level only, then `}`. A function prints where it
// is defined. The text stops at 4095 bytes, each line at 1023.
// Retail's second argument, which its Blizzard_ScriptErrors passes as true,
// leaves out the function and userdata values, at the top level and in
// tables.
//
// A number key in int range prints truncated with %d, as 4.3.4's
// IsIntegerNumber tests only the range: 2.5 prints as 2. Two departures from
// 4.3.4, both where it would fail inside the handler: a number key outside
// that range is formatted from a copy, not converted in place (that breaks
// lua_next), and a key that is not a number or string prints as its type in
// angle brackets (4.3.4 formats a null string).
//
// Retail also lists `(*temporary)` lines: 5.1's lua_getlocal names each stack
// slot past a level's locals so, a C level's arguments included (an
// `error("x")` report shows `(*temporary)="x"`). 1.12's lua_getlocal
// (0x6FBB20) is 5.0's, which names declared locals only. Those slots are
// the compiler's registers, which 5.0 and 5.1 allocate differently, so they
// are left out rather than imitated.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
#include "Offsets.h"
#include "baselib/ErrorCallstack.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
namespace BaseLib::DebugLocals {
namespace {
namespace Lua = Game::Lua;
using lua_getlocal_t = const char *(__fastcall *)(void *L, const void *ar, int n);
using lua_getupvalue_t = const char *(__fastcall *)(void *L, int funcindex, int n);
const auto GetLocal = reinterpret_cast<lua_getlocal_t>(Offsets::LUA_GET_LOCAL);
const auto GetUpvalue = reinterpret_cast<lua_getupvalue_t>(Offsets::LUA_GET_UPVALUE);
constexpr size_t kTextMax = 4095; // 4.3.4's 4096-byte buffer, less its NUL
constexpr size_t kLineSize = 1024;
struct Packer {
void *L;
bool skipFunctionsAndUserdata;
std::string text;
void Line(const char *fmt, const char *indent, const char *name, const char *value = "",
int number = 0) {
char line[kLineSize];
std::snprintf(line, sizeof line, fmt, indent, name, value, number);
const size_t room = kTextMax - text.size();
text.append(line, std::min(room, std::strlen(line)));
}
// The value on the stack top, as 4.3.4's FrameScript_PackVariable.
void Pack(const char *name, int depth) {
const char *indent = depth > 0 ? " " : "";
switch (Lua::Type(L, -1)) {
case Lua::TYPE_NIL:
Line("%s%s=nil\n", indent, name);
break;
case Lua::TYPE_BOOLEAN:
Line("%s%s=%s\n", indent, name, Lua::ToBoolean(L, -1) ? "true" : "false");
break;
case Lua::TYPE_LIGHTUSERDATA:
case Lua::TYPE_USERDATA:
if (!skipFunctionsAndUserdata)
Line("%s%s=<userdata>\n", indent, name);
break;
case Lua::TYPE_NUMBER:
Line("%s%s=%s\n", indent, name, Lua::ToString(L, -1)); // a copy: safe to convert
break;
case Lua::TYPE_STRING:
Line("%s%s=\"%s\"\n", indent, name, Lua::ToString(L, -1));
break;
case Lua::TYPE_TABLE:
PackTable(name, depth, indent);
break;
case Lua::TYPE_FUNCTION:
if (!skipFunctionsAndUserdata)
PackFunction(name, indent);
break;
default:
Line("%s%s=<%s>\n", indent, name, Lua::TypeName(L, Lua::Type(L, -1)));
break;
}
}
void PackTable(const char *name, int depth, const char *indent) {
void *object = Lua::ResolveObject(L, -1);
Line("%s%s=%s{\n", indent, name, object ? Lua::ObjectDisplayName(object) : "<table>");
if (depth == 0) {
Lua::PushNil(L);
while (Lua::Next(L, -2) != 0) {
char key[32];
const char *keyName = key;
const int keyType = Lua::Type(L, -2);
const double number = keyType == Lua::TYPE_NUMBER ? Lua::ToNumber(L, -2) : 0;
if (keyType == Lua::TYPE_NUMBER && std::isfinite(number) && number >= -2147483648.0 &&
number <= 2147483647.0) {
std::snprintf(key, sizeof key, "%d", static_cast<int>(number));
} else if (keyType == Lua::TYPE_STRING) {
keyName = Lua::ToString(L, -2);
} else if (keyType == Lua::TYPE_NUMBER) {
Lua::PushValue(L, -2);
std::snprintf(key, sizeof key, "%s", Lua::ToString(L, -1));
Lua::SetTop(L, -2);
} else {
std::snprintf(key, sizeof key, "<%s>", Lua::TypeName(L, keyType));
}
Pack(keyName, 1);
Lua::SetTop(L, -2); // the value; the key stays for lua_next
}
}
Line("%s}\n", indent, "");
}
void PackFunction(const char *name, const char *indent) {
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
Lua::PushValue(L, -1);
Lua::GetInfo(L, ">nS", ar); // pops the copy
const auto field = [&ar](uintptr_t offset) { return Game::Read<const char *>(ar, offset); };
const char *source = field(Offsets::OFF_LUA_DEBUG_SOURCE);
const int line = Game::Read<int>(ar, Offsets::OFF_LUA_DEBUG_LINEDEFINED);
if (const char *fn = field(Offsets::OFF_LUA_DEBUG_NAME)) {
char named[kLineSize];
std::snprintf(named, sizeof named, "%s() defined %s", fn, source ? source : "?");
Line("%s%s=%s:%d\n", indent, name, named, line);
} else {
Line("%s%s=<function> defined %s:%d\n", indent, name, source ? source : "?", line);
}
}
};
int __fastcall Script_debuglocals(void *L) {
if (ErrorCallstack::ErrorFunctionLevel(L) == 0)
return 0;
int level = static_cast<int>(Lua::ToNumber(L, 1));
if (level <= 0)
level = 1;
Packer packer{L, Lua::ToBoolean(L, 2) != 0, {}};
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
if (Lua::GetStack(L, level, ar)) {
const char *name;
for (int i = 1; (name = GetLocal(L, ar, i)) != nullptr; i++) {
packer.Pack(name, 0);
Lua::SetTop(L, -2);
}
Lua::GetInfo(L, "f", ar); // the level's function
for (int i = 1; (name = GetUpvalue(L, -1, i)) != nullptr; i++) {
packer.Pack(name, 0);
Lua::SetTop(L, -2);
}
Lua::SetTop(L, -2);
}
Lua::PushLString(L, packer.text.data(), static_cast<unsigned int>(packer.text.size()));
return 1;
}
const Game::Doc::Field kDebuglocalsArgs[] = {
Game::Doc::Opt("level", "number", "1", "The call level, as debugstack counts it."),
Game::Doc::Opt("skipFunctionsAndUserdata", "boolean", "false",
"Leaves out function and userdata values."),
};
const Game::Doc::Field kDebuglocalsRets[] = {
Game::Doc::Opt("locals", "string", nullptr,
"One `name=value` line for each local and upvalue. Nothing outside an "
"error handler."),
};
const Game::Doc::Function kDebuglocals{
"The local variables and upvalues of a call level as text, for an error report.",
kDebuglocalsArgs, kDebuglocalsRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("debuglocals", &Script_debuglocals, &kDebuglocals);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("debuglocals", &Script_debuglocals, &kDebuglocals);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DebugLocals
+102
View File
@@ -0,0 +1,102 @@
// 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/>.
// `debugstack([thread,] [start [, count1 [, count2]]])` — the stack of a
// coroutine, as 4.3.4's Script_debugstack takes it: with a thread first,
// the levels are that thread's and `start` defaults to 0 (the thread's
// innermost level) instead of 1.
//
// 1.12's debugstack (FUN_SCRIPT_DEBUGSTACK) has no thread argument, and a
// thread falls through its argument checks so that it returns the thread
// itself: `msg .. debugstack(co)` raises "attempt to concatenate a thread
// value" in the coroutine error paths of WeakAuras, Questie's ThreadLib,
// MRT and Zygor.
//
// The engine's function still does the work, so its text and its head and
// tail windows stay exactly as they are. It reads levels through six calls
// (five lua_getstack, one lua_getinfo), and those calls are redirected to
// read from the state Script_debugstack names for the call: the thread, or
// the calling state when there is none. Script_debugstack replaces the
// global and is the engine function's only way in, so the state is set for
// every call; an error part way cannot leave a thread behind for the next.
//
// Registered on both Lua states, like the base-library helpers.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <initializer_list>
namespace BaseLib::DebugStack {
namespace {
namespace Lua = Game::Lua;
// The state whose levels the engine's debugstack reads, or null for its own.
void *g_levelsOf = nullptr;
int __fastcall GetStack(void *L, int level, void *ar) {
return Lua::GetStack(g_levelsOf ? g_levelsOf : L, level, ar);
}
int __fastcall GetInfo(void *L, const char *what, void *ar) {
return Lua::GetInfo(g_levelsOf ? g_levelsOf : L, what, ar);
}
int __fastcall Script_debugstack(void *L) {
const auto engineDebugStack =
reinterpret_cast<Lua::CFunction>(Offsets::FUN_SCRIPT_DEBUGSTACK);
g_levelsOf = nullptr;
if (Lua::Type(L, 1) == Lua::TYPE_THREAD) {
g_levelsOf = Lua::ToThread(L, 1);
Lua::Remove(L, 1);
if (!Lua::IsNumber(L, 1)) { // start defaults to 0 for a thread
Lua::PushNumber(L, 0);
Lua::Insert(L, 1);
}
}
const int results = engineDebugStack(L);
g_levelsOf = nullptr;
return results;
}
void Apply() {
const auto getStack = reinterpret_cast<const void *>(&GetStack);
for (const uintptr_t site :
{Offsets::CALL_DEBUGSTACK_GETSTACK_FIRST, Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_PROBE,
Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_START, Offsets::CALL_DEBUGSTACK_GETSTACK_TAIL_SKIP,
Offsets::CALL_DEBUGSTACK_GETSTACK_NEXT})
Common::RedirectCall(site, Offsets::LUA_GET_STACK, getStack);
Common::RedirectCall(Offsets::CALL_DEBUGSTACK_GETINFO, Offsets::LUA_GET_INFO,
reinterpret_cast<const void *>(&GetInfo));
}
void RegisterInGame() {
Apply();
Lua::RegisterGlobalFunction("debugstack", &Script_debugstack);
}
void RegisterGlue() {
Apply();
Lua::RegisterGlueFunction("debugstack", &Script_debugstack);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DebugStack
+58
View File
@@ -0,0 +1,58 @@
// 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/>.
// `difftime(t2 [, t1])` — the seconds from `t1` (0 by default) to `t2`, the
// companion of the engine's `date` and `time` globals. 4.3.4's
// Script_difftime: C `difftime` over the two arguments as time_t.
//
// Registered on BOTH states, as the engine registers `date` and `time`.
#include "Game.h"
#include <ctime>
namespace BaseLib::DiffTime {
namespace {
int __fastcall Script_difftime(void *L) {
const auto t2 = static_cast<time_t>(Game::Lua::CheckNumber(L, 1));
const auto t1 = static_cast<time_t>(Game::Lua::OptNumber(L, 2, 0));
Game::Lua::PushNumber(L, std::difftime(t2, t1));
return 1;
}
const Game::Doc::Field kDifftimeArgs[] = {
Game::Doc::Req("time2", "number", "A time in seconds, as `time()` returns."),
Game::Doc::Opt("time1", "number", "0"),
};
const Game::Doc::Field kDifftimeRets[] = {
Game::Doc::Req("seconds", "number", "time2 minus time1."),
};
const Game::Doc::Function kDifftime{"The number of seconds from time1 to time2.",
kDifftimeArgs, kDifftimeRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("difftime", &Script_difftime, &kDifftime);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("difftime", &Script_difftime, &kDifftime);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::DiffTime
+145
View File
@@ -0,0 +1,145 @@
// 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/>.
// `GetCallstackHeight()`, `GetErrorCallstackHeight()` and
// `SetErrorCallstackHeight([height])` — retail's call stack heights. Its
// error handler turns them into the debugstack level of the code that
// failed, however many functions sit between that code and the handler:
// debugstack(GetCallstackHeight() - (GetErrorCallstackHeight() - 1))
// A height counts call levels as lua_getstack does, and so as debugstack
// does: C functions and lost tail calls count, and level 1 is the caller of
// the C function that asks.
//
// 1.12 records no error height, and needs none. Every error report runs the
// engine's error function (FUN_FRAMESCRIPT_CALL_ERROR_HANDLER) as a C
// closure: each script pcall passes it as the errfunc, so Lua calls it at
// the point of the error before the stack unwinds, and a chunk that fails
// to compile is reported by a pcall of it (FUN_LUA_RUN_STRING). It calls the
// seterrorhandler function with lua_call. So while any handler runs, that
// closure's level is on the stack directly above the code that failed, and
// GetErrorCallstackHeight answers the height below the innermost one.
// Nothing is recorded, so a handler that fails part way leaves nothing
// behind, and a nested error finds its own level.
//
// SetErrorCallstackHeight is for a handler called directly, with no error
// raised (retail's CallErrorHandler and assertsafe): with no engine level on
// the stack, GetErrorCallstackHeight answers the value set. An engine level
// wins over it, so a value a failed handler left set does not move the
// stack of a later error. Retail's order between the two is not known; no
// retail code sets the value around a raised error.
#include "Game.h"
#include "Offsets.h"
#include "baselib/ErrorCallstack.h"
#include <cstdint>
#include <optional>
namespace BaseLib::ErrorCallstack {
int ErrorFunctionLevel(void *L) {
const auto errorFunction = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_FRAMESCRIPT_CALL_ERROR_HANDLER);
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
for (int level = 1; Game::Lua::GetStack(L, level, ar); ++level) {
Game::Lua::GetInfo(L, "f", ar); // the level's function; nil for a lost tail call
const bool found = Game::Lua::ToCFunction(L, -1) == errorFunction;
Game::Lua::SetTop(L, -2);
if (found)
return level;
}
return 0;
}
namespace {
std::optional<int> g_setHeight;
// The number of call levels below the running C function.
int Height(void *L) {
alignas(4) uint8_t ar[Offsets::SIZEOF_LUA_DEBUG];
int height = 0;
while (Game::Lua::GetStack(L, height + 1, ar))
++height;
return height;
}
int __fastcall Script_GetCallstackHeight(void *L) {
Game::Lua::PushNumber(L, Height(L));
return 1;
}
int __fastcall Script_GetErrorCallstackHeight(void *L) {
const int level = ErrorFunctionLevel(L);
if (level > 0)
Game::Lua::PushNumber(L, Height(L) - level);
else if (g_setHeight)
Game::Lua::PushNumber(L, *g_setHeight);
else
Game::Lua::PushNil(L);
return 1;
}
int __fastcall Script_SetErrorCallstackHeight(void *L) {
if (Game::Lua::Type(L, 1) <= Game::Lua::TYPE_NIL)
g_setHeight.reset();
else if (Game::Lua::IsNumber(L, 1))
g_setHeight = static_cast<int>(Game::Lua::ToNumber(L, 1));
else
Game::Lua::Error(L, "Usage: SetErrorCallstackHeight([height])");
return 0;
}
const Game::Doc::Field kHeightRets[] = {
Game::Doc::Req("height", "number", "The calling function's level and every level below it."),
};
const Game::Doc::Field kErrorHeightRets[] = {
Game::Doc::Opt("height", "number", nullptr, "The stack height of the code that caused the error."),
};
const Game::Doc::Field kSetArgs[] = {
Game::Doc::Opt("height", "number", nullptr, "The height to return, or nil to clear it."),
};
const Game::Doc::Function kGetCallstackHeight{
"Returns the number of call levels on the stack, counting the calling function as level 1.",
{}, kHeightRets, "FrameScriptGlobals"};
const Game::Doc::Function kGetErrorCallstackHeight{
"Returns the call stack height of the code that caused the error being handled, or nil when no error is being handled.",
{}, kErrorHeightRets, "FrameScriptGlobals"};
const Game::Doc::Function kSetErrorCallstackHeight{
"Sets the height that GetErrorCallstackHeight returns for an error handler called with no error raised.",
kSetArgs, {}, "FrameScriptGlobals"};
// A new Lua state starts with no height set.
void RegisterInGame() {
g_setHeight.reset();
Game::Lua::RegisterGlobalFunction("GetCallstackHeight", &Script_GetCallstackHeight, &kGetCallstackHeight);
Game::Lua::RegisterGlobalFunction("GetErrorCallstackHeight", &Script_GetErrorCallstackHeight,
&kGetErrorCallstackHeight);
Game::Lua::RegisterGlobalFunction("SetErrorCallstackHeight", &Script_SetErrorCallstackHeight,
&kSetErrorCallstackHeight);
}
void RegisterGlue() {
g_setHeight.reset();
Game::Lua::RegisterGlueFunction("GetCallstackHeight", &Script_GetCallstackHeight, &kGetCallstackHeight);
Game::Lua::RegisterGlueFunction("GetErrorCallstackHeight", &Script_GetErrorCallstackHeight,
&kGetErrorCallstackHeight);
Game::Lua::RegisterGlueFunction("SetErrorCallstackHeight", &Script_SetErrorCallstackHeight,
&kSetErrorCallstackHeight);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::ErrorCallstack
+23
View File
@@ -0,0 +1,23 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace BaseLib::ErrorCallstack {
// The innermost call level whose function is the engine's error function
// (FUN_FRAMESCRIPT_CALL_ERROR_HANDLER), or 0 when no error is being
// reported. Nonzero exactly while an error handler runs.
int ErrorFunctionLevel(void *L);
} // namespace BaseLib::ErrorCallstack
+22 -1
View File
@@ -16,13 +16,16 @@
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
// - `math.cosh(x)`, `math.sinh(x)`, `math.tanh(x)` — hyperbolic functions.
//
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
// (not in 1.12's mathlib table), so we implement/define them here, as are the
// hyperbolic functions (5.1's math_cosh/sinh/tanh: luaL_checknumber, the C
// function, one number).
#include "Game.h"
#include "Offsets.h"
@@ -53,9 +56,27 @@ int __fastcall Script_math_modf(void *L) {
return 2;
}
int __fastcall Script_math_cosh(void *L) {
Game::Lua::PushNumber(L, std::cosh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
int __fastcall Script_math_sinh(void *L) {
Game::Lua::PushNumber(L, std::sinh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
int __fastcall Script_math_tanh(void *L) {
Game::Lua::PushNumber(L, std::tanh(Game::Lua::CheckNumber(L, 1)));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
Game::Lua::RegisterTableFunction("math", "cosh", &Script_math_cosh);
Game::Lua::RegisterTableFunction("math", "sinh", &Script_math_sinh);
Game::Lua::RegisterTableFunction("math", "tanh", &Script_math_tanh);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
+78
View File
@@ -0,0 +1,78 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `newproxy([false | true | proxy])` — Lua 5.1's empty userdata (luaB_newproxy,
// lbaselib.c). `false` or nothing: no metatable. `true`: a new metatable of
// its own. Another proxy: share that proxy's metatable. Only metatables made
// by `newproxy(true)` count; any other argument raises "boolean or proxy
// expected". 5.0 has userdata and their `__gc`, only not this function.
//
// As in 5.1, the closure's upvalue is a weak table that marks the valid
// metatables (weak[m] = true), and is its own metatable with __mode "kv".
//
// Registered on BOTH states (language-level helper, both are 5.0).
#include "Game.h"
namespace BaseLib::NewProxy {
namespace {
namespace Lua = Game::Lua;
int __fastcall Script_newproxy(void *L) {
Lua::SetTop(L, 1);
Lua::NewUserdata(L, 0); // the proxy, at 2
if (Lua::ToBoolean(L, 1) == 0)
return 1; // no metatable
if (Lua::Type(L, 1) == Lua::TYPE_BOOLEAN) {
Lua::NewTable(L); // a new metatable m ...
Lua::PushValue(L, -1);
Lua::PushBoolean(L, 1);
Lua::RawSet(L, Lua::UpvalueIndex(1)); // ... marked valid: weak[m] = true
} else {
bool valid = false;
if (Lua::GetMetatable(L, 1)) {
Lua::RawGet(L, Lua::UpvalueIndex(1));
valid = Lua::ToBoolean(L, -1) != 0;
Lua::SetTop(L, -2);
}
if (!valid)
Lua::ArgError(L, 1, "boolean or proxy expected");
Lua::GetMetatable(L, 1);
}
Lua::SetMetatable(L, 2);
return 1;
}
void Register() {
void *L = Lua::State();
if (L == nullptr)
return;
Lua::PushString(L, "newproxy");
Lua::NewTable(L); // the weak table w
Lua::PushString(L, "__mode");
Lua::PushString(L, "kv");
Lua::RawSet(L, -3);
Lua::PushValue(L, -1);
Lua::SetMetatable(L, -2); // w is its own metatable
Lua::PushCClosure(L, &Script_newproxy, 1);
Lua::SetTable(L, Lua::GLOBALS_INDEX);
}
const Game::ModuleAutoRegister _autoreg{&Register};
const Game::GlueModuleAutoRegister _autoregGlue{&Register};
} // namespace
} // namespace BaseLib::NewProxy
+63
View File
@@ -0,0 +1,63 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `scrub(...)` — returns its arguments, each one that is not a boolean,
// number or string replaced by nil. 4.3.4's Script_scrub.
//
// Registered on both Lua states, like the base-library helpers.
#include "Game.h"
namespace BaseLib::Scrub {
namespace {
namespace Lua = Game::Lua;
int __fastcall Script_scrub(void *L) {
const int top = Lua::GetTop(L);
Lua::CheckStack(L, 1);
for (int i = 1; i <= top; i++) {
const int type = Lua::Type(L, i);
if (type != Lua::TYPE_BOOLEAN && type != Lua::TYPE_NUMBER && type != Lua::TYPE_STRING) {
Lua::PushNil(L);
Lua::Replace(L, i);
}
}
return top;
}
const Game::Doc::Field kScrubArgs[] = {
Game::Doc::Vararg("any"),
};
const Game::Doc::Field kScrubRets[] = {
Game::Doc::Vararg("any", "Each argument, or nil when it is not a boolean, number or string."),
};
const Game::Doc::Function kScrub{
"Returns its arguments, with each one that is not a boolean, number or string replaced by nil.",
kScrubArgs, kScrubRets, "LuaGlobals"};
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("scrub", &Script_scrub, &kScrub);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("scrub", &Script_scrub, &kScrub);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Scrub
+86
View File
@@ -0,0 +1,86 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `string.format` takes two-digit argument numbers (`%10$s`).
//
// 1.12's str_format (0x7FD3D0) is Lua 4.0's: a spec may start with `n$`, but
// only one digit, `isdigit(p[0]) && p[1] == '$'`. In "%10$s" the "10" is then
// read as a width and the '$' as the conversion, which raises "invalid
// option in `format'". 4.3.4's str_format adds a second branch for two
// digits, `isdigit(p[1]) && p[2] == '$'`, and its combat log strings need it
// (ACTION_SPELL_PERIODIC_LEECH_RESULT is "(%10$s %8$s Gained)").
//
// The thunk below stands in for the engine's one-digit branch and adds the
// two-digit one. Everything else stays the engine's: its locale isdigit on
// the first digit (already tested before the site), `arg++` after the site,
// the flag/width/precision scan from EBX, and every conversion. Every caller
// goes through it: `string.format`, `format`, and the C callers of
// FUN_LUA_STR_FORMAT (Frame::FormattedText, Text::NumericFormatter).
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace BaseLib::StringFormat {
namespace {
static_assert(Offsets::LUA_STR_FORMAT_FRAME_ARG == 8, "ArgNumberThunk writes [ebp - 8]");
// ESI = the spec after '%', whose first byte is a digit. Sets the argument
// number in [ebp - 8] and moves EBX past the `n$`; leaves both alone when
// the spec has no `n$`.
__declspec(naked) void ArgNumberThunk() {
__asm {
cmp byte ptr [esi + 1], '$'
jne twoDigits
movsx eax, byte ptr [esi]
sub eax, '0'
mov dword ptr [ebp - 8], eax
lea ebx, [esi + 2]
ret
twoDigits:
movzx eax, byte ptr [esi + 1]
sub eax, '0'
cmp eax, 9
ja done
cmp byte ptr [esi + 2], '$'
jne done
movsx ecx, byte ptr [esi]
sub ecx, '0'
lea ecx, [ecx + ecx * 4]
lea eax, [eax + ecx * 2]
mov dword ptr [ebp - 8], eax
lea ebx, [esi + 3]
done:
ret
}
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kOneDigit[18] = {0x80, 0x7E, 0x01, 0x24, 0x75, 0x0C, 0x0F, 0xBE, 0x06,
0x83, 0xE8, 0x30, 0x89, 0x45, 0xF8, 0x8D, 0x5E, 0x02};
Common::ReplaceWithCall(Offsets::SITE_LUA_STR_FORMAT_ARG_NUMBER, kOneDigit, sizeof kOneDigit,
reinterpret_cast<const void *>(&ArgNumberThunk));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace BaseLib::StringFormat
+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/>.
// `string.gsub` takes 5.1's string and table replacements.
//
// 1.12's str_gsub (0x7FD0E0) is Lua 5.0's, and differs from 5.1 in its
// replacement argument:
// - it takes a string or a function, not a table;
// - `%0` in a replacement string, and `%1` when the pattern has no
// captures, raise "invalid capture index" (5.1: the whole match);
// - a function that returns nil or false deletes the match (5.1 keeps it),
// and one that returns a table deletes it too (5.1 raises an error).
//
// The first two only ever raised errors, so 5.1's behavior cannot break a
// 1.12 addon. The third does: SuperAPI's CombatText_AddMessage returns nil
// to delete the player's own " [0x...]" GUID from heal text. A function
// replacement therefore stays the engine's own add_s.
//
// str_gsub itself stays: its match loop, anchor, count and buffer are the
// same in both. Two parts are replaced:
// - the argument gate (SITE_LUA_STR_GSUB_REPL_CHECK) becomes CheckReplacement;
// - the one add_s call (CALL_LUA_STR_GSUB_ADD_S) goes to AddValue: 5.1's
// add_s for a string or number, 5.1's table lookup for a table, and the
// engine's add_s for a function. They use the engine's capture helpers.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace BaseLib::StringGsub {
namespace {
namespace Lua = Game::Lua;
// Lua 5.0's MatchState, as str_gsub builds it on its stack (str_format's
// spec scan reads the same layout: level at +0x0C, capture[0].len at +0x14).
struct MatchState {
const char *srcInit;
const char *srcEnd;
void *L;
int level;
struct {
const char *init;
int len;
} capture[32];
};
using add_s_t = void(__fastcall *)(MatchState *ms, void *b, const char *s, const char *e);
using push_onecapture_t = void(__fastcall *)(MatchState *ms, int i);
using luaL_addlstring_t = void(__fastcall *)(void *b, const char *s, unsigned int len);
using luaL_addvalue_t = void(__fastcall *)(void *b);
const auto EngineAddS = reinterpret_cast<add_s_t>(Offsets::FUN_LUA_STR_ADD_S);
const auto EnginePushOneCapture =
reinterpret_cast<push_onecapture_t>(Offsets::FUN_LUA_STR_PUSH_ONECAPTURE);
const auto AddLString = reinterpret_cast<luaL_addlstring_t>(Offsets::LUAL_ADD_LSTRING);
const auto AddBufferValue = reinterpret_cast<luaL_addvalue_t>(Offsets::LUAL_ADD_VALUE);
// 5.1's push_onecapture: capture `i`, or the whole match for `i == 0` when
// the pattern has no captures.
void PushOneCapture(MatchState *ms, int i, const char *s, const char *e) {
if (i < ms->level)
EnginePushOneCapture(ms, i);
else if (i == 0)
Lua::PushLString(ms->L, s, static_cast<unsigned int>(e - s));
else
Lua::Error(ms->L, "invalid capture index");
}
// 5.1's add_s: a replacement string, where `%0` is the whole match and `%1`
// to `%9` are captures.
void AddString(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
const char *news = Lua::ToString(L, 3);
const unsigned int l = Lua::StrLen(L, 3);
for (unsigned int i = 0; i < l; i++) {
if (news[i] != '%') {
AddLString(b, news + i, 1);
continue;
}
i++; // skip the escape
const char c = news[i];
if (c < '0' || c > '9') {
AddLString(b, news + i, 1);
} else if (c == '0') {
AddLString(b, s, static_cast<unsigned int>(e - s));
} else {
PushOneCapture(ms, c - '1', s, e);
AddBufferValue(b);
}
}
}
// 5.1's add_value for a string or table, the engine's add_s for a function.
// Called as add_s: __fastcall, ret 8.
void __fastcall AddValue(MatchState *ms, void *b, const char *s, const char *e) {
void *L = ms->L;
switch (Lua::Type(L, 3)) {
case Lua::TYPE_FUNCTION:
// A result that is not a string or number deletes the match.
EngineAddS(ms, b, s, e);
return;
case Lua::TYPE_TABLE:
break;
default: // number or string
AddString(ms, b, s, e);
return;
}
PushOneCapture(ms, 0, s, e);
Lua::GetTable(L, 3);
if (!Lua::ToBoolean(L, -1)) {
Lua::SetTop(L, -2);
Lua::PushLString(L, s, static_cast<unsigned int>(e - s)); // keep the match
} else if (!Lua::IsString(L, -1)) {
Lua::Error(L, "invalid replacement value (a %s)", Lua::TypeName(L, Lua::Type(L, -1)));
}
AddBufferValue(b);
}
// 5.1's gate on the replacement argument. Called in place of the engine's,
// with ECX = L.
void __fastcall CheckReplacement(void *L) {
const int t = Lua::Type(L, 3);
if (t != Lua::TYPE_NUMBER && t != Lua::TYPE_STRING && t != Lua::TYPE_FUNCTION &&
t != Lua::TYPE_TABLE)
Lua::ArgError(L, 3, "string/function/table expected");
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kGate[60] = {
0xE8, 0x0D, 0x5F, 0xEF, 0xFF, 0x83, 0xF8, 0x03, 0x7C, 0x21, 0xBA, 0x03,
0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x9C, 0x63, 0xEF, 0xFF, 0x85, 0xC0,
0x75, 0x22, 0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x7C, 0x62,
0xEF, 0xFF, 0x83, 0xF8, 0x06, 0x74, 0x11, 0x68, 0x84, 0x23, 0x88, 0x00,
0xBA, 0x03, 0x00, 0x00, 0x00, 0x8B, 0xCF, 0xE8, 0x76, 0x76, 0xEF, 0xFF,
};
Common::ReplaceWithCall(Offsets::SITE_LUA_STR_GSUB_REPL_CHECK, kGate, sizeof kGate,
reinterpret_cast<const void *>(&CheckReplacement));
Common::RedirectCall(Offsets::CALL_LUA_STR_GSUB_ADD_S, Offsets::FUN_LUA_STR_ADD_S,
reinterpret_cast<const void *>(&AddValue));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace BaseLib::StringGsub
+38 -124
View File
@@ -15,13 +15,13 @@
//
// - `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).
// - `string.gmatch(s, pattern)` — match iterator (5.1), also `gmatch`.
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
// - `string.byte(s, i, j)` / `strbyte` — 5.1's range form (5.0 reads only i).
// - `strlenutf8(s)` — WoW global; characters, not bytes.
// - `strcmputf8i(a, b)` — WoW global; case-insensitive UTF-8 order.
// - `strsplittable(sep, str [, pieces])`— WoW global; `strsplit` into a table.
@@ -341,126 +341,39 @@ int __fastcall Script_string_reverse(void *L) {
return 1;
}
// --- string.gsub table-replacement upgrade (Lua 5.1) ------------------------
//
// 5.1 allows a TABLE as gsub's replacement: each match looks up the first
// capture (the whole match when the pattern declares no captures) in the
// table; a nil/false value keeps the original match; string/number values
// substitute. This engine's gsub (FUN_LUA_STR_GSUB) is stock 5.0 —
// string/function only ("string or function expected" otherwise), and its
// add_value appends NOTHING when a replacement function returns a
// non-string (match → empty; verified by decode, see Offsets.h). So a
// plain table→closure adapter would EAT unmatched tokens where 5.1 keeps
// them, and the closure can't reconstruct the whole match from capture
// args alone.
//
// The shim therefore rewrites the pattern with one OUTER capture —
// `pat` → `(pat)`, keeping a leading `^` and an unescaped trailing `$`
// outside (both are anchors only at their positional ends; inside the
// parens they'd turn literal) — so the adapter closure always receives
// the whole match as arg 1, the pattern's own first capture as arg 2, and
// can hand arg 1 back to reproduce 5.1's keep-match for missing keys.
// Patterns containing a `%1`..`%9` back-reference can't be wrapped (the
// outer capture would renumber what `%N` refers to); those fall back to
// an unwrapped adapter whose missing-key behavior is the engine's own
// (match → empty) — the one documented divergence, and the combination
// (table replacement + pattern back-references) is essentially absent
// from real code. String/function replacements tail-call the engine gsub
// untouched.
// `string.byte(s [, i [, j]])` / `strbyte` — the codes of bytes i..j, as
// 4.3.4's str_byte (j defaults to i). 1.12's (0x7FC210) is 5.0's: it reads
// only `i` and pushes one code. With `j` absent the two give the same
// result for every `i`. Positions truncate toward zero as the engine's
// str_byte and str_sub do. The engine's lua_checkstack (0x6F2F30) refuses
// more than 2048 stack slots, so the longest range is about 2000 bytes.
// True when the pattern contains a `%1`..`%9` back-reference. Escape-aware:
// the char after any `%` is consumed, so `%%1` (literal percent, then '1')
// doesn't count.
bool PatternHasBackref(const char *p, unsigned len) {
for (unsigned i = 0; i + 1 < len; ++i) {
if (p[i] != '%')
continue;
const char c = p[i + 1];
if (c >= '1' && c <= '9')
return true;
++i; // skip the escaped char
}
return false;
// 5.1's posrelat: a negative position counts from the end; before the start is 0.
long PosRelat(long pos, long len) {
if (pos < 0)
pos += len + 1;
return pos >= 0 ? pos : 0;
}
// Adapter for the wrapped-pattern path: arg 1 = whole match, arg 2 = the
// pattern's own first capture (when it declared any). Upvalue 1 = the
// replacement table. Missing/false value → return the whole match (5.1's
// keep-match).
int __fastcall GsubTableLookupWrapped(void *L) {
const int nargs = Game::Lua::GetTop(L);
Game::Lua::PushValue(L, nargs >= 2 ? 2 : 1); // the 5.1 lookup key
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1)); // tbl[key]
const int t = Game::Lua::Type(L, -1);
if (t == Game::Lua::TYPE_NIL ||
(t == Game::Lua::TYPE_BOOLEAN && Game::Lua::ToBoolean(L, -1) == 0))
Game::Lua::PushValue(L, 1); // keep the original match
return 1; // top value is the result
}
// Adapter for back-reference patterns (unwrapped): arg 1 is already the
// 5.1 key. A missing key returns nil → the engine appends nothing.
int __fastcall GsubTableLookupPlain(void *L) {
Game::Lua::PushValue(L, 1);
Game::Lua::GetTable(L, Game::Lua::UpvalueIndex(1));
return 1;
}
int __fastcall Script_string_gsub(void *L) {
const auto engineGsub =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GSUB);
if (Game::Lua::Type(L, 3) != Game::Lua::TYPE_TABLE)
return engineGsub(L); // string/function/error paths untouched
const char *pat = Game::Lua::ToString(L, 2);
if (pat == nullptr)
return engineGsub(L); // let the engine raise its own arg error
const unsigned patLen = Game::Lua::StrLen(L, 2);
Game::Lua::SetTop(L, 4); // normalize to (s, pat, tbl, n) — gsub reads ≤4
if (!PatternHasBackref(pat, patLen)) {
// Build `(body)` with the anchors outside the capture.
unsigned b = 0, e = patLen;
std::string wrapped;
wrapped.reserve(patLen + 3);
if (e > b && pat[0] == '^') {
wrapped += '^';
b = 1;
}
bool anchoredBack = false;
if (e > b && pat[e - 1] == '$') {
// `$` is an anchor only when not %-escaped: an even number of
// `%` immediately before it means the `$` itself is unescaped.
unsigned pc = 0;
for (int idx = static_cast<int>(e) - 2;
idx >= static_cast<int>(b) && pat[idx] == '%'; --idx)
++pc;
anchoredBack = (pc % 2) == 0;
if (anchoredBack)
--e;
}
wrapped += '(';
wrapped.append(pat + b, e - b);
wrapped += ')';
if (anchoredBack)
wrapped += '$';
Game::Lua::PushLString(L, wrapped.data(),
static_cast<unsigned int>(wrapped.size())); // 5
Game::Lua::PushValue(L, 3); // 6: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupWrapped, 1); // 6: closure
Game::Lua::Insert(L, 2); // closure→2: s, closure, pat, tbl, n, wrapped
Game::Lua::Insert(L, 2); // wrapped→2: s, wrapped, closure, pat, tbl, n
Game::Lua::Remove(L, 4); // drop the original pattern
Game::Lua::Remove(L, 4); // drop the table → (s, wrapped, closure, n)
} else {
Game::Lua::PushValue(L, 3); // 5: tbl
Game::Lua::PushCClosure(L, &GsubTableLookupPlain, 1); // 5: closure
Game::Lua::Insert(L, 3); // → s, pat, closure, tbl, n
Game::Lua::Remove(L, 4); // → s, pat, closure, n
}
return engineGsub(L);
int __fastcall Script_string_byte(void *L) {
unsigned int l = 0;
const char *s = Game::Lua::CheckLString(L, 1, &l);
const long len = static_cast<long>(l);
long posi = PosRelat(static_cast<long>(Game::Lua::OptNumber(L, 2, 1)), len);
long pose =
PosRelat(static_cast<long>(Game::Lua::OptNumber(L, 3, static_cast<double>(posi))), len);
if (posi <= 0)
posi = 1;
if (pose > len)
pose = len;
if (posi > pose)
return 0;
const int n = static_cast<int>(pose - posi + 1);
if (!Game::Lua::CheckStack(L, n))
Game::Lua::Error(L, "stack overflow (%s)", "string slice too long");
for (int i = 0; i < n; i++)
Game::Lua::PushNumber(L, static_cast<unsigned char>(s[posi + i - 1]));
return n;
}
// --- Documentation ----------------------------------------------------------
@@ -515,27 +428,28 @@ const Game::Doc::Function kStrconcat{
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "gsub", &Script_string_gsub);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterTableFunction("string", "byte", &Script_string_byte);
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
Game::Lua::RegisterTableFunction("string", "trim", &Script_strtrim);
Game::Lua::RegisterTableFunction("string", "join", &Script_strjoin);
Game::Lua::RegisterTableFunction("string", "concat", &Script_strconcat, &kStrconcat);
// 4.3.4's compat.lua binds each short global and its string-table twin
// to ONE function by assignment (`gsub = str.gsub`, `strmatch =
// to ONE function by assignment (`gmatch = str.gmatch`, `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");
// file scope (AceConfigCmd-3.0: `strtrim = string.trim`). 1.12's own
// compat.lua set `strbyte` from the old string.byte, so it is re-aliased.
Game::Lua::RegisterGlobalAlias("gmatch", "string", "gmatch");
Game::Lua::RegisterGlobalAlias("strmatch", "string", "match");
Game::Lua::RegisterGlobalAlias("strsplit", "string", "split");
Game::Lua::RegisterGlobalAlias("strjoin", "string", "join");
Game::Lua::RegisterGlobalAlias("strtrim", "string", "trim");
Game::Lua::RegisterGlobalAlias("strrev", "string", "reverse");
Game::Lua::RegisterGlobalAlias("strbyte", "string", "byte");
Game::Lua::RegisterGlobalAlias("strconcat", "string", "concat", &kStrconcat);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strlenutf8", &Script_strlenutf8, &kStrlenutf8);
+3 -16
View File
@@ -47,6 +47,7 @@
// section's header follows.
#include "Inject.h"
#include "Common.h"
#include "Offsets.h"
#include "embed/Resource.h"
@@ -133,7 +134,7 @@ bool ExtractHeaderAttr(const char *tagStart, const char *tagEnd,
EmbeddedConfig ResolveEmbeddedConfig() {
EmbeddedConfig out = {};
const Embed::Bytes xml = Embed::Resource(Embed::Ids::kBindingsXml);
const Embed::Bytes xml = Embed::File(Embed::Ids::kBindingsXml, 0);
if (xml.data == nullptr) return out;
const char *content = reinterpret_cast<const char *>(xml.data);
const char *end = content + xml.size;
@@ -212,20 +213,6 @@ const char *FindSplicePosition(const char *content, size_t size,
return FindOutsideComment(content, end, "</Bindings>");
}
char NormalizeChar(char c) {
if (c == '/') return '\\';
if (c >= 'A' && c <= 'Z') return static_cast<char>(c + 32);
return c;
}
bool PathEqualsCI(const char *a, const char *b) {
while (*a != '\0' && *b != '\0') {
if (NormalizeChar(*a) != NormalizeChar(*b)) return false;
++a; ++b;
}
return *a == *b;
}
using SMemAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
@@ -233,7 +220,7 @@ using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int
bool TryHandle(int unused, const char *path, void **outBuf, size_t *outSize,
size_t extraBytes, int flag1, int flag2, FileReadFn original) {
if (path == nullptr || !PathEqualsCI(path, kFrameXMLBindingsPath))
if (path == nullptr || !Common::SamePath(path, kFrameXMLBindingsPath))
return false;
// Pull the real file through the original reader with the caller's
+1 -4
View File
@@ -61,10 +61,7 @@ const char *UnitName(uint64_t guid) {
if (guid == 0)
return nullptr;
if (void *unit = Object::ByGuid(Offsets::TYPEMASK_UNIT, guid, nullptr, 0)) {
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
const char *name =
reinterpret_cast<GetName_t>(static_cast<uintptr_t>(Offsets::FUN_OBJECT_GET_NAME))(
unit, nullptr);
const char *name = Unit::Identity::NameForUnit(unit);
if (name != nullptr && *name != '\0')
return name;
}
+10 -2
View File
@@ -29,6 +29,8 @@
// 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()`.
// - AutoComplete::Names::OnChat — a whisper partner becomes an
// InteractedWith name for C_AutoComplete.
//
// Signature — `__fastcall`, `RET 0x28` (stack args at entry):
// ECX = the MESSAGE text (`MOV ECX,[EBP-0x10]` in FUN_0049D560; copied into
@@ -68,6 +70,7 @@
#include "RaidMarkers.h"
#include "Game.h"
#include "autocomplete/Names.h"
#include "Offsets.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
@@ -158,10 +161,15 @@ void __fastcall ChatDispatch_h(
// escapes are added.
Chat::RaidMarkers::EventScope markers;
const uint64_t senderGuid = Sender(_ReturnAddress(), chatType, channel, flags, guidLo, guidHi);
// A whisper partner becomes an autocomplete name before the event fires,
// so a CHAT_MSG_WHISPER handler already finds them.
AutoComplete::Names::OnChat(chatType, languageID, senderName, senderGuid);
// 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);
Chat::EventArgs::SenderScope sender(senderGuid, flags, true);
g_original(msg, chatType, senderName, languageID, channel, targetName, flags,
speakerGuidLo, speakerGuidHi, rank, guidLo, guidHi);
+21 -9
View File
@@ -72,18 +72,25 @@ struct State {
uint32_t endMs = 0;
bool active = false; // at least one reset has happened this session
bool dirty = false; // needs a PLAYER_SWING fire on the next WorldTick
bool held = false; // the latest reset was recorded but not reported yet
};
State g_swing[3];
// `notify` false records the server's new timer without firing PLAYER_SWING:
// the reset is real, but no swing is coming yet (see OnSpellGo).
// the reset is real, but no swing is coming yet (see OnSpellGo). It stays
// `held` until the attack start reports it or a later reported reset replaces it.
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 = s.dirty || notify;
if (notify) {
s.dirty = true;
s.held = false;
} else {
s.held = true;
}
}
using HasAttackTarget_t = int(__fastcall *)(const void *unit);
@@ -410,11 +417,17 @@ void OnPacket(uint32_t opcode, CDataStore *packet) {
auto *playerObj = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
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.
// A cast reset made before the attack is still held back;
// report it now that a swing is coming. Only a held reset:
// a target switch mid-attack also lands here, and there the
// main-hand swing was never reset -- re-reporting its
// remaining time would read as a fresh, short swing.
State &main = g_swing[MAIN_HAND];
if (main.active && Time::Clock::Remaining(NowMs(), main.endMs) > 0)
main.dirty = true;
if (main.held) {
main.held = false;
if (Time::Clock::Remaining(NowMs(), main.endMs) > 0)
main.dirty = true;
}
}
}
return;
@@ -477,6 +490,7 @@ void OnWorldTick() {
if (!s.dirty)
continue;
s.dirty = false;
s.held = false;
if (slot < 0)
continue;
const uint32_t remaining = Time::Clock::Remaining(now, s.endMs);
@@ -497,9 +511,7 @@ const Game::Lua::EnumIntegerEntry kSwingTypeEntries[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "PlayerSwingType", kSwingTypeEntries,
sizeof(kSwingTypeEntries) / sizeof(kSwingTypeEntries[0]), "SwingTimer");
Game::Lua::RegisterIntegerEnum("Enum", "PlayerSwingType", kSwingTypeEntries, "SwingTimer");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+21 -24
View File
@@ -35,6 +35,7 @@
#include "combatlog/Entry.h"
#include "Game.h"
#include "combatlog/File.h"
#include "combatlog/History.h"
#include "Offsets.h"
#include "event/Custom.h"
@@ -44,6 +45,7 @@
#include "time/Clock.h"
#include "unit/Focus.h"
#include "unit/Identity.h"
#include "unit/Power.h"
#include <ctime>
@@ -298,7 +300,6 @@ using CacheRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t id, co
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); }
@@ -322,7 +323,7 @@ const char *ObjectName(uint64_t guid) {
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);
return Unit::Identity::NameForUnit(object);
if (type & Offsets::TYPEMASK_GAMEOBJECT)
return reinterpret_cast<GameObjectName_t>(Offsets::FUN_GAMEOBJECT_GET_NAME)(object);
if (type & Offsets::TYPEMASK_ITEM)
@@ -406,9 +407,7 @@ int Push(void *L, const Entry &e) {
PushString(L, name);
else
PushNil(L);
PushNumber(L, spell != nullptr
? SchoolMask(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_SCHOOL))
: e.spellSchool);
PushNumber(L, spell != nullptr ? SpellSchoolMask(spell) : e.spellSchool);
count += 3;
}
if (e.info & INFO_COUNT) {
@@ -420,14 +419,12 @@ int Push(void *L, const Entry &e) {
++count;
}
if (e.info & INFO_MISS) {
const int miss = e.v[0];
PushString(L, (miss >= 0 && miss < MISS_COUNT) ? kMissNames[miss] : kMissNames[0]);
PushString(L, MissName(e.v[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]);
PushString(L, EnvironmentalName(e.v[0]));
++count;
}
if (e.info & INFO_DAMAGE) {
@@ -489,9 +486,7 @@ int Push(void *L, const Entry &e) {
PushString(L, name);
else
PushNil(L);
PushNumber(L, extra != nullptr ? SchoolMask(Game::Read<uint32_t>(
extra, Offsets::OFF_SPELL_RECORD_SCHOOL))
: 0);
PushNumber(L, SpellSchoolMask(extra));
count += 3;
}
if (e.info & INFO_ITEM) {
@@ -582,6 +577,14 @@ const char *Name(int event) {
return (event >= 0 && event < EVENT_COUNT) ? kEventNames[event] : "";
}
const char *MissName(int missType) {
return (missType >= 0 && missType < MISS_COUNT) ? kMissNames[missType] : kMissNames[0];
}
const char *EnvironmentalName(int type) {
return (type >= 0 && type < 6) ? kEnvironmentalNames[type] : kEnvironmentalNames[0];
}
int PushEvent(void *L, const Entry &entry) { return Push(L, entry); }
const uint8_t *SpellRecord(int spellId) { return Spell::Lookup::RecordForID(spellId); }
@@ -609,15 +612,10 @@ bool Ranged(const uint8_t *spell) {
uint32_t SchoolMask(uint32_t school) { return school < kSchoolCount ? 1u << school : 0; }
bool HasAuraEffect(const uint8_t *spell, int auraName) {
if (spell == nullptr)
return false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
if (Game::Read<int>(spell, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME + i * 4) ==
auraName)
return true;
}
return false;
uint32_t SpellSchoolMask(const uint8_t *spell) {
return spell != nullptr
? SchoolMask(Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_SCHOOL))
: 0;
}
Entry Begin(uint64_t source, uint64_t dest, int event, const uint8_t *spell) {
@@ -676,9 +674,7 @@ void SetItem(Entry &e, int itemId) {
}
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);
const int divisor = static_cast<int>(Unit::Power::DisplayDivisor(powerType));
return divisor != 0 ? amount / divisor : amount;
}
@@ -701,6 +697,7 @@ void Queue(Entry &e) {
g_startTimeMs = e.timeMs;
}
History::Record(e);
File::Write(e);
Send(e);
}
+8 -3
View File
@@ -72,6 +72,12 @@ enum Subevent : int {
const char *Name(int event);
// The INFO_MISS value as 4.3.4's g_spellMissStrings names it.
const char *MissName(int missType);
// The INFO_ENVIRONMENTAL value as 4.3.4's s_environmentalDamageTypes names it.
const char *EnvironmentalName(int type);
// 4.3.4's m_infoFlags: which suffix blocks the event carries, pushed in
// this order after the spell prefix.
enum Info : uint32_t {
@@ -158,9 +164,8 @@ 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 applies the aura `auraName`
// (EffectApplyAuraName).
bool HasAuraEffect(const uint8_t *spell, int auraName);
// The spell's school as a mask (4.3.4's SpellRec m_schoolMask), 0 for nullptr.
uint32_t SpellSchoolMask(const uint8_t *spell);
// GetCombatLogEntry: an entry for `event` from `source` to `dest` (either may
// be 0), with the spell prefix when `spell` is a record.
+212
View File
@@ -0,0 +1,212 @@
// 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 file — 4.3.4's CombatLogEntry::PackEvent line, written as
// SendCombatLogEvents writes it while LoggingCombat is on.
//
// The engine pieces:
// - the switch is LoggingCombat's own flag, so /combatlog and
// LoggingCombat() turn both files on and off, and logging out (the chat
// teardown in CGGameUI::ShutdownGame, 0x00498700, clears the flag) stops
// both;
// - each time the flag turns on, a new file starts, named as retail names
// it: Logs\WoWCombatLog-MMDDYY_HHMMSS.txt, the local time logging started.
// 1.12's own Logs\WoWCombatLog.txt keeps its chat-text lines, which 1.12
// log readers parse;
// - the file is a Storm log, opened as LoggingCombat opens its own (append)
// and written with SLogWrite (which stamps "M/D HH:MM:SS.mmm " on each
// line, as in 4.3.4's file). When the flag turns off it is closed, which
// writes its buffered lines. Storm closes what is still open at exit;
// - the line is PackEvent's: no hideCaster, flags as hex, the spell school
// as a hex mask (the extra spell's in decimal), damage values as numbers,
// flags as 1 or nil, no isOffHand and no alternate power type.
//
// The flag is read once a frame (FrameTick, which also runs on the glue
// screen, so a logout's cleared flag is seen there) and before each line.
// LoggingCombat itself is not hooked: other DLLs add their own logs to it.
#include "combatlog/File.h"
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "tick/FrameTick.h"
#include <cstdio>
#include <ctime>
#include <string>
namespace CombatLog::File {
namespace {
constexpr uint32_t kAppend = 4; // SLogCreate: open at the end of the file
uint32_t g_log = 0;
bool Logging() { return Game::Read<int>(Offsets::VAR_COMBAT_LOGGING) != 0; }
// A new log named for the local time now. True when it is open.
bool Open() {
const std::time_t now = std::time(nullptr);
std::tm local{};
if (localtime_s(&local, &now) != 0)
return false;
char path[64];
std::strftime(path, sizeof path, "Logs\\WoWCombatLog-%m%d%y_%H%M%S.txt", &local);
using Create_t = int(__stdcall *)(const char *path, uint32_t flags, uint32_t *log);
const auto create = reinterpret_cast<Create_t>(static_cast<uintptr_t>(Offsets::FUN_SLOG_CREATE));
if (create(path, kAppend, &g_log) == 0)
g_log = 0;
return g_log != 0;
}
void Close() {
using Close_t = void(__stdcall *)(uint32_t log);
reinterpret_cast<Close_t>(static_cast<uintptr_t>(Offsets::FUN_SLOG_CLOSE))(g_log);
g_log = 0;
}
// Follows the flag: a new file when it turns on, the file closed when it
// turns off.
void OnFrame() {
const bool logging = Logging();
if (logging && g_log == 0)
Open();
else if (!logging && g_log != 0)
Close();
}
const Tick::FrameTick::AutoSubscribe _frame{&OnFrame};
// PackString
void Plain(std::string &line, const char *text) {
line += ',';
line += text;
}
// PackQuotedString, or PackString "nil" when there is no text.
void Quoted(std::string &line, const char *text) {
if (text == nullptr) {
Plain(line, "nil");
return;
}
line += ",\"";
for (const char *c = text; *c != '\0'; ++c) {
if (*c == '"' || *c == '\\')
line += '\\';
line += *c;
}
line += '"';
}
// PackHex
void Hex(std::string &line, uint32_t value) {
char text[16];
std::snprintf(text, sizeof text, ",0x%x", value);
line += text;
}
void Number(std::string &line, int value) {
char text[16];
std::snprintf(text, sizeof text, ",%d", value);
line += text;
}
// PackBool
void Flag(std::string &line, bool value) { line += value ? ",1" : ",nil"; }
void Unit(std::string &line, uint64_t guid, bool named, const std::string &name, uint32_t flags,
uint32_t raidFlags) {
char text[Guid::STRING_SIZE];
line += ',';
line += Guid::FormatAsString(guid, text, sizeof text);
Quoted(line, named ? name.c_str() : nullptr);
Hex(line, flags);
Hex(line, raidFlags);
}
// CombatLogEntry::PackEvent, the blocks 1.12 fills.
std::string Pack(const Entry &e) {
std::string line = Name(e.event);
Unit(line, e.source, e.sourceNamed, e.sourceName, e.sourceFlags, e.sourceRaidFlags);
Unit(line, e.dest, e.destNamed, e.destName, e.destFlags, e.destRaidFlags);
if (e.spellPrefix) {
const uint8_t *spell = SpellRecord(e.spellId);
Number(line, e.spellId);
Quoted(line, SpellName(spell));
Hex(line, spell != nullptr ? SpellSchoolMask(spell) : e.spellSchool);
}
if (e.info & INFO_COUNT)
Number(line, e.v[0]);
if (e.info & INFO_TEXT)
Quoted(line, e.text.c_str());
if (e.info & INFO_MISS)
Plain(line, MissName(e.v[0]));
if (e.info & INFO_ENVIRONMENTAL)
Plain(line, EnvironmentalName(e.v[0]));
if (e.info & INFO_DAMAGE) {
for (const int slot : {1, 2, 3, 5, 6, 4})
Number(line, e.v[slot]);
Flag(line, e.critical);
Flag(line, e.glancing);
Flag(line, e.crushing);
}
if (e.info & INFO_MISS_ABSORBED)
Number(line, e.v[4]);
if (e.info & INFO_MISS_RESISTED)
Number(line, e.v[5]);
if (e.info & INFO_MISS_BLOCKED)
Number(line, e.v[6]);
if (e.info & INFO_HEAL) {
for (int i = 0; i < 3; ++i)
Number(line, e.v[i]);
Flag(line, e.critical);
}
if (e.info & INFO_ENERGIZE) {
for (int i = 0; i < 2; ++i)
Number(line, e.v[i]);
}
if (e.info & INFO_DRAIN) {
for (int i = 0; i < 3; ++i)
Number(line, e.v[i]);
}
if (e.info & INFO_EXTRA_SPELL) {
const uint8_t *extra = SpellRecord(e.v[0]);
Number(line, e.v[0]);
Quoted(line, SpellName(extra));
Number(line, static_cast<int>(SpellSchoolMask(extra)));
}
if (e.info & INFO_ITEM) {
Number(line, e.v[0]);
Quoted(line, e.itemNamed ? e.itemName.c_str() : nullptr);
}
if (e.info & INFO_AURA_TYPE)
Plain(line, e.harmful ? "DEBUFF" : "BUFF");
if (e.info & INFO_AURA_AMOUNT)
Number(line, e.v[2]);
return line;
}
} // namespace
void Write(const Entry &entry) {
// A line in the frame logging turned on comes before the frame's check.
if (!Logging() || (g_log == 0 && !Open()))
return;
using Write_t = void(__cdecl *)(uint32_t log, const char *format, ...);
reinterpret_cast<Write_t>(static_cast<uintptr_t>(Offsets::FUN_SLOG_WRITE))(
g_log, "%s", Pack(entry).c_str());
}
} // namespace CombatLog::File
+29
View File
@@ -0,0 +1,29 @@
// 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 file — 4.3.4's WoWCombatLog.txt line, written while
// LoggingCombat is on to a file per logging session named as retail names it
// (Logs\WoWCombatLog-MMDDYY_HHMMSS.txt). 1.12's own WoWCombatLog.txt keeps
// its chat-text lines.
#include "combatlog/Entry.h"
namespace CombatLog::File {
// SendCombatLogEvents' write: CombatLogEntry::PackEvent's line, through
// CGChat::AddCombatMessage's SLogWrite, while combat logging is on.
void Write(const Entry &entry);
} // namespace CombatLog::File
+2 -1
View File
@@ -55,6 +55,7 @@
#include "aura/Data.h"
#include "net/PacketDispatch.h"
#include "object/Resolve.h"
#include "spell/Lookup.h"
#include "time/Clock.h"
#include <cstdint>
@@ -501,7 +502,7 @@ const uint8_t *ShieldSpell(uint64_t owner, uint32_t school) {
const uint8_t *spell = SpellRecord(static_cast<int>(spellId));
if (spell == nullptr ||
Game::Read<uint32_t>(spell, Offsets::OFF_SPELL_RECORD_SCHOOL) != school ||
!HasAuraEffect(spell, AURA_DAMAGE_SHIELD))
!Spell::Lookup::HasAuraEffect(spell, AURA_DAMAGE_SHIELD))
continue;
if (found != nullptr && found != spell)
return nullptr;
+83
View File
@@ -0,0 +1,83 @@
// 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 "compat/WowTurbo.h"
#include "Common.h"
#include <cstring>
#include <windows.h>
namespace Compat::WowTurbo {
namespace {
// The prologue patch wow_turbo's MinHook writes on x86: `jmp rel32` over the
// first five bytes. 1.12's entries open with `push ebp; mov ebp,esp`, never
// the `mov edi,edi` that would make MinHook hot-patch instead.
constexpr uint8_t kJmp = 0xE9;
constexpr size_t kJmpLen = 5;
// The `kJmpLen` bytes at `va` as WoW.exe holds them on disk, located through
// the loaded image's own section table.
bool ReadStock(uintptr_t va, uint8_t (&out)[kJmpLen]) {
const auto base = reinterpret_cast<uintptr_t>(GetModuleHandleW(nullptr));
const auto *dos = reinterpret_cast<const IMAGE_DOS_HEADER *>(base);
const auto *nt = reinterpret_cast<const IMAGE_NT_HEADERS *>(base + dos->e_lfanew);
const IMAGE_SECTION_HEADER *section = IMAGE_FIRST_SECTION(nt);
const uintptr_t rva = va - base;
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++section) {
if (rva < section->VirtualAddress ||
rva + kJmpLen > section->VirtualAddress + section->SizeOfRawData)
continue;
wchar_t path[MAX_PATH];
const DWORD len = GetModuleFileNameW(nullptr, path, MAX_PATH);
if (len == 0 || len == MAX_PATH)
return false;
const HANDLE file = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE)
return false;
OVERLAPPED at{};
at.Offset = static_cast<DWORD>(rva - section->VirtualAddress + section->PointerToRawData);
DWORD read = 0;
const bool ok = ReadFile(file, out, kJmpLen, &read, &at) != 0 && read == kJmpLen;
CloseHandle(file);
return ok;
}
return false;
}
} // namespace
void Reclaim(uintptr_t function) {
const HMODULE turbo = GetModuleHandleW(L"wow_turbo.dll");
if (turbo == nullptr)
return;
const auto *entry = reinterpret_cast<const uint8_t *>(function);
if (entry[0] != kJmp)
return;
int32_t rel = 0;
std::memcpy(&rel, entry + 1, sizeof rel);
HMODULE owner = nullptr;
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(function + kJmpLen + rel), &owner) ||
owner != turbo)
return;
uint8_t stock[kJmpLen];
if (ReadStock(function, stock) && stock[0] != kJmp)
Common::PatchBytes(reinterpret_cast<void *>(function), stock, kJmpLen);
}
} // namespace Compat::WowTurbo
+44
View File
@@ -0,0 +1,44 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// wow_turbo (github.com/athei/wow-mods) detours ~380 engine functions to Rust
// reimplementations and yields any function another mod has already hooked:
// its installer refuses a prologue that fails its signature. It installs from
// DllMain, but ClassicAPI installs from `Load`, which VanillaFixes calls after
// every DllMain, so wow_turbo never sees our hooks and always installs first.
// MinHook then copies wow_turbo's jump into our trampoline, and our hook calls
// the Rust body instead of the engine's. That loses everything that lives in
// the stock body: Frame::ScriptArgs' pcall redirects in both script runners,
// and InlineTexture / IndentedWordWrap in the text builder, whose Rust copy
// calls its own emitter.
//
// `Reclaim` gives the stock prologue back before we hook, the state wow_turbo
// itself produces when it loads after a mod that hooked first. wow_turbo keeps
// its original in place, logs "prologue overwritten ... by ClassicAPI.dll"
// and does not re-patch these entries.
//
// The DllMain core hooks need no reclaim: the only one wow_turbo also
// replaces is the integrity scan, which both make the same no-op.
namespace Compat::WowTurbo {
// Restores the engine's own first instruction at `function` when it is a
// `jmp` into wow_turbo.dll. No-op when wow_turbo is not loaded or the
// prologue is anything else.
void Reclaim(uintptr_t function);
} // namespace Compat::WowTurbo
+2 -6
View File
@@ -145,12 +145,8 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ConsoleGetAllCommands", &Script_GetAllCommands);
Game::Lua::RegisterGlobalFunction("ConsolePrintAllMatchingCommands",
&Script_ConsolePrintAllMatchingCommands);
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCommandType", kCommandTypeEntries,
sizeof(kCommandTypeEntries) /
sizeof(kCommandTypeEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCategory", kCategoryEntries,
sizeof(kCategoryEntries) /
sizeof(kCategoryEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCommandType", kCommandTypeEntries);
Game::Lua::RegisterIntegerEnum("Enum", "ConsoleCategory", kCategoryEntries);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -2
View File
@@ -49,6 +49,7 @@
#include "Game.h"
#include "Offsets.h"
#include "console/Registry.h"
#include "input/Modifier.h"
#include <algorithm>
#include <cstdint>
@@ -63,7 +64,6 @@ namespace {
using Handler_t = int(__fastcall *)(const void *data, void *param);
using RegisterEx_t = void(__fastcall *)(int eventId, Handler_t handler, void *param,
float priority);
using IsKeyDown_t = int(__fastcall *)(int key);
using GetInputLine_t = uintptr_t(__fastcall *)();
using MakeCommandCurrent_t = void(__fastcall *)(uintptr_t line, const char *text);
using SetInputString_t = void(__fastcall *)(const char *text);
@@ -205,7 +205,7 @@ int __fastcall OnKeyDown(const void *data, void * /*param*/) {
return 1;
if (Game::Read<uint32_t>(Offsets::VAR_CONSOLE_VISIBLE) == 0)
return 1;
if (reinterpret_cast<IsKeyDown_t>(Offsets::FUN_GAMEUI_IS_KEY_DOWN)(toggleKey) != 0)
if (Input::Modifier::IsKeyDown(toggleKey))
return 1;
const uintptr_t line =
+104
View File
@@ -0,0 +1,104 @@
// 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 "container/Bag.h"
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Locations.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "unit/Identity.h"
namespace Container::Bag {
namespace {
bool PlayerRange(int first, int count, View *out) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, first, count, 0};
return true;
}
} // namespace
// The main bank resolves only while a banker is open — 3.3.5 gates it on
// `CGBankInfo::m_banker` the same way. Its GUIDs sit in the player's list
// from login, but the server refuses every bank move without a banker and
// the engine's own slot reader (`0x6228a0`) hides slots 39..68 on the same
// banker-GUID test. Bank bags get that gate from `BagContainer`, whose
// engine GUID getter answers 0 for them while the bank is closed.
bool ViewFor(int bagID, View *out) {
if (bagID == Offsets::BAG_INDEX_BACKPACK)
return PlayerRange(Offsets::INVMGR_BACKPACK_FIRST_SLOT,
Offsets::BACKPACK_NUM_SLOTS, out);
if (bagID == Offsets::BAG_INDEX_BANK) {
if (!EquipmentSet::Locations::BankerOpen())
return false;
return PlayerRange(Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT -
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1,
out);
}
const uint8_t *bag = Item::Location::BagContainer(bagID);
auto *inv = static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (inv == nullptr)
return false;
const int slots = static_cast<int>(
Game::Read<uint32_t>(inv, Offsets::OFF_INVMGR_SLOT_COUNT));
if (slots <= 0)
return false;
// The bag's own family, as 3.3.5's `CGItem_C::GetBagFamily` reads it (0
// while its record isn't cached). `BitmaskForRecord` supplies the family
// a vanilla bag's empty field leaves out, so a quiver reports one rather
// than reading as general-purpose, and agrees with `C_Item.GetItemFamily`.
uint32_t family = 0;
const int bagItemID = Item::ID::FromCGItem(bag);
if (bagItemID > 0) {
if (auto *rec = Item::PeekRecord(static_cast<uint32_t>(bagItemID)))
family = Item::BagFamily::BitmaskForRecord(rec);
}
*out = {inv, 0, slots, family};
return true;
}
uint64_t SlotGuid(const View &view, int slotIndex0) {
const uint32_t linear = static_cast<uint32_t>(view.firstLinear + slotIndex0);
if (linear >= Game::Read<uint32_t>(view.invMgr, Offsets::OFF_INVMGR_SLOT_COUNT))
return 0;
auto *guids = Game::Read<const uint64_t *>(view.invMgr, Offsets::OFF_INVMGR_GUID_ARRAY);
return guids != nullptr ? guids[linear] : 0;
}
int CountFree(const View &view) {
if (view.invMgr != Unit::Identity::PlayerInventoryManager()) {
using CountFreeSlots_t = uint32_t(__thiscall *)(const uint8_t *invMgr);
auto countFreeSlots =
reinterpret_cast<CountFreeSlots_t>(Offsets::FUN_INVMGR_COUNT_FREE_SLOTS);
return static_cast<int>(countFreeSlots(view.invMgr));
}
int free = 0;
for (int i = 0; i < view.slotCount; ++i) {
if (SlotGuid(view, i) == 0)
free++;
}
return free;
}
} // namespace Container::Bag
+47
View File
@@ -0,0 +1,47 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Container::Bag {
// A container's storage, addressed without Lua: the inventory list holding
// its slot GUIDs, the linear index its first slot sits at, its slot count,
// and the BagFamily bitmask it accepts (0 = anything). The backpack and the
// main bank live inside the player's own inventory list at different linear
// bases; equipped bags and bank bags each have a list of their own.
struct View {
const uint8_t *invMgr;
int firstLinear;
int slotCount;
uint32_t family;
};
// Resolves Lua container `bagID` — backpack 0, bags 1..4, main bank -1, bank
// bags 5..10 — to its `View`. False for the keyring, an out-of-range ID, an
// empty bag slot, and any bank container while no banker is open. Pure C — no
// Lua stack.
bool ViewFor(int bagID, View *out);
// GUID of the item in 0-based slot `slotIndex0` of `view`, 0 when empty.
uint64_t SlotGuid(const View &view, int slotIndex0);
// Empty slots in `view`. A bag's own list is counted by the engine
// (`FUN_INVMGR_COUNT_FREE_SLOTS`, 3.3.5's `CGContainer_C::GetFreeSlots`); a
// range of the player's list (backpack, main bank) is walked slot by slot,
// the way 3.3.5's `Script_GetContainerNumFreeSlots` walks it.
int CountFree(const View &view);
} // namespace Container::Bag
+1 -2
View File
@@ -42,8 +42,7 @@ const Game::Lua::EnumIntegerEntry kEntries[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum("Enum", "BagIndex", kEntries,
sizeof(kEntries) / sizeof(kEntries[0]), "BagIndexConstants");
Game::Lua::RegisterIntegerEnum("Enum", "BagIndex", kEntries, "BagIndexConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+29 -115
View File
@@ -12,94 +12,21 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "Game.h"
#include "Offsets.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include <cstdint>
#include <vector>
#include "container/Bag.h"
namespace Container::FreeSlots {
namespace {
// Resolves the bag's metadata: total slot count + BagFamily bitfield.
// `C_Container.GetContainerNumFreeSlots(bagID)` — `(numFreeSlots,
// bagFamily)`. 3.3.5's `Script_GetContainerNumFreeSlots` (`0x005D7590`):
// the backpack and, at a banker, the main bank report family 0; bags 1..4
// and bank bags 5..10 report the bag's own family. A container that isn't
// there — keyring, an empty bag slot, the bank while it's closed — returns
// only `0`, as 3.3.5 does and as retail's nilable `bagFamily` documents.
//
// - bagID 0 (backpack) → (16, 0). Vanilla 1.12 backpack is fixed at 16
// slots and unfamilied (0). 3.3.5's `Script_GetContainerNumFreeSlots`
// hardcodes both values for the backpack path too.
// - bagID 1..4 → looks up the equipped bag at INVSLOT_BAGn, reads
// `m_containerSlots` and `m_bagFamily` from the cached `ItemStats_C`
// record. If no bag equipped or item not cached: (0, 0).
// - other bagIDs (bank, keyring) → (0, 0). Not supported in this
// implementation.
struct BagInfo {
int slotCount;
int bagType;
};
BagInfo ResolveBagInfo(int bagID) {
if (bagID == 0)
return {Offsets::BACKPACK_NUM_SLOTS, 0};
if (bagID < 1 || bagID > 4)
return {0, 0};
const int equipSlot = Offsets::INVSLOT_BAG1 + bagID - 1;
auto *bagItem = Item::Location::ResolveEquipmentSlot(equipSlot);
if (bagItem == nullptr)
return {0, 0};
const int itemID = Item::ID::FromCGItem(bagItem);
if (itemID == 0)
return {0, 0};
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return {0, 0};
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
// BagFamily bitmask: the item's field if populated, else derived from
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
// Soul Bag would otherwise report family 0 and get miscounted as a
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
// with C_Item.GetItemFamily so a bag and its contents agree. See
// `Item::BagFamily`.
const int family =
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
return {slots, family};
}
// Walks the bag and counts empty slots. Each `Item::Location::ResolveBag`
// call stomps Lua stack[1]/[2] (a requirement of the engine's
// `PackBagSlot` helper), but we own the stack here — Lua just called us
// — and the caller's bagID arg has already been read into a local. So
// it's safe to repeatedly clobber the stack across iterations.
int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
int free = 0;
for (int slot = 1; slot <= slotCount; slot++) {
if (Item::Location::ResolveBag(L, bagID, slot) == nullptr)
free++;
}
return free;
}
// `C_Container.GetContainerNumFreeSlots(bagID)` — returns
// `(numberOfFreeSlots, bagType)`. `bagID` is 0..4 (0 = backpack,
// 1..4 = equipped bag slots). For invalid or unsupported bag IDs
// (negative bank/keyring indices, out-of-range values), returns
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
// rather than nil/nothing.
//
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
// all read 0), so it's derived from the bag's class + subclass via
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
// bag and the items it holds. See that helper for the Turtle custom-family
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
// `bagFamily` is the modern bitmask; see `Container::Bag::ViewFor` for how
// a vanilla bag with an empty family field gets one.
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -108,11 +35,13 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const BagInfo info = ResolveBagInfo(bagID);
const int free = (info.slotCount > 0) ? CountFreeSlotsInBag(L, bagID, info.slotCount) : 0;
Game::Lua::PushNumber(L, static_cast<double>(free));
Game::Lua::PushNumber(L, static_cast<double>(info.bagType));
Container::Bag::View view;
if (!Container::Bag::ViewFor(bagID, &view)) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
Game::Lua::PushNumber(L, static_cast<double>(Container::Bag::CountFree(view)));
Game::Lua::PushNumber(L, static_cast<double>(view.family));
return 2;
}
@@ -120,11 +49,8 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
// as an array of slot numbers in ascending order, rather than
// `GetContainerNumFreeSlots`'s count of them. A full bag returns an
// empty table; a bag that isn't there returns nothing, which is how
// callers tell "no free slots" from "no such bag".
//
// `bagID` is 0..4 like the rest of this file (0 = backpack, 1..4 =
// equipped bag slots); bank and keyring indices are the "no such bag"
// case.
// callers tell "no free slots" from "no such bag". Takes the same
// containers as `GetContainerNumFreeSlots`.
int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -133,24 +59,17 @@ int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount <= 0)
Container::Bag::View view;
if (!Container::Bag::ViewFor(bagID, &view))
return 0; // nothing — no such bag
// Collect before building the table. Each `ResolveBag` stomps stack
// slots 1 and 2, so the table cannot be on the stack during the walk.
std::vector<int> freeSlots;
freeSlots.reserve(static_cast<size_t>(info.slotCount));
for (int slot = 1; slot <= info.slotCount; ++slot) {
if (Item::Location::ResolveBag(L, bagID, slot) == nullptr)
freeSlots.push_back(slot);
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
for (size_t i = 0; i < freeSlots.size(); ++i) {
int n = 0;
for (int i = 0; i < view.slotCount; ++i) {
if (Container::Bag::SlotGuid(view, i) != 0)
continue;
Game::Lua::PushNumber(L, static_cast<double>(++n));
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(freeSlots[i]));
Game::Lua::RawSet(L, -3);
}
return 1;
@@ -165,18 +84,13 @@ int __fastcall Script_C_Container_GetContainerFreeSlots(void *L) {
// free, family = C_Container.GetContainerNumFreeSlots(bag)
// if family == 0 then total = total + free end
//
// Takes no arguments. Specialty bags are correctly excluded: their family
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
// left out of the general-purpose total — matching retail's FrameXML
// behavior. (An earlier version miscounted them because Turtle leaves the
// raw family field at 0 for bags.)
// Takes no arguments.
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount > 0 && info.bagType == 0)
total += CountFreeSlotsInBag(L, bagID, info.slotCount);
Container::Bag::View view;
if (Container::Bag::ViewFor(bagID, &view) && view.family == 0)
total += Container::Bag::CountFree(view);
}
Game::Lua::PushNumber(L, static_cast<double>(total));
return 1;
+31 -13
View File
@@ -55,6 +55,7 @@
#include "item/Location.h"
#include "item/Record.h"
#include "item/TypeInfo.h"
#include "localization/Locale.h"
#include "unit/Identity.h"
#include <algorithm>
@@ -86,12 +87,29 @@ char16_t ToLower(char16_t c) {
return c;
}
// Appends the lowercased `c` with 4.3.4's accent folding for `locale`
// (frFR 2, deDE 3, esES/esMX 6/7, ruRU 8); æ, œ and ß become two letters.
// Returns the new length.
size_t AppendFolded(int locale, char16_t c, char16_t *out, size_t n) {
switch (locale) {
case 2: // frFR
// 4.3.4's accent-folding sets. 4.3.4 picks one by its locale index (frFR 2,
// deDE 3, esES/esMX 6/7, ruRU 8); 1.12's index has other slots, so the set
// is picked by locale name.
enum class Folding { None, French, German, Spanish, Russian };
Folding ClientFolding() {
using Localization::Locale::Is;
if (Is("frFR"))
return Folding::French;
if (Is("deDE"))
return Folding::German;
if (Is("esES") || Is("esMX"))
return Folding::Spanish;
if (Is("ruRU"))
return Folding::Russian;
return Folding::None;
}
// Appends the lowercased `c` with `folding`'s accent folding; æ, œ and ß
// become two letters. Returns the new length.
size_t AppendFolded(Folding folding, char16_t c, char16_t *out, size_t n) {
switch (folding) {
case Folding::French:
switch (c) {
case 0xE0: case 0xE2: case 0xE4: c = u'a'; break;
case 0xE6: out[n++] = u'a'; c = u'e'; break;
@@ -103,14 +121,13 @@ size_t AppendFolded(int locale, char16_t c, char16_t *out, size_t n) {
case 0xF9: case 0xFA: case 0xFB: case 0xFC: c = u'u'; break;
}
break;
case 3: // deDE
case Folding::German:
if (c == 0xDF) {
out[n++] = u's';
c = u's';
}
break;
case 6: // esES
case 7: // esMX
case Folding::Spanish:
switch (c) {
case 0xE1: c = u'a'; break;
case 0xE9: c = u'e'; break;
@@ -120,10 +137,12 @@ size_t AppendFolded(int locale, char16_t c, char16_t *out, size_t n) {
case 0xF1: c = u'n'; break;
}
break;
case 8: // ruRU
case Folding::Russian:
if (c == 0x451)
c = 0x435;
break;
case Folding::None:
break;
}
out[n++] = c;
return n;
@@ -141,11 +160,10 @@ size_t Fold(const char *text, char16_t (&out)[kFoldCap]) {
// One unit short of the buffer: a full conversion stores no terminator.
reinterpret_cast<Convert_t>(Offsets::FUN_SUNI_CONVERT_UTF8_TO_16)(
wide, kFoldCap - 1, text, 0x7FFFFFFF, nullptr, nullptr);
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const Folding folding = ClientFolding();
size_t n = 0;
for (size_t i = 0; wide[i] != 0 && n + 2 < kFoldCap; ++i)
n = AppendFolded(locale, ToLower(wide[i]), out, n);
n = AppendFolded(folding, ToLower(wide[i]), out, n);
out[n] = 0;
return n;
}
+7 -90
View File
@@ -60,18 +60,16 @@
#include "Game.h"
#include "Offsets.h"
#include "container/Bag.h"
#include "cvar/Factory.h"
#include "equipmentset/Locations.h"
#include "item/BagFamily.h"
#include "item/CGItem.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Record.h"
#include "item/Swap.h"
#include "object/Resolve.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
@@ -82,6 +80,8 @@ namespace Container::SortBags {
namespace {
using BagView = Container::Bag::View;
// The two sets this can arrange. Both go through the same merge,
// snapshot, comparator and placement.
constexpr int kInventoryBags[] = {0, 1, 2, 3, 4};
@@ -258,96 +258,13 @@ bool Precedes(const Entry &a, const Entry &b) {
return a.itemID < b.itemID; // total order, so the result is stable
}
// A bag's storage, addressed without Lua: the invMgr holding its GUID
// array, the linear index its first slot sits at, and the family it
// accepts. The backpack lives inside the player's own invMgr; bags 1..4
// each have one of their own.
struct BagView {
const uint8_t *invMgr;
int firstLinear;
int slotCount;
uint32_t family;
};
// The backpack and the main bank both live inside the player's own
// invMgr, at different linear bases. Equipped bags and bank bags each
// have an invMgr of their own, reached through the bag item.
//
// The bank resolves only while a banker is open: the server refuses every
// bank move without one, and the engine's own slot reader (`0x6228a0`)
// hides slots 39..68 on the same banker-GUID test. The main bank's GUIDs
// sit in the player's invMgr from login, so without this gate a closed-
// bank sort would plan and send moves the server rejects one by one.
bool ViewForBag(int bagID, BagView *out) {
if ((bagID == -1 || bagID >= 5) && !EquipmentSet::Locations::BankerOpen())
return false;
if (bagID == 0) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, Offsets::INVMGR_BACKPACK_FIRST_SLOT,
Offsets::BACKPACK_NUM_SLOTS, 0};
return true;
}
if (bagID == -1) {
auto *inv = Unit::Identity::PlayerInventoryManager();
if (inv == nullptr)
return false;
*out = {inv, Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT -
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1,
0};
return true;
}
// Resolve the bag item that owns the container.
int equipSlot = 0;
if (bagID >= 1 && bagID <= 4)
equipSlot = Offsets::INVSLOT_BAG1 + bagID - 1;
else if (bagID >= 5 && bagID <= 10)
equipSlot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT + 1 + (bagID - 5);
else
return false;
auto *bagItem = Item::Location::ResolveEquipmentSlot(equipSlot);
if (bagItem == nullptr)
return false;
auto *bagInv = static_cast<const uint8_t *>(
Item::Location::ContainerInventory(bagItem));
if (bagInv == nullptr)
return false;
const int slots = static_cast<int>(
Game::Read<uint32_t>(bagInv, Offsets::OFF_INVMGR_SLOT_COUNT));
if (slots <= 0)
return false;
// A specialty bag only accepts its own family, and Turtle leaves the
// raw field empty on bags — `BitmaskForRecord` derives it from the
// container subclass, so a quiver reports a family here rather than
// reading as general-purpose. Shared with `C_Item.GetItemFamily`, so
// a bag and the items it holds agree.
uint32_t family = 0;
const int bagItemID = Item::ID::FromCGItem(bagItem);
if (bagItemID > 0) {
auto *rec = Item::PeekRecord(static_cast<uint32_t>(bagItemID));
if (rec != nullptr)
family = Item::BagFamily::BitmaskForRecord(rec);
}
*out = {bagInv, 0, slots, family};
return true;
}
const uint8_t *ItemByGuid(uint64_t guid) {
return static_cast<const uint8_t *>(
Object::ByGuid(Offsets::TYPEMASK_ITEM, guid, "SortBags", 0));
}
const uint8_t *ItemAt(const BagView &v, int slotIndex0, uint64_t *guidOut) {
auto *guids = *reinterpret_cast<const uint64_t *const *>(
v.invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return nullptr;
const uint64_t guid = guids[v.firstLinear + slotIndex0];
const uint64_t guid = Container::Bag::SlotGuid(v, slotIndex0);
if (guid == 0)
return nullptr;
*guidOut = guid;
@@ -417,7 +334,7 @@ std::vector<Group> GatherGroups(const int *bags, int bagCount) {
for (int b = 0; b < bagCount; ++b) {
const int bag = bags[b];
BagView view;
if (!ViewForBag(bag, &view))
if (!Container::Bag::ViewFor(bag, &view))
continue;
for (int i = 0; i < view.slotCount; ++i) {
uint64_t guid = 0;
@@ -590,7 +507,7 @@ bool Place(const int *bags, int bagCount, int mergesPending) {
for (int b = 0; b < bagCount; ++b) {
const int bag = bags[b];
BagView view;
if (!ViewForBag(bag, &view))
if (!Container::Bag::ViewFor(bag, &view))
continue;
for (int i = 0; i < view.slotCount; ++i) {
const int slot = i + 1;
@@ -958,7 +875,7 @@ int __fastcall Script_SortBagsBags(void *L) {
int pushed = 0;
for (int b = 0; b < count; ++b) {
BagView view;
if (!ViewForBag(bags[b], &view))
if (!Container::Bag::ViewFor(bags[b], &view))
continue;
Game::Lua::PushNumber(L, bags[b]);
Game::Lua::PushNumber(L, view.slotCount);
+6 -4
View File
@@ -131,10 +131,7 @@ int __fastcall Script_RequestLoadCreatureByID(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
auto *cache = reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE);
const bool accepted =
Cache::QueryLoad::RequestLoad(cache, static_cast<uint32_t>(creatureID));
Game::Lua::PushBool(L, accepted);
Game::Lua::PushBool(L, RequestLoad(static_cast<uint32_t>(creatureID)));
return 1;
}
@@ -381,4 +378,9 @@ const uint8_t *CacheRecord(uint32_t creatureID) {
return creatureID != 0 ? PeekCreature(creatureID) : nullptr;
}
bool RequestLoad(uint32_t creatureID) {
return Cache::QueryLoad::RequestLoad(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
creatureID);
}
} // namespace Creature::Info
+6
View File
@@ -24,6 +24,12 @@ namespace Creature::Info {
// engine's SetDisplay takes, and `PlayerModel:SetCreature` passes it there.
const uint8_t *CacheRecord(uint32_t creatureID);
// Starts loading `creatureID` into the creature cache; fires
// `CREATURE_DATA_LOAD_RESULT(creatureID, success)` when it lands (at once
// when already cached). False only when the load could not be tracked.
// Backs `C_CreatureInfo.RequestLoadCreatureByID`.
bool RequestLoad(uint32_t creatureID);
// The faction group a player race belongs to, resolved the way
// `Script_UnitFactionGroup` does it for a player whose object isn't in the
// world (its name-cache branch, which reads the race): ChrRaces -> FactionTemplate
+2 -4
View File
@@ -27,10 +27,8 @@ const Game::Lua::EnumIntegerEntry kMoneyFormattingConstants[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterConstants("MoneyFormattingConstants", kMoneyFormattingConstants,
sizeof(kMoneyFormattingConstants) /
sizeof(kMoneyFormattingConstants[0]),
"MoneyConstants");
Game::Lua::RegisterConstants(
"MoneyFormattingConstants", kMoneyFormattingConstants, "MoneyConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -119,9 +119,7 @@ void OnTick() {
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "UICursorType", kCursorTypeEntries,
sizeof(kCursorTypeEntries) / sizeof(kCursorTypeEntries[0]));
Game::Lua::RegisterIntegerEnum("Enum", "UICursorType", kCursorTypeEntries);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+118
View File
@@ -0,0 +1,118 @@
// 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 cursor modes later clients added, shown on 1.12's cursor, and the
// `expandedCursors` cvar that turns them on (0 by default: they change what
// 1.12 shows).
//
// The engine's cursor is a table of 42 images of 32x32, one per mode;
// SendCursorImage copies slot VAR_CURSOR_MODE to the device. SetCursorMode
// stores the mode it is given and sends, so a mode past 41 must never reach
// it. Slot 41 is how the engine shows an image outside the table:
// SetCustomCursor loads a named BLP into it, stores mode 41, sends it and
// keeps the name, and returns at once while that name is already showing. The
// game object classifier uses it for a record-named cursor.
//
// Show() calls SetCustomCursor with the mode's path, so a file of that name (a
// patch MPQ, a loose file) wins, and a repeat each frame costs one name
// compare. 1.12 has none of these files, so on a miss Show() does what
// SetCustomCursor does after its load, with the image embedded in the DLL
// (Texture::Builtin), which a texture load of the same path also gets.
//
// The files are 4.3.4's Interface\Cursor images, under 4.3.4's names. 4.3.4
// already ships StableMaster.blp and Fishing.blp, but only as art a record
// could name. StableMaster is a mode in retail's table (Enum.Cursormode
// StablemasterCursor, listed after Ping and Enchant), not in 4.3.4's; no
// client's table has Fishing.
#include "cursor/Extended.h"
#include "Common.h"
#include "Game.h"
#include "cvar/Factory.h"
#include "texture/Builtin.h"
#include <cstring>
namespace Cursor::Extended {
namespace {
using SetCustomCursor_t = uint32_t(__fastcall *)(const char *path);
using SendCursorImage_t = void(__cdecl *)();
// In mode order, the Unable forms after the others.
const char *const kPaths[] = {
"Interface\\Cursor\\RepairNPC.blp",
"Interface\\Cursor\\Innkeeper.blp",
"Interface\\Cursor\\Quest.blp",
"Interface\\Cursor\\QuestRepeatable.blp",
"Interface\\Cursor\\QuestTurnIn.blp",
"Interface\\Cursor\\StableMaster.blp",
"Interface\\Cursor\\Fishing.blp",
"Interface\\Cursor\\UnableRepairNPC.blp",
"Interface\\Cursor\\UnableInnkeeper.blp",
"Interface\\Cursor\\UnableQuest.blp",
"Interface\\Cursor\\UnableQuestRepeatable.blp",
"Interface\\Cursor\\UnableQuestTurnIn.blp",
"Interface\\Cursor\\UnableStableMaster.blp",
"Interface\\Cursor\\UnableFishing.blp",
};
static_assert(sizeof kPaths / sizeof kPaths[0] == 2 * kUnableOffset, "one path per mode");
CVar::Factory::Handle g_enabled = nullptr;
void Register() {
g_enabled = CVar::Factory::Register(
"expandedCursors",
"Show what an NPC or object offers on the cursor: anvil for repairs, ! and ? for quests, "
"hook for your bobber",
Offsets::CVAR_CATEGORY_GAME, "0");
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
bool Enabled() { return CVar::Factory::GetInt(g_enabled) != 0; }
bool Is(uint32_t mode) { return mode >= kRepairNpc && mode < kRepairNpc + 2 * kUnableOffset; }
bool IsUnable(uint32_t mode) { return Is(mode) && mode >= kRepairNpc + kUnableOffset; }
bool IsQuest(uint32_t mode) {
if (!Is(mode))
return false;
const uint32_t kind = IsUnable(mode) ? mode - kUnableOffset : mode;
return kind >= kQuest && kind <= kQuestTurnIn;
}
const char *Path(uint32_t mode) { return kPaths[mode - kRepairNpc]; }
void Show(uint32_t mode) {
const char *path = Path(mode);
if (reinterpret_cast<SetCustomCursor_t>(Offsets::FUN_SET_CUSTOM_CURSOR)(path) != 0)
return;
const Texture::Builtin::Image *image = Texture::Builtin::Find(path);
if (image == nullptr || image->width != Offsets::CURSOR_IMAGE_SIZE ||
image->height != Offsets::CURSOR_IMAGE_SIZE)
return;
Game::Ref<uint32_t>(Offsets::VAR_CURSOR_MODE) = Offsets::CURSOR_MODE_CUSTOM;
std::memcpy(reinterpret_cast<void *>(Offsets::VAR_CURSOR_IMAGE_CUSTOM), image->texels,
Offsets::CURSOR_IMAGE_SIZE * Offsets::CURSOR_IMAGE_SIZE * sizeof(uint32_t));
reinterpret_cast<SendCursorImage_t>(Offsets::FUN_SEND_CURSOR_IMAGE)();
Common::BoundedCopy(reinterpret_cast<char *>(Offsets::VAR_CUSTOM_CURSOR_NAME), path,
Offsets::CURSOR_FILE_SIZE);
}
} // namespace Cursor::Extended
+57
View File
@@ -0,0 +1,57 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include "Offsets.h"
#include <cstdint>
namespace Cursor::Extended {
// The cursor modes later clients added that 1.12 has no image for, numbered
// on from the engine's last mode (CURSOR_MODE_CUSTOM): 4.3.4's RepairNPC,
// Innkeeper and quest cursors, retail's StableMaster, and the fish hook for
// the fishing bobber. As with the engine's modes, each has an Unable form,
// kUnableOffset further on.
constexpr uint32_t kRepairNpc = Offsets::CURSOR_MODE_CUSTOM + 1;
constexpr uint32_t kInnkeeper = kRepairNpc + 1;
constexpr uint32_t kQuest = kRepairNpc + 2;
constexpr uint32_t kQuestRepeatable = kRepairNpc + 3;
constexpr uint32_t kQuestTurnIn = kRepairNpc + 4;
constexpr uint32_t kStableMaster = kRepairNpc + 5;
constexpr uint32_t kFishing = kRepairNpc + 6;
constexpr uint32_t kUnableOffset = 7;
// Whether the `expandedCursors` cvar asks for these modes. The pickers that
// give them (cursor/Npc, cursor/Fishing) keep 1.12's cursor while it is 0.
bool Enabled();
// Whether `mode` is one of these, in either form.
bool Is(uint32_t mode);
// Whether `mode` is one of these in its Unable form.
bool IsUnable(uint32_t mode);
// Whether `mode` is a quest giver's cursor (Quest, QuestRepeatable,
// QuestTurnIn), in either form.
bool IsQuest(uint32_t mode);
// The image file of one of these modes, `Interface\Cursor\<its name>.blp`.
const char *Path(uint32_t mode);
// Shows one of these modes as the cursor. The engine's SetCursorMode cannot,
// so its hook (cursor/Hover) sends them here.
void Show(uint32_t mode);
} // namespace Cursor::Extended
+67
View File
@@ -0,0 +1,67 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// With `expandedCursors` at 1 (Cursor::Extended::Enabled), the cursor over
// the player's fishing bobber is a fish hook in place of the Interact gear.
//
// A game object's cursor is data. GetCursor (FUN_GAMEOBJECT_GET_CURSOR) gives
// the cursor its record names, else its type handler's: BaseHandlerGetCursor
// for most types, which gives a lock's LockType cursor, else Interact. The
// record's name comes from the server, and tortoise-wow sends it empty; even
// 3.3.5's data names none for the bobber (AzerothCore's template 35591), and
// a bobber has no lock, so every client shows the gear. Later clients ship
// the hook (Interface\Cursor\Fishing.blp) only for data to name.
//
// The fishing node handler (type 17) gets its own cursor slot, the way
// 4.3.4's guild bank type overrides its cursor. The slot still runs
// BaseHandlerGetCursor, which decides everything (0 for a bobber the player
// is not channelling, Unable when not usable), and swaps only the image. The
// classifier passes the mode to SetCursorMode, whose hook (cursor/Hover)
// shows it.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Extended.h"
#include <cstdint>
namespace Cursor::Fishing {
namespace {
using HandlerGetCursor_t = uint32_t(__thiscall *)(void *handler);
uint32_t __fastcall FishingNodeCursor(void *handler, void * /*edx*/) {
const uint32_t mode =
reinterpret_cast<HandlerGetCursor_t>(Offsets::FUN_GO_HANDLER_BASE_GET_CURSOR)(handler);
if (!Cursor::Extended::Enabled())
return mode;
if (mode == Offsets::CURSOR_MODE_INTERACT)
return Cursor::Extended::kFishing;
if (mode == Offsets::CURSOR_MODE_INTERACT + Offsets::CURSOR_MODE_UNABLE_OFFSET)
return Cursor::Extended::kFishing + Cursor::Extended::kUnableOffset;
return mode;
}
void Patch() {
Common::RedirectSlot(Offsets::SLOT_FISHING_NODE_HANDLER_GET_CURSOR,
Offsets::FUN_GO_HANDLER_BASE_GET_CURSOR,
reinterpret_cast<const void *>(&FishingNodeCursor));
}
const Game::ModuleAutoRegister _autoreg{&Patch};
} // namespace
} // namespace Cursor::Fishing
+23 -1
View File
@@ -27,14 +27,21 @@
// The spell-targeting and mouselook legs of 0x4828D0 are left out: they
// answer for the mouse, not for the object.
//
// The unit classifier and the fishing node's handler can also pick a mode of
// Cursor::Extended (cursor/Npc, cursor/Fishing). The engine's SetCursorMode
// cannot show one, so the same hook hands those to Cursor::Extended::Show,
// and while capturing records them like any other.
//
// The images are the engine's own: `Interface\Cursor\<name>.blp` from its
// cursor name table, and a game object's named cursor from its record
// (FormatGameObjectCursorFile, `Unable` when not usable).
// (FormatGameObjectCursorFile, `Unable` when not usable). An extended mode's
// image is its embedded file (Cursor::Extended::Path).
#include "cursor/Hover.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Extended.h"
#include <cstdio>
@@ -58,6 +65,10 @@ void __fastcall SetCursorMode_h(uint32_t mode) {
g_captured = mode;
return;
}
if (Cursor::Extended::Is(mode)) {
Cursor::Extended::Show(mode);
return;
}
SetCursorMode_o(mode);
}
@@ -122,6 +133,17 @@ bool ImagePath(void *object, uint32_t mode, bool lowPriority, char *buf, std::si
std::snprintf(buf, size, "%s", file);
return true;
}
if (Cursor::Extended::Is(mode)) {
// Retail shows no icon for a quest giver's cursor, not even the
// low-priority pointer (checked in game, 2026-11-03, with the soft
// interact icon and SoftTargetLowPriorityIcons 1). Blizzard_NamePlates
// shows that icon exactly when SetUnitCursorTexture returns true, so
// the refusal is the function's own.
if (Cursor::Extended::IsQuest(mode))
return false;
std::snprintf(buf, size, "%s", Cursor::Extended::Path(mode));
return true;
}
if (mode >= Offsets::CURSOR_MODE_CUSTOM)
return false;
const char *name = Game::Read<const char *>(Offsets::VAR_CURSOR_NAMES + 4 * mode);
+7 -6
View File
@@ -18,9 +18,9 @@
namespace Cursor::Hover {
// The cursor mode (Offsets::CURSOR_MODE_*) the world cursor would show with
// the mouse over `object` — a unit, player, game object or corpse — or 0
// when it shows none. Computes it without showing it.
// The cursor mode (Offsets::CURSOR_MODE_* or Cursor::Extended) the world
// cursor would show with the mouse over `object` — a unit, player, game
// object or corpse — or 0 when it shows none. Computes it without showing it.
uint32_t Mode(void *object);
// For a game object's named cursor (CURSOR_MODE_CUSTOM): whether the player
@@ -28,9 +28,10 @@ uint32_t Mode(void *object);
bool IsUsable(void *object);
// Writes the image file of `mode` over `object` into `buf`. False, with
// `buf` untouched, for no mode, and for the base (resting) mode unless
// `lowPriority` — retail's low-priority image, the plain pointer over a unit
// with nothing to do (SetUnitCursorTexture's includeLowPriority).
// `buf` untouched, for no mode, for a quest giver's cursor (retail shows no
// icon for one), and for the base (resting) mode unless `lowPriority` —
// retail's low-priority image, the plain pointer over a unit with nothing to
// do (SetUnitCursorTexture's includeLowPriority).
bool ImagePath(void *object, uint32_t mode, bool lowPriority, char *buf, std::size_t size);
} // namespace Cursor::Hover
+187
View File
@@ -0,0 +1,187 @@
// 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/>.
// With `expandedCursors` at 1 (Cursor::Extended::Enabled), the cursor over an
// NPC shows what it offers, as in later clients: an anvil for a repairer, a
// hearthstone for an innkeeper, a horseshoe for a stable master, and a quest
// giver's ! or ? (Cursor::Extended). At 0, the default, it stays 1.12's.
//
// 1.12 picks the cursor for a hovered unit in ClassifyHoveredUnit
// (FUN_WORLD_FRAME_CLASSIFY_UNIT). For a unit the player can interact with,
// its NPC flag chain takes the lowest flag bit first: gossip, quest giver,
// vendor, flight master and so on. It never tests the repair flag, and shows
// Interact for an innkeeper. 4.3.4's CursorTrackUnit first asks the quest
// giver's status (GetQuestGiverCursor), then tests the services from repair
// down, with vendor and gossip near the end.
//
// The thunk stands in for the head of 1.12's chain
// (SITE_CLASSIFY_UNIT_NPC_FLAGS). Everything the engine decides before it
// stays the engine's: mouselook, spell targeting, charm, whether the unit can
// be interacted with now, and whether it is beyond the interact reach (EBX,
// the Unable form). With the cvar at 0 the thunk does the instruction it
// replaced and the stock chain runs. The pick goes to the engine's
// SetCursorMode, whose hook (cursor/Hover) shows an extended mode and records
// every mode while the soft-interact code asks for the hover verdict.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Extended.h"
#include <cstdint>
namespace Cursor::Npc {
namespace {
// UNIT_NPC_FLAGS bits, as 1.12's chain tests them (tortoise-wow
// UnitDefines.h names them).
constexpr uint32_t kGossip = 0x1;
constexpr uint32_t kVendor = 0x4;
constexpr uint32_t kFlightMaster = 0x8;
constexpr uint32_t kTrainer = 0x10;
constexpr uint32_t kSpiritHealer = 0x20;
constexpr uint32_t kSpiritGuide = 0x40;
constexpr uint32_t kInnkeeper = 0x80;
constexpr uint32_t kBanker = 0x100;
constexpr uint32_t kPetitioner = 0x200;
constexpr uint32_t kTabardDesigner = 0x400;
constexpr uint32_t kBattleMaster = 0x800;
constexpr uint32_t kAuctioneer = 0x1000;
constexpr uint32_t kStableMaster = 0x2000;
constexpr uint32_t kRepair = 0x4000;
// Quest giver statuses (OFF_CGUNIT_QUEST_STATUS). 1.12 numbers them its own
// way (tortoise-wow QuestDef.h); its status-to-marker table (0x80C454) shows
// each one's meaning: 3 a grey ?, 4 a blue ?, 5 a yellow !, 6 and 7 a yellow
// ?. 0 none, 1 unavailable (grey !) and 2 chat (no marker) have no cursor,
// as 4.3.4's none and unavailable have none.
constexpr uint32_t kStatusIncomplete = 3;
constexpr uint32_t kStatusRewardRepeatable = 4;
constexpr uint32_t kStatusAvailable = 5;
constexpr uint32_t kStatusRewardOld = 6;
constexpr uint32_t kStatusReward = 7;
using IsQuestGiverToTalk_t = uint32_t(__stdcall *)(void *unit);
using SetCursorMode_t = void(__fastcall *)(uint32_t mode);
uintptr_t g_classifyReturn = Offsets::CLASSIFY_UNIT_RETURN;
// 4.3.4's quest cursor for a status (GetQuestGiverCursor), matched by the
// status's meaning: 4.3.4 gives Quest to an available and to an incomplete
// quest, QuestRepeatable to a repeatable reward (its blue ?), and
// QuestTurnIn to a reward (its yellow ?).
uint32_t QuestMode(uint32_t status) {
switch (status) {
case kStatusIncomplete:
case kStatusAvailable:
return Cursor::Extended::kQuest;
case kStatusRewardRepeatable:
return Cursor::Extended::kQuestRepeatable;
case kStatusRewardOld:
case kStatusReward:
return Cursor::Extended::kQuestTurnIn;
default:
return 0;
}
}
// 4.3.4's order (CursorTrackUnit) in 1.12's flag bits, or 0 for none.
// 4.3.4's guild master, guild banker and spell click tests have no 1.12
// flag, and its flight master test also reads an icon type; for that flag
// 1.12 shows Taxi, as here. 4.3.4 also takes a cursor that the creature's
// cache record names (IconName), which 1.12's creature query does not carry.
// A stable master gets retail's StableMaster in 4.3.4's place for it, where
// 4.3.4 shows Speak. Where 4.3.4 tests gossip, this also takes the quest
// giver 1.12 would speak to (IsQuestGiverToTalk), so a status without a
// quest cursor keeps 1.12's Speak.
uint32_t ServiceMode(void *unit) {
if (const uint32_t quest = QuestMode(Game::Read<uint32_t>(unit, Offsets::OFF_CGUNIT_QUEST_STATUS)))
return quest;
const uint32_t flags =
Game::Read<uint32_t>(Game::Read<const void *>(unit, Offsets::OFF_UNIT_DESCRIPTOR),
Offsets::OFF_UNIT_DESCRIPTOR_NPC_FLAGS);
if (flags & kRepair)
return Cursor::Extended::kRepairNpc;
if (flags & kInnkeeper)
return Cursor::Extended::kInnkeeper;
if (flags & kFlightMaster)
return Offsets::CURSOR_MODE_TAXI;
if (flags & kTrainer)
return Offsets::CURSOR_MODE_TRAINER;
if (flags & (kSpiritHealer | kSpiritGuide))
return Offsets::CURSOR_MODE_SPEAK;
if (flags & kBanker)
return Offsets::CURSOR_MODE_BUY;
if (flags & (kPetitioner | kTabardDesigner | kBattleMaster))
return Offsets::CURSOR_MODE_SPEAK;
if (flags & kAuctioneer)
return Offsets::CURSOR_MODE_BUY;
if (flags & kStableMaster)
return Cursor::Extended::kStableMaster;
if (flags & kVendor)
return Offsets::CURSOR_MODE_PICKUP;
if ((flags & kGossip) ||
reinterpret_cast<IsQuestGiverToTalk_t>(Offsets::FUN_IS_QUEST_GIVER_TO_TALK)(unit) != 0)
return Offsets::CURSOR_MODE_SPEAK;
return 0;
}
// True when it set the mode. `unable` is the engine's EBX: 1 beyond the
// interact reach, else 0. A unit with no service gets the base mode, where
// the stock chain ends too (ShowBaseCursor).
bool __stdcall Pick(void *unit, uint32_t unable) {
if (!Cursor::Extended::Enabled())
return false;
uint32_t mode = ServiceMode(unit);
if (mode == 0)
mode = Game::Read<uint32_t>(Offsets::VAR_CURSOR_BASE_MODE);
else if (unable != 0)
mode += Cursor::Extended::Is(mode) ? Cursor::Extended::kUnableOffset
: Offsets::CURSOR_MODE_UNABLE_OFFSET;
reinterpret_cast<SetCursorMode_t>(Offsets::FUN_SET_CURSOR_MODE)(mode);
return true;
}
static_assert(Offsets::OFF_UNIT_DESCRIPTOR == 0x110, "NpcFlagsThunk replays [esi + 0x110]");
// Stands in for `mov ecx, [esi + 0x110]`, the head of the chain: the unit in
// ESI, the Unable flag in EBX. When Pick set the mode, it leaves the
// classifier through the chain's epilogue; otherwise it does the instruction
// it replaced and the chain runs.
__declspec(naked) void NpcFlagsThunk() {
__asm {
push ebx
push esi
call Pick
test al, al
jnz picked
mov ecx, dword ptr [esi + 110h]
ret
picked:
add esp, 4
jmp dword ptr [g_classifyReturn]
}
}
void Patch() {
static const uint8_t kFlagsRead[6] = {0x8B, 0x8E, 0x10, 0x01, 0x00, 0x00};
Common::ReplaceWithCall(Offsets::SITE_CLASSIFY_UNIT_NPC_FLAGS, kFlagsRead, sizeof kFlagsRead,
reinterpret_cast<const void *>(&NpcFlagsThunk));
}
const Game::ModuleAutoRegister _autoreg{&Patch};
} // namespace
} // namespace Cursor::Npc
+5 -3
View File
@@ -25,11 +25,13 @@ const char *NormalizeEmpty(const char *s) {
} // namespace
// The id is compared unsigned: a DBC column's "none" is -1, which a signed
// compare passes as less than the count and then indexes records[-1]. The
// engine rejects it with a signed `jl` before its `jg` against the count
// (0x00472797). An unloaded store's count is -1, so it takes no id.
const uint8_t *Record(uintptr_t recordsVar, uintptr_t countVar, uint32_t id) {
if (id == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(countVar);
if (static_cast<int>(id) > count)
if (id == 0 || count <= 0 || id > static_cast<uint32_t>(count))
return nullptr;
const uint8_t *const *records =
*reinterpret_cast<const uint8_t *const *const *>(recordsVar);
+2 -1
View File
@@ -39,7 +39,8 @@ namespace DBC {
// Resolves `id` to its record pointer in the table whose records-base
// global is at `recordsVar` and count global at `countVar`. Returns
// `nullptr` for `id == 0`, OOR ids (> count), and unpopulated slots.
// `nullptr` for `id == 0`, OOR ids (> count, which includes -1 and every
// other negative id), and unpopulated slots.
// Caller reads whatever fields it needs off the returned record.
//
// Use this when `LocalizedField` / `StringField` don't fit — e.g. for
+2 -3
View File
@@ -206,9 +206,8 @@ void RegisterLuaFunctions() {
&Script_GetSelfResurrectOptions, &kGetSelfResurrectOptions);
Game::Lua::RegisterTableFunction("C_DeathInfo", "UseSelfResurrectOption",
&Script_UseSelfResurrectOption, &kUseSelfResurrectOption);
Game::Lua::RegisterIntegerEnum("Enum", "SelfResurrectOptionType", kOptionTypeEntries,
sizeof(kOptionTypeEntries) / sizeof(kOptionTypeEntries[0]),
"DeathInfo");
Game::Lua::RegisterIntegerEnum(
"Enum", "SelfResurrectOptionType", kOptionTypeEntries, "DeathInfo");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+528
View File
@@ -0,0 +1,528 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_DurationUtil.CreateDuration()` and `Enum.DurationTimeModifier`
// (DurationUtilDocumentation.lua, LuaDurationObjectAPIDocumentation.lua). The
// object is C in retail, so what follows is retail's behavior as probed in a
// 12.1.5 client (2026-11-01; the log is in docs/notes/duration-timer.md):
//
// - The object is a userdata, tostring "LuaDurationObject: <address>",
// getmetatable false. A span is a start time, a total and a mod rate.
// - Elapsed is now - start clamped to [0, total]; remaining is total -
// elapsed, so before the start it is the whole total. The percents are
// 0 to 1; a zero span is 100% elapsed, 0% remaining.
// - HasStarted is now >= start, HasExpired now >= end, IsActive between
// them. A fresh or Reset object (start 0, total 0) has started and
// expired.
// - SetTimeSpan with the end before the start keeps the start and a zero
// total. SetTimeFromEnd sets the start to end - duration.
// - modRate leaves every real-time answer alone. In base time the total is
// total / modRate from the same start: 1 s into a 4 s span at mod rate 2
// has 1 s left of a 2 s total.
// - Copy is independent of the original; Reset does not reach a copy.
//
// Times are GetTime() seconds and the clock is GetTime()'s, so a span
// straddling the 2^32 ms tick wrap jumps exactly as GetTime() does.
//
// The Format* methods hand the value to a Text::NumericFormatter. Not ported:
// the Evaluate* methods (retail curve objects) and SetClock /
// C_DurationUtil.CreateManualClock. 1.12 has no secret values, so
// HasSecretValues is always false.
#include "duration/Object.h"
#include "Game.h"
#include "text/NumericFormatter.h"
#include "time/Clock.h"
#include <cmath>
#include <cstdio>
namespace Duration::Object {
namespace Lua = Game::Lua;
struct Span {
int refs = 0;
double start = 0.0;
double total = 0.0;
double modRate = 1.0;
};
void Retain(Span *s) { ++s->refs; }
void Release(Span *s) {
if (s != nullptr && --s->refs == 0)
delete s;
}
namespace {
// Enum.DurationTimeModifier.
enum Modifier : int {
MODIFIER_REAL_TIME = 0,
MODIFIER_BASE_TIME = 1,
};
constexpr const char *kMetatableKey = "ClassicAPI.LuaDurationObject";
void PushMetatable(void *L) {
Lua::PushString(L, kMetatableKey);
Lua::RawGet(L, Lua::REGISTRY_INDEX);
}
int AbsIndex(void *L, int idx) { return idx > 0 ? idx : Lua::GetTop(L) + idx + 1; }
double Total(const Span &s, int m) { return m == MODIFIER_BASE_TIME ? s.total / s.modRate : s.total; }
double Elapsed(const Span &s, int m, double now) {
const double e = now - s.start;
const double total = Total(s, m);
return e < 0.0 ? 0.0 : (e > total ? total : e);
}
double ElapsedPct(const Span &s, int m, double now) {
const double total = Total(s, m);
return total > 0.0 ? Elapsed(s, m, now) / total : 1.0;
}
double RemainingPct(const Span &s, int m, double now) {
const double total = Total(s, m);
return total > 0.0 ? (total - Elapsed(s, m, now)) / total : 0.0;
}
void Set(Span &s, double start, double total, double modRate) {
s.start = start;
s.total = total > 0.0 ? total : 0.0;
s.modRate = modRate;
}
// ---- arguments ------------------------------------------------------------------
void Usage(void *L, int arg, const char *usage) { Lua::Error(L, "bad argument #%d to '?' (Usage: %s)", arg, usage); }
Span *Self(void *L, const char *usage) {
Span *s = ToSpan(L, 1);
if (s == nullptr)
Usage(L, 1, usage);
return s;
}
// A finite number at `idx`; raises the usage error otherwise.
bool Number(void *L, int idx, const char *usage, double &out) {
out = Lua::Type(L, idx) == Lua::TYPE_NUMBER ? Lua::ToNumber(L, idx) : NAN;
if (std::isfinite(out))
return true;
Usage(L, idx, usage);
return false;
}
// The optional mod rate at `idx`: 1 when nil, else a positive number.
bool ModRate(void *L, int idx, const char *usage, double &out) {
out = 1.0;
if (Lua::Type(L, idx) <= Lua::TYPE_NIL)
return true;
if (Number(L, idx, usage, out) && out > 0.0)
return true;
Usage(L, idx, usage);
return false;
}
// The optional DurationTimeModifier at `idx`: RealTime when nil.
bool ModifierArg(void *L, int idx, const char *usage, int &out) {
if (ToModifier(L, idx, out))
return true;
Usage(L, idx, usage);
return false;
}
// ---- the methods ----------------------------------------------------------------
// The getters that take a modifier: `self:Get...([modifier])`.
template <typename Answer> int Query(void *L, const char *usage, Answer answer) {
Span *s = Self(L, usage);
int m;
if (s == nullptr || !ModifierArg(L, 2, usage, m))
return 0;
answer(*s, m, Time::Clock::NowSeconds());
return 1;
}
int __fastcall Script_GetStartTime(void *L) {
return Query(L, "local startTime = self:GetStartTime([modifier])",
[L](const Span &s, int, double) { Lua::PushNumber(L, s.start); });
}
int __fastcall Script_GetEndTime(void *L) {
return Query(L, "local endTime = self:GetEndTime([modifier])",
[L](const Span &s, int m, double) { Lua::PushNumber(L, s.start + Total(s, m)); });
}
int __fastcall Script_GetTotalDuration(void *L) {
return Query(L, "local totalDuration = self:GetTotalDuration([modifier])",
[L](const Span &s, int m, double) { Lua::PushNumber(L, Total(s, m)); });
}
int __fastcall Script_GetElapsedDuration(void *L) {
return Query(L, "local elapsedDuration = self:GetElapsedDuration([modifier])",
[L](const Span &s, int m, double now) { Lua::PushNumber(L, Elapsed(s, m, now)); });
}
int __fastcall Script_GetRemainingDuration(void *L) {
return Query(L, "local remainingDuration = self:GetRemainingDuration([modifier])",
[L](const Span &s, int m, double now) { Lua::PushNumber(L, Total(s, m) - Elapsed(s, m, now)); });
}
int __fastcall Script_GetElapsedPercent(void *L) {
return Query(L, "local elapsedPercent = self:GetElapsedPercent([modifier])",
[L](const Span &s, int m, double now) { Lua::PushNumber(L, ElapsedPct(s, m, now)); });
}
int __fastcall Script_GetRemainingPercent(void *L) {
return Query(L, "local remainingPercent = self:GetRemainingPercent([modifier])",
[L](const Span &s, int m, double now) { Lua::PushNumber(L, RemainingPct(s, m, now)); });
}
int __fastcall Script_HasStarted(void *L) {
return Query(L, "local hasStarted = self:HasStarted([modifier])",
[L](const Span &s, int, double now) { Lua::PushBool(L, now >= s.start); });
}
int __fastcall Script_HasExpired(void *L) {
return Query(L, "local hasExpired = self:HasExpired([modifier])",
[L](const Span &s, int m, double now) { Lua::PushBool(L, now >= s.start + Total(s, m)); });
}
int __fastcall Script_IsActive(void *L) {
return Query(L, "local isActive = self:IsActive([modifier])", [L](const Span &s, int m, double now) {
Lua::PushBool(L, now >= s.start && now < s.start + Total(s, m));
});
}
// `self:Format...Duration(formatter [, modifier])`: the formatter's text for
// the value, or its format error, raised as FormatNumber raises it.
int Format(void *L, const char *usage, const char *method, int property) {
Span *s = Self(L, usage);
if (s == nullptr)
return 0;
Text::NumericFormatter::Formatter *f = Text::NumericFormatter::ToFormatter(L, 2);
int m;
if (f == nullptr) {
Usage(L, 2, usage);
return 0;
}
if (!ModifierArg(L, 3, usage, m))
return 0;
if (Text::NumericFormatter::Format(L, f, Sample(s, property, m)) == nullptr) {
const char *msg = Lua::ToString(L, -1);
Lua::Error(L, "LuaDurationObject:%s(): %s", method, msg != nullptr ? msg : "?");
}
return 1;
}
int __fastcall Script_FormatElapsedDuration(void *L) {
return Format(L, "local formatted = self:FormatElapsedDuration(formatter [, modifier])",
"FormatElapsedDuration", PROPERTY_ELAPSED_DURATION);
}
int __fastcall Script_FormatRemainingDuration(void *L) {
return Format(L, "local formatted = self:FormatRemainingDuration(formatter [, modifier])",
"FormatRemainingDuration", PROPERTY_REMAINING_DURATION);
}
int __fastcall Script_FormatTotalDuration(void *L) {
return Format(L, "local formatted = self:FormatTotalDuration(formatter [, modifier])",
"FormatTotalDuration", PROPERTY_TOTAL_DURATION);
}
int __fastcall Script_IsZero(void *L) {
Span *s = Self(L, "local isZero = self:IsZero()");
if (s == nullptr)
return 0;
Lua::PushBool(L, s->total == 0.0);
return 1;
}
int __fastcall Script_GetModRate(void *L) {
Span *s = Self(L, "local modRate = self:GetModRate()");
if (s == nullptr)
return 0;
Lua::PushNumber(L, s->modRate);
return 1;
}
// Nil: the default clock, GetTime().
int __fastcall Script_GetClock(void *L) {
if (Self(L, "local clock = self:GetClock()") == nullptr)
return 0;
Lua::PushNil(L);
return 1;
}
int __fastcall Script_GetClockTime(void *L) {
if (Self(L, "local clockTime = self:GetClockTime()") == nullptr)
return 0;
Lua::PushNumber(L, Time::Clock::NowSeconds());
return 1;
}
int __fastcall Script_HasSecretValues(void *L) {
if (Self(L, "local hasSecretValues = self:HasSecretValues()") == nullptr)
return 0;
Lua::PushBool(L, false);
return 1;
}
int __fastcall Script_SetTimeFromStart(void *L) {
constexpr const char *kUsage = "self:SetTimeFromStart(startTime, duration [, modRate])";
Span *s = Self(L, kUsage);
double start, duration, modRate;
if (s == nullptr || !Number(L, 2, kUsage, start) || !Number(L, 3, kUsage, duration) ||
!ModRate(L, 4, kUsage, modRate))
return 0;
Set(*s, start, duration, modRate);
return 0;
}
int __fastcall Script_SetTimeFromEnd(void *L) {
constexpr const char *kUsage = "self:SetTimeFromEnd(endTime, duration [, modRate])";
Span *s = Self(L, kUsage);
double end, duration, modRate;
if (s == nullptr || !Number(L, 2, kUsage, end) || !Number(L, 3, kUsage, duration) ||
!ModRate(L, 4, kUsage, modRate))
return 0;
if (duration < 0.0)
duration = 0.0;
Set(*s, end - duration, duration, modRate);
return 0;
}
// Takes no mod rate, so the span has none (not probed).
int __fastcall Script_SetTimeSpan(void *L) {
constexpr const char *kUsage = "self:SetTimeSpan(startTime, endTime)";
Span *s = Self(L, kUsage);
double start, end;
if (s == nullptr || !Number(L, 2, kUsage, start) || !Number(L, 3, kUsage, end))
return 0;
Set(*s, start, end - start, 1.0);
return 0;
}
int __fastcall Script_Reset(void *L) {
if (Span *s = Self(L, "self:Reset()"))
Set(*s, 0.0, 0.0, 1.0);
return 0;
}
// Reset plus the secret flag and the clock, neither of which 1.12 has.
int __fastcall Script_SetToDefaults(void *L) {
if (Span *s = Self(L, "self:SetToDefaults()"))
Set(*s, 0.0, 0.0, 1.0);
return 0;
}
int __fastcall Script_Assign(void *L) {
constexpr const char *kUsage = "self:Assign(other)";
Span *s = Self(L, kUsage);
if (s == nullptr)
return 0;
const Span *other = ToSpan(L, 2);
if (other == nullptr) {
Usage(L, 2, kUsage);
return 0;
}
Set(*s, other->start, other->total, other->modRate);
return 0;
}
int __fastcall Script_Copy(void *L) {
Span *s = Self(L, "local copy = self:Copy()");
if (s == nullptr)
return 0;
Span *copy = new Span;
Set(*copy, s->start, s->total, s->modRate);
Push(L, copy);
return 1;
}
int __fastcall Meta_gc(void *L) {
auto **block = static_cast<Span **>(Lua::ToUserdata(L, 1));
if (block != nullptr) {
Release(*block);
*block = nullptr;
}
return 0;
}
int __fastcall Meta_tostring(void *L) {
char text[48];
std::snprintf(text, sizeof(text), "LuaDurationObject: %p", static_cast<void *>(ToSpan(L, 1)));
Lua::PushString(L, text);
return 1;
}
int __fastcall Script_CreateDuration(void *L) {
Push(L, new Span);
return 1;
}
struct Method {
const char *name;
Lua::CFunction fn;
};
const Method kMethods[] = {
{"Assign", &Script_Assign},
{"Copy", &Script_Copy},
{"FormatElapsedDuration", &Script_FormatElapsedDuration},
{"FormatRemainingDuration", &Script_FormatRemainingDuration},
{"FormatTotalDuration", &Script_FormatTotalDuration},
{"GetClock", &Script_GetClock},
{"GetClockTime", &Script_GetClockTime},
{"GetElapsedDuration", &Script_GetElapsedDuration},
{"GetElapsedPercent", &Script_GetElapsedPercent},
{"GetEndTime", &Script_GetEndTime},
{"GetModRate", &Script_GetModRate},
{"GetRemainingDuration", &Script_GetRemainingDuration},
{"GetRemainingPercent", &Script_GetRemainingPercent},
{"GetStartTime", &Script_GetStartTime},
{"GetTotalDuration", &Script_GetTotalDuration},
{"HasExpired", &Script_HasExpired},
{"HasSecretValues", &Script_HasSecretValues},
{"HasStarted", &Script_HasStarted},
{"IsActive", &Script_IsActive},
{"IsZero", &Script_IsZero},
{"Reset", &Script_Reset},
{"SetTimeFromEnd", &Script_SetTimeFromEnd},
{"SetTimeFromStart", &Script_SetTimeFromStart},
{"SetTimeSpan", &Script_SetTimeSpan},
{"SetToDefaults", &Script_SetToDefaults},
};
void SetFunction(void *L, const char *key, Lua::CFunction fn) {
Lua::PushString(L, key);
Lua::PushCClosure(L, fn, 0);
Lua::SetTable(L, -3);
}
// registry[kMetatableKey] = {__index = methods, __gc, __tostring, __metatable = false}
void CreateMetatable(void *L) {
PushMetatable(L);
const bool exists = Lua::Type(L, -1) == Lua::TYPE_TABLE;
Lua::SetTop(L, -2);
if (exists)
return;
Lua::PushString(L, kMetatableKey);
Lua::NewTable(L);
Lua::PushString(L, "__index");
Lua::NewTable(L);
for (const Method &m : kMethods)
SetFunction(L, m.name, m.fn);
Lua::SetTable(L, -3);
SetFunction(L, "__gc", &Meta_gc);
SetFunction(L, "__tostring", &Meta_tostring);
Lua::PushString(L, "__metatable");
Lua::PushBool(L, false);
Lua::SetTable(L, -3);
Lua::RawSet(L, Lua::REGISTRY_INDEX);
}
const Lua::EnumIntegerEntry kModifiers[] = {
{"RealTime", MODIFIER_REAL_TIME},
{"BaseTime", MODIFIER_BASE_TIME},
};
const Game::Doc::Field kCreateRets[] = {
Game::Doc::Req("duration", "LuaDurationObject"),
};
const Game::Doc::Function kCreateDuration{"Creates a zero duration object that can hold a time span.", {},
kCreateRets};
void RegisterLuaFunctions() {
CreateMetatable(Lua::State());
Lua::RegisterTableFunction("C_DurationUtil", "CreateDuration", &Script_CreateDuration, &kCreateDuration);
Lua::RegisterIntegerEnum("Enum", "DurationTimeModifier", kModifiers, "LuaDurationObjectShared");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterLuaFunctions};
} // namespace
Span *ToSpan(void *L, int idx) {
if (Lua::Type(L, idx) != Lua::TYPE_USERDATA)
return nullptr;
idx = AbsIndex(L, idx);
if (!Lua::GetMetatable(L, idx))
return nullptr;
PushMetatable(L);
const bool ours = Lua::ToPointer(L, -1) == Lua::ToPointer(L, -2);
Lua::SetTop(L, -3);
return ours ? *static_cast<Span **>(Lua::ToUserdata(L, idx)) : nullptr;
}
void Push(void *L, Span *s) {
auto **block = static_cast<Span **>(Lua::NewUserdata(L, sizeof(Span *)));
*block = s;
Retain(s);
PushMetatable(L);
Lua::SetMetatable(L, -2);
}
void PushFromStart(void *L, double startSec, double totalSec) {
Span *s = new Span;
Set(*s, startSec, totalSec, 1.0);
Push(L, s);
}
double ElapsedPercent(const Span *s) { return ElapsedPct(*s, MODIFIER_REAL_TIME, Time::Clock::NowSeconds()); }
double RemainingPercent(const Span *s) { return RemainingPct(*s, MODIFIER_REAL_TIME, Time::Clock::NowSeconds()); }
double Sample(const Span *s, int property, int modifier) {
const double now = Time::Clock::NowSeconds();
switch (property) {
case PROPERTY_REMAINING_DURATION:
return Total(*s, modifier) - Elapsed(*s, modifier, now);
case PROPERTY_REMAINING_PERCENT:
return RemainingPct(*s, modifier, now);
case PROPERTY_ELAPSED_DURATION:
return Elapsed(*s, modifier, now);
case PROPERTY_ELAPSED_PERCENT:
return ElapsedPct(*s, modifier, now);
case PROPERTY_TOTAL_DURATION:
return Total(*s, modifier);
case PROPERTY_START_TIME:
return s->start;
default:
return s->start + Total(*s, modifier);
}
}
bool IsZero(const Span *s) { return s->total == 0.0; }
bool HasExpired(const Span *s, int modifier) {
return Time::Clock::NowSeconds() >= s->start + Total(*s, modifier);
}
bool ToModifier(void *L, int idx, int &out) {
out = MODIFIER_REAL_TIME;
if (Lua::Type(L, idx) <= Lua::TYPE_NIL)
return true;
const double n = Lua::Type(L, idx) == Lua::TYPE_NUMBER ? Lua::ToNumber(L, idx) : NAN;
if (n != MODIFIER_REAL_TIME && n != MODIFIER_BASE_TIME)
return false;
out = static_cast<int>(n);
return true;
}
} // namespace Duration::Object
+66
View File
@@ -0,0 +1,66 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// Retail's LuaDurationObject, as the engine-side users take it
// (StatusBar:SetTimerDuration, UnitCastingDuration). A span is shared, not
// copied: a retail bar follows a change made to the object after it was
// handed over, and each getter call returns a new Lua object for it (retail
// probe 2026-11-01). So the span is reference-counted, and every Lua object
// and every holder owns one reference.
namespace Duration::Object {
struct Span;
void Retain(Span *s);
// Null-safe.
void Release(Span *s);
// The span of the duration object at `idx`, or null when the value is not one.
Span *ToSpan(void *L, int idx);
// Pushes a new Lua object for `s`.
void Push(void *L, Span *s);
// Pushes a new duration object for `totalSec` from `startSec`, both in
// GetTime() seconds.
void PushFromStart(void *L, double startSec, double totalSec);
// The share of the span gone, or left, now: GetElapsedPercent /
// GetRemainingPercent in real time, 0 to 1.
double ElapsedPercent(const Span *s);
double RemainingPercent(const Span *s);
// Enum.DurationTextBindingProperty: the values Sample reads.
enum Property : int {
PROPERTY_REMAINING_DURATION = 0,
PROPERTY_REMAINING_PERCENT = 1,
PROPERTY_ELAPSED_DURATION = 2,
PROPERTY_ELAPSED_PERCENT = 3,
PROPERTY_TOTAL_DURATION = 4,
PROPERTY_START_TIME = 5,
PROPERTY_END_TIME = 6,
};
// `s`'s `property` now, in time `modifier` (Enum.DurationTimeModifier), as
// the matching Get... method answers it.
double Sample(const Span *s, int property, int modifier);
bool IsZero(const Span *s);
bool HasExpired(const Span *s, int modifier);
// The DurationTimeModifier at `idx`, RealTime for nil; false when the value
// is neither.
bool ToModifier(void *L, int idx, int &out);
} // namespace Duration::Object
+790
View File
@@ -0,0 +1,790 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_DurationUtil.CreateDurationTextBinding()` and
// `Enum.DurationTextBindingProperty` (DurationTextBindingObjectAPIDocumentation.lua,
// DurationTextBindingSharedDocumentation.lua). A binding keeps a font string's
// text set from a duration object. Retail's behavior, probed in a 12.1.5
// client (2026-11-01; the log is in docs/notes/duration-timer.md):
//
// - The object is a userdata, tostring "DurationTextBinding: <address>". A
// new binding is disabled, updates every tick (interval 0), and has no
// expired or zero text; CanFormatText and CanUpdateFontString are false.
// - Without a formatter it writes nothing, even enabled: the text stays as
// it was and GetFormattedText returns "".
// - With SetFormatter, the text is the formatter's text for the remaining
// seconds, unrounded (2.949 with "%.1f" -> "2.9"), written at once on
// enable and then every tick.
// - The binding keeps the duration object, not a copy: a change to it shows
// at the next tick. GetDuration returns a new Lua object for it.
// - Once the duration has expired, the expired text shows; the binding
// stays enabled.
//
// Not probed, and decided here: a zero (or missing) duration shows the zero
// text when one is set, before the expired text; Assign copies everything
// but the enabled state; Copy starts disabled; a "{}" in SetTextFormat with
// no component writes nothing.
//
// An enabled binding updates with no Lua reference to it (retail's aura
// button keeps one binding because a replaced, still-enabled one would go on
// writing), so enabling takes a reference and disabling drops it. Updates
// run at the head of each UI frame (Tick::FrameTick::AutoSubscribeBefore),
// before its OnUpdate pass and draw, so the text shows in the same frame as a
// timer bar's fill from that pass; at the tail it would show a frame late.
// They set the text only when it differs from the font string's own. Destroyed font strings are
// dropped through Frame::RegionDestruction. Not ported: the color curves
// (SetTextColorCurve, GetTextColorCurve, ClearTextColorCurve,
// GetFormattedTextColor).
#include "Game.h"
#include "Offsets.h"
#include "duration/Object.h"
#include "frame/RegionDestruction.h"
#include "text/NumericFormatter.h"
#include "tick/FrameTick.h"
#include "time/Clock.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace Duration::TextBinding {
namespace {
namespace Lua = Game::Lua;
namespace Fmt = Text::NumericFormatter;
using Duration::Object::Span;
struct Component {
int property = Duration::Object::PROPERTY_REMAINING_DURATION;
Fmt::Formatter *formatter = nullptr; // one reference
};
struct Binding {
int refs = 0;
bool enabled = false; // and so held by g_active
void *fontString = nullptr;
Span *duration = nullptr; // one reference
Fmt::Formatter *formatter = nullptr; // SetFormatter; one reference
bool hasTextFormat = false; // SetTextFormat
std::string textFormat;
std::vector<Component> components;
bool hasExpiredText = false;
std::string expiredText;
bool hasZeroText = false;
std::string zeroText;
int modifier = 0;
double interval = 0.0;
double lastUpdate = -INFINITY;
};
// Every live binding, for Frame::RegionDestruction; and the enabled ones,
// each holding a reference.
std::vector<Binding *> g_all;
std::vector<Binding *> g_active;
void Retain(Binding *b) { ++b->refs; }
void ClearComponents(Binding &b) {
for (Component &c : b.components)
Fmt::Release(c.formatter);
b.components.clear();
}
// Drops every reference the binding holds and its settings.
void Clear(Binding &b) {
Duration::Object::Release(b.duration);
b.duration = nullptr;
Fmt::Release(b.formatter);
b.formatter = nullptr;
ClearComponents(b);
b.hasTextFormat = false;
b.textFormat.clear();
b.fontString = nullptr;
b.hasExpiredText = b.hasZeroText = false;
b.expiredText.clear();
b.zeroText.clear();
b.modifier = 0;
b.interval = 0.0;
b.lastUpdate = -INFINITY;
}
void Release(Binding *b) {
if (b == nullptr || --b->refs != 0)
return;
Clear(*b);
g_all.erase(std::find(g_all.begin(), g_all.end(), b));
delete b;
}
Binding *New() {
Binding *b = new Binding;
g_all.push_back(b);
return b;
}
bool CanFormat(const Binding &b) { return b.formatter != nullptr || b.hasTextFormat; }
bool CanUpdate(const Binding &b) { return CanFormat(b) && b.fontString != nullptr; }
double Sample(const Binding &b, int property) {
return b.duration != nullptr ? Duration::Object::Sample(b.duration, property, b.modifier) : 0.0;
}
// Appends the formatter's text for `value`; false on a format error, whose
// message is then at the top of the stack.
bool AppendFormatted(void *L, Fmt::Formatter *f, double value, std::string &out) {
const char *text = Fmt::Format(L, f, value);
if (text == nullptr)
return false;
out += text;
Lua::SetTop(L, -2);
return true;
}
// The binding's text. False on a format error, whose message is then pushed.
bool Formatted(void *L, const Binding &b, std::string &out) {
out.clear();
if (!CanFormat(b))
return true;
const bool zero = b.duration == nullptr || Duration::Object::IsZero(b.duration);
if (zero && b.hasZeroText) {
out = b.zeroText;
return true;
}
if (b.hasExpiredText && (b.duration == nullptr || Duration::Object::HasExpired(b.duration, b.modifier))) {
out = b.expiredText;
return true;
}
if (b.formatter != nullptr)
return AppendFormatted(L, b.formatter, Sample(b, Duration::Object::PROPERTY_REMAINING_DURATION), out);
size_t next = 0;
for (size_t i = 0; i < b.textFormat.size(); ++i) {
if (b.textFormat[i] == '{' && i + 1 < b.textFormat.size() && b.textFormat[i + 1] == '}') {
if (next < b.components.size()) {
const Component &c = b.components[next];
if (!AppendFormatted(L, c.formatter, Sample(b, c.property), out))
return false;
}
++next;
++i;
} else {
out += b.textFormat[i];
}
}
return true;
}
// Writes the binding's text into its font string when it differs. A format
// error writes nothing.
void Update(void *L, Binding &b) {
if (!CanUpdate(b))
return;
b.lastUpdate = Time::Clock::NowSeconds();
const int top = Lua::GetTop(L);
std::string text;
const bool ok = Formatted(L, b, text);
Lua::SetTop(L, top);
if (!ok)
return;
const char *current = Game::Read<const char *>(b.fontString, Offsets::OFF_FONTSTRING_TEXT);
if (current != nullptr && text == current)
return;
using SetText_t = void(__fastcall *)(void *fs, void *edx, const char *text, int expand);
reinterpret_cast<SetText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(b.fontString, nullptr, text.c_str(), 1);
}
void Disable(Binding *b) {
if (!b->enabled)
return;
b->enabled = false;
g_active.erase(std::find(g_active.begin(), g_active.end(), b));
Release(b);
}
void SetEnabled(void *L, Binding *b, bool enabled) {
if (!enabled) {
Disable(b);
return;
}
if (b->enabled)
return;
b->enabled = true;
Retain(b);
g_active.push_back(b);
Update(L, *b);
}
// An enabled binding's own reference outlives the Lua state; drop it before
// the state goes. Copy first: each Disable edits g_active.
void PrepareForReload() {
const std::vector<Binding *> active = g_active;
for (Binding *b : active)
Disable(b);
}
const Game::ReloadAutoRegister _reload{&PrepareForReload};
void OnTick() {
if (g_active.empty())
return;
void *L = Lua::State();
if (L == nullptr)
return;
const double now = Time::Clock::NowSeconds();
// By index: a format allocation can run the GC, whose finalizers may
// release (never free) an active binding.
for (size_t i = 0; i < g_active.size(); ++i) {
Binding &b = *g_active[i];
if (b.interval > 0.0 && now - b.lastUpdate < b.interval)
continue;
Update(L, b);
}
}
const Tick::FrameTick::AutoSubscribeBefore _tickSub{&OnTick};
void OnRegionDestroyed(void *region) {
for (Binding *b : g_all)
if (b->fontString == region)
b->fontString = nullptr;
}
const Frame::RegionDestruction::AutoSubscribe _destructionSub{&OnRegionDestroyed};
// ---- the Lua object -------------------------------------------------------------
constexpr const char *kMetatableKey = "ClassicAPI.DurationTextBinding";
void PushMetatable(void *L) {
Lua::PushString(L, kMetatableKey);
Lua::RawGet(L, Lua::REGISTRY_INDEX);
}
int AbsIndex(void *L, int idx) { return idx > 0 ? idx : Lua::GetTop(L) + idx + 1; }
Binding *ToBinding(void *L, int idx) {
if (Lua::Type(L, idx) != Lua::TYPE_USERDATA)
return nullptr;
idx = AbsIndex(L, idx);
if (!Lua::GetMetatable(L, idx))
return nullptr;
PushMetatable(L);
const bool ours = Lua::ToPointer(L, -1) == Lua::ToPointer(L, -2);
Lua::SetTop(L, -3);
return ours ? *static_cast<Binding **>(Lua::ToUserdata(L, idx)) : nullptr;
}
void Push(void *L, Binding *b) {
auto **block = static_cast<Binding **>(Lua::NewUserdata(L, sizeof(Binding *)));
*block = b;
Retain(b);
PushMetatable(L);
Lua::SetMetatable(L, -2);
}
void Usage(void *L, int arg, const char *usage) { Lua::Error(L, "bad argument #%d to '?' (Usage: %s)", arg, usage); }
Binding *Self(void *L, const char *usage) {
Binding *b = ToBinding(L, 1);
if (b == nullptr)
Usage(L, 1, usage);
return b;
}
// An optional string at `idx`: `has` false for nil.
bool OptionalText(void *L, int idx, const char *usage, bool &has, std::string &out) {
has = false;
out.clear();
if (Lua::Type(L, idx) <= Lua::TYPE_NIL)
return true;
if (!Lua::IsString(L, idx)) {
Usage(L, idx, usage);
return false;
}
has = true;
out.assign(Lua::ToString(L, idx), Lua::StrLen(L, idx));
return true;
}
void PushOptionalText(void *L, bool has, const std::string &text) {
if (has)
Lua::PushLString(L, text.data(), static_cast<unsigned int>(text.size()));
else
Lua::PushNil(L);
}
// Copies `from`'s settings into `to`, which keeps its enabled state.
void CopySettings(Binding &to, const Binding &from) {
if (&to == &from)
return;
Span *duration = from.duration;
Fmt::Formatter *formatter = from.formatter;
std::vector<Component> components = from.components;
if (duration != nullptr)
Duration::Object::Retain(duration);
if (formatter != nullptr)
Fmt::Retain(formatter);
for (Component &c : components)
Fmt::Retain(c.formatter);
Clear(to);
to.fontString = from.fontString;
to.duration = duration;
to.formatter = formatter;
to.hasTextFormat = from.hasTextFormat;
to.textFormat = from.textFormat;
to.components = std::move(components);
to.hasExpiredText = from.hasExpiredText;
to.expiredText = from.expiredText;
to.hasZeroText = from.hasZeroText;
to.zeroText = from.zeroText;
to.modifier = from.modifier;
to.interval = from.interval;
}
// ---- the methods ----------------------------------------------------------------
int __fastcall Script_Assign(void *L) {
constexpr const char *kUsage = "self:Assign(other)";
Binding *b = Self(L, kUsage);
if (b == nullptr)
return 0;
const Binding *other = ToBinding(L, 2);
if (other == nullptr) {
Usage(L, 2, kUsage);
return 0;
}
CopySettings(*b, *other);
return 0;
}
int __fastcall Script_Copy(void *L) {
Binding *b = Self(L, "local copy = self:Copy()");
if (b == nullptr)
return 0;
Binding *copy = New();
CopySettings(*copy, *b);
Push(L, copy);
return 1;
}
int __fastcall Script_CanFormatText(void *L) {
Binding *b = Self(L, "local canFormatText = self:CanFormatText()");
if (b == nullptr)
return 0;
Lua::PushBool(L, CanFormat(*b));
return 1;
}
int __fastcall Script_CanUpdateFontString(void *L) {
Binding *b = Self(L, "local canUpdateText = self:CanUpdateFontString()");
if (b == nullptr)
return 0;
Lua::PushBool(L, CanUpdate(*b));
return 1;
}
int __fastcall Script_Enable(void *L) {
if (Binding *b = Self(L, "self:Enable()"))
SetEnabled(L, b, true);
return 0;
}
int __fastcall Script_Disable(void *L) {
if (Binding *b = Self(L, "self:Disable()"))
SetEnabled(L, b, false);
return 0;
}
int __fastcall Script_SetEnabled(void *L) {
if (Binding *b = Self(L, "self:SetEnabled(enabled)"))
SetEnabled(L, b, Lua::ToFlag(L, 2));
return 0;
}
int __fastcall Script_IsEnabled(void *L) {
Binding *b = Self(L, "local enabled = self:IsEnabled()");
if (b == nullptr)
return 0;
Lua::PushBool(L, b->enabled);
return 1;
}
int __fastcall Script_SetDuration(void *L) {
constexpr const char *kUsage = "self:SetDuration(duration)";
Binding *b = Self(L, kUsage);
if (b == nullptr)
return 0;
Span *duration = Duration::Object::ToSpan(L, 2);
if (duration == nullptr) {
Usage(L, 2, kUsage);
return 0;
}
Duration::Object::Retain(duration);
Duration::Object::Release(b->duration);
b->duration = duration;
return 0;
}
int __fastcall Script_GetDuration(void *L) {
Binding *b = Self(L, "local duration = self:GetDuration()");
if (b == nullptr)
return 0;
if (b->duration != nullptr)
Duration::Object::Push(L, b->duration);
else
Lua::PushNil(L);
return 1;
}
int __fastcall Script_SetFontString(void *L) {
constexpr const char *kUsage = "self:SetFontString(fontString)";
Binding *b = Self(L, kUsage);
if (b == nullptr)
return 0;
void *fontString = Lua::ResolveFontString(L, 2, false);
if (fontString == nullptr) {
Usage(L, 2, kUsage);
return 0;
}
b->fontString = fontString;
return 0;
}
int __fastcall Script_GetFontString(void *L) {
Binding *b = Self(L, "local fontString = self:GetFontString()");
if (b == nullptr)
return 0;
if (b->fontString != nullptr)
Lua::PushObject(L, b->fontString);
else
Lua::PushNil(L);
return 1;
}
int __fastcall Script_SetFormatter(void *L) {
constexpr const char *kUsage = "self:SetFormatter(formatter)";
Binding *b = Self(L, kUsage);
if (b == nullptr)
return 0;
Fmt::Formatter *f = Fmt::ToFormatter(L, 2);
if (f == nullptr) {
Usage(L, 2, kUsage);
return 0;
}
Fmt::Retain(f);
Fmt::Release(b->formatter);
b->formatter = f;
ClearComponents(*b);
b->hasTextFormat = false;
b->textFormat.clear();
return 0;
}
// The component at stack index `entry`: {property =
// Enum.DurationTextBindingProperty, formatter = NumericFormatter}. Leaves the
// stack as it was; false when the entry is not one.
bool ReadComponent(void *L, int entry, Component &c) {
if (Lua::Type(L, entry) != Lua::TYPE_TABLE)
return false;
Lua::PushString(L, "property");
Lua::RawGet(L, entry);
const double p = Lua::Type(L, -1) == Lua::TYPE_NUMBER ? Lua::ToNumber(L, -1) : NAN;
Lua::PushString(L, "formatter");
Lua::RawGet(L, entry);
c.formatter = Fmt::ToFormatter(L, -1);
Lua::SetTop(L, entry);
if (!(p >= Duration::Object::PROPERTY_REMAINING_DURATION && p <= Duration::Object::PROPERTY_END_TIME) ||
p != static_cast<int>(p) || c.formatter == nullptr)
return false;
c.property = static_cast<int>(p);
return true;
}
// Replaces the binding's text format with `formatString` and the component
// list at stack index `t`, all or nothing.
bool ApplyTextFormat(void *L, Binding &b, int formatString, int t) {
std::vector<Component> components;
for (int i = 1;; ++i) {
Lua::RawGetI(L, t, i);
const int entry = Lua::GetTop(L);
Component c;
const bool end = Lua::Type(L, entry) == Lua::TYPE_NIL;
const bool ok = !end && ReadComponent(L, entry, c);
Lua::SetTop(L, entry - 1);
if (end)
break;
if (!ok) {
for (Component &read : components)
Fmt::Release(read.formatter);
return false;
}
Fmt::Retain(c.formatter);
components.push_back(c);
}
Fmt::Release(b.formatter);
b.formatter = nullptr;
ClearComponents(b);
b.components = std::move(components);
b.textFormat.assign(Lua::ToString(L, formatString), Lua::StrLen(L, formatString));
b.hasTextFormat = true;
return true;
}
// `components` holds one entry for each "{}" in order.
int __fastcall Script_SetTextFormat(void *L) {
constexpr const char *kUsage = "self:SetTextFormat(formatString, components)";
Binding *b = Self(L, kUsage);
if (b == nullptr)
return 0;
if (!Lua::IsString(L, 2)) {
Usage(L, 2, kUsage);
return 0;
}
if (Lua::Type(L, 3) != Lua::TYPE_TABLE || !ApplyTextFormat(L, *b, 2, 3))
Usage(L, 3, kUsage);
return 0;
}
int __fastcall Script_SetExpiredText(void *L) {
constexpr const char *kUsage = "self:SetExpiredText([text])";
if (Binding *b = Self(L, kUsage))
OptionalText(L, 2, kUsage, b->hasExpiredText, b->expiredText);
return 0;
}
int __fastcall Script_GetExpiredText(void *L) {
Binding *b = Self(L, "local text = self:GetExpiredText()");
if (b == nullptr)
return 0;
PushOptionalText(L, b->hasExpiredText, b->expiredText);
return 1;
}
int __fastcall Script_SetZeroDurationText(void *L) {
constexpr const char *kUsage = "self:SetZeroDurationText([text])";
if (Binding *b = Self(L, kUsage))
OptionalText(L, 2, kUsage, b->hasZeroText, b->zeroText);
return 0;
}
int __fastcall Script_GetZeroDurationText(void *L) {
Binding *b = Self(L, "local text = self:GetZeroDurationText()");
if (b == nullptr)
return 0;
PushOptionalText(L, b->hasZeroText, b->zeroText);
return 1;
}
int __fastcall Script_SetTimeModifier(void *L) {
constexpr const char *kUsage = "self:SetTimeModifier(modifier)";
Binding *b = Self(L, kUsage);
if (b == nullptr)
return 0;
int m;
if (Lua::Type(L, 2) <= Lua::TYPE_NIL || !Duration::Object::ToModifier(L, 2, m)) {
Usage(L, 2, kUsage);
return 0;
}
b->modifier = m;
return 0;
}
int __fastcall Script_GetTimeModifier(void *L) {
Binding *b = Self(L, "local modifier = self:GetTimeModifier()");
if (b == nullptr)
return 0;
Lua::PushNumber(L, b->modifier);
return 1;
}
int __fastcall Script_SetUpdateInterval(void *L) {
constexpr const char *kUsage = "self:SetUpdateInterval(updateInterval)";
Binding *b = Self(L, kUsage);
if (b == nullptr)
return 0;
const double n = Lua::Type(L, 2) == Lua::TYPE_NUMBER ? Lua::ToNumber(L, 2) : NAN;
if (!(n >= 0.0) || !std::isfinite(n)) {
Usage(L, 2, kUsage);
return 0;
}
b->interval = n;
return 0;
}
int __fastcall Script_GetUpdateInterval(void *L) {
Binding *b = Self(L, "local updateInterval = self:GetUpdateInterval()");
if (b == nullptr)
return 0;
Lua::PushNumber(L, b->interval);
return 1;
}
int __fastcall Script_GetFormattedText(void *L) {
Binding *b = Self(L, "local text = self:GetFormattedText()");
if (b == nullptr)
return 0;
bool ok;
{ // the string is gone before lua_error, which unwinds no C++ frame
std::string text;
ok = Formatted(L, *b, text);
if (ok)
Lua::PushLString(L, text.data(), static_cast<unsigned int>(text.size()));
}
if (!ok) {
const char *msg = Lua::ToString(L, -1); // the format error, on the stack
Lua::Error(L, "DurationTextBinding:GetFormattedText(): %s", msg != nullptr ? msg : "?");
return 0;
}
return 1;
}
int __fastcall Script_UpdateFontString(void *L) {
if (Binding *b = Self(L, "self:UpdateFontString()"))
Update(L, *b);
return 0;
}
// Clears everything and disables the binding.
int __fastcall Script_SetToDefaults(void *L) {
if (Binding *b = Self(L, "self:SetToDefaults()")) {
Retain(b); // SetEnabled(false) may drop the last reference but ours
SetEnabled(L, b, false);
Clear(*b);
Release(b);
}
return 0;
}
int __fastcall Script_HasSecretValues(void *L) {
if (Self(L, "local hasSecretValues = self:HasSecretValues()") == nullptr)
return 0;
Lua::PushBool(L, false);
return 1;
}
int __fastcall Meta_gc(void *L) {
auto **block = static_cast<Binding **>(Lua::ToUserdata(L, 1));
if (block != nullptr) {
Release(*block);
*block = nullptr;
}
return 0;
}
int __fastcall Meta_tostring(void *L) {
char text[48];
std::snprintf(text, sizeof(text), "DurationTextBinding: %p", static_cast<void *>(ToBinding(L, 1)));
Lua::PushString(L, text);
return 1;
}
int __fastcall Script_CreateDurationTextBinding(void *L) {
Push(L, New());
return 1;
}
struct Method {
const char *name;
Lua::CFunction fn;
};
const Method kMethods[] = {
{"Assign", &Script_Assign},
{"CanFormatText", &Script_CanFormatText},
{"CanUpdateFontString", &Script_CanUpdateFontString},
{"Copy", &Script_Copy},
{"Disable", &Script_Disable},
{"Enable", &Script_Enable},
{"GetDuration", &Script_GetDuration},
{"GetExpiredText", &Script_GetExpiredText},
{"GetFontString", &Script_GetFontString},
{"GetFormattedText", &Script_GetFormattedText},
{"GetTimeModifier", &Script_GetTimeModifier},
{"GetUpdateInterval", &Script_GetUpdateInterval},
{"GetZeroDurationText", &Script_GetZeroDurationText},
{"HasSecretValues", &Script_HasSecretValues},
{"IsEnabled", &Script_IsEnabled},
{"SetDuration", &Script_SetDuration},
{"SetEnabled", &Script_SetEnabled},
{"SetExpiredText", &Script_SetExpiredText},
{"SetFontString", &Script_SetFontString},
{"SetFormatter", &Script_SetFormatter},
{"SetTextFormat", &Script_SetTextFormat},
{"SetTimeModifier", &Script_SetTimeModifier},
{"SetToDefaults", &Script_SetToDefaults},
{"SetUpdateInterval", &Script_SetUpdateInterval},
{"SetZeroDurationText", &Script_SetZeroDurationText},
{"UpdateFontString", &Script_UpdateFontString},
};
void SetFunction(void *L, const char *key, Lua::CFunction fn) {
Lua::PushString(L, key);
Lua::PushCClosure(L, fn, 0);
Lua::SetTable(L, -3);
}
// registry[kMetatableKey] = {__index = methods, __gc, __tostring, __metatable = false}
void CreateMetatable(void *L) {
PushMetatable(L);
const bool exists = Lua::Type(L, -1) == Lua::TYPE_TABLE;
Lua::SetTop(L, -2);
if (exists)
return;
Lua::PushString(L, kMetatableKey);
Lua::NewTable(L);
Lua::PushString(L, "__index");
Lua::NewTable(L);
for (const Method &m : kMethods)
SetFunction(L, m.name, m.fn);
Lua::SetTable(L, -3);
SetFunction(L, "__gc", &Meta_gc);
SetFunction(L, "__tostring", &Meta_tostring);
Lua::PushString(L, "__metatable");
Lua::PushBool(L, false);
Lua::SetTable(L, -3);
Lua::RawSet(L, Lua::REGISTRY_INDEX);
}
const Lua::EnumIntegerEntry kProperties[] = {
{"RemainingDuration", Duration::Object::PROPERTY_REMAINING_DURATION},
{"RemainingPercent", Duration::Object::PROPERTY_REMAINING_PERCENT},
{"ElapsedDuration", Duration::Object::PROPERTY_ELAPSED_DURATION},
{"ElapsedPercent", Duration::Object::PROPERTY_ELAPSED_PERCENT},
{"TotalDuration", Duration::Object::PROPERTY_TOTAL_DURATION},
{"StartTime", Duration::Object::PROPERTY_START_TIME},
{"EndTime", Duration::Object::PROPERTY_END_TIME},
};
const Game::Doc::Field kCreateRets[] = {
Game::Doc::Req("binding", "DurationTextBinding"),
};
const Game::Doc::Function kCreateDurationTextBinding{
"Creates a binding that keeps a font string's text set from a duration object.", {}, kCreateRets};
void RegisterLuaFunctions() {
CreateMetatable(Lua::State());
Lua::RegisterTableFunction("C_DurationUtil", "CreateDurationTextBinding", &Script_CreateDurationTextBinding,
&kCreateDurationTextBinding);
Lua::RegisterIntegerEnum(
"Enum", "DurationTextBindingProperty", kProperties, "DurationTextBindingShared");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Duration::TextBinding
+12 -22
View File
@@ -28,11 +28,13 @@
// return addresses, get the new count. The cursor stays where it was.
//
// XML sets it with the EditBox attribute `countInvisibleLetters="true"`, read
// after the engine's own EditBox loader and parsed the way it parses
// `autoFocus`.
// after the engine's own EditBox loader (Xml::TypeLoad) and parsed the way it
// parses `autoFocus`.
#include "Game.h"
#include "Offsets.h"
#include "xml/FrameLoad.h"
#include "xml/TypeLoad.h"
#include <cstdint>
#include <intrin.h> // _ReturnAddress
@@ -84,29 +86,17 @@ int __fastcall CountChars_h(void *editBox, void * /*edx*/, int start, int count)
const Game::HookAutoRegister _countHook{Offsets::FUN_EDITBOX_COUNT_CHARS, reinterpret_cast<void *>(&CountChars_h),
reinterpret_cast<void **>(&g_origCountChars)};
using Load_t = uintptr_t(__thiscall *)(void *self, const void *node, void *status);
using GetAttr_t = const char *(__thiscall *)(const void *node, const char *name);
Load_t g_origEditBoxLoad = nullptr;
// `self` is the loader sub-object inside the edit box.
uintptr_t __fastcall EditBoxLoad_h(void *self, void * /*edx*/, const void *node, void *status) {
const uintptr_t result = g_origEditBoxLoad(self, node, status);
if (node == nullptr)
return result;
const char *value =
reinterpret_cast<GetAttr_t>(Offsets::FUN_XML_NODE_GET_ATTRIBUTE)(node, "countInvisibleLetters");
void OnEditBoxNode(void *object, const void *node, void * /*status*/) {
const char *value = Xml::Attr(node, "countInvisibleLetters");
if (value == nullptr || *value == '\0')
return result;
void *editBox = static_cast<uint8_t *>(self) - Offsets::OFF_XML_LOADER_SUBOBJECT;
return;
if (Game::StringToFlag(value))
g_counting.insert(editBox);
g_counting.insert(object);
else
g_counting.erase(editBox);
return result;
g_counting.erase(object);
}
const Game::HookAutoRegister _loadHook{Offsets::FUN_XML_EDITBOX_LOAD, reinterpret_cast<void *>(&EditBoxLoad_h),
reinterpret_cast<void **>(&g_origEditBoxLoad)};
const Xml::TypeLoad::AutoSubscribe _xmlSub{Xml::TypeLoad::EDIT_BOX, &OnEditBoxNode};
int __fastcall Script_SetCountInvisibleLetters(void *L) {
void *editBox = Game::Lua::ResolveEditBox(L);
@@ -135,8 +125,8 @@ const Game::Lua::FrameMethodEntry g_methods[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -83,9 +83,7 @@ const Game::Doc::Method g_methodDocs[] = {
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])), g_methodDocs,
static_cast<int>(sizeof(g_methodDocs) / sizeof(g_methodDocs[0])));
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods, g_methodDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -86,9 +86,7 @@ void RegisterLuaFunctions() {
Common::RedirectCall(Offsets::CALL_EDITBOX_IME_COMPOSITION_INSERT, Offsets::FUN_EDITBOX_INSERT,
reinterpret_cast<const void *>(&CompositionInsert));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])), g_methodDocs,
static_cast<int>(sizeof(g_methodDocs) / sizeof(g_methodDocs[0])));
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods, g_methodDocs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -2
View File
@@ -30,8 +30,7 @@ const Game::Lua::EnumIntegerEntry kEntries[] = {
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum("Enum", "InputContext", kEntries,
sizeof(kEntries) / sizeof(kEntries[0]), "InputConstants");
Game::Lua::RegisterIntegerEnum("Enum", "InputContext", kEntries, "InputConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+1 -3
View File
@@ -234,9 +234,7 @@ const Game::Lua::FrameMethodEntry g_methods[] = {
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY),
g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
reinterpret_cast<void *>(Offsets::VAR_EDITBOX_METHOD_REGISTRY), g_methods);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
@@ -1,6 +1,7 @@
## Interface: 11200
## Title: !!!ClassicAPI
## Notes: Library to emulate aspects of the modern WoW API on 1.12.1 / Lua 5.0.
## Notes-ruRU: Библиотека, эмулирующая аспекты современного API WoW на 1.12.1 / Lua 5.0.
## Version: DEV
## Author: Brues
## X-Website: https://github.com/brues-code/ClassicAPI
@@ -134,9 +135,16 @@ FrameXML\Scroll\TrimScrollBar.lua
FrameXML\Scroll\TrimScrollBar.xml
FrameXML\LayoutFrame.lua
FrameXML\LayoutFrame.xml
FrameXML\AnimationTemplates.xml
FrameXML\SharedTooltipTemplates.xml
FrameXML\SharedUIPanelTemplates.xml
FrameXML\InputBoxTemplates.xml
FrameXML\SearchBoxTemplate.xml [ExcludeLoadGameType turtle]
FrameXML\ColorSwatch.lua
FrameXML\ColorSwatch.xml
FrameXML\ScriptErrorsFrame.lua
FrameXML\ScriptErrorsFrame.xml
FrameXML\DressUpFrame.lua
Menu\MenuConstants.lua
Menu\Classic\MenuConstants.lua
+10
View File
@@ -14,3 +14,13 @@ C_ActionBar = C_ActionBar or {}
function C_ActionBar.ShouldShowKeyring()
return true
end
-- C_AutoComplete's names come from the DLL. This client has no Battle.net
-- friends to give a presence ID for, and no other realm a name can address.
function C_AutoComplete.GetAutoCompletePresenceID(name)
return nil
end
function C_AutoComplete.GetAutoCompleteRealms()
return {}
end
@@ -0,0 +1,12 @@
-- Backport of one mixin from retail's Blizzard_SharedXML/AnimationTemplates.lua.
-- Anim group whose parent is hidden or shown based on script calls defined in the template xml being used
VisibleWhilePlayingAnimGroupMixin = {}
function VisibleWhilePlayingAnimGroupMixin:Show()
self:GetParent():Show();
end
function VisibleWhilePlayingAnimGroupMixin:Hide()
self:GetParent():Hide();
end
@@ -0,0 +1,14 @@
<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 template from retail's Blizzard_SharedXML/AnimationTemplates.xml. -->
<Script file="AnimationTemplates.lua"/>
<!-- The anim group's parent is visible while the group is playing, hidden otherwise -->
<AnimationGroup name="VisibleWhilePlayingAnimGroupTemplate" mixin="VisibleWhilePlayingAnimGroupMixin" virtual="true">
<Scripts>
<OnLoad method="Hide"/>
<OnPlay method="Show"/>
<OnStop method="Hide"/>
<OnFinished method="Hide"/>
</Scripts>
</AnimationGroup>
</Ui>
@@ -0,0 +1,19 @@
-- 4.3.4's DressUpItemLink (FrameXML/ModelFrames.lua): the whole link goes to
-- the model, so DressUpModel:TryOn shows the link's enchant glow, and an item
-- the model cannot wear does not open the window. 1.12.1's version passes
-- only the item ID.
--
-- Differences from the 4.3.4 source:
-- - The link goes through DressUpItem, which 4.3.4 folded in. Here
-- Blizzard_AuctionUI replaces DressUpItem to show its own model, and
-- some clients add steps to it (Turtle dresses the tabard).
-- - An uncached item skips the IsDressableItem test, which reads only the
-- cache. 1.12.1 opens no tooltip before a chat link's Control-click, so
-- the test would drop most chat links; TryOn loads the item instead.
function DressUpItemLink(link)
if ( not link or (C_Item.IsItemDataCachedByID(link) and not IsDressableItem(link)) ) then
return;
end
DressUpItem(link);
end
@@ -0,0 +1,115 @@
-- Backport of the instructions and search box parts of retail's
-- Blizzard_SharedXML/Shared/InputBox/InputBoxTemplates.lua.
--
-- Implementation differences from the retail source:
-- - No narration: ClearButtonMixin has no NarrationGetName.
function EditBox_ClearFocus(self)
self:ClearFocus();
end
function InputBoxInstructions_OnTextChanged(self)
self.Instructions:SetShown(self:GetText() == "");
end
function InputBoxInstructions_UpdateColorForEnabledState(self, color)
if color then
self:SetTextColor(color:GetRGBA());
end
end
function InputBoxInstructions_OnDisable(self)
InputBoxInstructions_UpdateColorForEnabledState(self, self.disabledColor);
end
function InputBoxInstructions_OnEnable(self)
InputBoxInstructions_UpdateColorForEnabledState(self, self.enabledColor);
end
function InputBoxInstructions_OnEnter(self)
if self.showTooltipForTruncatedInstructions then
InputBoxInstructions_ShowTooltipIfTruncated(self);
end
end
function InputBoxInstructions_OnLeave(self)
if self.showTooltipForTruncatedInstructions then
-- Note: Some inheritors of this template may have a special version of GetAppropriateTooltip (Ex. CatalogShopMixin:GetAppropriateTooltip)
-- If this functionality is desired then this probably needs to be overridden
GetAppropriateTooltip():Hide();
end
end
function InputBoxInstructions_ShowTooltipIfTruncated(self)
if not self.Instructions:IsShown() or not self.Instructions:IsTruncated() then
return;
end
local tooltip = GetAppropriateTooltip();
tooltip:SetOwner(self, "ANCHOR_RIGHT");
local text = self.Instructions:GetText();
GameTooltip_AddHighlightLine(tooltip, text);
tooltip:Show();
end
function SearchBoxTemplate_OnLoad(self)
self.searchIcon:SetVertexColor(0.6, 0.6, 0.6);
self.Instructions:SetText(self.instructionText);
end
function SearchBoxTemplate_OnEditFocusLost(self)
if ( self:GetText() == "" ) then
self.searchIcon:SetVertexColor(0.6, 0.6, 0.6);
self.clearButton:Hide();
end
end
function SearchBoxTemplate_OnEditFocusGained(self)
self.searchIcon:SetVertexColor(1.0, 1.0, 1.0);
self.clearButton:Show();
end
function SearchBoxTemplate_OnTextChanged(self)
if ( not self:HasFocus() and self:GetText() == "" ) then
self.searchIcon:SetVertexColor(0.6, 0.6, 0.6);
self.clearButton:Hide();
else
self.searchIcon:SetVertexColor(1.0, 1.0, 1.0);
self.clearButton:Show();
end
InputBoxInstructions_OnTextChanged(self);
end
function SearchBoxTemplate_ClearText(self)
self:SetText("");
self:ClearFocus();
end
function SearchBoxTemplateClearButton_OnClick(self)
PlaySound(SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON);
SearchBoxTemplate_ClearText(self:GetParent());
end
ClearButtonMixin = {};
function ClearButtonMixin:OnEnter()
self.Icon:SetAlpha(1.0);
end
function ClearButtonMixin:OnLeave()
self.Icon:SetAlpha(0.5);
end
function ClearButtonMixin:OnMouseDown()
if self:IsEnabled() then
self.Icon:SetPoint("TOPLEFT", self, "TOPLEFT", 4, -4);
end
end
function ClearButtonMixin:OnMouseUp()
self.Icon:SetPoint("TOPLEFT", self, "TOPLEFT", 3, -3);
end
function ClearButtonMixin:OnClick()
SearchBoxTemplateClearButton_OnClick(self);
end
@@ -0,0 +1,97 @@
<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">
<!--
Two templates from retail's Blizzard_SharedXML/Shared/InputBox/InputBoxTemplates.xml.
- InputBoxTemplate is this client's own (1.12 FrameXML, same border art and
geometry), so it is not redefined.
- The search and clear icons are atlases this client does not have. The
search icon is the world map's magnifying glass, cropped square. The
clear icon is the cancel button art, cropped to the button.
- SearchBoxTemplate has no narration mixin.
- Turtle's FrameXML has its own SearchBoxTemplate (a different box, which
its UI uses), and the client keeps the first template registered under a
name. So the retail box is ClassicAPISearchBoxTemplate, which ClassicAPI's
own UI inherits, and SearchBoxTemplate.xml names it SearchBoxTemplate on
every client but Turtle.
-->
<Script file="InputBoxTemplates.lua"/>
<EditBox name="InputBoxInstructionsTemplate" inherits="InputBoxTemplate" virtual="true">
<!--KeyValues>
Optional
<KeyValue key="disabledColor" value="GRAY_FONT_COLOR" type="global"/>
<KeyValue key="enabledColor" value="HIGHLIGHT_FONT_COLOR" type="global"/>
<KeyValue key="showTooltipForTruncatedInstructions" value="false" type="boolean"/>
</KeyValues-->
<Layers>
<Layer level="ARTWORK">
<FontString parentKey="Instructions" justifyH="LEFT" justifyV="MIDDLE" inherits="GameFontDisableSmall">
<Anchors>
<Anchor point="TOPLEFT" x="16" y="0"/>
<Anchor point="BOTTOMRIGHT" x="-20" y="0"/>
</Anchors>
<Color r="0.35" g="0.35" b="0.35"/>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnTextChanged function="InputBoxInstructions_OnTextChanged"/>
<OnDisable function="InputBoxInstructions_OnDisable"/>
<OnEnable function="InputBoxInstructions_OnEnable"/>
<OnEnter function="InputBoxInstructions_OnEnter"/>
<OnLeave function="InputBoxInstructions_OnLeave"/>
</Scripts>
<FontString inherits="GameFontHighlightSmall"/>
</EditBox>
<EditBox name="ClassicAPISearchBoxTemplate" inherits="InputBoxInstructionsTemplate" autoFocus="false" virtual="true">
<KeyValues>
<KeyValue key="instructionText" value="SEARCH" type="global"/>
</KeyValues>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentSearchIcon" file="Interface\WorldMap\WorldMap-MagnifyingGlass" parentKey="searchIcon">
<Size x="10" y="10"/>
<Anchors>
<Anchor point="LEFT" x="1" y="-1"/>
</Anchors>
<TexCoords left="0.09375" right="0.90625" top="0.03125" bottom="0.84375"/>
</Texture>
</Layer>
</Layers>
<Frames>
<Button name="$parentClearButton" parentKey="clearButton" mixin="ClearButtonMixin" hidden="true">
<Size x="17" y="17"/>
<Anchors>
<Anchor point="RIGHT" x="-3" y="0"/>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture parentKey="Icon" file="Interface\Buttons\CancelButton-Up" alpha="0.5">
<Size x="10" y="10"/>
<Anchors>
<Anchor point="TOPLEFT" x="3" y="-3"/>
</Anchors>
<TexCoords left="0.21875" right="0.78125" top="0.21875" bottom="0.78125"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnter method="OnEnter" />
<OnLeave method="OnLeave" />
<OnMouseDown method="OnMouseDown" />
<OnMouseUp method="OnMouseUp" />
<OnClick method="OnClick"/>
</Scripts>
</Button>
</Frames>
<TextInsets left="16" right="20" bottom="0" top="0"/>
<Scripts>
<OnLoad function="SearchBoxTemplate_OnLoad"/>
<OnEscapePressed function="EditBox_ClearFocus"/>
<OnEnterPressed function="EditBox_ClearFocus"/>
<OnEditFocusLost function="SearchBoxTemplate_OnEditFocusLost"/>
<OnEditFocusGained function="SearchBoxTemplate_OnEditFocusGained"/>
<OnTextChanged function="SearchBoxTemplate_OnTextChanged"/>
</Scripts>
</EditBox>
</Ui>
@@ -364,7 +364,7 @@ GameMenuFrame:HookScript("OnShow", function()
menuButton:SetPoint(point, relativeTo, relativePoint, x, y)
GameMenuButtonKeybindings:ClearAllPoints()
GameMenuButtonKeybindings:SetPoint(point, menuButton, relativePoint, x, y)
GameMenuFrame:SetHeight(GameMenuFrame:GetHeight() + menuButton:GetHeight() - y)
GameMenuFrame:SetHeight(GameMenuFrame:GetUnscaledHeight() + menuButton:GetUnscaledHeight() - y)
end)
menuButton:SetScript("OnClick", function()
PlaySound("igMainMenuOption")
@@ -173,7 +173,7 @@ end
local sizeRoundingMultiple = 0.1;
function BaseLayoutMixin:GetChildSize(child, ignoreRect)
local childWidth, childHeight = child:GetSize(ignoreRect);
local childWidth, childHeight = child:GetUnscaledSize(ignoreRect);
if self.usePreciseChildSizes then
return childWidth, childHeight;
else
@@ -182,7 +182,7 @@ function BaseLayoutMixin:GetChildSize(child, ignoreRect)
end
function BaseLayoutMixin:GetChildWidth(child, ignoreRect)
local childWidth = child:GetWidth(ignoreRect);
local childWidth = child:GetUnscaledWidth(ignoreRect);
if self.usePreciseChildSizes then
return childWidth;
else
@@ -191,7 +191,7 @@ function BaseLayoutMixin:GetChildWidth(child, ignoreRect)
end
function BaseLayoutMixin:GetChildHeight(child, ignoreRect)
local childHeight = child:GetHeight(ignoreRect);
local childHeight = child:GetUnscaledHeight(ignoreRect);
if self.usePreciseChildSizes then
return childHeight;
else
@@ -0,0 +1,100 @@
-- Backport of retail's Blizzard_SharedXML/PortraitFrame.lua.
--
-- Implementation differences from the retail source:
-- - The border is a backdrop (SharedUIPanelTemplates.xml), the same with
-- and without the portrait, so SetBorder has no layout to apply.
-- - No SetPortraitToClassIcon or SetPortraitToSpecIcon: this client has no
-- class circles art (CLASS_ICON_TCOORDS) and no specializations.
-- - No DefaultPanelMixin or PortraitFrameFlatBaseMixin: their templates are
-- not ported.
TitledPanelMixin = {};
function TitledPanelMixin:GetTitleText()
return self.TitleContainer.TitleText;
end
function TitledPanelMixin:SetTitleColor(color)
self:GetTitleText():SetTextColor(color:GetRGBA());
end
function TitledPanelMixin:SetTitle(title)
self:GetTitleText():SetText(title);
end
function TitledPanelMixin:SetTitleFormatted(fmt, ...)
self:GetTitleText():SetFormattedText(fmt, ...);
end
function TitledPanelMixin:SetTitleMaxLinesAndHeight(maxLines, height)
self:GetTitleText():SetMaxLines(maxLines);
self:GetTitleText():SetHeight(height);
end
function TitledPanelMixin:SetTitleOffsets(leftOffset, rightOffset)
self.TitleContainer:SetPoint("TOPLEFT", self, "TOPLEFT", leftOffset or 58, -1);
self.TitleContainer:SetPoint("TOPRIGHT", self, "TOPRIGHT", rightOffset or -24, -1);
end
PortraitFrameMixin = CreateFromMixins(TitledPanelMixin);
function PortraitFrameMixin:GetPortrait()
return self.PortraitContainer.portrait;
end
function PortraitFrameMixin:HasPortraitTexture()
return self.PortraitContainer.portrait:GetTexture();
end
function PortraitFrameMixin:SetBorder(layoutName)
end
function PortraitFrameMixin:SetPortraitToAsset(texture)
self:GetPortrait():SetTexture(texture);
end
function PortraitFrameMixin:SetPortraitToUnit(unit)
SetPortraitTexture(self:GetPortrait(), unit);
end
function PortraitFrameMixin:SetPortraitToBag(bagID)
C_Container.SetBagPortraitTexture(self:GetPortrait(), bagID);
end
function PortraitFrameMixin:SetPortraitTextureRaw(texture)
self:GetPortrait():SetTexture(texture);
end
function PortraitFrameMixin:SetPortraitAtlasRaw(atlas, ...)
self:GetPortrait():SetAtlas(atlas, ...);
end
function PortraitFrameMixin:SetPortraitTexCoord(...)
self:GetPortrait():SetTexCoord(...);
end
function PortraitFrameMixin:SetPortraitShown(shown)
self:GetPortrait():SetShown(shown);
end
function PortraitFrameMixin:SetPortraitTextureSizeAndOffset(size, offsetX, offsetY)
local portrait = self:GetPortrait();
portrait:SetSize(size, size);
portrait:SetPoint("TOPLEFT", self, "TOPLEFT", offsetX, offsetY);
end
do
local function SetFrameLevelInternal(frame, level)
if frame then
assertsafe(level < 10000, "Base level needs to be low enough to accomodate the existing layering"); -- TODO: Should become a constant
frame:SetFrameLevel(level);
end
end
function PortraitFrameMixin:SetFrameLevelsFromBaseLevel(baseLevel)
SetFrameLevelInternal(self.NineSlice, baseLevel + 500);
SetFrameLevelInternal(self.PortraitContainer, baseLevel + 400);
SetFrameLevelInternal(self.TitleContainer, baseLevel + 510);
SetFrameLevelInternal(self.CloseButton, baseLevel + 510);
end
end
@@ -0,0 +1,155 @@
-- Backport of retail's Blizzard_ScriptErrorsFrame to vanilla 1.12 / Lua 5.0:
-- the window that shows each Lua error with its time, count and stack, and
-- pages through them. Retail always loads it; it lives here, not in the
-- load-on-demand ClassicAPI_DebugTools, so that it is there for the first
-- error.
--
-- How errors get here: 1.12's error handler (_ERRORMESSAGE, FrameXML\
-- BasicControls.xml) reports each one as
-- ScriptErrors_Message:SetText(message); ScriptErrors:Show();
-- The frame's OnLoad takes those two slots, the same two !BugGrabber takes,
-- so the stock box never shows and this window gets the error instead:
-- - An addon that loads later and takes the slots again (!BugGrabber, which
-- BugSack reads) replaces this window.
-- - An addon that calls seterrorhandler replaces _ERRORMESSAGE, so neither
-- shows; one that chains to the old handler (geterrorhandler) ends here.
-- The stock box stays hidden, so the handler's IsVisible gate always passes
-- and every error arrives. message() calls _ERRORMESSAGE, so its text shows
-- here too.
--
-- Implementation differences from the retail source:
-- - Global functions in the 3.3.5 style, not retail's
-- ScriptErrorsFrameMixin.
-- - The text is a ScrollingEditBoxTemplate with a MinimalScrollBar, the
-- ScrollBox pair retail uses for scrolling edit boxes, not retail's
-- ScrollFrameTemplate, which ClassicAPI does not port.
-- - The stack and locals are retail's GetErrorData, taken in the SetText
-- slot: GetErrorCallstackHeight finds the code that failed however many
-- handlers sit between it and the slot. No |K strings to remove, and no
-- C_StringUtil.EscapeDecimalNonPrintables.
-- - No scriptErrors cvar (1.12 always shows errors), no LUA_WARNING, no
-- gamepad legend.
-- - Lua 5.0: no `...` expression, no # operator.
local function GetErrorData()
-- Example of how debug stack level is calculated
-- Current stack: [1, 2, 3, 4, 5] (current function is at 1, total current height is 5)
-- Stack at time of error: [1, 2] (these are currently now index 4 and 5, but at the time of error the stack height is 2)
-- To calcuate the level to debug (4): curentStackHeight - (errorStackHeight - 1) = 5 - (2 - 1) = 4
local currentStackHeight = GetCallstackHeight();
local errorCallStackHeight = GetErrorCallstackHeight();
local errorStackOffset = errorCallStackHeight and (errorCallStackHeight - 1);
local debugStackLevel = currentStackHeight - (errorStackOffset or 0);
local skipFunctionsAndUserdata = true;
local stack = debugstack(debugStackLevel);
local locals = debuglocals(debugStackLevel, skipFunctionsAndUserdata);
return stack, locals;
end
local ERROR_FORMAT = "|cffffd200Message:|r|cffffffff %s|r\n"
.. "|cffffd200Time:|r|cffffffff %s|r\n"
.. "|cffffd200Count:|r|cffffffff %s|r\n"
.. "|cffffd200Stack:|r\n"
.. "|cffffffff%s|r\n"
.. "|cffffd200Locals:|r\n"
.. "|cffffffff%s|r";
local INDEX_ORDER_FORMAT = "%d / %d";
function ScriptErrorsFrame_OnLoad(self)
self.index = 0;
self.errorData = {};
self.seen = {};
-- No default text: with none set, an empty window would apply nil.
self.ScrollingEditBox:SetDefaultTextEnabled(false);
self.ScrollingEditBox:RegisterCallback("OnFocusGained", function(_, editBox)
editBox:HighlightText(0);
end, self);
-- Retail turns the wheel on with the OnMouseWheel script; 1.12 does not.
local scrollBox = self.ScrollingEditBox:GetScrollBox();
scrollBox:EnableMouseWheel(true);
ScrollUtil.RegisterScrollBoxWithScrollBar(scrollBox, self.ScrollBar);
ScriptErrors_Message.SetText = function(_, message)
ScriptErrorsFrame_OnError(message, GetErrorData());
end
ScriptErrors.Show = function() end
end
function ScriptErrorsFrame_OnError(message, stack, locals)
local self = ScriptErrorsFrame;
message = tostring(message);
stack = stack or "";
locals = locals or "<none>";
local messageKey = message .. "\n" .. stack;
local index = self.seen[messageKey];
if ( index ) then
local errorData = self.errorData[index];
errorData.count = errorData.count + 1;
else
table.insert(self.errorData, { count = 1, message = message, time = date(), stack = stack, locals = locals });
index = table.getn(self.errorData);
self.seen[messageKey] = index;
end
if ( not self:IsShown() ) then
self.index = index;
self:Show();
else
ScriptErrorsFrame_Update();
end
end
function ScriptErrorsFrame_Update()
local self = ScriptErrorsFrame;
if ( not self.errorData[self.index] ) then
self.index = table.getn(self.errorData);
end
-- SetText puts the cursor and the scroll back at the top.
local errorData = self.errorData[self.index];
if ( errorData ) then
self.ScrollingEditBox:SetText(string.format(ERROR_FORMAT, errorData.message, errorData.time, errorData.count, errorData.stack, errorData.locals));
self.ScrollingEditBox:GetEditBox():HighlightText(0);
else
self.ScrollingEditBox:ClearText();
end
local numErrors = table.getn(self.errorData);
ScriptErrorsFramePrevious:SetEnabled(numErrors > 1);
ScriptErrorsFrameNext:SetEnabled(numErrors > 1);
ScriptErrorsFrameIndexLabel:SetText(string.format(INDEX_ORDER_FORMAT, self.index, numErrors));
end
-- Previous / Next wrap around, as on retail.
function ScriptErrorsFrame_ChangeDisplayedIndex(delta)
local self = ScriptErrorsFrame;
local numErrors = table.getn(self.errorData);
local index = self.index + delta;
if ( index < 1 ) then
index = numErrors;
elseif ( index > numErrors ) then
index = 1;
end
self.index = index;
ScriptErrorsFrame_Update();
end
function ScriptErrorsFrame_HandleSlashCmd(msg)
local self = ScriptErrorsFrame;
if ( string.lower(msg or "") == "clear" ) then
self.index = 0;
self.errorData = {};
self.seen = {};
ScriptErrorsFrame_Update();
elseif ( self:IsShown() ) then
self:Hide();
else
self:Show();
end
end

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