Compare commits

...
28 Commits
Author SHA1 Message Date
Brues 5f13c63c99 unit: add UnitSpellHaste(unit) -- backport of the TBC+ haste getter
Vanilla 1.12 has no spell-haste API. UnitSpellHaste reads UNIT_MOD_CAST_SPEED
(the cast-time multiplier at descriptor +0x22c that the server folds into
SpellEntry::GetCastTime) and converts it to the modern haste percentage:

    haste% = (1 / modCastSpeed - 1) * 100

so 1.0 -> 0, 0.5 (half cast time) -> 100, and a slow like Curse of Tongues
(>1.0) -> negative. This is the Blizzard-shaped surface over the same field
nampower exposes raw as GetUnitField(unit, "modCastSpeed"), letting addons
drop that dependency (raw multiplier recoverable as 1/(1 + haste/100)).

Formula verified byte-exact against 4.3.4's Script_UnitSpellHaste
(FUN_004555b0): `(C1 / mult) * C2 - C2` with C1=1.0, C2=100.0 and a
div-by-zero guard -- the constants are forced by "unhasted must read 0" and
"return a percentage". The 4.3.4 field lives at desc+0x10c; +0x22c is the
verified 1.12 offset. Our div-guard uses `> 0` vs retail's `!= 0` (identical
for the always-positive cast-speed field, marginally more defensive).

New src/unit/SpellHaste.cpp + OFF_UNIT_MOD_CAST_SPEED; docs + README updated.
2026-07-25 00:27:17 -05:00
Brues afa1f62720 unit: add the unmodified arg to UnitPower / UnitPowerMax
Completes the retail signature UnitPower(unit [, powerType [, unmodified]])
(and UnitPowerMax). A truthy third arg skips the per-type display divisor and
returns the raw internal value, via the same ToBoolean(L, 3) path already used
by UnitPowerMissing.

Verified in-game on a warrior (rage, divisor 10):
  UnitPower(1)=96  UnitPower(1,true)=961   (floor(961/10)=96)
  UnitPowerMax(1)=100  UnitPowerMax(1,true)=1000
  UnitPowerMissing(1)=4  UnitPowerMissing(1,true)=39
The missing=4 (not 3) confirms the display-value subtraction: 100-96, not
(1000-961)/10 floored to 3.

Docs updated (signature, TOC anchor, unmodified note).
2026-07-25 00:10:09 -05:00
Brues c369373efe unit: add UnitPowerMissing(unit [, powerType [, unmodified]])
The power analogue of UnitHealthMissing: returns UnitPowerMax - UnitPower for
the given type in one native call. Reuses the Power module's ResolveArgs /
PowerDivisor, so it inherits the primary-power fallback (powerType omitted)
and per-type display divisor. The unmodified third arg (truthy) skips the
divisor for the raw internal deficit, matching retail's UnitPower.

Subtracts the two display values (each integer-divided) rather than dividing
the raw difference, so it equals UnitPowerMax(...) - UnitPower(...) exactly --
the rage case would otherwise be off by one (100/5 -> 95, not (1000-55)/10 =
94). Returns 0 for invalid units and for types the unit has no pool for
(e.g. energy on a mana user). Verified in-game: mana 6685-5929 = 756.

Docs + README updated.
2026-07-25 00:04:16 -05:00
Brues 1c86d0dead unit: add UnitHealthMissing(unit)
Returns the health deficit -- the equivalent of
UnitHealthMax(unit) - UnitHealth(unit) -- in one native call, so healing
addons don't call both engine getters and subtract in Lua every frame.
One token resolve + two descriptor field reads (HEALTH +0x40, MAXHEALTH
+0x58); no display divisor (health is stored at face value), clamped at 0.

Tracks the engine's own UnitHealth/UnitHealthMax exactly in both forms:
real values (player 3600-1 = 3599) and vanilla's percentage form for
non-grouped units (target 100-85 = 15). Returns 0 for a valid-but-absent
unit, matching UnitHealth's convention.
2026-07-24 23:58:46 -05:00
Brues cedbd04553 cast: mirror the server's +500ms ranged aim time in the cast-time prediction
FUN_006e3340 (the spell tooltip's cast-time helper, which our zero-latency
CastStartSet_h stamp reuses) omits the flat +500ms "aim time" the server adds
to every ranged, non-autorepeat spell -- SpellEntry::GetCastTime does
`castTime += 500` for SPELL_ATTR_RANGED. So the client prediction ran ~500ms
short of the server on Volley / Aimed Shot / Multi-Shot (Volley's real cast is
3000ms base + 500 = 3500ms), and the confirming SMSG_SPELL_START then snapped
the bar out ~1 RTT later. This was the dominant cause of the Volley overhang,
present with or without Quick Shots.

CastTimeMs now adds the +500 for SPELL_ATTR_RANGED spells (excluding Auto Shot
/ Shoot via the AttributesEx2 autorepeat bit, matching the server), so the
prediction is right from frame 1. Only applied when the spell already has a
cast time; instant ranged abilities the server gives a 500ms aim time still
surface through the packet path. The HandleSpellStart reconcile stays as the
backstop for any residual client/server divergence.
2026-07-24 23:21:26 -05:00
Brues 3b1bba2131 cast: reconcile local-player cast time to the server on the confirming packet
The client-side FUN_CAST_START_SET hook stamps g_cast with a zero-latency
prediction from FUN_006e3340, which folds in the caster's cast-speed /
ranged-haste multiplier (descriptor +0x22c). That is correct for spells the
server also hastes (Aimed Shot / Multi-Shot), but wrong for ones it does
not. Volley (1510 / 14294 / 14295) is the case: it is NOT channeled in the
client DBC (AttrEx has no 0x4/0x40) so it takes the regular-cast path, and
its Attributes & 0x30 == 0 so FUN_006e3340 applies the +0x22c multiplier --
yet Volley gains no benefit from Quick Shots server-side. The predicted bar
therefore ended ~before the real cast finished ("still casting after the bar
ends").

SMSG_SPELL_START carries the server's authoritative cast time, but for a
cast the client already stamped we were deduping the packet away entirely to
avoid restarting the bar. Instead, keep startMs (no visual restart) and snap
endMs to the server's castTime (preserving any accumulated pushback). Aimed
Shot / Multi-Shot are unaffected -- the server hastes them too, so client and
server agree and the reconcile is a no-op.

Closes https://github.com/brues-code/pfUI/issues/28
2026-07-24 22:40:27 -05:00
Brues 786428c9fb focus/who: reset per-character state on logout
Both modules held per-character state in process globals that the DLL
never cleared on a logout -> character-select -> login (the DLL is not
reloaded across that transition), the same bug class as the equipment-set
leak.

Focus: g_focusGUID / g_focusTargetGUID carried a dead GUID from the prior
character into the next. The WorldTick despawn probe self-healed it within
a tick, but fired a spurious PLAYER_FOCUS_CHANGED and could not cover a
GUID collision. Clear it from a GlueModuleAutoRegister, which fires on the
world->glue return a logout triggers but NOT on /reload -- so focus still
survives /reload (retail behavior) and drops only on logout.

Who: a /who whose SMSG_WHO response never crossed a logout left g_inFlight
stuck (IsWhoQueryPending wrongly true for the next character) and the
VAR_WHO_TO_UI_FLAG override forced on (routing the next character's /who to
the WhoList). Reset in-flight state and restore the flag on every in-game
Lua (re)build via ModuleAutoRegister, which fires per login and per /reload
before any addon runs -- previously only ResetIfStuck's lazy 10s-on-next-
send backstop covered this.
2026-07-24 22:29:05 -05:00
Brues f5ecdc69c5 equipmentset: reload sets per character, not once per process
EnsureLoaded latched on a process-global g_loaded flag that was never
reset. The injected DLL survives a logout -> character-select -> login
(no process restart), so opening the Equipment Manager on character A
cached A's file path and sets permanently; character B then read, and
saved into, A's data. Only a full game restart cleared the globals, and
never opening the manager on A left B correct -- matching the report.

Re-resolve the per-character path on every EnsureLoaded call and reload
when it changes, so the cache self-heals across any character/account
switch. Also clear the per-session ignored-slot state on switch. Every
mutation Persist()s immediately, so dropping the old in-memory sets
never loses unsaved work.

Fixes https://github.com/brues-code/pfUI/issues/29
2026-07-24 22:13:55 -05:00
Brues 7531223b9a nameplate: assign stable slots instead of a compacting list
Switch nameplateN indexing from a compacting vector (append on ADDED,
erase + shift-down on REMOVED) to retail-exact slot assignment: a plate
keeps its slot for its whole lifetime, a removal frees the slot, and the
next new plate reuses the lowest free one. Survivors are never renumbered
when another plate is removed - matching modern WoW, where a middle plate
vanishing leaves the others' tokens unchanged and its slot goes vacant
until reused.

g_orderedGUIDs -> g_slots (sparse; free slots hold GUID 0, trailing
empties trimmed). GetGUIDByIndex returns 0 for a free or out-of-range
slot; new GetSlotCount exposes the high-water mark. The reverse token walk
in Unit::Identity now scans 1..GetSlotCount() and skips gaps instead of
stopping at the first 0. The per-plate observer lifecycle is unchanged
(keyed on GUID: register on ADD, unregister on REMOVE).

Verified in-game: 3 plates in slots 1/2/3, removing the middle one leaves
slots 1 and 3 occupied and 2 vacant - no renumber.
2026-07-24 18:45:42 -05:00
Brues cb4e428592 unit: fire per-token unit events for focustarget
Extend the focus observer to the focus unit's current target, so
UNIT_HEALTH / UNIT_AURA / ... fire with arg1 == "focustarget" like a
native token. Unlike focus (a sticky GUID), the focustarget GUID is
volatile - it changes whenever the focus re-targets, and vanilla fires no
event for a unit changing its target (UNIT_FIELD_TARGET, field 10, has no
event-table slot). So the WorldTick watcher re-reads the focus's target
each tick and re-points a second TokenObserver at it only when that
identity changes; the focustarget events themselves still fire
event-driven through the observer (only the re-pointing is tick-gated,
<=1 tick lag). Set() drops the stale focustarget observer immediately on
a focus change/clear.

Verified in-game: focustarget events fire and re-point when the focus
switches targets.
2026-07-24 18:13:08 -05:00
Brues e7e2fe05dc unit: guard TokenObserver against a null observer-registry root
Register/Unregister funnel through FUN_00464890, which dereferences the
client/player-rooted observer registry at [VAR_LOCAL_PLAYER_PTR] (+0x24).
That root is null out of world (login, loading screens, logout teardown),
where the helper null-derefs -> ERROR #132 (ACCESS_VIOLATION reading 0x24).

The nameplate teardown on world-exit fires NAME_PLATE_UNIT_REMOVED for
every plate, each calling Unregister while the root was already null ->
crash. Player::Equipment never hit this because it only registers from the
enter-world hook; focus/nameplate register from Lua / WorldTick, which can
run mid-transition.

Gate both Register and Unregister on RegistryReady() ([VAR_LOCAL_PLAYER_PTR]
!= null). Out of world there are no live objects or observer nodes to touch
anyway - nodes die with their objects on teardown, and in-world callers
re-register / clean up on the next tick.
2026-07-24 18:10:42 -05:00
Brues d7078827fd unit: fire per-token unit events for focus and nameplateN
Make the synthetic focus / nameplateN unit tokens first-class sources of
the standard per-token unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA,
UNIT_LEVEL, ...): arg1 == "focus" / "nameplateN" now fires just like
"party1", so focus and nameplate frames can update event-driven instead
of polling.

Vanilla fires these events only for units it "watches"
(target/party/raid/pet/mouseover) - FUN_0060c520 recomputes a unit's
token-category mask and, on 0->nonzero, FUN_0051bbb0 registers bank-UNIT
descriptor-field observers whose callback broadcasts the field's event to
the guid's tokens (field index == event id; health +0x40 = event 16 =
UNIT_HEALTH). Focus and nameplates are not watch categories, so they
never fired.

New Unit::TokenObserver replicates that watch registration (FUN_0051bbb0's
field set + per-index sizes) with a caller-supplied callback, via the
engine's own observer registrar - the same API Player::Equipment uses. No
hooks: zero MinHook trampolines, hence no collision risk (the reason this
beats co-hooking the broadcast). The registrar appends a fresh node per
(guid, field), so our observers coexist with the engine's - a unit that is
target AND focus AND a nameplate fires all three tokens. Node lifetime is
engine-managed (nodes die with the object; unregister no-ops post-despawn).

- Focus: register/unregister in Unit::Focus::Set.
- Nameplates: register on NAME_PLATE_UNIT_ADDED, unregister on _REMOVED
  and (all) in PrepareForReload, since objects survive /reload; the
  callback resolves the changed guid to its current plate index at fire
  time.

Offsets cross-confirmed against nampower's offsets.hpp (SendUnitSignal
0x515e50, StringParamFormat "%s" 0x82e280, SignalEventParam 0x703f50,
UNIT_HEALTH 16); the GUID->token reverse map nampower left as a TODO is
FUN_00515c50.
2026-07-24 17:26:23 -05:00
Brues c571c1a852 equipmentset: build the tooltip natively, not via Lua method dispatch
SetEquipmentSet built its lines by calling back through self:SetText /
self:AddLine / self:Show - a PushValue/GetTable/Insert/Call dance per
line. Replace that with the engine's own per-tooltip clear plus raw
add-line (with a small r,g,b to packed-color helper) - the same native
path SetTotem and SetHyperlinkCompareItem use. Localization is unchanged:
PushLocalizedFormatInt for the count lines, Lua string.format for the
ITEM_MISSING %s template.

Shows itself via Script_Show at the end, matching retail (the inner
builder tail-calls the show) and PaperDollFrame.lua, which sets the
anchor then calls SetEquipmentSet with no trailing :Show().
2026-07-23 22:38:12 -05:00
Brues a7b205cd56 totem: add GameTooltip:SetTotem(slot)
Backport of the TBC (2.4.0) tooltip method. Fills the tooltip with the
slot's active totem as two native lines - name (NORMAL_FONT_COLOR) and
time remaining (white) - mirroring retail's Script_GameTooltip_SetTotem
(verified against the 3.3.5 binary; the two line colors are read verbatim
from its color data). Built with the engine's own per-tooltip clear plus
raw add-line - no Lua method dispatch - and shows itself via Script_Show,
matching retail's TotemFrame.xml OnEnter which calls SetTotem with no
trailing :Show().

The time line mirrors SecondsToTimeAbbrev (raw seconds under a minute,
minutes rounded up above) and localizes through the FrameXML GlobalStrings
SPELL_TIME_REMAINING_SEC/_MIN with English fallbacks; slot data is the
existing GetTotemInfo tracker.
2026-07-23 22:38:00 -05:00
Brues a3f224876f event: add a correctly-timed on-demand table-grow valve
Re-introduces the event-table grow valve (removed in 538beda as
mis-timed) at the correct point. Event::Custom::Grow runs from
RetryClaims -- the frame:RegisterEvent hook -- on the first in-game
registration, which is AFTER RebuildEventTable (and SuperWoW/nampower's
clamp-to-700 that co-hooks it) but BEFORE any chain is populated. So
every chain is still an empty self-sentinel and the buffer can be moved
safely. A one-shot latch (reset per reload) runs it at most once per
build, and a hard "abort if any chain is live" scan backs the timing.

Same realloc logic the old EnsureCapacity used -- SMemAlloc a new
buffer, copy name pointers, re-init empty-sentinel anchors, swap the
{cap,count,base} globals, SMemFree the old -- bypassing SetEventCount's
destructive =700 clamp. Consumers read base fresh; the engine's reload
teardown frees our buffer + names as its own.

Dormant in practice (~200 low gaps vs ~46 reservations). Validated via a
new _classicapi_ForceEventGrow() diagnostic that forces a grow on the
next /reload: table grew 700->716, all events (ours, engine,
SuperWoW/nampower, VMT) survived with names intact, dispatch worked on
the grown table, and a following /reload cleanly freed the buffer.
2026-07-23 21:27:29 -05:00
Brues a40236eb84 spell: add GameTooltip:AddSpellByID(spellID)
Append counterpart to SetSpellByID -- adds a spell's tooltip (name, cast
time, cost, range, description) to the current tooltip WITHOUT clearing
existing lines, so callers can compose tooltips like
AddLine(header) + AddSpellByID(id).

BuildSpellTooltip's last arg gates the clear: SetSpellByID passes 0
(clears via FUN_00530050 first); AddSpellByID passes 1 (skips the clear
and appends). That append path is the engine's talent "next rank"
preview, so it emits a "Next rank:" header instead of the spell name and
doesn't stash the displayed spellID -- we overwrite that header line (at
the pre-build numLines index) with the real name from Spell.dbc and let
GetSpell keep reflecting the base tooltip. Verified in-game.
2026-07-23 21:12:06 -05:00
Brues 538beda499 event: remove the mis-timed EnsureCapacity table-grow valve
EnsureCapacity was meant to grow the event table when the engine's ~200
low NULL gaps ran short of our ~46 reservations, by reallocating the
entry buffer via Storm and swapping the base/count globals. But it was
called from LoadScriptFunctions_h, which runs BEFORE RebuildEventTable
inside FUN_0048FBF0 -- so it always operated on the previous cycle's
still-populated table, tripped its own "abort if any chain is live"
guard, and never actually grew anything. Mis-timed dead code doing risky
buffer surgery on the shared event table.

Claim-NULL into the engine's low gaps is the sole mechanism now; it
always suffices, and TryClaim logs once if it somehow doesn't. Drop the
function, its Storm alloc/free helpers, and the now-inaccurate offset
comments.
2026-07-23 21:06:36 -05:00
Brues af01a4b408 event: claim NULL slots low-first + on-demand table expansion
Reworks custom-event slot reservation to be coordination-free and
durable without depending on DLL load/hook order.

- Claim NULL-name slots walking LOW->high instead of from the tail.
  SuperWoW/nampower own the high range (fixed IDs 549..655) and
  destructively clamp the table to 700, and VanillaMinimapTracking also
  walks the tail, so the top is contested; the engine's low gaps (~200,
  incl. a ~64-slot hole at 42..106) are stable and uncontested. Verified
  in-game: our events now land at 1..64, VMT alone at 699 -- clean
  separation, independent of the clamp.

- Add Event::Custom::EnsureCapacity: on-demand grow run once from
  LoadScriptFunctions_h (after module registration so all reservations
  incl. lazy TTS are counted, before any frame registers). If NULL slots
  < reserved, it reallocates the entry buffer via Storm and swaps the
  {cap,count,base} globals directly, bypassing SetEventCount's clamp --
  so it's load/hook-order independent. Defensive: reads fresh, verifies
  all chains empty before moving the buffer (aborts otherwise), frees
  only the old buffer (names are shared), publishes base before count.
  Dormant in the common case (low gaps dwarf our count).

- Drops the SetEventCount co-hook (Capacity.cpp) entirely -- nothing new
  on a SuperWoW/nampower-contended function. Keeps the one-shot
  table-full log as the backstop.

Adds Storm SMemAlloc/SMemFree + VAR_EVENT_TABLE_CAP offsets.
2026-07-23 18:13:41 -05:00
Brues 33c8c3e37c Add C_TaxiMap.GetTaxiRoute(slotIndex)
Exposes the client's computed flight route to a taxi-map slot as an array
of TaxiNodes.dbc IDs in flight order { currentNodeID, ..., destNodeID } --
the exact chain TakeTaxiNode sends the server.

Mirrors the TakeTaxiNode worker (FUN_004dc8d0): a single direct hop when a
TaxiPath edge exists (via the FUN_004dbad0 adjacency matrix), otherwise the
client's precomputed multi-hop route array (slot +0x14, length +0x18). This
lets addons identify every hop of a per-character route without matching
flight-map coordinates back to nodes.

Offsets added: per-slot route array/count/flag, the current-node record
pointer, and the node adjacency matrix. Documented in docs/API.md + README.
2026-07-23 06:00:55 -05:00
Brues c5d5415afe taxi: add C_TaxiMap.GetTaxiPaths + GetTaxiPathWaypoints
Expose the taxi routing graph and per-path geometry that vanilla keeps in
TaxiPath.dbc / TaxiPathNode.dbc but never surfaces to Lua (and addons can't
read DBCs at all).

- GetTaxiPaths() -> array of { pathID, fromNodeID, toNodeID, cost } (every
  directed flight-path edge; cost is the routing weight in copper).
- GetTaxiPathWaypoints(pathID) -> array of { x, y, z, mapID } world
  coordinates ordered by node index; sum the 3D chords for path length
  (/ 32 yd/s ~= single-hop flight seconds).

Lets addons build the flight graph, run travel-time / routing math, or drive
a map overlay without baking the geometry in. Pure DBC readers, no flight
master needed. Offsets for TaxiPathNode.dbc + TaxiPath cost added to
Offsets.h.
2026-07-23 03:59:04 -05:00
Brues 39dcfc73de event: add GetFramesRegisteredForEvent(event)
Returns the frames currently subscribed to an event as multiple return
values (retail's vararg signature). Reads the engine's own subscriber
chain off the event-table entry (entry+0xC head; node next @+0x04,
frame @+0x08, low-bit/null end sentinel) — the exact list
frame:RegisterEvent appends to, so it stays in sync with real
registrations, including AutoReserve custom events. Each frame is
pushed via the shared UI::FrameObject wrapper so frame methods work.
A counting pass drives lua_checkstack up front against heavily
subscribed events.
2026-07-21 14:41:10 -05:00
Brues 6e5f7c3e43 tradeskilllink: gate detail wheel on overflow
The detail ScrollFrame had mouse wheel enabled unconditionally, so even
when the content fit (bar hidden, 0 range) it still consumed wheel events
— clamping to a no-op but swallowing the scroll instead of letting it pass
through. Enable the wheel from SetDetailBarShown alongside the bar, so it's
on only when there's something to scroll.
2026-07-21 14:25:43 -05:00
Brues 2e05e86362 tradeskilllink: fix detail-pane scroll range + auto-hide
The trade-skill-link viewer's reagent pane couldn't scroll to the last
row for some recipes (issue #10). Root cause, found by comparing against
Blizzard's Blizzard_TradeSkillUI: resizing a ScrollFrame's child does NOT
recompute its scroll range in 1.12 — you must call UpdateScrollChildRect()
(TradeSkillFrame_SetSelection does exactly this). We resized the child but
never refreshed the range, so the bar's travel was capped at a stale max.

- Call detailScroll:UpdateScrollChildRect() after (re)sizing the child in
  RenderDetail and ClearDetail.
- Size the child from actual laid-out geometry (bottom of the last reagent
  button relative to the child top + name-plate overhang) instead of a
  hand-tallied constant that undercounted.
- Drive scrollbar visibility from the computed overflow (SetDetailBarShown)
  rather than the engine's range-changed event: scrollBarHideable only
  auto-hides on a range CHANGE, so content that fits from the start (e.g.
  Turtle's roomy pane) never hid, and GetVerticalScrollRange isn't reliably
  readable the same tick as UpdateScrollChildRect.

Net removes the old detailNeedScroll bool-tracking, per-need mouse-wheel
toggling, and the OnShow re-assert workaround — all of which were
compensating for the missing range recompute.

Fixes #10
2026-07-21 14:21:25 -05:00
Brues bcc631c4ed tradeskilllink: fix ColorMixin clobber from Fonts.xml re-includers
Addons that re-include ..\..\FrameXML\Fonts.xml in their TOC (notably
SpecialTalentUI, LoadOnDemand — pulled in when the talent frame is first
opened) re-run its inline script, which resets NORMAL_FONT_COLOR /
HIGHLIGHT_FONT_COLOR / GRAY_FONT_COLOR to plain {r,g,b} tables and strips
the ColorMixin methods our UI relies on. That crashed the trade-skill-link
viewer's NORMAL_FONT_COLOR:GetRGB() (issue #10).

Factor the standard color-global setup into CAPI_ApplyStandardColorGlobals
(idempotent — only (re)creates a tag that's missing :GetRGB) and re-run it
after SpecialTalentUI loads via EventUtil.ContinueOnAddOnLoaded, next to the
existing pfUI compat in AddOnCompat.lua.

Also simplify the viewer frame anchor to SetPoint("CENTER").
2026-07-21 12:54:39 -05:00
Brues f9fb32186d TradeSkillLink: Play Open/Close Sound 2026-07-21 11:11:41 -05:00
Brues 60adbba7c3 pointless fallback 2026-07-21 11:11:09 -05:00
Brues c35d883af0 targeting: backport TargetDirection*/TargetNearest* + GetPlayerFacing
Adds the post-vanilla TargetScript selection functions 1.12.1 lacks:

  TargetDirectionEnemy(facing[,cone]) / TargetDirectionFriend
  TargetNearest([reverse])
  TargetNearestEnemyPlayer([reverse]) / TargetNearestFriendPlayer
  GetPlayerFacing()

All five targeting functions reuse the engine's own tab-target core: the
visible-unit enumeration, the per-mode validity predicate FUN_00493e40
(reaction/attackability/alive, modes 1=enemy/2=friend), and the selection
commit. The only new logic is a player-GUID filter for the *Player variants
and the cone geometry for the direction variants. Cycle functions keep a
frozen per-filter snapshot advanced within a ~1s window (no ping-pong).

Target-setting routes through FUN_TARGET_BY_GUID (0x00489A40), which
rejects a GUID that no longer resolves to a live unit rather than
committing it — a stale snapshot GUID would otherwise leave "target"
pointing at freed unit data and crash the next handler that reads it.

GetPlayerFacing reads the CGUnit movement-block orientation at +0x9C4
(located by probing the player object across a known 180-degree turn); it's
the facing source the direction-target functions need.

Not backported (no vanilla equivalent): TargetPriorityHighlightStart/End,
IsTargetLoose, TargetToggle.

New offsets: FUN_TARGET_CANDIDATE_VALID (0x00493E40),
FUN_SET_SELECTION_BY_GUID (0x00493540), VAR_CURRENT_SELECTION_GUID_LO/HI,
OFF_UNIT_FACING (0x9C4).
2026-07-21 00:49:05 -05:00
Brues c202c1000f chatbubbles: add C_ChatBubbles.GetAllChatBubbles
Returns the active chat-bubble Frame objects, matching modern WoW's
7.2.5 signature. Vanilla's CGChatBubbleFrames are full CSimpleFrames
the engine creates in C++ without CreateFrame (same as default
nameplates), kept on an intrusive Storm TSList at 0x00B6F038 (next
link +0x318, low-bit sentinel). Mirror the engine's own walk
(FUN_004b1060) and push each as its canonical engine wrapper, so the
returned frames are real and method-capable and their text FontString
(+0x31c) surfaces via :GetRegions().

includeForbidden is accepted and ignored (vanilla has no forbidden
frames).

Factor the ScriptRegister->rawgeti wrapper push out of nameplate/Info
into a shared UI::FrameObject::Push; PushNamePlateFrame now forwards to
it (no behavior change).
2026-07-20 23:00:03 -05:00
33 changed files with 2603 additions and 292 deletions
+5 -1
View File
@@ -38,4 +38,8 @@ if C_AddOns.DoesAddOnExist('pfUI') then
end)
end
end)
end
end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
+7 -4
View File
@@ -108,11 +108,14 @@ for _, v in pairs(ITEM_QUALITY_COLORS) do
v.color = CreateColor(v.r, v.g, v.b, 1)
end
do
function CAPI_ApplyStandardColorGlobals()
local envTbl = _G
for _, dbColor in ipairs(C_UIColor.GetColors()) do
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
end
end
end
CAPI_ApplyStandardColorGlobals()
+54 -60
View File
@@ -248,7 +248,7 @@ end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetPoint("CENTER");
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
@@ -259,6 +259,9 @@ local function CreateFrame_TradeSkillLink()
f:Hide();
tinsert(UISpecialFrames, f:GetName())
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
@@ -296,7 +299,7 @@ local function CreateFrame_TradeSkillLink()
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT or "Exit");
exit:SetText(EXIT);
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
@@ -393,29 +396,13 @@ local function CreateFrame_TradeSkillLink()
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
f.detailScroll = detailScroll;
-- Off until a recipe overflows the pane (toggled in RenderDetail).
-- The template shows its slider and enables the wheel by default; on
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
-- wheel events and over-scrolls a hair. Start both disabled.
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
-- the computed overflow). `scrollBarHideable` still lets the template's
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
detailScroll.scrollBarHideable = 1;
detailScroll:EnableMouseWheel(false);
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if detailSB then
detailSB:Hide();
end
-- The template re-shows its slider when the frame transitions to shown
-- (on first open, before the auto-selected recipe's RenderDetail runs),
-- leaving a dead scrollbar until the first click. Re-assert the hidden
-- state on show unless the current recipe genuinely overflows.
detailScroll:SetScript("OnShow", function()
f:UpdateDetailScrollbar();
RunNextFrame(function()
if f:IsShown() then
f:UpdateDetailScrollbar();
end
end);
end);
f.detailScroll = detailScroll;
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
@@ -514,18 +501,20 @@ local function CreateFrame_TradeSkillLink()
return btn;
end
-- Show the detail slider + enable the wheel only when the current
-- recipe overflows the pane (tracked by `detailNeedScroll`).
function f:UpdateDetailScrollbar()
local need = self.detailNeedScroll and true or false;
self.detailScroll:EnableMouseWheel(need);
-- Show the detail bar/borders only when the content overflows the pane.
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
-- auto-hide only fires on a range CHANGE (so content that fits from the
-- start never hides), and GetVerticalScrollRange isn't reliably readable
-- the same tick as UpdateScrollChildRect.
function f:SetDetailBarShown(show)
self.detailScroll:EnableMouseWheel(show);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(need) end
if sb then sb:SetShown(show) end
local top = getglobal(base .. "Top");
if top then top:SetShown(need) end
if top then top:SetShown(show) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(need) end
if bottom then bottom:SetShown(show) end
end
function f:RenderDetail(entry)
@@ -571,19 +560,21 @@ local function CreateFrame_TradeSkillLink()
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local r = reagents[i];
local name, tex, link = itemInfo(r.itemID);
local reagent = reagents[i];
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. r.itemID));
local need = r.count or 1;
local have = C_Item.GetItemCount(r.itemID) or 0;
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
local r, g, b = GRAY_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
@@ -594,29 +585,32 @@ local function CreateFrame_TradeSkillLink()
self.reagents[i]:Hide();
end
-- Grow the scroll child to the content height so the pane scrolls
-- once the header + reagent rows exceed the visible area. Reagent
-- row 0 sits ~67px below the child top (icon + label); add the
-- wrapped description height for spell recipes.
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
local rows = math.ceil(total / 2);
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Reset to the top and manage the slider explicitly: a range that
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
-- range-changed event that `scrollBarHideable` relies on, so the
-- slider would otherwise sit visible-but-useless. Show it only when
-- the content actually overflows.
self.detailNeedScroll = contentH > LO.detail[4];
local function applyContentHeight()
local contentH = LO.detail[4];
if total > 0 then
local childTop = self.detailChild:GetTop();
local lastBtn = self.reagents[total];
local lastBottom = lastBtn and lastBtn:GetBottom();
if childTop and lastBottom then
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
contentH = (childTop - lastBottom) + 22;
end
end
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Recompute the scroll range from the child's new height, then
-- show the bar only if the content actually overflows the pane.
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
end
applyContentHeight();
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
self:UpdateDetailScrollbar();
RunNextFrame(function()
if self:IsShown() then
self:UpdateDetailScrollbar();
if self:IsShown() and self.selectedSpellID == entry.spellID then
applyContentHeight();
end
end)
end
@@ -639,8 +633,8 @@ local function CreateFrame_TradeSkillLink()
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailNeedScroll = false;
self:UpdateDetailScrollbar();
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(false);
self.detailScroll:SetVerticalScroll(0);
end
+6 -4
View File
@@ -21,6 +21,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
@@ -29,7 +30,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
@@ -37,7 +38,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
@@ -61,12 +62,13 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
+371 -7
View File
@@ -95,6 +95,7 @@ build instructions.
- [Events](#events)
- [`C_EventUtils.IsEventValid(eventName)`](#c_eventutilsiseventvalideventname)
- [`GetFramesRegisteredForEvent(event)`](#getframesregisteredforeventevent)
- [`PLAYER_ENTERING_WORLD` payload (`isInitialLogin`, `isReloadingUi`)](#player_entering_world-payload-isinitiallogin-isreloadingui)
- [`PLAYER_TOTEM_UPDATE` event](#player_totem_update-event)
- [`BAG_UPDATE_DELAYED` event](#bag_update_delayed-event)
@@ -160,6 +161,7 @@ build instructions.
- [GameTooltip](#gametooltip)
- [`GameTooltip:SetSpellByID(spellID)`](#gametooltipsetspellbyidspellid)
- [`GameTooltip:AddSpellByID(spellID)`](#gametooltipaddspellbyidspellid)
- [`GameTooltip:SetTalentByID(talentID)`](#gametooltipsettalentbyidtalentid)
- [`GameTooltip:SetInventoryItemByID(itemID)`](#gametooltipsetinventoryitembyiditemid)
- [`GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`](#gametooltipsethyperlinkcompareitemitemlink--offset-shiftbutton-comparisontooltip)
@@ -167,6 +169,7 @@ build instructions.
- [`OnTooltipSetItem` script](#ontooltipsetitem-script)
- [`GameTooltip:SetItemByGUID(itemGUID)`](#gametooltipsetitembyguiditemguid)
- [`GameTooltip:SetEquipmentSet(name)`](#gametooltipsetequipmentsetname)
- [`GameTooltip:SetTotem(slot)`](#gametooltipsettotemslot)
- [`GameTooltip:GetItem()`](#gametooltipgetitem)
- [`GameTooltip:GetSpell()`](#gametooltipgetspell)
- [`GameTooltip:HasItem()` / `GameTooltip:HasSpell()`](#gametooltiphasitem--gametooltiphasspell)
@@ -334,6 +337,9 @@ build instructions.
- [`C_MerchantFrame.IsMerchantItemRefundable(slot)`](#c_merchantframeismerchantitemrefundableslot)
- [`C_MerchantFrame.IsSellAllJunkEnabled()`](#c_merchantframeissellalljunkenabled)
- [ChatBubbles](#chatbubbles)
- [`C_ChatBubbles.GetAllChatBubbles([includeForbidden])`](#c_chatbubblesgetallchatbubblesincludeforbidden)
- [NamePlate](#nameplate)
- [`C_NamePlate.GetNamePlates()`](#c_nameplategetnameplates)
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
@@ -445,9 +451,20 @@ build instructions.
- [`GetTalentSpellID(tabIndex, talentIndex, [rank])`](#gettalentspellidtabindex-talentindex-rank)
- [`GetTalentIDByIndex(tabIndex, talentIndex)`](#gettalentidbyindextabindex-talentindex)
- [Targeting](#targeting)
- [`GetPlayerFacing()`](#getplayerfacing)
- [`TargetDirectionEnemy(facing [, coneAngle])`](#targetdirectionenemyfacing--coneangle)
- [`TargetDirectionFriend(facing [, coneAngle])`](#targetdirectionfriendfacing--coneangle)
- [`TargetNearest([reverse])`](#targetnearestreverse)
- [`TargetNearestEnemyPlayer([reverse])`](#targetnearestenemyplayerreverse)
- [`TargetNearestFriendPlayer([reverse])`](#targetnearestfriendplayerreverse)
- [TaxiMap](#taximap)
- [`C_TaxiMap.GetTaxiNodesForMap([mapID])`](#c_taximapgettaxinodesformapmapid)
- [`C_TaxiMap.GetAllTaxiNodes([uiMapID])`](#c_taximapgetalltaxinodesuimapid)
- [`C_TaxiMap.GetTaxiPaths()`](#c_taximapgettaxipaths)
- [`C_TaxiMap.GetTaxiPathWaypoints(pathID)`](#c_taximapgettaxipathwaypointspathid)
- [`C_TaxiMap.GetTaxiRoute(slotIndex)`](#c_taximapgettaxiroutepslotindex)
- [Time](#time)
- [`GetServerTime()`](#getservertime)
@@ -499,8 +516,11 @@ build instructions.
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitInLineOfSight(unit)`](#unitinlineofsightunit)
- [`ClosestUnitPosition(creatureID)`](#closestunitpositioncreatureid)
- [`UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`](#unitpowerunit--powertype--unitpowermaxunit--powertype)
- [`UnitHealthMissing(unit)`](#unithealthmissingunit)
- [`UnitPower(unit [, powerType [, unmodified]])` / `UnitPowerMax(unit [, powerType [, unmodified]])`](#unitpowerunit--powertype--unmodified--unitpowermaxunit--powertype--unmodified)
- [`UnitPowerMissing(unit [, powerType [, unmodified]])`](#unitpowermissingunit--powertype--unmodified)
- [`UnitPowerType(unit)`](#unitpowertypeunit)
- [`UnitSpellHaste(unit)`](#unitspellhasteunit)
- [UnitAuras](#unitauras)
- [`C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`](#c_unitaurasgetauradatabyindexunit-index--filter)
@@ -2235,6 +2255,27 @@ SuperWoWhook patches the binary's event names with its own. This matches
what `frame:RegisterEvent` will actually accept, which is what addon code
needs to know.
### `GetFramesRegisteredForEvent(event)`
Returns the frames currently registered for `event`, as multiple return
values (matching the modern signature — not a table). Returns nothing when
no frame is registered or the event name is unknown.
```lua
local f1, f2 = GetFramesRegisteredForEvent("PLAYER_LOGIN")
for i = 1, select("#", GetFramesRegisteredForEvent("BAG_UPDATE")) do
local frame = select(i, GetFramesRegisteredForEvent("BAG_UPDATE"))
print(frame:GetName())
end
```
Reads the engine's own subscriber chain — the exact list
`frame:RegisterEvent` appends to — so it stays in sync with real
registrations, including our `AutoReserve`-backed custom events. Frames
created purely in C++ (nameplates, etc.) come back as their canonical
wrapper, so `:GetName()` and other methods work; an anonymous frame's
`:GetName()` is `nil`, same as on retail.
### `PLAYER_ENTERING_WORLD` payload (`isInitialLogin`, `isReloadingUi`)
Backports the 8.0.1 payload onto vanilla's (natively arg-less)
@@ -3394,6 +3435,17 @@ so addons get the engine semantics they expect.
Returns `nil` cleanly when no focus is set; doesn't raise the
"Unknown unit name" error.
**Unit events fire for `"focus"`.** `UNIT_HEALTH`, `UNIT_MANA`,
`UNIT_AURA`, `UNIT_LEVEL`, `UNIT_MODEL_CHANGED`, … fire with
`arg1 == "focus"` whenever the focused unit's corresponding descriptor
field changes — even when it isn't your target, so a focus frame keeps
updating while you fight something else. Backed by `Unit::TokenObserver`,
which registers the engine's own unit-event descriptor-field observers
for the focus unit (the exact watch the engine gives target/party/raid).
A unit that is simultaneously your target and focus fires both
`"target"` and `"focus"`. (Vanilla `OnEvent` handlers read the `arg1`
global, not a function parameter.)
[`UnitTokenFromGUID`](#unittokenfromguidguid) scans `"focus"` right
after `"target"` (matching retail order), so a focused unit's GUID
reverse-resolves to `"focus"` only if it isn't already addressable
@@ -3751,6 +3803,32 @@ GameTooltip:SetSpellByID(133) -- Fireball
GameTooltip:Show()
```
### `GameTooltip:AddSpellByID(spellID)`
The append counterpart to [`SetSpellByID`](#gametooltipsetspellbyidspellid):
adds a spell's full tooltip (name, cast time, cost, range, description) to
the *current* tooltip **without clearing** the existing lines. Works for any
`spellID`, learned or not. Lets you compose tooltips — e.g. a header line
plus a spell block.
```lua
GameTooltip:SetOwner(UIParent, "ANCHOR_CURSOR")
GameTooltip:AddLine("Rank 3 grants:")
GameTooltip:AddSpellByID(133) -- Fireball, appended below the header
GameTooltip:Show()
```
Unlike `SetSpellByID`, this does **not** update what
[`GetSpell`](#gametooltipgetspell) reports — the appended spell isn't the
tooltip's "primary" spell, so `GetSpell` keeps reflecting whatever `SetX`
call (if any) built the base tooltip. Silent no-op for `spellID <= 0` or an
unknown spell.
> Vanilla's tooltip builder only exposes "append" via its internal talent
> "next rank" preview, which emits a `Next rank:` header instead of the
> spell name; we overwrite that header line with the real name so the
> appended block reads normally.
### `GameTooltip:GetItem()`
Returns `(name, link, itemID)` for whichever item the tooltip is
@@ -4187,6 +4265,41 @@ before the itemID field was added load fine but render their
missing slots with the count summary `%d missing` instead of named
lines; re-saving a set repopulates itemIDs.
### `GameTooltip:SetTotem(slot)`
Fills the tooltip with the shaman totem currently active in `slot`
(`1` Fire, `2` Earth, `3` Water, `4` Air). A TBC (2.4.0) tooltip method
backported to 1.12; mirrors retail's `Script_GameTooltip_SetTotem`.
```lua
GameTooltip:SetOwner(TotemButton, "ANCHOR_BOTTOMRIGHT")
GameTooltip:SetTotem(1) -- Fire slot; shows itself, no :Show() needed
```
```
Searing Totem (name, yellow)
45 Sec (time remaining, white)
```
Two lines, matching retail exactly: the totem name (`NORMAL_FONT_COLOR`)
and the time remaining (white). Built natively with the engine's own
per-tooltip clear and raw add-line — the same path `SetSpellByID` and
`SetHyperlinkCompareItem` use — and **shows itself** at the end, so no
trailing `:Show()` is required (retail's `TotemFrame.xml` OnEnter calls
`SetOwner` then `SetTotem` with no show, and the C method shows). Set the
owner/anchor before the call.
Silent no-op when the slot has no active totem, so a totem-button
`OnEnter` can call it unconditionally.
**Localization.** The time line mirrors retail's `SecondsToTimeAbbrev`
(raw seconds under a minute, minutes rounded up above) and formats
through the FrameXML GlobalStrings `SPELL_TIME_REMAINING_SEC` /
`SPELL_TIME_REMAINING_MIN` (English `%d Sec` / `%d Min` fallbacks for
servers stripped of those keys). The totem name comes from the summon
spell's localized `Spell.dbc` name. Slot data is the same tracker behind
[`GetTotemInfo`](#gettoteminfoslot).
## Globals
### `CLASSIC_API_VERSION`
@@ -8012,6 +8125,42 @@ No retail equivalent (retail ships only the explored getter; its inverse,
`C_Map.GetMapOverlays`, returns *everything*). Same table shape and
`areaID` semantics as the explored getter.
## ChatBubbles
### `C_ChatBubbles.GetAllChatBubbles([includeForbidden])`
Returns a 1-based table of the currently-active chat-bubble `Frame`
objects (the speech bubbles above characters who `/say` or `/yell`).
Modern WoW added this in 7.2.5 to replace the old "iterate over
`WorldFrame` children and guess which are bubbles" idiom — we hand you
the exact set instead.
```lua
for _, bubble in ipairs(C_ChatBubbles.GetAllChatBubbles()) do
-- The spoken text lives in the bubble's FontString region.
for _, region in ipairs({bubble:GetRegions()}) do
if region.GetText and region:GetText() then
print(region:GetText())
end
end
end
```
Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full frames the
engine creates in C++ without ever calling `CreateFrame` (same as
default nameplates). The returned frames are the engine's **canonical
wrappers**: real, method-capable (`:GetRegions()`, `:IsShown()`,
`:GetPoint()`, …), and identical to the object any other addon that
touches the bubble sees, so decorations survive. The spoken-text
FontString is parented to the bubble, so it shows up in
`:GetRegions()`.
`includeForbidden` is accepted for signature parity and ignored —
vanilla has no forbidden frames, so every active bubble is returned
regardless. Returns an empty table when no bubbles are showing. A
bubble whose owner just despawned can linger for one frame before the
engine prunes it; filter on `bubble:IsShown()` if that matters.
## NamePlate
Modern `C_NamePlate.*` returns nameplate `Frame` objects keyed off
@@ -8156,11 +8305,14 @@ for i = 1, 40 do
end
```
Ordering is **creation-order** — each new plate appends to the end of
the list and stays at its index until the unit goes out of range or
the plate is removed, at which point later indices shift down.
Stable for the lifetime of a single plate; no mid-frame reordering.
Same semantics as modern WoW.
Indices are **assigned slots**, matching modern WoW exactly: a plate keeps
its slot for its entire lifetime, so surviving plates are **never
renumbered** when another plate is removed. A removed plate frees its slot,
and the next new plate reuses the lowest free slot. So a middle plate
vanishing leaves the others' `nameplateN` tokens unchanged (that slot simply
becomes vacant until a new plate reuses it) — `UnitExists("nameplateN")`
returns `false` for a currently-free slot. Stable for the lifetime of a
single plate; no reordering, ever.
Token chains work too — `"nameplate1target"`, `"nameplate1targettarget"`,
etc. — by mirroring the engine's own `targettarget`-style suffix
@@ -8171,6 +8323,15 @@ own walker either, so they don't compose.
Out-of-range indices return `nil` cleanly without raising "Unknown
unit name" — `UnitExists("nameplate99")` just returns `false`.
**Unit events fire for `"nameplateN"`.** Like party/raid tokens,
`UNIT_HEALTH` / `UNIT_AURA` / `UNIT_LEVEL` / … fire with
`arg1 == "nameplateN"` when a nameplated unit's descriptor field changes
(vanilla only watched its own target/party/raid units). Backed by
`Unit::TokenObserver` — observers are registered per plate on
`NAME_PLATE_UNIT_ADDED` and torn down on `_REMOVED`; the changed GUID is
resolved to its *current* index at fire time, since indices shift as
plates vanish. Note the per-plate observer cost in very large scenes.
**Implementation note.** We hook `FUN_TOKEN_TO_GUID` (the central
token→GUID resolver) so the entire `Script_Unit*` surface gains the
new token form transparently. The hook is gated by an `SStrCmpI`
@@ -10935,6 +11096,74 @@ a different field.
Equivalent to the talentID return slot of `GetTalentInfo` in modern
WoW (5.0+; not exposed at all in 1.12).
## Targeting
Backports of the post-vanilla `TargetScript` selection functions. Vanilla
already ships `TargetNearestEnemy/Friend/PartyMember/RaidMember`,
`TargetLastEnemy/Target`, `AssistUnit`, `TargetUnit`, `ClearTarget`; these
fill in the ones added in later patches. They're built on the engine's own
tab-targeting core — the same visible-unit enumeration, per-mode validity
predicate (reaction/attackability/alive), and selection commit the native
`TargetNearestEnemy` uses — so hostility semantics match the client exactly.
Not backported (no vanilla equivalent — soft-target / action-camera era):
`TargetPriorityHighlightStart/End`, `IsTargetLoose`, `TargetToggle`.
### `GetPlayerFacing()`
Returns the player's facing as an angle in radians (`0` … `2*pi`,
increasing counter-clockwise), or nothing off-world (glue / loading).
Vanilla never shipped this (only character-create/select facing exist), but
it's the companion the direction-target functions below need — pass it to
aim "in front of me."
```lua
local facing = GetPlayerFacing() -- e.g. 1.65
```
Read from the player unit's movement-block orientation field
(`player + 0x9C4`).
### `TargetDirectionEnemy(facing [, coneAngle])`
Targets the nearest **attackable** unit within a cone centered on `facing`
(an absolute world angle in radians, as returned by `GetPlayerFacing()`).
`coneAngle` is the full cone width in radians; omitted, it defaults to
`pi/2` (90° — 45° to either side).
```lua
-- Target whatever enemy you're looking at:
TargetDirectionEnemy(GetPlayerFacing())
-- Wider 180° sweep in front:
TargetDirectionEnemy(GetPlayerFacing(), math.pi)
```
Does nothing if no enemy falls inside the cone. "Attackable" uses the
engine's own reaction check, so it matches what `TargetNearestEnemy` would
consider a valid enemy (hostile, alive, not a critter).
### `TargetDirectionFriend(facing [, coneAngle])`
Same as `TargetDirectionEnemy`, but selects the nearest **friendly**
(assistable) unit in the cone.
### `TargetNearest([reverse])`
Cycles the nearest unit you can attack or assist. Repeated calls within ~1s
step through candidates nearest→farthest (a frozen snapshot, so the order
is stable mid-cycle); a longer gap or an externally-changed target rebuilds
and re-targets the nearest. `reverse` (truthy) steps the other way.
### `TargetNearestEnemyPlayer([reverse])`
Like `TargetNearest`, but restricted to hostile **players** (PvP targeting).
Same cycle behavior and `reverse` flag.
### `TargetNearestFriendPlayer([reverse])`
Like `TargetNearestEnemyPlayer`, but restricted to friendly **players**.
## TaxiMap
Backports of the retail taxi-map node enumerators, reading
@@ -11049,6 +11278,74 @@ and player-relative (its whole point is the open master's reachable set),
which is why a static database like pfQuest uses `GetTaxiNodesForMap`
instead.
### `C_TaxiMap.GetTaxiPaths()`
Returns every flight path (directed edge) in `TaxiPath.dbc` as an array of
tables — the taxi routing graph. No flight master needed; reads the DBC
directly.
```lua
local paths = C_TaxiMap.GetTaxiPaths()
-- paths[i] = { pathID = 94, fromNodeID = 8, toNodeID = 16, cost = 110 }
```
| Field | Type | Meaning |
|-------|------|---------|
| `pathID` | number | `TaxiPath.dbc` id — pass to `GetTaxiPathWaypoints` |
| `fromNodeID` | number | source `TaxiNodes.dbc` id |
| `toNodeID` | number | destination `TaxiNodes.dbc` id |
| `cost` | number | flight cost in copper (the routing weight) |
Directed — `A→B` and `B→A` are two separate entries with their own path IDs
and geometry. Combine with `GetTaxiNodesForMap` (the node table) to build a
weighted graph, and with `GetTaxiPathWaypoints` to measure real distances.
### `C_TaxiMap.GetTaxiPathWaypoints(pathID)`
Returns the waypoint polyline of one flight path as an array of world
coordinates, ordered by node index (flight order).
```lua
local wp = C_TaxiMap.GetTaxiPathWaypoints(94)
-- wp[i] = { x = -8851.66, y = 498.55, z = 111.26, mapID = 0 }
```
| Field | Type | Meaning |
|-------|------|---------|
| `x`, `y`, `z` | number | world position (yards) |
| `mapID` | number | continent (`Map.dbc` id) |
Returns nothing for an unknown `pathID` or an empty path. Sum the 3D distances
between consecutive waypoints for the path's length; vanilla taxi flight
speed is 32 yd/s, so `length / 32 ≈ flight seconds` for a single hop.
(Multi-hop trips are shorter than the sum of their segments — the server cuts
the corner at each intermediate flight master via data not present in any
client file, so a client-side estimate of a chained route is an upper bound.)
### `C_TaxiMap.GetTaxiRoute(slotIndex)`
Returns the node chain the client would actually fly to a taxi-map slot, as an
array of `TaxiNodes.dbc` IDs in flight order (`{ currentNodeID, …, destNodeID }`).
`slotIndex` is the legacy `1..NumTaxiNodes()` index (the same one
`TaxiNodeName`/`TakeTaxiNode` take). **Only meaningful while the taxi map is
open.**
```lua
local route = C_TaxiMap.GetTaxiRoute(slotIndex)
-- route = { 8, 16, 43, 66 } (Thelsamar -> Refuge Pointe -> Aerie Peak -> Chillwind)
```
This is the exact chain `TakeTaxiNode(slotIndex)` would send to the server —
mirrors the client's own routing decision: a single direct hop when a `TaxiPath`
edge exists (`{ current, dest }`), otherwise the client's precomputed multi-hop
route (which, per character, may detour around flight points you haven't
discovered). Returns nothing when the map is closed, the slot is out of range,
it's the current node, or no route exists.
Pairs with `GetTaxiPaths` (each consecutive `(from, to)` names a `pathID`) and
`GetTaxiPathWaypoints` (that path's geometry) to measure a chained trip without
matching flight-map pixel coordinates back to nodes.
## Time
### `GetServerTime()`
@@ -12055,7 +12352,28 @@ encodes `creatureID` (bits 24–47), and returns the closest one's position
> zone the way retail's database can — but for "where's the nearest `<mob>`
> I can see" it's more general than retail.
### `UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`
### `UnitHealthMissing(unit)`
The health deficit — `UnitHealthMax(unit) - UnitHealth(unit)` — in one call.
A convenience for healing addons (overheal math, "missing health" bars) that
would otherwise call both engine functions and subtract in Lua every frame.
```lua
local missing = UnitHealthMissing("player") -- 0 at full health
```
Returns `0` at full health and for a valid-but-absent unit (e.g. `"target"`
with nothing targeted), matching `UnitHealth`'s 0-for-missing convention;
clamped so a transient `current > max` never returns negative.
Tracks the engine's own `UnitHealth` / `UnitHealthMax` exactly, including
vanilla's percentage form for non-grouped units (where `UnitHealthMax` is
`100`): a target at `85/100` reports `15`.
Direct descriptor reads — `desc[+0x40]` (HEALTH), `desc[+0x58]` (MAXHEALTH).
No engine call, no Lua-stack roundtrip.
### `UnitPower(unit [, powerType [, unmodified]])` / `UnitPowerMax(unit [, powerType [, unmodified]])`
Modern multi-power-type getters. Vanilla 1.12 only ships
`UnitMana(unit)` / `UnitManaMax(unit)` which return whichever
@@ -12112,10 +12430,35 @@ the divisor table at `0x0086F978`:
So a fresh warrior reads `UnitPower("player", 1)` = `0..100`, not
`0..1000`. Matches retail Classic.
Pass a truthy `unmodified` (third arg) to bypass the divisor and get the raw
internal value — `UnitPower("player", 1, true)` returns rage as `0..1000`.
Same third argument retail's `UnitPower` / `UnitPowerMax` take.
Direct descriptor reads — `desc[+0x44 + type*4]` for current power,
`desc[+0x5C + type*4]` for max, divided by the table entry for the
type. No engine call, no Lua-stack roundtrip.
### `UnitPowerMissing(unit [, powerType [, unmodified]])`
The power deficit — `UnitPowerMax(...) - UnitPower(...)` for the given type —
in one call. The power analogue of [`UnitHealthMissing`](#unithealthmissingunit),
for energy/mana/rage "resource needed" checks. Same `unit` / `powerType`
handling and invalid-unit-returns-`0` semantics as `UnitPower`.
```lua
local energyNeeded = UnitPowerMissing("player", Enum.PowerType.Energy)
local rageMissing = UnitPowerMissing("player") -- primary power
```
`unmodified` (arg 3, any truthy value) skips the display divisor and returns
the raw internal deficit — the same third argument retail's `UnitPower` takes.
Computed as the difference of the two **display** values (each integer-divided
by the per-type divisor), not by dividing the raw difference, so it equals
`UnitPowerMax(...) - UnitPower(...)` exactly. Those diverge for rage: raw
`1000/55` displays as `100/5`, a deficit of `95`, whereas `(1000-55)/10`
truncates to `94`. Clamped at 0.
### `UnitPowerType(unit)`
Vanilla 1.12 ships this returning just the integer power type;
@@ -12149,6 +12492,27 @@ Chains to the engine's original `Script_UnitPowerType` at
manager lookup with pet / totem / vehicle fallbacks), then reads
the just-pushed integer and appends the token string.
### `UnitSpellHaste(unit)`
Backport of the TBC+ spell-haste getter — vanilla 1.12 has no haste API.
Returns the spell haste **percentage**: `0` for an unhasted unit, positive
when casting is sped up, negative when slowed (Curse of Tongues).
```lua
local haste = UnitSpellHaste("player") -- 0 for most vanilla casters
```
Reads `UNIT_MOD_CAST_SPEED` — the cast-time multiplier at descriptor `+0x22c`
that the server folds into `SpellEntry::GetCastTime`
(`castTime *= modCastSpeed`) — and converts it to a percentage:
`haste% = (1 / modCastSpeed - 1) * 100`. So `modCastSpeed` `1.0` → `0`,
`0.5` (half cast time) → `100`, `>1.0` → negative.
This is the Blizzard-shaped surface over the field nampower exposes raw as
`GetUnitField(unit, "modCastSpeed")`, so addons can drop that dependency.
Consumers that need the raw multiplier back can recover it as
`1 / (1 + UnitSpellHaste(unit) / 100)`. Returns `0` for invalid units.
## UnitAuras
Backport of the modern `C_UnitAuras` namespace. Returns
+127
View File
@@ -463,6 +463,30 @@ enum Offsets {
// (lo) and `guid[1]` (hi) through the pointer.
FUN_TARGET_BY_GUID = 0x00489A40,
// Tab-targeting internals, shared with our backported TargetNearest* /
// TargetDirection* family (`target/Nearest.cpp`).
//
// `FUN_TARGET_CANDIDATE_VALID` (0x00493E40) — the engine's per-mode
// candidate validity predicate used by the native `TargetNearestEnemy`
// / `TargetNearestFriend` cycle. `__fastcall(CGUnit *player /*ecx*/,
// CGUnit *candidate /*edx*/, int mode) → int` (nonzero = valid). Modes:
// 1 = enemy (attackable + alive + not-critter), 2 = friend (assistable +
// alive), 3 = party, 4 = raid. Wraps the reaction cores
// `FUN_00606980` (can-attack) / `FUN_006066f0` (can-assist), so calling
// it gives us the engine's exact hostility semantics for free.
FUN_TARGET_CANDIDATE_VALID = 0x00493E40,
// `FUN_SET_SELECTION_BY_GUID` (0x00493540) — canonical target setter the
// native tab-cycle commits through. `__cdecl(uint32_t guidLo, uint32_t
// guidHi)`. Validates + fires CMSG_SET_SELECTION + client target +
// event 0xC3, and maintains the current-selection globals below.
// Passing {0,0} clears the target.
FUN_SET_SELECTION_BY_GUID = 0x00493540,
// Current target-selection GUID (lo/hi), written by
// `FUN_SET_SELECTION_BY_GUID`. We read it to detect whether the player's
// target changed out from under an in-progress tab-cycle.
VAR_CURRENT_SELECTION_GUID_LO = 0x00B4E2D8,
VAR_CURRENT_SELECTION_GUID_HI = 0x00B4E2DC,
// Local-player CGObject-like global. Not the same pointer as
// ResolveUnitToken("player") returns — that one's the canonical
// CGPlayer_C used by inventory etc. This pointer's +0xC0 field
@@ -640,6 +664,25 @@ enum Offsets {
// The live (new) value is NOT passed — read it from the object (the
// engine's bag callback reads the invMgr GUID array).
// ---- Unit-event token observers (Unit::TokenObserver) ---------------
// The engine makes target/party/raid/pet/mouseover units fire per-token
// unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA, …) by watching bank-UNIT
// descriptor fields: FUN_0051bbb0 loops event index i in
// [0, EVENT_NAME_UNIT_MAX); for each whose static name slot is non-null it
// registers a bank-3 observer on field i*4 (per-index size below) with
// callback FUN_0051bd50 → the broadcast FUN_00515e50(guid, i), which fires
// event i once per token referencing the guid. The FIELD INDEX IS THE EVENT
// ID (health field +0x40 → event 16 = UNIT_HEALTH). Unit::TokenObserver
// replicates that exact loop with a caller's own callback to make synthetic
// tokens (focus, nameplateN) first-class; the registrar always appends a
// fresh node (FUN_00467f00, no dedup), so ours coexists with the engine's —
// a unit that is also the target fires both "target" and our token. The
// broadcast always fires with format "%s" + the token (DAT_0082e280).
// Per-index watched size (FUN_0051bbb0's switch): i∈{0,2,4,10}=8,
// 0x29=0xD8 (UNIT_FIELD_AURA), 0x6B=0x30, 0xA7/0xAE=0x1C, else 4.
VAR_EVENT_NAME_TABLE_STATIC = 0x00BE1198, // char*[] boot name array; slot i (base+i*4) non-null ⇒ real event i
EVENT_NAME_UNIT_MAX = 0xB6, // FUN_0051bbb0's loop bound
// The engine's inventory observer setup — registers the bag-slot
// GUID-field observers above, once per enter-world (sole caller is the
// enter-world initializer FUN_004908C0, latched by DAT_00B4B424).
@@ -655,6 +698,7 @@ enum Offsets {
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
DESC_PLAYER_EQUIP_SLOTS = 19,
DESC_OBSERVER_BANK_PLAYER = 4,
DESC_OBSERVER_BANK_UNIT = 3, // CGUnit descriptor bank (FUN_0051bbb0's watch loop)
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
@@ -782,6 +826,15 @@ enum Offsets {
// mana/rage/focus/energy/happiness order as POWER1..5). Vanilla
// 1.12 has 5 power types; the WotLK additions (Runes / Runic Power)
// don't exist in this descriptor layout.
// UNIT_MOD_CAST_SPEED — the cast-time multiplier float (1.0 = normal,
// <1.0 = faster). The server folds it into `SpellEntry::GetCastTime`
// (`castTime *= GetFloatValue(UNIT_MOD_CAST_SPEED)`); the client reads it
// in `FUN_006e3340` at descriptor `+0x22c`. Backs `UnitSpellHaste`
// (`haste% = (1/mult - 1) * 100`). nampower surfaces the raw float as its
// `modCastSpeed` unit field.
OFF_UNIT_MOD_CAST_SPEED = 0x22C,
UNIT_POWER_MIN_TYPE = 0,
UNIT_POWER_MAX_TYPE = 4, // happiness; types 5/6 only valid post-WotLK
@@ -1476,6 +1529,15 @@ enum Offsets {
// `0x0048BACF` / `0x0048BADC`.
OFF_CGOBJECT_VTBL_GET_POSITION = 0x14,
// CGUnit movement-block orientation (facing) in radians, 0..2*pi. Lives
// right after the {x,y,z} position block (at +0x9B8), i.e. pos+0xC. The
// `GetPosition` virtual copies only x/y/z into the caller's buffer, so
// read facing off the object directly. Backs `GetPlayerFacing`. Empirically
// located by probing the player object across a known 180-degree turn
// (value flipped by ~pi, stayed in range); mirrored at +0x9F8 / +0xC98
// (last-sent / render copies) — +0x9C4 is the authoritative one.
OFF_UNIT_FACING = 0x9C4,
// CGObject `m_objectType` field — 0 = (base) object, 1 = item,
// 2 = container, 3 = unit, 4 = player, 5 = gameobject, … Read as
// `*(int *)(obj + 0x14)`. (Distinct from GET_POSITION above, which
@@ -2405,6 +2467,29 @@ enum Offsets {
// "DISTANT" / other) — the node's reachability for the open session.
FUN_TAXI_NODE_TYPE = 0x004DC4E0,
// Per-slot computed route (the chain that TakeTaxiNode sends to the
// server). Derived from FUN_004dc8d0 (the TakeTaxiNode worker) and the
// route builder FUN_004dbce0:
// +0x0C (byte) nonzero when the multi-hop route array below is built
// +0x14 (int*) array of TaxiNodes.dbc IDs, current->dest in flight
// order, length at +0x18
// +0x18 (int) route length (node count; hops = count - 1)
// A direct hop (a TaxiPath edge exists current->dest) is flown as just
// {current, dest} and does NOT populate +0x14 — see VAR_TAXI_ADJACENCY.
OFF_TAXI_SLOT_MULTIHOP = 0x0C,
OFF_TAXI_SLOT_ROUTE = 0x14,
OFF_TAXI_SLOT_ROUTE_COUNT = 0x18,
// Pointer to the CURRENT flight master's TaxiNodes.dbc record (id @ +0);
// the origin of every route. Used by FUN_004dc8d0 / FUN_004dbce0.
VAR_TAXI_CURRENT_REC = 0x00BB4A80,
// Node x node -> pathID adjacency matrix (256-wide rows, int cells; cell
// [from*0x100 + to] is the direct TaxiPath id, or 0 if none). Backs the
// direct-hop test FUN_004dbad0. IDs are 1..256.
VAR_TAXI_ADJACENCY = 0x00B7365C,
TAXI_ADJACENCY_DIM = 0x100,
// TaxiPath.dbc (4 fields, 0x10 bytes): {id, fromNode@+0x04, toNode@+0x08,
// cost}. A real flight master is a node some flight path connects to;
// Northshire Abbey / spurious duplicate rows / standalone markers are not
@@ -2413,6 +2498,23 @@ enum Offsets {
VAR_TAXIPATH_COUNT = 0x00C0D69C,
OFF_TAXIPATH_FROM = 0x04,
OFF_TAXIPATH_TO = 0x08,
OFF_TAXIPATH_COST = 0x0C,
// TaxiPathNode.dbc — the per-path flight waypoints. 1-based
// record-pointer array (records/count slots per docs/DBCs.md). Record
// (verified against the extracted DBC + the loading-screen spline
// reader FUN_004075A0, which reads mapID@+0xC, X@+0x10, Y@+0x14):
// {ID@+0, pathID@+4, nodeIndex@+8, mapID@+0xC,
// X@+0x10, Y@+0x14, Z@+0x18 (float), flags@+0x1C, delay@+0x20}
// Waypoints of a path share pathID and order by nodeIndex.
VAR_TAXIPATHNODE_RECORDS = 0x00C0D6AC,
VAR_TAXIPATHNODE_COUNT = 0x00C0D6B0,
OFF_TAXIPATHNODE_PATH_ID = 0x04,
OFF_TAXIPATHNODE_INDEX = 0x08,
OFF_TAXIPATHNODE_MAP = 0x0C,
OFF_TAXIPATHNODE_X = 0x10,
OFF_TAXIPATHNODE_Y = 0x14,
OFF_TAXIPATHNODE_Z = 0x18,
// Engine player-info cache — populated by the
// SMSG_NAME_QUERY_RESPONSE handler (opcode 0x51) at 0x005551A0,
@@ -3914,6 +4016,10 @@ enum Offsets {
// the frame into the matching entry's chain.
VAR_EVENT_TABLE_BASE_PTR = 0x00CEEF68,
VAR_EVENT_TABLE_COUNT = 0x00CEEF64,
// The FrameScript_EventObject is a `{cap@+0, count@+4, data@+8}` at
// 0x00CEEF60, so COUNT/BASE above are its +4/+8. `Event::Custom::Grow`
// writes all three (base, cap, count) when it enlarges the table.
VAR_EVENT_TABLE_CAP = 0x00CEEF60,
EVENT_ENTRY_STRIDE = 0x10,
OFF_EVENT_ENTRY_NAME = 0x00,
OFF_EVENT_ENTRY_HEAD = 0x0C,
@@ -3954,6 +4060,17 @@ enum Offsets {
// table after the rebuild settles. Kept for reference.
FUN_REBUILD_EVENT_TABLE = 0x00703D90,
// Event-table allocator — `__thiscall(EventObject *this, uint32 count)`.
// Called by `RebuildEventTable` to (re)size the entry array. SuperWoW and
// nampower co-hook it to destructively clamp the FrameXML count (`> 200`;
// glue is smaller) to 700. We deliberately DON'T hook it: our default is
// claim-NULL into the engine's ~200 low gaps (which always suffices), and
// when a grow IS needed `Event::Custom::Grow` reallocates the buffer +
// swaps the globals directly (bypassing this clamp) rather than routing a
// size through here. Also documents the empty-entry init the grow mirrors
// ({name=0, +4=0, +8=&self, +0xC=(&self)|1}). Kept for reference.
FUN_FRAMESCRIPT_SET_EVENT_COUNT = 0x007053B0,
// `FireEvent` — the engine's event dispatcher. 149 callers in the
// binary. `__cdecl void(int eventID, const char *format, ...)`.
// Indexes into `[VAR_EVENT_TABLE_BASE_PTR] + eventID * 0x10` and walks
@@ -4167,6 +4284,16 @@ enum Offsets {
// from `SMemAlloc` (which it requires).
FUN_STORM_SSTRDUP = 0x0064A620,
// Storm allocator/free — the pair the engine uses for the event-table
// buffer, so its reload teardown `SMemFree`s our grown buffer as its own.
// Both `__stdcall`, 4 args (`ret 0x10`); SMemAlloc flag bit 8 (`& 8`)
// zero-fills. `Event::Custom::Grow` uses them to reallocate the event
// buffer. (Same addresses as FUN_STORM_SMEM_ALLOC / _FREE above.)
// void* __stdcall SMemAlloc(uint size, const char *file, int line, int flags)
// char __stdcall SMemFree (void *ptr, const char *file, int line, int flags)
FUN_STORM_SMEMALLOC = 0x006462E0,
FUN_STORM_SMEMFREE = 0x00646430,
// Talent system — per-player talent state populated at login from
// (class, race) + Talent.dbc / TalentTab.dbc. The engine maintains
// one `TabInfo *` per talent tab in the array at
+82
View File
@@ -0,0 +1,82 @@
// 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_ChatBubbles.GetAllChatBubbles([includeForbidden])` — returns an
// array of the currently-active chat-bubble Frame objects, matching
// modern WoW's 7.2.5 signature. Addons use it to recolor / translate /
// reposition speech bubbles by reading the bubble's FontString region.
//
// Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full
// `CSimpleFrame`s created purely in C++ (never via `CreateFrame`), so
// they have no name and no Lua wrapper until something pushes them. This
// is exactly the default-nameplate case: we push each via the shared
// `UI::FrameObject::Push`, which delegates to the engine's own
// `FrameScript_Object::ScriptRegister` to lazily build the canonical
// wrapper. The returned frames respond to real frame methods — the
// FontString holding the spoken text (bubble + 0x31c, created with the
// bubble as its parent) shows up in `bubble:GetRegions()`, so the modern
// "iterate GetRegions, find the FontString, read GetText()" idiom works
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
// wiki describes, but handed the exact set instead of the whole world.
//
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
// link sits at `+0x318` and the list terminator carries the low-bit
// sentinel. We mirror the engine's own walk in the per-frame update
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
// unit has despawned are pruned on the engine's next update tick, so a
// just-orphaned bubble can linger one frame — harmless, and addons that
// care filter on `bubble:IsShown()` (a real method here) anyway.
//
// `includeForbidden` is accepted and ignored: vanilla has no forbidden
// frames, so every bubble is returned regardless.
#include "Game.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace ChatBubble::Info {
namespace {
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
// forward link, and the end-of-list low-bit sentinel — see the engine
// walks at 0x004b1060 / 0x004b0f80.
constexpr uintptr_t kListHead = 0x00B6F038;
constexpr int kOffNextLink = 0x318;
} // namespace
static int __fastcall Script_GetAllChatBubbles(void *L) {
Game::Lua::NewTable(L);
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
int nextIndex = 1;
while (node != 0 && (node & 1) == 0) {
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
}
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ChatBubbles", "GetAllChatBubbles",
&Script_GetAllChatBubbles);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace ChatBubble::Info
+20 -5
View File
@@ -88,12 +88,27 @@ void Persist() {
} // namespace
void EnsureLoaded() {
if (g_loaded)
return;
g_path = Storage::ResolveFilePath();
if (g_path.empty())
return; // session globals not yet populated; retry on next call
// Re-resolve the per-character path on every call rather than latching
// once. The injected DLL persists across a logout→character-select→login
// (no process restart), so a permanent `g_loaded` latch would leave the
// previous character's file path and cached sets in place — the next
// character would then read, and save into, character A's data. Detecting
// a path change here makes the cache self-heal on any character/account
// switch. Every mutation Persist()s immediately, so dropping the old
// in-memory sets never loses unsaved work.
std::string path = Storage::ResolveFilePath();
if (path.empty())
return; // session globals not yet populated; keep current, retry later
if (g_loaded && path == g_path)
return; // already loaded for this character
g_path = std::move(path);
g_sets.clear();
Storage::Load(g_path, &g_sets);
// Per-session "ignore this slot on next save" state belongs to the
// character it was set on — clear it when switching characters.
for (int i = 0; i < SLOT_COUNT; ++i)
g_ignoredForSave[i] = false;
g_loaded = true;
}
+5 -3
View File
@@ -22,9 +22,11 @@ namespace EquipmentSet::Storage {
// Resolves the per-character file path. Returns an empty string until
// the engine has populated all three session globals (account, realm,
// character). Path format: `WTF\Account\<acct>\<realm>\<char>\
// ClassicAPI_EquipmentSets.txt`. Once the engine has the player in
// world the values are stable for the rest of the session, so callers
// can cache after the first non-empty return.
// ClassicAPI_EquipmentSets.txt`. The values are stable only while the
// current character is in world — they change on a logout→login to a
// different character (the DLL is NOT reloaded across that transition),
// so callers must re-resolve and compare rather than caching the first
// non-empty return permanently. See `Data::EnsureLoaded`.
std::string ResolveFilePath();
// Reads the file at `path` and overwrites `outSets` with the parsed
+50 -66
View File
@@ -40,11 +40,12 @@
// `C_EquipmentSet.GetItemIDs` + `C_EquipmentSet.GetItemLocations`
// themselves.
//
// Tooltip filling is done by calling back through Lua (`self:AddLine`
// etc.) rather than hitting the engine's tooltip-line internals
// directly. The Lua dispatch goes through the engine's own frame-
// method registry, so any addon-installed hook on AddLine (DBM,
// pfUI's tooltip skin, etc.) still runs.
// Tooltip lines are built with the engine's own primitives — the
// per-tooltip clear (FUN_GAMETOOLTIP_CLEAR) and the raw add-line
// (FUN_GAMETOOLTIP_ADD_LINE), the same native path SetTotem and
// SetHyperlinkCompareItem use; no Lua method dispatch. It shows itself
// at the end (Script_Show), matching retail: PaperDollFrame.lua sets the
// anchor then calls SetEquipmentSet with no trailing :Show().
#include "Game.h"
#include "Offsets.h"
@@ -58,57 +59,41 @@ namespace EquipmentSet::Tooltip {
namespace {
// Calls `self:Method()` with no Lua args. Stack at entry must have
// the tooltip at index 1 (the standard `frame:method()` self slot).
// Leaves the stack unchanged.
void CallSelfNoArgs(void *L, const char *methodName) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, methodName);
Game::Lua::GetTable(L, -2); // method = self[methodName]
Game::Lua::Insert(L, -2); // [method, self]
Game::Lua::Call(L, 1, 0);
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using ClearTooltip_t = void(__fastcall *)(void *self);
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
// Packs r,g,b (0..1) into the 0xAARRGGBB value FUN_GAMETOOLTIP_ADD_LINE's
// color arg wants — its little-endian bytes are the {b,g,r,a} the engine's
// line-color setter reads. Alpha is always opaque.
uint32_t Pack(double r, double g, double b) {
auto ch = [](double v) {
const int i = static_cast<int>(v * 255.0 + 0.5);
return static_cast<uint32_t>(i < 0 ? 0 : (i > 255 ? 255 : i));
};
return 0xFF000000u | (ch(r) << 16) | (ch(g) << 8) | ch(b);
}
// Calls `self:AddLine(text, r, g, b)`. Same self-on-stack precondition.
void AddLine(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "AddLine");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0);
// Left-aligned colored line via the engine's raw add-line. The packed
// color is a local so its address stays valid across the call.
void AddColoredLine(void *self, const char *text, double r, double g, double b) {
const uint32_t color = Pack(r, g, b);
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
self, text, nullptr, &color, nullptr, 0);
}
// Calls `self:SetText(text, r, g, b)`. Same precondition. Used for
// the header line — `SetText` is the standard vanilla entry for
// "first line of a fresh tooltip" and handles internal redraw state
// `AddLine` skips when it's the first content after a clear.
void SetText(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "SetText");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0); // self + text + r + g + b
}
// Format-and-add one localized count line. Calls
// `Game::Lua::PushLocalizedFormatInt` to resolve the format string
// (Blizzard's FrameXML globals like `ITEMS_EQUIPPED` first, with the
// C `fallback` for stripped-down servers), then dispatches `AddLine`.
void AddLocalizedLineInt(void *L, const char *globalName, const char *fallback, int n,
double r, double g, double b) {
// Adds one localized "N unit" line: `string.format(_G[globalName] or
// fallback, n)` (the project's GlobalString localize-or-fallback helper,
// with the C `fallback` for servers stripped of the standard strings),
// then the native colored add-line.
void AddLocalizedLineInt(void *L, void *self, const char *globalName,
const char *fallback, int n, double r, double g, double b) {
const int savedTop = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, n);
const char *text = Game::Lua::ToString(L, -1);
if (text != nullptr)
AddLine(L, text, r, g, b);
AddColoredLine(self, text, r, g, b);
Game::Lua::SetTop(L, savedTop);
}
@@ -185,39 +170,36 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
if (s == nullptr)
return 0;
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return 0;
const Tally t = TallySet(*s);
const int totalSlots = t.equipped + t.inInventory + t.missing;
// Trim the Lua stack to just the tooltip self-arg so our internal
// method-call sequences don't accumulate the original args past
// their useful lifetime. `setName` was a `const char *` lifted out
// by ToString — Lua's strings are pinned by reference count, so
// dropping the string from the stack doesn't invalidate the
// pointer for the lifetime of this call.
Game::Lua::SetTop(L, 1);
// Header — set name, white. Doubles as the "ClearLines" boundary;
// SetText replaces line 1 and clears subsequent lines in vanilla.
SetText(L, s->name.c_str(), 1.0, 1.0, 1.0);
// Header — set name, white. Clear first so it lands as line 0
// (the clear resets the line count the add-line body indexes from).
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
AddColoredLine(self, s->name.c_str(), 1.0, 1.0, 1.0);
// Body lines — try Blizzard's localized FORMAT_* globals first.
// Same key names retail FrameXML uses (`ITEMS_VARIABLE_QUANTITY`,
// `ITEMS_EQUIPPED`, ...); addons that need their own wording can
// override by reassigning `_G[name]` at runtime. The fallbacks
// only fire on servers stripped of the standard GlobalStrings.
AddLocalizedLineInt(L, "ITEMS_VARIABLE_QUANTITY", "%d items",
AddLocalizedLineInt(L, self, "ITEMS_VARIABLE_QUANTITY", "%d items",
totalSlots, 1.0, 1.0, 1.0);
if (t.equipped > 0) {
AddLocalizedLineInt(L, "ITEMS_EQUIPPED", "%d equipped",
AddLocalizedLineInt(L, self, "ITEMS_EQUIPPED", "%d equipped",
t.equipped, 0.0, 1.0, 0.0); // green
}
if (t.inInventory > 0) {
AddLocalizedLineInt(L, "ITEMS_IN_INVENTORY", "%d in inventory",
AddLocalizedLineInt(L, self, "ITEMS_IN_INVENTORY", "%d in inventory",
t.inInventory, 1.0, 1.0, 1.0);
}
if (t.ignored > 0) {
AddLocalizedLineInt(L, "ITEM_SLOTS_IGNORED", "%d slots ignored",
AddLocalizedLineInt(L, self, "ITEM_SLOTS_IGNORED", "%d slots ignored",
t.ignored, 0.5, 0.5, 0.5); // gray
}
// Missing slots: list each by name using the itemID we stored at
@@ -255,18 +237,20 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
Game::Lua::Call(L, 2, 1);
const char *line = Game::Lua::ToString(L, -1);
if (line != nullptr)
AddLine(L, line, 1.0, 0.0, 0.0); // red
AddColoredLine(self, line, 1.0, 0.0, 0.0); // red
Game::Lua::SetTop(L, savedTop);
}
if (unnamedMissing > 0) {
// Pre-itemID-format files or items the cache has no record of.
// No Blizzard format global maps cleanly here; addons can
// override with `_G.CLASSICAPI_EQUIPMENTSET_MISSING`.
AddLocalizedLineInt(L, "CLASSICAPI_EQUIPMENTSET_MISSING",
AddLocalizedLineInt(L, self, "CLASSICAPI_EQUIPMENTSET_MISSING",
"%d missing", unnamedMissing, 1.0, 0.0, 0.0);
}
CallSelfNoArgs(L, "Show");
// Show ourselves — self is still at Lua stack index 1 (Script_Show
// reads it there). Retail's SetEquipmentSet shows internally too.
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
return 0;
}
+178 -7
View File
@@ -46,6 +46,22 @@ struct ReservedName {
ReservedName g_reserved[MAX_RESERVED];
int g_reservedCount = 0;
bool g_writesEnabled = false;
// One-shot latch so a genuinely full event table (no NULL slot left to
// claim) is logged once per session instead of silently dropping the
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
// SuperWoW/nampower clamp the FrameXML table to 700 and the engine leaves
// ~200 NULL gaps in the low range, dwarfing our ~46 reservations. The log is
// purely a backstop if some future install somehow drains the gap pool.
bool g_warnedTableFull = false;
// One grow attempt per in-game table build. Reset in `PrepareForReload`
// (the table is rebuilt fresh on every /reload + logout). See `Grow`.
bool g_growLatched = false;
// Debug: force a grow on the next table build regardless of headroom, so
// the (otherwise dormant) grow path can actually be exercised in-game.
// Armed by `_classicapi_ForceEventGrow`, consumed by the next `Grow`.
bool g_forceGrowNext = false;
// Engine's SStrDup at `FUN_STORM_SSTRDUP` — uses `SMemAlloc` internally
// and is exactly what the engine itself does when filling event table
@@ -61,14 +77,38 @@ char *SStrDup(const char *s) {
return fn(s, __FILE__, __LINE__);
}
// Walk the engine's event table from the END looking for a NULL-name
// Storm allocator/free — the same pair the engine uses for the event-table
// buffer (see RebuildEventTable), so its reload teardown `SMemFree`s our
// grown buffer exactly as its own. Both `__stdcall`, 4 args (`ret 0x10`).
// SMemAlloc flag bit 8 zero-fills (matching SetEventCount's fresh entries).
void *SMemAlloc(unsigned size) {
using SMemAlloc_t = void *(__stdcall *)(unsigned size, const char *file,
int line, int flags);
auto fn = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEMALLOC);
return fn(size, __FILE__, __LINE__, 8 /* zero-fill */);
}
void SMemFree(void *ptr) {
using SMemFree_t = int(__stdcall *)(void *ptr, const char *file, int line,
int flags);
auto fn = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEMFREE);
fn(ptr, __FILE__, __LINE__, 0);
}
// Walk the engine's event table from the LOW end looking for a NULL-name
// slot. The bulk-init at `0x0051AB30` leaves ~200 NULL gaps scattered
// through the 548-slot input names array (it only writes ~350 of the
// slots explicitly; the rest stay zero-initialized from .data). UP
// and DOWN walks both find these gaps — we go DOWN for convention with
// VanillaMinimapTracking and so the dump in `_classicapi_DumpAllEvents`
// shows our events grouped at the tail, visually distinct from engine
// events.
// slots explicitly; the rest stay zero-initialized from .data) — a big
// contiguous hole around 42..106 alone dwarfs our reservation count.
//
// We go LOW (ascending) deliberately: SuperWoW and nampower own the HIGH
// range (fixed event IDs 549..655, and they destructively clamp the table
// to 700), and other claim-based DLLs (VanillaMinimapTracking) also walk
// from the tail — so the top is the contested end. Claiming from the low
// engine gaps keeps us entirely out of their way and independent of their
// clamp / hook order. The engine's ~200 low NULL gaps dwarf our ~46
// reservations, so a free slot is essentially always available; the
// skip-occupied invariant keeps this safe against future engine additions
// regardless of direction.
int TryClaim(const char *eventName) {
if (!g_writesEnabled)
return -1;
@@ -76,7 +116,7 @@ int TryClaim(const char *eventName) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = count - 1; i >= 0; --i) {
for (int i = 0; i < count; ++i) {
auto **namePtr = reinterpret_cast<const char **>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME);
@@ -88,9 +128,119 @@ int TryClaim(const char *eventName) {
return i;
}
}
// Writes are enabled and the table is valid, but every slot is taken —
// real capacity exhaustion despite `Grow`. Warn once so it's diagnosable.
if (!g_warnedTableFull) {
g_warnedTableFull = true;
Debug::Log::Printf(
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
" and any further custom events; even Grow couldn't make room.",
count, eventName ? eventName : "?");
}
return -1;
}
// On-demand event-table grow. Correctly TIMED: called once per in-game
// table build from `RetryClaims` (the `frame:RegisterEvent` hook), on the
// FIRST such call — which is AFTER `RebuildEventTable` has built the fresh
// table (frames can only register once it exists) but BEFORE any frame has
// joined a chain (it's the first registration, and we run before the
// original registers it). So every chain is still an empty self-sentinel,
// and the buffer can be moved safely.
//
// (This is what the removed `EnsureCapacity` got wrong: it ran from
// `LoadScriptFunctions_h`, BEFORE `RebuildEventTable`, so it saw the stale
// previous/glue table and always aborted — it never actually grew. The
// realloc logic itself was fine; only the timing was broken.)
//
// Grows only when the engine's low NULL gaps are fewer than our
// reservations — which never happens in practice (~200 gaps vs ~46
// reservations), so this is a dormant durability valve. `g_forceGrowNext`
// (debug) forces it so the path can be exercised. It grows by reallocating
// the entry buffer with the engine's own Storm allocator and swapping the
// `{cap@0, count@4, base@8}` globals at `0x00CEEF60` — bypassing
// SetEventCount's destructive `= 700` clamp. All consumers (RegisterEvent,
// the dispatcher, SuperWoW, nampower) read the base pointer fresh, so the
// swap is picked up everywhere; the engine's reload teardown `SMemFree`s
// our buffer and frees the (shared) name strings via its per-entry helper,
// exactly as its own.
void Grow() {
if (g_growLatched || !g_writesEnabled)
return;
g_growLatched = true; // one attempt per build, whether or not it grows
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
return;
int nulls = 0;
for (int i = 0; i < count; ++i)
if (*reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
++nulls;
const bool force = g_forceGrowNext;
g_forceGrowNext = false;
if (nulls >= g_reservedCount && !force)
return; // plenty of room — the common (only) real case
// Growing moves the buffer, invalidating every entry's self-referential
// list anchor, so it's safe ONLY while all chains are empty. We run
// before the first registration, so that holds — but VERIFY it (a live
// chain means someone registered earlier via a path we didn't intercept;
// moving the buffer would corrupt their subscription). Abort if so and
// fall back to claim-NULL.
for (int i = 0; i < count; ++i) {
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
Debug::Log::Printf(
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
return;
}
}
constexpr int kMargin = 16;
const int deficit = (nulls < g_reservedCount) ? (g_reservedCount - nulls) : 0;
const int newCount = count + deficit + kMargin;
auto *newBuf = static_cast<uint8_t *>(
SMemAlloc(static_cast<unsigned>(newCount) * Offsets::EVENT_ENTRY_STRIDE));
if (newBuf == nullptr)
return; // alloc failed — keep the old table; claiming still works
// Rebuild each entry at its NEW address, mirroring SetEventCount's own
// per-entry init: copy the name pointer (shared string, not duplicated);
// the `+0x04` field stays 0 (zero-filled, matching the engine); re-init
// the anchor as an empty self-sentinel at the new location. Old entries
// past the copy and all new entries are already zero-filled + get fresh
// anchors. Safe because every chain is empty.
for (int i = 0; i < newCount; ++i) {
auto *e = reinterpret_cast<uint32_t *>(
newBuf + i * Offsets::EVENT_ENTRY_STRIDE);
if (i < count) // copy name; new slots stay null
e[0] = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE);
const uint32_t anchor = static_cast<uint32_t>(
reinterpret_cast<uintptr_t>(&e[2])); // entry+0x08
e[2] = anchor; // self-referential list anchor
e[3] = anchor | 1; // empty-chain sentinel head (+0x0C)
}
// Publish base BEFORE count so the globals never advertise more entries
// than the buffer holds. Then free the OLD buffer only — the name
// strings live on in newBuf and are freed by the reload teardown.
*reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
SMemFree(base);
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
count, newCount, nulls, g_reservedCount,
force ? ", FORCED" : "");
}
} // namespace
AutoReserve::AutoReserve(const char *name) {
@@ -133,6 +283,11 @@ int LookupByName(const char *name) {
}
void RetryClaims() {
// Grow the table first if the low gap pool is short (dormant valve).
// Runs at most once per table build; on the first call every chain is
// still empty (this is the first registration), which is the only safe
// time to move the buffer.
Grow();
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
g_reserved[i].slot = TryClaim(g_reserved[i].name);
@@ -145,6 +300,8 @@ void PrepareForReload() {
for (int i = 0; i < g_reservedCount; ++i)
g_reserved[i].slot = -1;
g_writesEnabled = false;
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
g_growLatched = false; // re-arm the one-shot grow for the new table
}
// Diagnostic Lua functions — registered via the auto-register pattern
@@ -190,10 +347,24 @@ int __fastcall Script_DumpAllEvents(void *) {
return 0;
}
// `_classicapi_ForceEventGrow()` — arms a one-shot forced table grow for
// the NEXT in-game table build, so the (otherwise dormant) grow path can be
// exercised: call it, then `/reload`. The grow fires at the first
// `RegisterEvent` after the rebuild (chains still empty) and logs
// `[event] grew table N -> M (..., FORCED)`. Diagnostic only.
int __fastcall Script_ForceEventGrow(void *) {
g_forceGrowNext = true;
Debug::Log::Printf(
"[event] force-grow armed — /reload to trigger it on the next build");
return 0;
}
void RegisterDiagnostics() {
Game::Lua::RegisterGlobalFunction("_classicapi_DumpSlot", &Script_DumpSlot);
Game::Lua::RegisterGlobalFunction("_classicapi_DumpAllEvents",
&Script_DumpAllEvents);
Game::Lua::RegisterGlobalFunction("_classicapi_ForceEventGrow",
&Script_ForceEventGrow);
}
const Game::ModuleAutoRegister _autoreg{&RegisterDiagnostics};
+98
View File
@@ -0,0 +1,98 @@
// 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/>.
// `GetFramesRegisteredForEvent(event)` → the Frame objects currently
// subscribed to `event`, returned as multiple return values (matching
// modern WoW's vararg signature — not a table). Empty result set returns
// nothing.
//
// The engine already keeps exactly this list: every `frame:RegisterEvent`
// appends the frame to a subscriber chain hanging off the matching event
// table entry. We find the entry by name (same table `Event::Custom`
// walks), then walk the chain the same way `Frame::RegisterEvent`
// (`FUN_00702140`) does — node next at `+0x04`, subscriber frame at
// `+0x08`, with a low-bit-set / null value marking the end sentinel — and
// push each frame via the shared `UI::FrameObject::Push` so callers get
// the canonical wrapper table (real frame methods, `:GetName()`, etc.).
//
// Event names resolve through `Event::Custom::LookupByName`, so this also
// works for our `AutoReserve`-claimed custom events, not just engine ones.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace Event::Registered {
namespace {
int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const char *event = Game::Lua::ToString(L, 1);
if (event == nullptr)
return 0;
const int slot = Event::Custom::LookupByName(event);
if (slot < 0)
return 0;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr)
return 0;
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
const uintptr_t head = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
// First pass: count subscribers so we can reserve stack up front — an
// event with many registered frames would otherwise risk overflowing
// the default Lua stack slack as we push.
int count = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
if (*reinterpret_cast<void *const *>(node + Offsets::OFF_EVENT_NODE_FRAME))
++count;
}
if (count == 0)
return 0;
Game::Lua::CheckStack(L, count);
int pushed = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
void *frame = *reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME);
if (frame != nullptr) {
UI::FrameObject::Push(L, frame);
++pushed;
}
}
return pushed;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetFramesRegisteredForEvent",
&Script_GetFramesRegisteredForEvent);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Event::Registered
+18
View File
@@ -282,7 +282,25 @@ void RegisterLuaFunctions() {
&Script_C_FriendList_IsWhoQueryPending);
}
// Clear transient /who state on every in-game Lua (re)build — login OR
// /reload. The DLL survives a logout→character-select→login, so a query
// whose SMSG_WHO response never crossed that boundary would otherwise leave
// g_inFlight stuck (IsWhoQueryPending() wrongly true for the next character)
// and our VAR_WHO_TO_UI_FLAG override forced on (routing the next
// character's unrelated /who to the WhoList). ResetIfStuck only heals this
// lazily on a *subsequent* send; a fresh-init reset makes every character
// start clean. RestoreFlagIfOwned puts the engine flag back to the value we
// snapshotted before overriding. ModuleAutoRegister fires per login and per
// /reload, before any addon runs, so this always precedes a new burst.
void ResetState() {
g_inFlight = 0;
RestoreFlagIfOwned();
g_haveSent = false;
g_lastSendTick = 0;
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ModuleAutoRegister _resetOnInit{&ResetState};
} // namespace
+96 -35
View File
@@ -30,17 +30,23 @@
// (dispatcher leaves `_G.arg<N>` alone when no codes are parsed),
// restore.
// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the
// `"nameplateN"` unit token (formatted from the plate's index in
// `g_orderedGUIDs` at fire time). The token resolves to the unit
// via the `nameplateN`-aware token resolver in `unit/TokenExtensions.cpp`
// — addons can pass it straight to `UnitName`, `UnitGUID`, etc.,
// or to `GetNamePlateForUnit` for the frame.
// `"nameplateN"` unit token (`N` = the plate's assigned slot). The token
// resolves to the unit via the `nameplateN`-aware token resolver in
// `unit/TokenExtensions.cpp` — addons can pass it straight to `UnitName`,
// `UnitGUID`, etc., or to `GetNamePlateForUnit` for the frame.
//
// Slot assignment (retail-exact): a plate keeps its slot for its whole
// lifetime, so surviving plates are NEVER renumbered when another is removed
// — a removal frees the slot and the next new plate reuses the lowest free
// one. `g_slots` is therefore a sparse array (freed middle slots read back as
// the empty GUID 0 until reused), not a compacting list.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "nameplate/Walk.h"
#include "tick/WorldTick.h"
#include "unit/TokenObserver.h"
#include <algorithm>
#include <cstdint>
@@ -84,13 +90,27 @@ std::unordered_map<uint64_t, const void *> g_currentTickPlates;
// then tops out as pool reuse covers all subsequent shows.
std::unordered_set<const void *> g_seenPlates;
// Ordered list of currently-visible nameplate GUIDs, in
// creation-order. Append on UNIT_ADDED, erase on UNIT_REMOVED. Backs
// the `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`. Order
// matches modern WoW semantics: stable for the lifetime of each
// plate, gaps when middle plates vanish (until the next REMOVED
// shifts later entries down).
std::vector<uint64_t> g_orderedGUIDs;
// Nameplate token slots: slot `i` (0-based) holds the GUID assigned to
// `nameplate(i+1)`, or 0 when free. Retail-exact slot assignment — a plate
// keeps its slot for its whole lifetime (survivors are never renumbered),
// UNIT_REMOVED frees the slot (sets 0), and a new plate reuses the lowest
// free slot. Sparse: freed middle slots stay as gaps until reused. Backs the
// `nameplateN` unit-token resolver in `unit/TokenExtensions.cpp`.
std::vector<uint64_t> g_slots;
// Assign `guid` the lowest free slot, reusing a vacated one when present and
// growing the array only when every slot is occupied. Returns the 0-based
// slot. Never shifts an existing entry.
int AssignSlot(uint64_t guid) {
for (size_t i = 0; i < g_slots.size(); ++i) {
if (g_slots[i] == 0) {
g_slots[i] = guid;
return static_cast<int>(i);
}
}
g_slots.push_back(guid);
return static_cast<int>(g_slots.size() - 1);
}
// Fire `eventName` with a pre-formatted string as `arg1`. The engine
// dispatcher's `%s` format code pushes the C string into `_G.arg1`
@@ -114,6 +134,32 @@ const char *FormatNamePlateToken(char *buf, size_t bufSize, int oneBasedIndex) {
return buf;
}
// Descriptor-field observer callback — makes unit events (UNIT_HEALTH,
// UNIT_AURA, …) fire with the `"nameplateN"` token, so a nameplated unit is a
// first-class unit-event source exactly like a party member. Registered per
// nameplate GUID via `Unit::TokenObserver` on UNIT_ADDED, unregistered on
// UNIT_REMOVED. We reverse-look-up the changed GUID's slot at fire time
// (cheap; the array is small) so the token reflects its current assignment.
// The event id is the field index (`fieldOffset >> 2`); the engine fires every
// unit event as "%s" + token, so we match. A GUID no longer in any slot
// (removal raced this field change) simply drops the fire.
int __fastcall NamePlateFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/, void * /*userArg*/) {
const uint64_t guid =
(static_cast<uint64_t>(guidHi) << 32) | static_cast<uint64_t>(guidLo);
auto it = std::find(g_slots.begin(), g_slots.end(), guid);
if (it == g_slots.end())
return 1;
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
char tokenBuf[24];
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased);
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
static_cast<int>(fieldOffset >> 2), "%s", tokenBuf);
return 1;
}
// Fire `eventName` with the nameplate `Frame` set as `_G.arg1`.
// `FUN_FIRE_EVENT`'s format-string parser only handles primitive
// types, but it only mutates `_G.arg<N>` for codes it actually
@@ -178,38 +224,42 @@ void OnWorldTick() {
// Fire CREATED (with the Frame as arg1) for never-before-seen
// frame pointers; ADDED (with `"nameplateN"` token as arg1) for
// GUIDs not in last tick's snapshot. New entries are appended to
// the ordered list *before* firing so the token resolves to the
// newly-added plate during the event handler.
// GUIDs not in last tick's snapshot. The slot is assigned *before*
// firing so the token resolves to the newly-added plate during the
// event handler.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
FireWithFrame(kEventCreated, const_cast<void *>(kv.second));
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
g_orderedGUIDs.push_back(kv.first);
const int slot = AssignSlot(kv.first);
// Watch this unit's fields so UNIT_HEALTH/UNIT_AURA/… fire with
// its "nameplateN" token (the engine only watches its own
// target/party/raid tokens, not nameplates).
Unit::TokenObserver::Register(kv.first, &NamePlateFieldCb);
char tokenBuf[24];
FireWithString(kEventUnitAdded,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf,
static_cast<int>(g_orderedGUIDs.size())));
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, slot + 1));
}
}
// Fire REMOVED for GUIDs in last tick's snapshot but not current.
// We compute the token from the position *before* erasing so the
// event payload reflects the slot the unit just vacated; the
// handler can still resolve the token to the unit via
// `g_orderedGUIDs[slot]` during dispatch. Later plates shift down
// when we erase — matches modern semantics.
// Fire REMOVED for GUIDs in last tick's snapshot but not current. The
// slot is *freed* (set 0), not erased, so surviving plates keep their
// index (retail-exact — no shift); the next ADD reuses it. Token is
// computed before freeing so the payload names the slot just vacated.
// Trailing empties are trimmed so the array tracks the high-water mark.
for (const auto &kv : g_lastTickPlates) {
if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end()) {
auto it = std::find(g_orderedGUIDs.begin(), g_orderedGUIDs.end(),
kv.first);
if (it == g_orderedGUIDs.end())
auto it = std::find(g_slots.begin(), g_slots.end(), kv.first);
if (it == g_slots.end())
continue;
const int oneBased = static_cast<int>(it - g_orderedGUIDs.begin()) + 1;
const int oneBased = static_cast<int>(it - g_slots.begin()) + 1;
char tokenBuf[24];
FireWithString(kEventUnitRemoved,
FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
g_orderedGUIDs.erase(it);
Unit::TokenObserver::Unregister(kv.first, &NamePlateFieldCb);
*it = 0; // free the slot (no shift of survivors)
while (!g_slots.empty() && g_slots.back() == 0)
g_slots.pop_back();
}
}
@@ -230,24 +280,35 @@ static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// suppress every refire, and the freshly-built wrapper would lack
// the addon's `.nameplate` field.
//
// `g_orderedGUIDs` is also cleared so the post-reload token indices
// start at `nameplate1` again, matching the order plates re-fire in.
// `g_slots` is also cleared so post-reload token slots start fresh.
void PrepareForReload() {
g_seenPlates.clear();
g_lastTickPlates.clear();
g_orderedGUIDs.clear();
// Objects survive a /reload, so their observer nodes do too. The first
// post-reload tick re-fires ADDED and re-registers, which would stack a
// second observer per field (the registrar never dedups) → double events.
// Tear ours down here so re-registration starts clean (skip free slots).
for (uint64_t guid : g_slots)
if (guid != 0)
Unit::TokenObserver::Unregister(guid, &NamePlateFieldCb);
g_slots.clear();
}
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
// in `unit/TokenExtensions.cpp` can map an index to a GUID without seeing
// the internal vector.
// the internal array. Returns 0 for an out-of-range OR currently-free slot.
uint64_t GetGUIDByIndex(int oneBased) {
if (oneBased <= 0)
return 0;
const size_t idx = static_cast<size_t>(oneBased - 1);
if (idx >= g_orderedGUIDs.size())
if (idx >= g_slots.size())
return 0;
return g_orderedGUIDs[idx];
return g_slots[idx]; // 0 when the slot is free
}
// Number of slots (1-based max index). Because the slot array is SPARSE,
// callers that iterate all plates must scan `1..GetSlotCount()` and skip the
// slots where `GetGUIDByIndex` returns 0 — they can't stop at the first gap.
int GetSlotCount() { return static_cast<int>(g_slots.size()); }
} // namespace NamePlate::Events
+7 -66
View File
@@ -56,6 +56,7 @@
#include "Offsets.h"
#include "guid/Guid.h"
#include "nameplate/Walk.h"
#include "ui/FrameObject.h"
#include <cstdint>
@@ -66,9 +67,6 @@ namespace {
using NamePlate::Walk::ForEachNamePlatedUnit;
using NamePlate::Walk::kOffUnitNamePlate;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
const char *name);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
@@ -115,70 +113,13 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
// it through the unqualified name (they live in the same namespace).
//
// Delegate wrapper construction to the engine's own
// `FrameScript_Object::ScriptRegister`. First call for a never-seen
// nameplate builds a `{[0] = lightuserdata}` table with the
// framescript metatable, `luaL_ref`s it into the registry, and
// writes the refkey to `nameplate + 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`).
// Subsequent calls find a populated refkey and just rawgeti.
//
// Why delegate instead of building our own wrapper:
//
// - **Single source of truth.** Every code path that pushes this
// frame — ours via this function, addons' `CreateFrame("Type",
// "name", plate)` extracting the parent, the engine's own internal
// pushers — funnels through `rawgeti(REGISTRY, plate+0x08)` and
// gets the same Lua table. Earlier "build our own wrapper +
// `luaL_ref` it" approach kept a side cache, but pfUI's
// `CreateFrame("Button", "name", parent)` inside `NAME_PLATE_CREATED`
// ends up causing the engine to register the parent itself —
// the engine's wrapper diverges from ours, and later
// `GetNamePlateForUnit` calls return the engine's bare wrapper
// instead of our decorated one. Addon fields on the wrapper
// (`plate.nameplate = styledButton`) disappear from the API's
// perspective even though they still exist on the orphaned
// table.
//
// - **Refcount-pinning is benign here.** `ScriptRegister`
// unconditionally increments `nameplate + 0x04`, which normally
// keeps the engine from GC-ing the CObject. Nameplates are
// pool-managed and never destroyed during a session, so pinning
// doesn't actually leak anything. We call `ScriptRegister` at
// most once per nameplate pointer (gated by `refKey <= 0`), so
// the refcount tops out at one increment per pool slot.
//
// Defensive fallback: if the registry slot exists but isn't a table
// any more (some other code freed it), we re-register. This shouldn't
// happen in practice — engines only unref on frame destruction —
// but a stale type check keeps callers from ever seeing a non-table.
// Thin wrapper over the shared `UI::FrameObject::Push` — nameplates and
// chat bubbles are both engine-created-in-C++ `CSimpleFrame`s that need
// the same lazy `ScriptRegister` → `rawgeti` push so addons get the
// engine's canonical wrapper (see that helper for the pfUI-divergence
// rationale that motivated delegating to the engine).
void PushNamePlateFrame(void *L, void *nameplate) {
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
int refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
if (refKey <= 0) {
scriptRegister(nameplate, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
}
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2);
// Slot got freed somewhere — re-register to rebuild a fresh
// wrapper and slot. Zero the refcount so `ScriptRegister`
// re-enters its build branch.
*reinterpret_cast<int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
scriptRegister(nameplate, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
static_cast<uint8_t *>(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
UI::FrameObject::Push(L, nameplate);
}
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
+10 -4
View File
@@ -37,12 +37,18 @@ void PushNamePlateFrame(void *L, void *nameplate);
namespace NamePlate::Events {
// Returns the GUID currently bound to `nameplateN` (1-based, matching
// the modern Lua token form). `0` means no nameplate at that index.
// Backed by the per-tick UNIT_ADDED / UNIT_REMOVED diff in
// `Events.cpp` — order is creation-order (append on ADDED, erase on
// REMOVED), stable within a frame.
// the modern Lua token form). `0` means the slot is out of range OR
// currently free. Backed by the retail-exact slot assignment in
// `Events.cpp`: a plate keeps its slot for its lifetime and freed slots
// are reused, so the slot array is SPARSE (gaps read back as 0).
uint64_t GetGUIDByIndex(int oneBased);
// Highest slot index in use (1-based). Callers that iterate every plate
// must loop `1..GetSlotCount()` and skip indices where `GetGUIDByIndex`
// returns 0 — they must NOT stop at the first gap, since the array is
// sparse.
int GetSlotCount();
// Clears the per-tick diff state (seen pointers, last tick's
// snapshot, ordered GUID list) so the post-`/reload` tick refires
// CREATED + UNIT_ADDED for every currently-visible plate. Called
+55
View File
@@ -0,0 +1,55 @@
// 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/>.
// `GetPlayerFacing()` → facing angle of the player in radians (0 =
// north/+Y in WoW's world convention, increasing counter-clockwise), or
// nothing when there's no player (glue / loading). Vanilla 1.12 doesn't
// ship it (only character-create/select facing exist), but it's the
// missing half that makes our backported `TargetDirectionEnemy/Friend`
// usable — a caller passes `GetPlayerFacing()` to target what's in front.
//
// The orientation is a field in the player's CGUnit movement block
// (`OFF_UNIT_FACING` = +0x9C4), sitting just past the {x,y,z} position at
// +0x9B8. The `GetPosition` virtual only copies x/y/z into a caller buffer,
// so the facing is read off the object directly.
#include "Game.h"
#include "Offsets.h"
#include "unit/Position.h"
#include <cstdint>
namespace Player::Facing {
namespace {
int __fastcall Script_GetPlayerFacing(void *L) {
void *player = Unit::Position::ResolveToken("player");
if (player == nullptr)
return 0;
const float facing = *reinterpret_cast<const float *>(
static_cast<const uint8_t *>(player) + Offsets::OFF_UNIT_FACING);
Game::Lua::PushNumber(L, static_cast<double>(facing));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetPlayerFacing", &Script_GetPlayerFacing);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Player::Facing
+46 -4
View File
@@ -91,6 +91,7 @@ using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, in
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
constexpr int OFF_ATTRIBUTES = 0x18; // u32 Attributes flags
constexpr int OFF_ATTRIBUTES_EX2 = 0x24; // u32 AttributesEx2 flags
constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
// SPELL_ATTR_TRADESPELL — set on profession recipe casts (enchant,
@@ -99,6 +100,12 @@ constexpr int OFF_SUBRECORD_VALUE = 0x04; // SpellIcon path field
// there, +0x18 here — the bit value is the same across builds).
constexpr uint32_t SPELL_ATTR_TRADESPELL = 0x20;
// SPELL_ATTR_RANGED — every ranged ability carries it; the server adds a
// flat +500ms "aim time" to these (SpellEntry::GetCastTime). Auto-repeat
// shots (Auto Shot / Shoot, AttributesEx2 bit 0x20) are the one exclusion.
constexpr uint32_t SPELL_ATTR_RANGED = 0x2;
constexpr uint32_t SPELL_ATTR_EX2_AUTOREPEAT = 0x20;
struct TrackedSpell {
int spellID; // 0 = not casting / channeling
int startMs;
@@ -163,8 +170,30 @@ void *Resolve(const char *token) {
// matches the engine's cast bar exactly — including talents like a Mage's
// faster casts. Unit arg 0 = local player; flag 0 clamps negatives to 0.
int CastTimeMs(int spellID) {
return static_cast<int>(
int ms = static_cast<int>(
reinterpret_cast<GetCastTime_t>(Offsets::FUN_GET_CAST_TIME)(spellID, 0, 0));
// FUN_006e3340 (also the spell tooltip's cast-time source) does NOT add the
// flat +500ms "aim time" the server applies to ranged, non-autorepeat
// spells — SpellEntry::GetCastTime does `castTime += 500` for
// SPELL_ATTR_RANGED. So our zero-latency prediction runs ~500ms short of
// the server for Volley / Aimed Shot / Multi-Shot; the confirming
// SMSG_SPELL_START then snaps the bar out ~1 RTT later. Mirror the server
// here so the prediction is right from frame 1 (only for spells that
// already have a cast time — instant ranged abilities that the server
// gives a 500ms aim time still surface through the packet path). The
// cast-time reconcile in HandleSpellStart remains the backstop.
if (ms > 0) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec != nullptr) {
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES);
const uint32_t attrEx2 =
*reinterpret_cast<const uint32_t *>(rec + OFF_ATTRIBUTES_EX2);
if ((attr & SPELL_ATTR_RANGED) && !(attrEx2 & SPELL_ATTR_EX2_AUTOREPEAT))
ms += 500;
}
}
return ms;
}
// Effective channel duration (ms) for the local player, 0 if none. Uses
@@ -407,13 +436,26 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// Spell_C_CastSpell at `spellID == VAR_CURRENT_CAST_SPELL`, so the
// client path (FUN_CAST_START_SET hook) never fires for them — this
// server packet is the only signal. Dedup against a fresh client stamp
// (same spell, started within ~latency) so a normal cast's confirming
// packet doesn't restart its bar; otherwise it's a chained recast the
// (same spell, started within ~latency): a normal cast's confirming
// packet mustn't RESTART its bar; otherwise it's a chained recast the
// client bailed on — take it, and flag it so the VAR==0 poll (which
// never saw it) won't clear it.
if (g_cast.spellID == spellID && now < g_cast.endMs &&
now - g_cast.startMs < kCastStartDedupMs)
now - g_cast.startMs < kCastStartDedupMs) {
// Confirming packet. Keep startMs (no visual restart), but SNAP the
// end time to the server's authoritative castTime. The client stamp
// came from FUN_006e3340, which folds in the caster's cast-speed /
// ranged-haste multiplier (descriptor +0x22c) — right for spells the
// server also hastes (Aimed/Multi-Shot), WRONG for ones it doesn't.
// Volley is the case that surfaced this: not channeled, not sped by
// Quick Shots server-side, so the hasted prediction ended the bar
// early and the player was "still casting after the bar finished."
// The server's castTime is reality; reconcile to it (preserving any
// pushback already accumulated). A no-op when client and server agree.
g_cast.endMs =
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
return;
}
g_cast = TrackedSpell{spellID, now, now + static_cast<int>(castTime), 0};
g_castFromServer = true;
g_channel.spellID = 0; // a cast supersedes any channel
+66
View File
@@ -55,6 +55,71 @@ static int __fastcall Script_GameTooltipSetSpellByID(void *L) {
return 0;
}
// `GameTooltip:AddSpellByID(spellID)` — APPENDS a spell's tooltip to the
// current one instead of clearing+rebuilding (the `SetSpellByID` behavior).
// The natural pair to `SetSpellByID`; lets callers compose tooltips like
// `AddLine(talentName) + AddSpellByID(rankSpellID)`.
//
// `BuildSpellTooltip`'s last arg (`param_7`) gates the clear: `0` calls the
// per-tooltip Clear (`FUN_00530050`) first — that's `SetSpellByID`; non-zero
// SKIPS the clear and appends. The append path is the engine's talent
// "next rank" preview, so it (a) emits a `"\nNext rank:"` header line
// instead of the spell name, and (b) does NOT stash the displayed spellID at
// `+0x39C` (so `GetSpell` keeps reflecting the base tooltip — correct for an
// append). We fix (a) by overwriting that header line — which lands at index
// `numLines`-before-the-build (0-based) — with the real spell name via the
// line pool's left-text FontString. Verified against `FUN_0052E610`.
void AppendByID(void *L, int spellID) {
if (spellID <= 0)
return;
void *tooltipObj = Game::Lua::ResolveObject(L, 1);
if (tooltipObj == nullptr)
return;
auto *tt = static_cast<uint8_t *>(tooltipObj);
const int before = *reinterpret_cast<const int *>(
tt + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
auto BuildSpellTooltip =
reinterpret_cast<BuildSpellTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP);
BuildSpellTooltip(tooltipObj, spellID, 0, 0, 0, 0, 0, /*param_7 (append)=*/1);
const int after = *reinterpret_cast<const int *>(
tt + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
if (after <= before)
return; // nothing appended (bad spellID / missing Spell.dbc record)
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return;
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_NAME + locale * 4);
if (name == nullptr || name[0] == '\0')
return;
// Left-text FontString array: descriptor at +0x324, data ptr at +0x8.
auto **textLeft = *reinterpret_cast<void ***>(
tt + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC + 8);
if (textLeft == nullptr)
return;
void *fs = textLeft[before];
if (fs == nullptr)
return;
using SetText_t = void(__thiscall *)(void *fs, const char *text, int flag);
reinterpret_cast<SetText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(fs, name, 0);
}
static int __fastcall Script_GameTooltipAddSpellByID(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE ||
!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GameTooltip:AddSpellByID(spellID)");
return 0;
}
AppendByID(L, static_cast<int>(Game::Lua::ToNumber(L, 2)));
return 0;
}
// `GameTooltip:GetSpell()` → (name, rank, spellID) for whichever spell
// the tooltip is currently displaying, or nothing if it isn't showing
// a spell. BuildSpellTooltip writes the spellID to `tooltip+0x39C`;
@@ -126,6 +191,7 @@ static int __fastcall Script_GameTooltipHasSpell(void *L) {
static const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetSpellByID", &Script_GameTooltipSetSpellByID},
{"AddSpellByID", &Script_GameTooltipAddSpellByID},
{"GetSpell", &Script_GameTooltipGetSpell},
{"HasSpell", &Script_GameTooltipHasSpell},
};
+9
View File
@@ -26,4 +26,13 @@ namespace Spell::Tooltip {
// so we render the rank-1 spell tooltip instead).
void ShowByID(void *L, int spellID);
// Like `ShowByID` but APPENDS the spell's tooltip to the current one
// (no clear), and overwrites the engine's talent "next rank" header with
// the real spell name. Backs `GameTooltip:AddSpellByID`. Silent no-op if
// `spellID <= 0`, stack[1] isn't a frame object, or the build added no
// lines. Does not stash the displayed spellID (so `GetSpell` still
// reflects the base tooltip). Useful for cross-class talent tooltips
// (`AddLine(talentName) + AppendByID(rankSpellID)`).
void AppendByID(void *L, int spellID);
} // namespace Spell::Tooltip
+328
View File
@@ -0,0 +1,328 @@
// 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/>.
// Backports the post-vanilla TargetScript selection functions that 1.12.1
// doesn't ship, built on the engine's own tab-targeting internals:
//
// TargetNearest() — cycle nearest attackable/assistable unit
// TargetNearestEnemyPlayer([rev]) — cycle nearest hostile player (PvP)
// TargetNearestFriendPlayer([rev]) — cycle nearest friendly player
// TargetDirectionEnemy(facing[,cone]) — nearest hostile in a facing cone
// TargetDirectionFriend(facing[,cone]) — nearest friendly in a facing cone
//
// Vanilla already ships `TargetNearestEnemy/Friend/PartyMember/RaidMember`;
// they run through `FUN_00493f60(reverse, mode)` — enumerate visible units,
// keep those `FUN_00493e40(player, cand, mode)` accepts, sort by distance,
// cycle, and commit via `FUN_00493540(guidLo, guidHi)`. We reuse the two
// load-bearing pieces of that machinery directly:
//
// * `FUN_00493e40` (Offsets::FUN_TARGET_CANDIDATE_VALID) — the exact
// per-mode validity predicate (mode 1 = enemy: attackable + alive +
// not-critter; mode 2 = friend: assistable + alive). Calling it gives
// us the engine's real reaction/hostility semantics with zero
// reimplementation.
// * `FUN_00493540` (Offsets::FUN_SET_SELECTION_BY_GUID) — the canonical
// setter the native cycle commits through (CMSG_SET_SELECTION + client
// target + event 0xC3), which also maintains the current-selection
// globals we read back for cycle continuity.
//
// So the only genuinely new logic here is (a) an extra player-GUID filter
// for the `*Player` variants and (b) the cone geometry for the direction
// variants — everything reaction-related defers to the engine.
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "unit/Position.h"
#include <cmath>
#include <cstdint>
namespace Target::Nearest {
namespace {
// Candidate class each function selects from.
enum class Filter { Enemy, Friend, EnemyPlayer, FriendPlayer, Any };
constexpr int kFilterCount = 5;
// Default cone (full width, radians) when `coneAngle` is omitted — 90°, so
// 45° to either side of the facing. The engine's own default constant
// wasn't cleanly recoverable from the 3.3.5 reference binary; this is a
// deliberate, documented choice that matches the "roughly in front of me"
// intent of a direction-target keybind.
constexpr float kDefaultConeAngle = 1.5707963f; // pi/2
// Re-cycle window: repeated presses within this many ms advance through the
// frozen candidate snapshot (no ping-pong as distances shift); a longer gap
// rebuilds and re-targets the nearest. Mirrors the ~1s reset the native
// cycle uses.
constexpr uint32_t kCycleWindowMs = 1000;
constexpr int kMaxCandidates = 64;
// Engine entry points ─────────────────────────────────────────────────
using Predicate_t = int(__fastcall *)(void *player, void *cand, int mode);
using SetTargetByGuid_t = void(__fastcall *)(const uint64_t *guid);
using EnumCallback_t = int(__fastcall *)(void *ctx, void *unusedEdx, uint64_t guid);
using EnumVisibleObjects_t = int(__fastcall *)(EnumCallback_t cb, void *ctx);
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
uint32_t guidLo, uint32_t guidHi,
int dbgCode);
using TickMs_t = uint32_t(__fastcall *)();
bool PassesFilter(void *player, void *cand, uint64_t guid, Filter f) {
auto valid = reinterpret_cast<Predicate_t>(Offsets::FUN_TARGET_CANDIDATE_VALID);
switch (f) {
case Filter::Enemy: return valid(player, cand, 1) != 0;
case Filter::Friend: return valid(player, cand, 2) != 0;
case Filter::EnemyPlayer: return Guid::IsPlayer(guid) && valid(player, cand, 1) != 0;
case Filter::FriendPlayer: return Guid::IsPlayer(guid) && valid(player, cand, 2) != 0;
case Filter::Any: return valid(player, cand, 1) != 0 ||
valid(player, cand, 2) != 0;
}
return false;
}
struct Candidate {
uint64_t guid;
float x, y; // world XY (for the direction cone)
float distSq; // 3D squared distance from the player (for nearest)
};
struct CollectCtx {
void *player;
Filter filter;
float px, py, pz;
Candidate list[kMaxCandidates];
int count;
};
int __fastcall CollectCb(CollectCtx *ctx, void * /*edx*/, uint64_t guid) {
if (ctx->count >= kMaxCandidates)
return 1;
// Players and creatures both resolve under TYPEMASK_UNIT (CGPlayer
// derives from CGUnit); non-unit objects resolve to null and drop out.
auto ObjectPtr =
reinterpret_cast<ObjectPtr_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *cand = ObjectPtr(Offsets::TYPEMASK_UNIT, nullptr,
static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0);
if (cand == nullptr || cand == ctx->player)
return 1;
if (!PassesFilter(ctx->player, cand, guid, ctx->filter))
return 1;
float pos[3];
if (!Unit::Position::Read(cand, pos))
return 1;
const float dx = pos[0] - ctx->px;
const float dy = pos[1] - ctx->py;
const float dz = pos[2] - ctx->pz;
Candidate &c = ctx->list[ctx->count++];
c.guid = guid;
c.x = pos[0];
c.y = pos[1];
c.distSq = dx * dx + dy * dy + dz * dz;
return 1;
}
// Enumerate visible units matching `f` into `out`. Returns false (empty)
// pre-world or on a player-position miss.
bool Collect(Filter f, CollectCtx *out) {
out->count = 0;
// The enumerator derefs the object manager unconditionally; null on the
// glue / character-select screen.
if (*reinterpret_cast<void *volatile *>(Offsets::VAR_LOCAL_PLAYER_PTR) ==
nullptr)
return false;
void *player = Unit::Position::ResolveToken("player");
float ppos[3];
if (!Unit::Position::Read(player, ppos))
return false;
out->player = player;
out->filter = f;
out->px = ppos[0];
out->py = ppos[1];
out->pz = ppos[2];
auto Enum = reinterpret_cast<EnumVisibleObjects_t>(
Offsets::FUN_CLNT_OBJ_MGR_ENUM_VISIBLE_OBJECTS);
Enum(reinterpret_cast<EnumCallback_t>(&CollectCb), out);
return true;
}
// Commit the selection through the engine's `Script_TargetUnit` helper
// rather than the tab-cycle's raw `FUN_00493540`: this one first checks the
// GUID resolves to a live unit and bails otherwise, so a stale GUID left in
// a cycle snapshot (unit despawned / went out of range between presses)
// gets rejected instead of committed. Committing a dead GUID would leave
// `"target"` pointing at freed unit data and crash the next handler that
// reads it. `FUN_TARGET_BY_GUID` still routes through `FUN_00493540`
// internally, so the current-selection globals we read back are maintained.
void SetTarget(uint64_t guid) {
reinterpret_cast<SetTargetByGuid_t>(Offsets::FUN_TARGET_BY_GUID)(&guid);
}
uint64_t CurrentSelection() {
const uint32_t lo = *reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_CURRENT_SELECTION_GUID_LO);
const uint32_t hi = *reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_CURRENT_SELECTION_GUID_HI);
return (static_cast<uint64_t>(hi) << 32) | lo;
}
uint32_t NowMs() {
return reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
}
// Ascending insertion sort by 3D distance (candidate counts are tiny).
void SortByDistance(Candidate *a, int n) {
for (int i = 1; i < n; ++i) {
Candidate key = a[i];
int j = i - 1;
while (j >= 0 && a[j].distSq > key.distSq) {
a[j + 1] = a[j];
--j;
}
a[j + 1] = key;
}
}
// Per-filter tab-cycle state. A frozen snapshot of GUIDs (sorted nearest→
// farthest) plus a cursor; presses within `kCycleWindowMs` that haven't had
// the target changed out from under them advance the cursor over the frozen
// list, so the order stays stable through a cycle burst.
struct CycleState {
bool valid = false;
uint64_t snapshot[kMaxCandidates];
int count = 0;
int index = 0;
uint32_t lastPressMs = 0;
uint64_t lastSetGuid = 0;
};
CycleState g_cycle[kFilterCount];
void DoCycle(void *L, Filter f) {
const bool reverse = Game::Lua::ToBoolean(L, 1) != 0;
CycleState &st = g_cycle[static_cast<int>(f)];
const uint32_t now = NowMs();
const bool cont = st.valid && st.count > 0 &&
(now - st.lastPressMs) <= kCycleWindowMs &&
CurrentSelection() == st.lastSetGuid;
if (cont) {
st.index = reverse ? (st.index + st.count - 1) % st.count
: (st.index + 1) % st.count;
} else {
CollectCtx c;
if (!Collect(f, &c) || c.count == 0) {
st.valid = false;
return;
}
SortByDistance(c.list, c.count);
st.count = c.count;
for (int i = 0; i < c.count; ++i)
st.snapshot[i] = c.list[i].guid;
st.index = 0;
st.valid = true;
}
const uint64_t guid = st.snapshot[st.index];
SetTarget(guid);
st.lastSetGuid = guid;
st.lastPressMs = now;
}
void DoDirection(void *L, bool hostile, const char *usage) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, usage);
return;
}
const float facing = static_cast<float>(Game::Lua::ToNumber(L, 1));
float cone = Game::Lua::IsNumber(L, 2)
? static_cast<float>(Game::Lua::ToNumber(L, 2))
: kDefaultConeAngle;
if (cone <= 0.0f)
cone = kDefaultConeAngle;
const float cosHalf = std::cos(cone * 0.5f);
const float dirX = std::cos(facing);
const float dirY = std::sin(facing);
CollectCtx c;
if (!Collect(hostile ? Filter::Enemy : Filter::Friend, &c) || c.count == 0)
return;
int best = -1;
float bestDistSq = 0.0f;
for (int i = 0; i < c.count; ++i) {
const float vx = c.list[i].x - c.px;
const float vy = c.list[i].y - c.py;
const float horizSq = vx * vx + vy * vy;
if (horizSq <= 0.0001f)
continue;
// cos(angle between facing and the horizontal vector to the unit).
const float cosAngle = (dirX * vx + dirY * vy) / std::sqrt(horizSq);
if (cosAngle < cosHalf)
continue; // outside the cone
if (best < 0 || c.list[i].distSq < bestDistSq) {
best = i;
bestDistSq = c.list[i].distSq;
}
}
if (best >= 0)
SetTarget(c.list[best].guid);
}
// Lua entry points ─────────────────────────────────────────────────────
int __fastcall Script_TargetNearest(void *L) {
DoCycle(L, Filter::Any);
return 0;
}
int __fastcall Script_TargetNearestEnemyPlayer(void *L) {
DoCycle(L, Filter::EnemyPlayer);
return 0;
}
int __fastcall Script_TargetNearestFriendPlayer(void *L) {
DoCycle(L, Filter::FriendPlayer);
return 0;
}
int __fastcall Script_TargetDirectionEnemy(void *L) {
DoDirection(L, /*hostile*/ true,
"Usage: TargetDirectionEnemy(facing [, coneAngle])");
return 0;
}
int __fastcall Script_TargetDirectionFriend(void *L) {
DoDirection(L, /*hostile*/ false,
"Usage: TargetDirectionFriend(facing [, coneAngle])");
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("TargetNearest", &Script_TargetNearest);
Game::Lua::RegisterGlobalFunction("TargetNearestEnemyPlayer",
&Script_TargetNearestEnemyPlayer);
Game::Lua::RegisterGlobalFunction("TargetNearestFriendPlayer",
&Script_TargetNearestFriendPlayer);
Game::Lua::RegisterGlobalFunction("TargetDirectionEnemy",
&Script_TargetDirectionEnemy);
Game::Lua::RegisterGlobalFunction("TargetDirectionFriend",
&Script_TargetDirectionFriend);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Target::Nearest
+76
View File
@@ -366,6 +366,80 @@ int __fastcall Script_GetAllTaxiNodes(void *L) {
return 1;
}
// Direct-edge test — mirrors FUN_004dbad0: a 256x256 [from][to] matrix of
// TaxiPath IDs (0 = no direct flight path). IDs outside 1..256 are never direct.
bool DirectEdge(int from, int to) {
if (from <= 0 || from > Offsets::TAXI_ADJACENCY_DIM ||
to <= 0 || to > Offsets::TAXI_ADJACENCY_DIM)
return false;
const int *adj = reinterpret_cast<const int *>(Offsets::VAR_TAXI_ADJACENCY);
return adj[from * Offsets::TAXI_ADJACENCY_DIM + to] != 0;
}
// `C_TaxiMap.GetTaxiRoute(slotIndex)` — the TaxiNodes.dbc ID chain the client
// would fly to the taxi-map slot `slotIndex` (1..NumTaxiNodes), in flight
// order { currentNodeID, ..., destNodeID }. This mirrors TakeTaxiNode's own
// decision (FUN_004dc8d0): a direct hop when a TaxiPath edge exists, otherwise
// the client's precomputed multi-hop route array. It is the exact node list
// TakeTaxiNode would hand the server, so an addon can identify every hop
// without matching flight-map coordinates back to nodes. Returns nothing when
// no taxi map is open, the slot is out of range, it is the current node, or no
// route exists.
int __fastcall Script_GetTaxiRoute(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int slot1 = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_TAXI_NODE_COUNT);
if (count <= 0 || slot1 < 1 || slot1 > count)
return 0;
const auto *arrayBase =
reinterpret_cast<const uint8_t *>(Offsets::VAR_TAXI_NODE_ARRAY);
const uint8_t *slotRec =
arrayBase + (slot1 - 1) * Offsets::TAXI_NODE_STRIDE;
const uint8_t *curRec = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_TAXI_CURRENT_REC);
const uint8_t *dstRec = *reinterpret_cast<const uint8_t *const *>(
slotRec + Offsets::OFF_TAXI_SLOT_RECORD);
if (curRec == nullptr || dstRec == nullptr)
return 0;
const int currentID = IntField(curRec, 0);
const int destID = IntField(dstRec, 0);
if (currentID == 0 || destID == 0 || currentID == destID)
return 0;
// Collect the chain exactly as FUN_004dc8d0 (TakeTaxiNode) would send it.
int ids[64];
int n = 0;
if (DirectEdge(currentID, destID)) {
ids[n++] = currentID;
ids[n++] = destID;
} else if (*(slotRec + Offsets::OFF_TAXI_SLOT_MULTIHOP) != 0) {
const int *route = *reinterpret_cast<const int *const *>(
slotRec + Offsets::OFF_TAXI_SLOT_ROUTE);
const int rc = *reinterpret_cast<const int *>(
slotRec + Offsets::OFF_TAXI_SLOT_ROUTE_COUNT);
if (route == nullptr || rc < 2)
return 0;
const int cap = rc < 64 ? rc : 64;
for (int i = 0; i < cap; ++i)
ids[n++] = route[i];
} else {
return 0; // reachable node with no computed route (shouldn't happen)
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
for (int i = 0; i < n; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushNumber(L, static_cast<double>(ids[i]));
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
static const Game::Lua::EnumIntegerEntry kFaction[] = {
{"Neutral", FACTION_NEUTRAL},
@@ -385,6 +459,8 @@ void RegisterLuaFunctions() {
&Script_GetTaxiNodesForMap);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetAllTaxiNodes",
&Script_GetAllTaxiNodes);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiRoute",
&Script_GetTaxiRoute);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+139
View File
@@ -0,0 +1,139 @@
// 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_TaxiMap.GetTaxiPaths()` and `C_TaxiMap.GetTaxiPathWaypoints(pathID)` —
// direct readers for `TaxiPath.dbc` (the directed flight-path edge list with
// costs) and `TaxiPathNode.dbc` (each path's world-coordinate waypoint
// polyline). Vanilla exposes neither; addons can't read DBCs at all, so this
// hands them the raw taxi graph + geometry.
//
// Together they let an addon build the taxi routing graph (nodes + weighted
// directed edges) and measure real flight distances (sum the 3D chords of a
// path's waypoints). Uses for a flight-path database, a map overlay, or a
// travel-time estimator — see the sibling `C_TaxiMap.GetTaxiNodesForMap` for
// the node table these edges connect.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <algorithm>
#include <cstdint>
#include <vector>
namespace Taxi::Paths {
namespace {
int IntField(const uint8_t *rec, int off) {
return *reinterpret_cast<const int *>(rec + off);
}
float FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
// `C_TaxiMap.GetTaxiPaths()` — every row of TaxiPath.dbc as
// { pathID, fromNodeID, toNodeID, cost }. Directed: A->B and B->A are two
// separate entries.
int __fastcall Script_GetTaxiPaths(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATH_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATH_RECORDS,
Offsets::VAR_TAXIPATH_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "pathID", id);
Game::Lua::SetFieldNumber(L, "fromNodeID", IntField(rec, Offsets::OFF_TAXIPATH_FROM));
Game::Lua::SetFieldNumber(L, "toNodeID", IntField(rec, Offsets::OFF_TAXIPATH_TO));
Game::Lua::SetFieldNumber(L, "cost", IntField(rec, Offsets::OFF_TAXIPATH_COST));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// `C_TaxiMap.GetTaxiPathWaypoints(pathID)` — the path's waypoints as
// { x, y, z, mapID }, ordered by node index (flight order). Returns nothing
// for a missing / unknown pathID or an empty path.
int __fastcall Script_GetTaxiPathWaypoints(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int wantPath = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (wantPath <= 0)
return 0;
struct Waypoint {
int index;
float x, y, z;
int mapID;
};
std::vector<Waypoint> pts;
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATHNODE_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATHNODE_RECORDS,
Offsets::VAR_TAXIPATHNODE_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (IntField(rec, Offsets::OFF_TAXIPATHNODE_PATH_ID) != wantPath)
continue;
pts.push_back({IntField(rec, Offsets::OFF_TAXIPATHNODE_INDEX),
FloatField(rec, Offsets::OFF_TAXIPATHNODE_X),
FloatField(rec, Offsets::OFF_TAXIPATHNODE_Y),
FloatField(rec, Offsets::OFF_TAXIPATHNODE_Z),
IntField(rec, Offsets::OFF_TAXIPATHNODE_MAP)});
}
if (pts.empty())
return 0;
// DBC rows for a path are stored in index order, but sort to guarantee a
// dense 1-based (ipairs-safe) array in flight order regardless.
std::sort(pts.begin(), pts.end(),
[](const Waypoint &a, const Waypoint &b) { return a.index < b.index; });
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
int outIdx = 0;
for (const Waypoint &p : pts) {
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "x", p.x);
Game::Lua::SetFieldNumber(L, "y", p.y);
Game::Lua::SetFieldNumber(L, "z", p.z);
Game::Lua::SetFieldNumber(L, "mapID", p.mapID);
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiPaths",
&Script_GetTaxiPaths);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiPathWaypoints",
&Script_GetTaxiPathWaypoints);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Taxi::Paths
+111
View File
@@ -47,6 +47,7 @@
#include "item/Count.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -499,6 +500,108 @@ int __fastcall Script_TargetTotem(void *L) {
return 0;
}
// ---- GameTooltip:SetTotem(slot) -------------------------------------------
//
// Built with the engine's own tooltip primitives — the per-tooltip clear
// and the raw add-line — exactly as retail's `Script_GameTooltip_SetTotem`
// does (no Lua method dispatch). Colors are the packed `{b,g,r,a}` values
// `FUN_GAMETOOLTIP_ADD_LINE` wants, pulled straight from `UI::ColorData`.
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using ClearTooltip_t = void(__fastcall *)(void *self);
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
// Retail's two line colors, read verbatim from the 3.3.5 binary's SetTotem
// color data: name = NORMAL_FONT_COLOR (0xFFFFD200), time = white. Both are
// already in the 0xAARRGGBB packing the line-color setter consumes.
constexpr uint32_t kNameColor = UI::ColorData::ByTag("NORMAL_FONT_COLOR");
constexpr uint32_t kTimeColor = UI::ColorData::ByTag("HIGHLIGHT_FONT_COLOR");
// Left-aligned colored line via the engine's raw add-line. `color` must
// outlive the call (its address is passed through).
void AddColoredLine(void *self, const char *text, const uint32_t &color) {
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
self, text, nullptr, &color, nullptr, 0);
}
// `GameTooltip:SetTotem(slot)` — a TBC (2.4.0) tooltip method backported to
// 1.12. Fills the tooltip with the slot's active totem, mirroring retail's
// two-line shape (3.3.5 `Script_GameTooltip_SetTotem` at 0x006205c0):
//
// <Totem Name> NORMAL_FONT_COLOR (yellow, 0xFFFFD200)
// <Time Left> white — SecondsToTimeAbbrev(SPELL_TIME_REMAINING)
//
// The time line mirrors retail's `SecondsToTimeAbbrev` with its round-up
// flag: raw seconds under a minute, minutes rounded up above. The unit
// wording is localized through the project's GlobalString helper (retail's
// `SPELL_TIME_REMAINING_SEC`/`_MIN` keys, English fallback). No-op (tooltip
// left untouched) when the slot has no active totem — same as retail's
// `+8/+0xc` timing guard. Shows itself at the end (via the engine's
// Script_Show) — retail's TotemFrame.xml OnEnter calls SetTotem with no
// trailing :Show(), so the method is expected to show.
int __fastcall Script_GameTooltipSetTotem(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE ||
!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GameTooltip:SetTotem(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (slot < 1 || slot > kSlots)
return 0;
const Slot &t = g_slots[slot - 1];
if (!t.active || t.spellID == 0)
return 0;
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return 0;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(t.spellID));
if (rec == nullptr)
return 0;
const int locale =
*reinterpret_cast<const int *>(static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
const char *name =
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
if (name == nullptr || name[0] == '\0')
return 0;
// Milliseconds remaining (0 = unknown / expired duration).
uint32_t msLeft = 0;
if (t.durationMs != 0) {
const uint32_t now = NowMs();
const uint32_t end = t.startMs + t.durationMs;
if (now < end)
msLeft = end - now;
}
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
AddColoredLine(self, name, kNameColor);
if (msLeft > 0) {
const int savedTop = Game::Lua::GetTop(L);
if (msLeft < 60000) {
Game::Lua::PushLocalizedFormatInt(L, "SPELL_TIME_REMAINING_SEC",
"%d Sec",
static_cast<int>(msLeft / 1000));
} else {
const int minutes = static_cast<int>((msLeft + 59999) / 60000); // ceil
Game::Lua::PushLocalizedFormatInt(L, "SPELL_TIME_REMAINING_MIN",
"%d Min", minutes);
}
const char *timeText = Game::Lua::ToString(L, -1);
if (timeText != nullptr)
AddColoredLine(self, timeText, kTimeColor);
Game::Lua::SetTop(L, savedTop);
}
// Show ourselves — self is still at Lua stack index 1, which is what
// Script_Show reads.
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTotemInfo", &Script_GetTotemInfo);
Game::Lua::RegisterGlobalFunction("GetTotemTimeLeft",
@@ -506,6 +609,14 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTotemDuration",
&Script_GetTotemDuration);
Game::Lua::RegisterGlobalFunction("TargetTotem", &Script_TargetTotem);
static const Game::Lua::FrameMethodEntry kTooltipMethods[] = {
{"SetTotem", &Script_GameTooltipSetTotem},
};
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY),
kTooltipMethods,
static_cast<int>(sizeof(kTooltipMethods) / sizeof(kTooltipMethods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+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/>.
#include "ui/FrameObject.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace UI::FrameObject {
namespace {
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
const char *name);
} // namespace
// Delegate wrapper construction to the engine's own
// `FrameScript_Object::ScriptRegister`. First call for a never-seen frame
// builds a `{[0] = lightuserdata}` table with the framescript metatable,
// `luaL_ref`s it into the registry, and writes the refkey to `frame +
// 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`). Subsequent calls find a
// populated refkey and just rawgeti.
//
// Why delegate instead of building our own wrapper: single source of
// truth. Every code path that pushes this frame — ours, addons'
// `CreateFrame(..., parent)` extracting the frame, the engine's own
// pushers — funnels through `rawgeti(REGISTRY, frame + 0x08)` and gets the
// same Lua table, so addon fields set on the wrapper don't get orphaned
// onto a divergent copy. See the long note in `nameplate/Info.cpp`'s
// original implementation for the pfUI-divergence story that motivated
// this.
//
// `ScriptRegister` unconditionally bumps the CObject refcount at `+0x04`,
// which pins the frame against GC. That's benign for the pooled/permanent
// frames we push here (nameplates, chat bubbles) — we call it at most
// once per frame pointer (gated by `refKey <= 0`), so the refcount tops
// out at one increment per pool slot.
//
// Defensive fallback: if the registry slot exists but isn't a table any
// more (freed elsewhere), re-register to rebuild a fresh wrapper. This
// shouldn't happen in practice — engines only unref on frame destruction.
void Push(void *L, void *frame) {
auto rawgeti = reinterpret_cast<LuaRawGetI_t>(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
auto scriptRegister = reinterpret_cast<ScriptRegister_t>(
static_cast<uintptr_t>(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
auto *cobj = static_cast<uint8_t *>(frame);
int refKey = *reinterpret_cast<const int *>(
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
if (refKey <= 0) {
scriptRegister(frame, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
}
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
Game::Lua::SetTop(L, -2);
// Slot got freed somewhere — re-register to rebuild a fresh wrapper
// and slot. Zero the refcount so `ScriptRegister` re-enters its build
// branch.
*reinterpret_cast<int *>(cobj + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
scriptRegister(frame, nullptr, nullptr);
refKey = *reinterpret_cast<const int *>(
cobj + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
}
} // namespace UI::FrameObject
+34
View File
@@ -0,0 +1,34 @@
// 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 UI::FrameObject {
// Pushes a C++ `CFrameScriptObject`-derived frame onto the Lua stack as
// its canonical wrapper table — the same object every other engine path
// resolves via `rawgeti(REGISTRY, frame + 0x08)`, so addon-set fields on
// the wrapper survive roundtrips through our API and method dispatch
// (`:GetRegions()`, `:GetName()`, …) works normally.
//
// For frames the engine created purely in C++ without ever going through
// `CreateFrame` (default nameplates, chat bubbles), the registry ref at
// `+0x08` starts at 0; the first push delegates to the engine's own
// `FrameScript_Object::ScriptRegister` to lazily build the wrapper +
// registry slot. Subsequent pushes just `rawgeti` the cached slot.
//
// Leaves exactly one value (the wrapper table, or the re-registered
// fallback) on the stack. `frame` must be non-null.
void Push(void *L, void *frame);
} // namespace UI::FrameObject
+114 -9
View File
@@ -33,6 +33,17 @@
// every world tick — when it returns null, we `Set(0)` which fires
// the event. Cost is one hash-table lookup per tick while focus is
// set; zero when no focus is active.
//
// Unit events for `focus` / `focustarget`: `Unit::TokenObserver` watches
// the focus unit's descriptor fields so `UNIT_HEALTH` / `UNIT_AURA` / …
// fire with `arg1 == "focus"` (see FocusFieldCb). `focustarget` gets the
// same treatment via a second observer — but its GUID is the focus unit's
// *current target*, which changes with no event to signal it (vanilla's
// `UNIT_FIELD_TARGET`, field 10, has no event-table slot). So the WorldTick
// watcher re-reads the focus's target each tick and re-points the
// focustarget observer only when that identity changes; the focustarget
// *events* still fire event-driven through the observer — only the
// re-pointing is tick-gated (≤1 tick lag after the focus swaps targets).
#include "Focus.h"
@@ -40,6 +51,7 @@
#include "Offsets.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include "unit/TokenObserver.h"
#include <cstdint>
@@ -52,6 +64,43 @@ const Event::Custom::AutoReserve _reserve{kEventName};
uint64_t g_focusGUID = 0;
// Descriptor-field observer callback — fires the changed field's unit event
// with the `"focus"` token, making `arg1 == "focus"` work for `UNIT_HEALTH`,
// `UNIT_MANA`, `UNIT_AURA`, … exactly like a native token. Registered per
// focus unit via `Unit::TokenObserver` (mirrors the engine's own watch of
// target/party/raid units). The event id is the field index (`fieldOffset >>
// 2`); the engine fires every unit event with format "%s" + the token, so we
// match that. Guarded on the live focus GUID so an in-flight field change that
// races an unregister can't fire a stale token.
int __fastcall FocusFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/, void * /*userArg*/) {
if (guidLo != static_cast<uint32_t>(g_focusGUID) ||
guidHi != static_cast<uint32_t>(g_focusGUID >> 32))
return 1;
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
static_cast<int>(fieldOffset >> 2), "%s", "focus");
return 1;
}
// GUID currently watched as `focustarget` (the focus unit's target), or 0.
// Re-pointed by `OnWorldTick`; distinct from `g_focusGUID`.
uint64_t g_focusTargetGUID = 0;
// Same as `FocusFieldCb` but fires the `"focustarget"` token.
int __fastcall FocusTargetFieldCb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/, void * /*userArg*/) {
if (guidLo != static_cast<uint32_t>(g_focusTargetGUID) ||
guidHi != static_cast<uint32_t>(g_focusTargetGUID >> 32))
return 1;
using Fire_t = void(__cdecl *)(int eventId, const char *fmt, ...);
reinterpret_cast<Fire_t>(static_cast<uintptr_t>(Offsets::FUN_FIRE_EVENT))(
static_cast<int>(fieldOffset >> 2), "%s", "focustarget");
return 1;
}
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
@@ -71,15 +120,39 @@ uint64_t ResolveTokenGUID(const char *token) {
// "leaves render distance → focus drops" behavior. Won't refocus
// when the unit comes back, also matching modern.
void OnWorldTick() {
if (g_focusGUID == 0)
return;
auto resolve = reinterpret_cast<ResolveByGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
if (resolve(Offsets::OBJ_TYPE_UNIT, "Focus",
static_cast<uint32_t>(g_focusGUID),
static_cast<uint32_t>(g_focusGUID >> 32),
0x172) == nullptr)
Set(0);
// Resolve the focus object (this is also the despawn check) and, if it's
// alive, read its current target — that GUID is `focustarget`. Vanilla
// fires no event when a unit re-targets, so this once-per-tick identity
// read is the only way to notice the focus switching targets; the
// focustarget *events* still fire event-driven via the observer below.
uint64_t desiredFocusTarget = 0;
if (g_focusGUID != 0) {
auto resolve = reinterpret_cast<ResolveByGUID_t>(
static_cast<uintptr_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
auto *focusObj = static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "Focus",
static_cast<uint32_t>(g_focusGUID),
static_cast<uint32_t>(g_focusGUID >> 32), 0x172));
if (focusObj == nullptr) {
Set(0); // focus left the object table → drop focus
} else {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
focusObj + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
if (fields != nullptr)
desiredFocusTarget = *reinterpret_cast<const uint64_t *>(
fields + Offsets::OFF_UNIT_FIELD_TARGET);
}
}
// Re-point the focustarget observer only when the target identity
// changes (focus swapped targets, focus cleared, or focus has no target).
if (desiredFocusTarget != g_focusTargetGUID) {
if (g_focusTargetGUID != 0)
TokenObserver::Unregister(g_focusTargetGUID, &FocusTargetFieldCb);
g_focusTargetGUID = desiredFocusTarget;
if (g_focusTargetGUID != 0)
TokenObserver::Register(g_focusTargetGUID, &FocusTargetFieldCb);
}
}
// `FocusUnit(unit)` — sets focus to whatever GUID `unit` currently
@@ -111,6 +184,24 @@ void RegisterLuaFunctions() {
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// Drop focus when the player logs out to the glue screen. The DLL isn't
// reloaded on a logout→character-select→login, so g_focusGUID would
// otherwise carry a dead GUID from character A into character B. The
// WorldTick despawn probe self-heals it within a tick (ObjectByGUID miss
// → Set(0)), but that fires a spurious PLAYER_FOCUS_CHANGED on B's first
// tick and can't cover the (astronomically rare) GUID-collision case, so
// clearing here is both cleaner and exact retail parity. We use the glue
// hook, NOT FrameScript_Initialize, because it fires on the world→glue
// return a logout triggers but NOT on /reload — so focus correctly
// survives /reload (retail behavior) and drops only on logout. No event /
// observer work: char A's focus observers already died with the object
// during world teardown, and B starts with focus nil (no event expected).
void ClearOnLogout() {
g_focusGUID = 0;
g_focusTargetGUID = 0;
}
const Game::GlueModuleAutoRegister _clearOnLogout{&ClearOnLogout};
} // namespace
uint64_t Get() { return g_focusGUID; }
@@ -118,7 +209,21 @@ uint64_t Get() { return g_focusGUID; }
void Set(uint64_t guid) {
if (guid == g_focusGUID)
return; // no-op: same target → no event fire (matches 3.3.5)
// Stop watching the old focus, start watching the new one, so unit events
// (UNIT_HEALTH, …) fire with "focus" for whatever is focused — even a unit
// the engine isn't otherwise watching. Unregister is a safe no-op if the
// old unit already despawned (nodes died with it).
if (g_focusGUID != 0)
TokenObserver::Unregister(g_focusGUID, &FocusFieldCb);
// Focus changed → the old focustarget is stale; drop its observer now.
// WorldTick re-resolves and re-points to the new focus's target next tick.
if (g_focusTargetGUID != 0) {
TokenObserver::Unregister(g_focusTargetGUID, &FocusTargetFieldCb);
g_focusTargetGUID = 0;
}
g_focusGUID = guid;
if (guid != 0)
TokenObserver::Register(guid, &FocusFieldCb);
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "");
+79
View File
@@ -0,0 +1,79 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `UnitHealthMissing(unit)` — the health deficit, i.e. the equivalent of
// `UnitHealthMax(unit) - UnitHealth(unit)`. A convenience for healing addons
// (overheal checks, "missing health" bars) that otherwise call both engine
// functions and subtract in Lua every frame. Reads the two health fields off
// the unit's descriptor directly (same path `Unit::Power` uses for POWER),
// so it's a single resolve + two field reads.
//
// Health carries no display divisor — unlike rage/happiness, HEALTH and
// MAXHEALTH are stored at face value — so the deficit is a plain subtraction,
// clamped at 0 (a transient current > max should never surface as negative).
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::Health {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn(token));
}
// `UnitHealthMissing("unit")` — returns `max - current` health (never
// negative). Returns 0 for a valid-but-absent unit (e.g. `"target"` with no
// target), matching `UnitHealth`'s 0-for-missing convention.
static int __fastcall Script_UnitHealthMissing(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitHealthMissing(\"unit\")");
return 0;
}
const uint8_t *desc = Descriptor(ResolveUnit(Game::Lua::ToString(L, 1)));
if (desc == nullptr) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_HEALTH);
const uint32_t max = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_MAXHEALTH);
Game::Lua::PushNumber(L, static_cast<double>(max > cur ? max - cur : 0u));
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitHealthMissing", &Script_UnitHealthMissing);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::Health
+8 -10
View File
@@ -289,16 +289,14 @@ const char *TokenFromGUID(uint64_t target, char *buf, size_t bufSize) {
if (GuidForToken(buf) == target) return buf;
}
// Visible nameplates. The ordered list (see
// `NamePlate::Events::g_orderedGUIDs`) is dense — `GetGUIDByIndex`
// returns 0 once we step past the populated count, terminating
// the loop. Skips the token-resolver hook + SStrCmpI cost per
// index by comparing GUIDs directly.
for (int i = 1; ; ++i) {
const uint64_t plateGuid = NamePlate::Events::GetGUIDByIndex(i);
if (plateGuid == 0)
break;
if (plateGuid == target) {
// Visible nameplates. The slot array is SPARSE (retail-exact slot
// assignment — freed middle slots stay as gaps until reused), so we
// scan `1..GetSlotCount()` and skip empties rather than stopping at
// the first 0. Compares GUIDs directly, skipping the token-resolver
// hook + SStrCmpI cost per index.
const int plateCount = NamePlate::Events::GetSlotCount();
for (int i = 1; i <= plateCount; ++i) {
if (NamePlate::Events::GetGUIDByIndex(i) == target) {
std::snprintf(buf, bufSize, "nameplate%d", i);
return buf;
}
+46 -7
View File
@@ -8,9 +8,10 @@
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
// Modern power-API polyfill: `UnitPower(unit [, type])`,
// `UnitPowerMax(unit [, type])`, and an extended `UnitPowerType(unit)`
// that returns the modern 2-tuple `(powerType, powerToken)`.
// Modern power-API polyfill: `UnitPower(unit [, type [, unmodified]])`,
// `UnitPowerMax(unit [, type [, unmodified]])`, `UnitPowerMissing(unit
// [, type [, unmodified]])`, and an extended `UnitPowerType(unit)` that
// returns the modern 2-tuple `(powerType, powerToken)`.
//
// Shape modeled on 3.3.5's `Script_UnitPower` (`0x0060ED40` in the
// Frostmourne client) and `Script_UnitPowerMax` (`0x0060EF40`). The
@@ -146,7 +147,7 @@ bool ResolveArgs(void *L, const uint8_t **outDesc, int *outType) {
// vanilla).
static int __fastcall Script_UnitPower(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitPower(\"unit\" [, type])");
Game::Lua::Error(L, "Usage: UnitPower(\"unit\" [, type [, unmodified]])");
return 0;
}
const uint8_t *desc;
@@ -157,7 +158,9 @@ static int __fastcall Script_UnitPower(void *L) {
}
const uint32_t raw = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
const uint32_t divisor = PowerDivisor(type);
// `unmodified` (arg 3, truthy) returns the raw internal value; otherwise
// apply the per-type display divisor. Matches retail's third UnitPower arg.
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
return 1;
}
@@ -167,7 +170,7 @@ static int __fastcall Script_UnitPower(void *L) {
// `UnitPower`.
static int __fastcall Script_UnitPowerMax(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitPowerMax(\"unit\" [, type])");
Game::Lua::Error(L, "Usage: UnitPowerMax(\"unit\" [, type [, unmodified]])");
return 0;
}
const uint8_t *desc;
@@ -178,11 +181,46 @@ static int __fastcall Script_UnitPowerMax(void *L) {
}
const uint32_t raw = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
const uint32_t divisor = PowerDivisor(type);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
Game::Lua::PushNumber(L, static_cast<double>(raw / divisor));
return 1;
}
// `UnitPowerMissing("unit" [, powerType [, unmodified]])` — the power
// deficit, i.e. `UnitPowerMax - UnitPower` for the given type. Same arg
// shape and fallbacks as `UnitPower`; `unmodified` (arg 3, truthy) skips
// the display divisor and returns the raw internal deficit, mirroring
// retail's third `UnitPower` arg.
//
// Computed as the difference of the two DISPLAY values (each integer-divided
// by the per-type divisor), NOT by dividing the raw difference — so it equals
// `UnitPowerMax(...) - UnitPower(...)` exactly. For rage those diverge:
// raw 1000/55 displays as 100/5, giving a deficit of 95, whereas
// `(1000-55)/10` truncates to 94.
static int __fastcall Script_UnitPowerMissing(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(
L, "Usage: UnitPowerMissing(\"unit\" [, powerType [, unmodified]])");
return 0;
}
const uint8_t *desc;
int type;
if (!ResolveArgs(L, &desc, &type)) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
const uint32_t cur = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_POWER1 + type * 4);
const uint32_t max = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_MAXPOWER1 + type * 4);
const uint32_t divisor = Game::Lua::ToBoolean(L, 3) ? 1u : PowerDivisor(type);
const uint32_t curDisp = cur / divisor;
const uint32_t maxDisp = max / divisor;
Game::Lua::PushNumber(
L, static_cast<double>(maxDisp > curDisp ? maxDisp - curDisp : 0u));
return 1;
}
// `UnitPowerType(unit)` — extended to return `(powerType, powerToken)`.
// Vanilla 1.12's implementation pushes only the integer; we chain
// to the engine's original (`0x00517940`) to preserve its full
@@ -210,6 +248,7 @@ static int __fastcall Script_UnitPowerType(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitPower", &Script_UnitPower);
Game::Lua::RegisterGlobalFunction("UnitPowerMax", &Script_UnitPowerMax);
Game::Lua::RegisterGlobalFunction("UnitPowerMissing", &Script_UnitPowerMissing);
Game::Lua::RegisterGlobalFunction("UnitPowerType", &Script_UnitPowerType);
Game::Lua::RegisterIntegerEnum(
"Enum", "PowerType",
+92
View File
@@ -0,0 +1,92 @@
// 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/>.
// `UnitSpellHaste(unit)` — backport of the TBC+ spell-haste getter to 1.12,
// which has no native haste API. It reads UNIT_MOD_CAST_SPEED (the cast-time
// multiplier at descriptor +0x22c that the server folds into
// `SpellEntry::GetCastTime` — `castTime *= GetFloatValue(UNIT_MOD_CAST_SPEED)`)
// and converts it to the modern haste PERCENTAGE:
//
// haste% = (1 / modCastSpeed - 1) * 100
//
// so `1.0` (no modifier) -> `0`, `0.5` (half cast time) -> `100`, and a
// slowing effect like Curse of Tongues (`modCastSpeed > 1.0`) -> a negative
// value. This is the same field nampower surfaces raw as `modCastSpeed`;
// `UnitSpellHaste` is the Blizzard-shaped percentage over it, so addons can
// drop the `GetUnitField("player", "modCastSpeed")` nampower dependency.
// (Consumers that need the raw multiplier can recover it as
// `1 / (1 + UnitSpellHaste(unit) / 100)`.)
//
// Vanilla has very few caster-haste sources, so this reads `0` for most
// casters most of the time — the value is meaningful for Curse of Tongues,
// Nature's Grace, and the handful of other cast-speed effects (and, as the
// cast-time work showed, whatever bleeds into the field for hunter shots).
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::SpellHaste {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Descriptor(const uint8_t *unit) {
if (unit == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
}
const uint8_t *ResolveUnit(const char *token) {
if (token == nullptr)
return nullptr;
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn(token));
}
// `UnitSpellHaste("unit")` — spell haste percentage. `0` for an unhasted or
// invalid unit; negative for a slowed one (Curse of Tongues).
static int __fastcall Script_UnitSpellHaste(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSpellHaste(\"unit\")");
return 0;
}
const uint8_t *desc = Descriptor(ResolveUnit(Game::Lua::ToString(L, 1)));
if (desc == nullptr) {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
const float mult = *reinterpret_cast<const float *>(
desc + Offsets::OFF_UNIT_MOD_CAST_SPEED);
// `mult` is the cast-time multiplier; haste% is its inverse deviation from
// 1.0. Guard a non-positive / uninitialized field against div-by-zero
// (treat it as "no haste").
const double haste = (mult > 0.0f)
? (1.0 / static_cast<double>(mult) - 1.0) * 100.0
: 0.0;
Game::Lua::PushNumber(L, haste);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitSpellHaste", &Script_UnitSpellHaste);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::SpellHaste
+128
View File
@@ -0,0 +1,128 @@
// 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/>.
// Replicates the engine's per-unit "watch descriptor fields → fire per-token
// unit events" registration (`FUN_0051bbb0` / `FUN_0051bdb0`) so synthetic
// tokens can be made first-class. The engine reaches this from
// `FUN_0060c520`, which recomputes a unit's token-category mask
// (target/party/raid/pet/mouseover) at `unit+0xc90` and, on a 0→nonzero
// transition, registers a bank-UNIT observer on every named event field with
// callback `FUN_0051bd50` → `FUN_00515e50(guid, eventId)` → the GUID→token
// reverse map `FUN_00515c50` → fire the event once per token.
//
// We register the same field set with a caller-supplied callback instead, so
// the caller fires its own token. The field set / sizes are read from the
// static event-name table exactly as the engine does; see the
// FUN_DESC_OBSERVER_REGISTER block in Offsets.h.
//
// Cross-checked against nampower's offsets.hpp: `SendUnitSignal` (the
// broadcast) = `0x00515e50`, `StringParamFormat` ("%s") = `0x0082E280`,
// `SignalEventParam` = `0x00703F50`, `UNIT_HEALTH` = 16 — all consistent with
// the field-index-is-event-id derivation here. (nampower left its
// `GetNamesFromGUID` reverse-map offset as an unfilled TODO; it is
// `FUN_00515c50`.)
#include "unit/TokenObserver.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::TokenObserver {
namespace {
using Register_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
uint32_t guidLo, uint32_t guidHi, int size,
const void *callback, void *userArg1,
void *userArg2);
using Unregister_t = void(__fastcall *)(int bank, uint32_t fieldOffset,
uint32_t guidLo, uint32_t guidHi,
const void *callback, void *userArg1);
const char *const *EventNames() {
return reinterpret_cast<const char *const *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_NAME_TABLE_STATIC));
}
// Both register and unregister funnel through `FUN_00464890`, which
// dereferences the client/player-rooted observer registry at
// `[VAR_LOCAL_PLAYER_PTR]` (reads its `+0x24` mask). That root is NULL out of
// world — login screen, loading screens, logout teardown — where the engine
// helper null-derefs (crash: `MOV EAX,[EDX+0x24]`, EDX=0). Bail when it isn't
// up: there are no live objects or observer nodes to touch anyway (nodes die
// with their objects during teardown), and in-world callers re-register/clean
// up on the next tick. Unlike `Player::Equipment` (which only ever registers
// from the enter-world hook), our consumers register from Lua / WorldTick,
// which can run mid-transition — hence the guard here.
bool RegistryReady() {
return *reinterpret_cast<void *const volatile *>(
static_cast<uintptr_t>(Offsets::VAR_LOCAL_PLAYER_PTR)) != nullptr;
}
// Bytes the engine watches for the observer on event index `i` — verbatim
// from `FUN_0051bbb0`'s size switch. The 8-byte entries are the 64-bit GUID
// fields; index 0x29 (`UNIT_FIELD_AURA`) spans the whole 0xD8 aura block.
int SizeForIndex(int i) {
switch (i) {
case 0:
case 2:
case 4:
case 10:
return 8;
case 0x29:
return 0xD8;
case 0x6B:
return 0x30;
case 0xA7:
case 0xAE:
return 0x1C;
default:
return 4;
}
}
} // namespace
void Register(uint64_t guid, FieldCallback cb) {
if (guid == 0 || cb == nullptr || !RegistryReady())
return;
auto reg = reinterpret_cast<Register_t>(Offsets::FUN_DESC_OBSERVER_REGISTER);
const char *const *names = EventNames();
const uint32_t lo = static_cast<uint32_t>(guid);
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
for (int i = 0; i < Offsets::EVENT_NAME_UNIT_MAX; ++i) {
if (names[i] == nullptr)
continue; // unnamed slot → not a real unit event
reg(Offsets::DESC_OBSERVER_BANK_UNIT, static_cast<uint32_t>(i * 4), lo, hi,
SizeForIndex(i), reinterpret_cast<const void *>(cb), nullptr, nullptr);
}
}
void Unregister(uint64_t guid, FieldCallback cb) {
if (guid == 0 || cb == nullptr || !RegistryReady())
return;
auto unreg =
reinterpret_cast<Unregister_t>(Offsets::FUN_DESC_OBSERVER_UNREGISTER);
const char *const *names = EventNames();
const uint32_t lo = static_cast<uint32_t>(guid);
const uint32_t hi = static_cast<uint32_t>(guid >> 32);
for (int i = 0; i < Offsets::EVENT_NAME_UNIT_MAX; ++i) {
if (names[i] == nullptr)
continue;
unreg(Offsets::DESC_OBSERVER_BANK_UNIT, static_cast<uint32_t>(i * 4), lo, hi,
reinterpret_cast<const void *>(cb), nullptr);
}
}
} // namespace Unit::TokenObserver
+52
View File
@@ -0,0 +1,52 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Unit::TokenObserver {
// Descriptor-field observer callback — the engine's __AUCMirrorHandler__ node
// ABI (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h): return 1,
// callee cleans 0x10. `fieldOffset` is the bank-relative offset as registered;
// the corresponding unit event id is `fieldOffset >> 2` (the descriptor field
// index equals the unit event id — health field +0x40 → event 16 =
// `UNIT_HEALTH`).
using FieldCallback = int(__fastcall *)(uint32_t fieldOffset, uint32_t size,
uint32_t guidLo, uint32_t guidHi,
const uint32_t *oldValue, void *userArg);
// Register / unregister the engine's *complete* unit-event descriptor-field
// observer set for `guid`, dispatching to `cb` on each watched field change.
// Mirrors the engine's own `FUN_0051bbb0` / `FUN_0051bdb0` (identical field
// set + per-field watched sizes), so `cb` fires for exactly the fields the
// engine treats as token-scoped unit events (`UNIT_HEALTH`, `UNIT_MANA`,
// `UNIT_AURA`, `UNIT_LEVEL`, …).
//
// This makes a synthetic token (focus, nameplateN) a first-class unit-event
// source: the engine only registers these observers for its own tokens
// (target/party/raid/pet/mouseover via the `unit+0xc90` watch mask), so a
// focused mob that isn't otherwise one of them fires no unit events until we
// watch it ourselves. The registrar appends a fresh node per (guid, field)
// with no dedup, so our observer coexists with the engine's — a unit that is
// simultaneously the target keeps firing `"target"` while ours fires its own
// token. Unregister is a safe no-op once the object is gone (the engine frees
// observer nodes with the object).
//
// `cb` runs inside SMSG_UPDATE_OBJECT dispatch; firing engine events from it
// is sanctioned (the engine's own callback `FUN_0051bd50` does exactly that).
void Register(uint64_t guid, FieldCallback cb);
void Unregister(uint64_t guid, FieldCallback cb);
} // namespace Unit::TokenObserver