Compare commits

...
50 Commits
Author SHA1 Message Date
Brues 8e9e30567c compat: hide HookScript on older pfUI forks' action bar buttons
Our DLL adds the modern HookScript widget method; older pfUI forks branch
on `if button.HookScript` and take a modern path they weren't written for
on pfActionBar buttons, breaking them. While the actionbar module builds,
wrap CreateFrame to shadow HookScript with a falsy instance field (nil
doesn't work — it's a metatable method, so the lookup falls through) so
those forks use their vanilla SetScript path. The maintained "brues" fork
is detected via the TOC X-Website tag and left alone; the wrapper is
removed once the actionbar module finishes loading.
2026-07-10 20:19:37 -05:00
Brues 8be78ad946 Fix trade skill link button positioning and add ATSW support
Make profession link button position responsive to title text changes and justification. The button now repositions whenever the title's text or visibility changes, using the title's string width and justification mode to calculate the correct anchor point. Also add support for AdvancedTradeSkillWindow and AdvancedTradeSkillWindow2 addons with appropriate link callbacks.

Closes #7
2026-07-10 18:22:25 -05:00
Brues 0dea869ab1 tradeskill: hide detail scrollbar track groove when not scrolling
The ClassTrainer detail template's decorative track pieces
($parentTop/$parentBottom) stayed visible when the slider was hidden,
leaving an empty scrollbar groove on recipes that fit. Toggle them with
the slider in UpdateDetailScrollbar (no-ops on Turtle's plain template).
2026-07-10 17:11:26 -05:00
Brues b4dffb75c0 tradeskill: reagent tooltip fires over the whole element
Extend each reagent button's hit rect rightward across the name plate
(capped short of the next column so hovers don't bleed across), so the
item tooltip shows anywhere on the element instead of only over the icon.
2026-07-10 16:56:42 -05:00
Brues 9445fd6333 tradeskill: native-style detail scroll, reagent overflow, separator
Rework the trade-skill link viewer's detail pane to match the native
1.12 TradeSkillFrame:

- Detail is now a real ScrollFrame (ClassTrainer templates on stock,
  plain on Turtle) whose scroll child holds the product header + reagent
  grid, so the whole pane pixel-scrolls and clips partial reagent rows
  instead of the old fixed 2-row faux-scroll that clipped many-reagent
  recipes. Slider is managed explicitly (shown only when content
  overflows; re-asserted a frame after show so the engine's layout pass
  can't leave a dead scrollbar on Turtle's roomy pane).
- Reagent buttons built on demand (any count) inside the scroll child.
- Add the recipe/detail separator (stock only) using the top-quarter rod
  of UI-ClassTrainer-HorizontalBar, stretched to width.
- Stock list/detail scrolls use the ClassTrainer scroll templates for the
  decorative UI-ClassTrainer-ScrollBar track; Turtle keeps plain ones.
2026-07-10 16:45:56 -05:00
Brues c8effdd6ec addons: fix crash in GetAddOnOptionalDependencies numeric path
FUN_ADDON_GET_BY_INDEX returns a name pointer, not the AddOnEntry
record, so reading the OptionalDeps offsets (+0x3C/+0x40) directly off
it landed in addon-name string memory and dereferenced garbage as an
array base -> ACCESS_VIOLATION (pfUI's addon list iterates by index and
hit it immediately). Route both numeric and string args through the
by-name hash resolver FUN_ADDON_RESOLVE_REQ_DEPS to reach the real
record, exactly as stock Script_GetAddOnDependencies does for both.
2026-07-10 02:33:14 -05:00
Brues b3ac1c07f7 addons: add C_AddOns.GetAddOnOptionalDependencies
Backport the optional-deps getter vanilla never exposed (it ships
GetAddOnDependencies for the required array only). Resolves the arg
the same way stock Script_GetAddOnDependencies does -- numeric via
FUN_ADDON_GET_BY_INDEX, string via the by-name hash lookup 0x0051E350
(returns record+0x48; subtract to recover the base) -- then walks the
OptionalDeps descriptor at record+0x38 (count +0x3C, data +0x40),
verified against the TOC parser FUN_0051C9B0. Arg/error behavior
matches stock: out-of-range index or a non-string/number arg errors;
an unknown name returns nothing.
2026-07-10 02:22:28 -05:00
Brues cfbc4f4e9c loot: add C_LootHistory (group-loot roll history backport)
Backport of the MoP C_LootHistory namespace. Vanilla 1.12 has no
loot-history store, but it receives the live group-loot roll traffic,
which the stock UI only turns into chat. This reconstructs the history
client-side (like Aura::Source): four co-hooks on the loot-roll packet
handlers accumulate a ring (128) of rolled items with per-player
Need/Greed/Pass results and the winner, exposed through the namespace.

Handlers, from the opcode dispatch FUN_005E6010:
  - START_ROLL  0x2A1 FUN_0061B310 (__fastcall(packet), RET 0)
  - ALL_PASSED  0x29E FUN_0061B640 (msg=EDX, RET 0)
  - ROLL_WON    0x29F FUN_0061B9E0 (ctx=ECX, msg=EDX, +stack, RET 4)
  - ROLL        0x2A2 FUN_0061C0B0 (msg=ECX, ctx=EDX, +stack, RET 4)
The per-handler ABI (message register, stack arg, RET) is load-bearing:
matching it exactly is what keeps the co-hooks from corrupting ESP.

Names and class come from the engine NameCache (FUN_0055F080, null-
callback read of a loaded entry), captured at roll time — so they
resolve even for a roller who has left the group by the time their roll
arrives. The item's random suffix is only in SMSG_LOOT_START_ROLL (the
result packets send 0), so it's read off the store-head node right after
that handler runs, giving a correct item:id:0:suffix:unique link.

Surface: C_LootHistory.GetNumItems / GetItem / GetPlayerInfo (name,
class token, rollType, roll, isWinner, isMe) + LOOT_HISTORY_ROLL_CHANGED
/ _ROLL_COMPLETE events (_FULL_UPDATE reserved). The MoP master-loot
functions (SetExpiration / GiveMasterLoot / CanMasterLoot) are omitted —
they need server support 1.12 lacks. Documented in docs/API.md + README.
2026-07-10 01:59:52 -05:00
Brues bb9579c398 aura: flush source cache on map transition (stuck buffs after leaving BG)
Leaving a battleground (incl. the /afk teleport out while dead) bulk-
clears the player's aura descriptor WITHOUT firing per-aura
OnAuraRemoved, so Aura::Source entries for auras that are now gone
lingered and Aura::Data's descriptor-empty fallback kept surfacing them.
The tell from the report: a real buff (filling a descriptor slot)
temporarily masks the phantoms, removing it brings them back.

Can't evict on descriptor-empty — that's the exact state the fallback
must survive (rogue stealth, party-range drops). The clean distinguisher
is a map transition: a BG/instance/continent change moves the Map.dbc
ID, while stealth/range don't. OnWorldTick now polls VAR_CURRENT_MAP_ID
and flushes the whole cache on any change (no new hook). Every other
unit from the old map has despawned, and the player's real auras re-sync
from the descriptor / PLAYER_BUFF tables on entering the new world, so a
full flush loses nothing legitimate.

Reported via pfUI issue #20.
2026-07-09 23:55:12 -05:00
Brues 5642644833 add ClassicAPITradeSkillLinkFrame to UISpecialFrames 2026-07-09 23:13:42 -05:00
Brues 58e7e1a2c6 frame: add Frame:IsEventRegistered
Vanilla has RegisterEvent / UnregisterEvent as frame methods but not the
query IsEventRegistered (added in a later expansion). There's no engine
Script_* to delegate to, so we replicate the membership check already
embedded in Frame::RegisterEvent (FUN_00702140): find the event by name
in the live event table (SStrCmpI), then walk that entry's subscriber
chain (head@+0xC, next@+4, frame@+8, low-bit/null sentinel) for this
frame. self is resolved via Game::Lua::ResolveObject, yielding the same
CFrameScriptObject* the chain stores.

Registered on the Frame method registry (VAR_FRAME_METHOD_REGISTRY,
alongside RegisterEvent) so it resolves on any frame. Pure read, no new
hooks. Returns a boolean, matching the 3.3.5-era contract; verified
in-game (true for a registered event, false otherwise). Documented in
docs/API.md and README.md.
2026-07-09 18:08:17 -05:00
Brues 5339c97b1e tradeskill: native single-column link viewer layout on stock 1.12.1
The link viewer mirrored Turtle's HD 768x512 two-column TradeSkillFrame,
whose TW-TradeSkill art doesn't exist in the base 1.12.1 client — an
earlier stretch fallback smeared the vanilla art's internal borders.

Split every layout value that differs by client into an LO table gated on
TURTLE_WOW_VERSION. On stock clients the viewer now mirrors the native
Blizzard_TradeSkillUI geometry: 384x512, single column (list on top,
detail below), built from the vanilla ClassTrainer + UI-TradeSkill
quadrant art at native size, 8 visible rows, and the Exit button placed
exactly where the base TradeSkillCancelButton sits. Turtle keeps its
768-wide two-column layout unchanged.
2026-07-09 17:26:10 -05:00
Brues 7406ac5db1 tradeskill: add link buttons to TradeSkillFrame and CraftFrame
Backport of the WotLK "link this profession to chat" button. A
petition-icon button sits right of the window title; clicking inserts
the window's |Htrade:...|h link (C_TradeSkillUI.GetTradeSkillListLink /
GetCraftListLink) into the chat edit box, opening it if hidden. Other
players click the link to open the recipe viewer.

Both windows are LoadOnDemand addons on this client, so creation is
deferred via EventUtil.ContinueOnAddOnLoaded (fires immediately if
already loaded). Uses the native GossipFrame\PetitionGossipIcon since
the 3.3.5 UI-TradeSkill-LinkButton art doesn't exist in 1.12. Adds the
LINK_TRADESKILL_TOOLTIP locale string for the hover text.
2026-07-09 16:57:49 -05:00
Brues 1c263152e3 spell: fix negative cast/channel startTimeMs/endTimeMs on long-lived tick
C_Spell.UnitCastingInfo/UnitChannelInfo returned negative startTimeMs
and endTimeMs for users whose engine ms tick has crossed 2^31. The tick
(FUN_OS_TICKCOUNT_MS) has two backends: GetTickCount, or a scaled rdtsc.
The rdtsc value survives warm reboots (only a full power-off resets the
CPU TSC), so it can sit well past 2^31 even right after a "restart" --
which is why rebooting didn't clear the negatives. We stored the tick in
a signed int and pushed it straight to a double, so the high bit read as
the sign.

Script_GetTime (0x00515EA0) computes (double)(tick & 0xFFFFFFFF) * 0.001,
i.e. it masks the tick to the low 32 bits UNSIGNED. New PushMs helper
reinterprets our stored tick as uint32 before the double conversion,
reproducing GetTime()'s exact value so startTimeMs/endTimeMs stay
consistent with GetTime()*1000 across the whole range (both wrap together
at the ~49.7-day 2^32 boundary). Carrying full 64-bit precision would be
wrong -- it would diverge from GetTime() past 2^32 and break addons'
(now-start)/(end-start) math.

Routes all four timestamp pushes (cast + channel, local and remote)
through PushMs. The aura expirationTime path already handled the tick as
unsigned, so no change there.
2026-07-09 13:10:57 -05:00
Brues ab211962e5 unit: fix IsStealthed via UNIT_FIELD_BYTES_1 sneak flag
The old IsStealthed read descriptor +0x17C & 0x02, which is actually
UNIT_FIELD_AURALEVELS (per-slot caster-level bytes) - it only worked by
coincidence on the original test char and read false at level 60
(0x3C & 0x02 == 0), so Prowl/Stealth reported not-stealthed.

_classicapi_DiffAll on a Prowl toggle from a Cat-Form baseline showed
the real signal: UNIT_FIELD_BYTES_1 (+0x210) byte 3 flips 0x00->0x02
(the sneak/anim-tier flag the client render path reads to draw a
stealthed unit semi-transparent); everything else that changed was
aura-array slot metadata. IsStealthed now reads that broadcast bit
directly - verified in-game for Rogue Stealth, Druid Prowl, and
Shadowmeld, with no mount false-positive.

Also refactored the shared buff-slot scan into FindPlayerBuffWithEffect
(Dismount still uses it for the mount aura) and dropped the bogus
OFF_PLAYER_FIELD_VIS_BYTES / PLAYER_VIS_BIT_STEALTH constants.
2026-07-09 11:53:58 -05:00
Brues 33a23c563c map: make GetMapOverlays overlay tiling HD-map-patch safe
A custom patch-*.mpq that upscales the WorldMap overlay art broke the tile
resolution: the resolver read the actual BLP dimensions but did its grid and
texcoord math against a hardcoded 256px cell, so HD tiles rendered sheared
and half-clipped.

- Grid resolution detects the full-cell file size S (256*scale) and uses it
  as the partial-tile threshold instead of a literal 256, so HD edge tiles
  aren't mistaken for full ones.
- Texcoords now use content/nextPOT(content) — the engine's own WorldMapFrame
  fraction, which is scale-invariant (an HD file is POT*scale, same fraction)
  and needs no file-size guessing. Fixes overlays whose stock file was a 128
  cell (content <=128): the patch doubled them to 256, which the earlier
  S>=256 floor read as un-upscaled and halved their texcoord (the clipped
  dark patches).

Draw sizes/positions stay in logical map pixels; stock data is unchanged
(its files already are next-POT). File reads now only drive grid resolution
and the rare DBC-rounded sliver case.
2026-07-09 11:18:27 -05:00
Brues 17304929b5 tradeskill: show craftable [N] on rows, refresh on BAG_UPDATE_DELAYED
Each recipe row appends " [N]" (min over reagents of floor(onHand/required),
via GetItemCount) like Blizzard's list. The frame registers
BAG_UPDATE_DELAYED and, while shown, re-runs Refresh (row counts) and
re-renders the selected recipe's detail pane (reagent have/need) so the
numbers track the reader's bags live.
2026-07-09 00:50:36 -05:00
Brues d88f02fc90 tradeskill: tighten link-viewer frame, add Exit button + craft bottom art
- SetHitRectInsets(2,82,2,73) so the mouse/drag region matches the visible
  parchment (decoded from the corner BLPs' alpha: opaque art spans frame
  x[2..686], y[2..439]) instead of the full 768x512 texture rect.
- Bottom quadrants use the CraftFrame's TW-TradeSkill-BotLeft2/BotRight2
  (the flat button-bar bottom) and add an Exit button positioned like
  CraftCancelButton.
- Quad helper uses the bare SetPoint(point) one-arg form (enabled by the
  Script_SetPoint co-hook in src/frame/Modern.cpp).
2026-07-09 00:44:39 -05:00
Brues 1c79742cc3 cleanup 2026-07-09 00:09:12 -05:00
Brues d696a16ac4 tradeskill: load link-viewer items via the Item mixin, drop the item-info poll
The detail pane and subclass dropdown now resolve crafted items/reagents
through Item:CreateFromItemID():ContinueOnItemLoad() — each uncached item
re-renders only its own pane/dropdown when its data arrives, guarded so a
load completing after a newer selection can't clobber it. Replaces the
global GET_ITEM_INFO_RECEIVED watcher that re-derived everything on a 0.3s
debounce whenever any item loaded anywhere.

The 'All' subclass view no longer reapplies the filter (and resets scroll)
as items trickle in; have/need counts still use GetItemCount and the
subclass name still uses the synchronous GetItemInfoInstant.
2026-07-08 23:57:53 -05:00
Brues 2c1962b207 tradeskill: add recipe detail pane to the trade-skill link viewer
Clicking a recipe row now shows a Blizzard-styled detail pane: the crafted
item (or enchant) with its icon on the Class-Trainer detail-header bar, the
spell description for enchants, and the reagent grid (icon + name plate +
have/need counts) matching the CraftFrame layout. The first recipe is
auto-selected on open; selecting a row tints the row highlight to the
recipe's difficulty colour and whitens its text.

Rows are now real ClassTrainerSkillButtonTemplate buttons (the template the
CraftFrame/TradeSkillFrame use) so name rendering and spacing match exactly.
Reagent + product data (createdItem, numMade, reagents) is folded into the
existing C_TradeSkillUI.GetTradeSkillListRecipes decoder rather than adding a
new function.

The real GetTradeSkillReagentInfo family is indexed by the player's own open
window, so it can't describe a linked recipe from someone else's profession;
the reagents are read straight from Spell.dbc instead.
2026-07-08 23:15:29 -05:00
Brues 23cdbe196d spell: CastAtCursor/CastAtUnit cast exact rank by spellID, accept spell name 2026-07-08 20:12:16 -05:00
Brues 8b6760cd77 tradeskill: crafting-window-style link viewer (difficulty colors, subclass filter, spell tooltips) 2026-07-08 14:24:25 -05:00
Brues 7c2317aca1 Suppress aura cache fallback when descriptor is authoritative
Stuck auras (pfUI issue #20 part 2): a no-duration debuff (Ghost/Spirit
in battlegrounds) stayed visible after the unit resurrected. It can only
come from the Aura::Source cache fallback — the descriptor path re-reads
the live aura array each call.

The aura is cached with expirationMs == 0 (infinite), so OnWorldTick
never expires it, and its real removal came via a bulk object drop
(death/leave-visibility, then resurrect) rather than a per-aura removal,
so OnAuraRemoved never fired to evict it. The infinite entry lingered and
the fallback resurrected it on the now-alive unit.

The fallback only exists for rogue stealth and party range fluctuation,
which both clear the unit's entire aura array at once. Gate both fallback
entry points on DescriptorHasVisibleAura: if the descriptor exposes any
live aura it's authoritative, so a cache-only aura is genuinely gone.
Stealth/range recovery is unaffected — those produce a wholesale-empty
aura array, so the fallback still fires there exactly as before.
2026-07-08 11:18:37 -05:00
Brues b764b14df1 Filter hidden auras in cache fallback path
Add visibility check to `FallbackEligible` to prevent hidden/internal auras (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE) from leaking through as phantom buffs. The descriptor path already drops these via `IsSlotPopulated`; the cache fallback must apply the same engine predicate to avoid surfacing invisible auras.
2026-07-08 10:55:33 -05:00
Brues fc1bd30bef tradeskill: add trade-skill list links (GetTradeSkillListLink/GetCraftListLink + viewer) 2026-07-08 00:18:07 -05:00
Brues fe29ec050c loc: fire LOSS_OF_CONTROL_UPDATE with unit payload ("player") 2026-07-07 17:46:51 -05:00
Brues 7fafb89d1d docs: move LOSS_OF_CONTROL events into the Events section 2026-07-07 17:40:53 -05:00
Brues bcefe3b87f loc: add C_LossOfControl (data query + ADDED/UPDATE events)
Synthesize the modern C_LossOfControl surface for the local player, which
vanilla lacks entirely (a Mists-era aggregation layer):

- SCHOOL_INTERRUPT: co-hook the SMSG_SPELL_COOLDOWN handler (FUN_006e9460,
  same __stdcall(opCode*, CDataStore*) shape/registrar as Spell::Cast's
  failure/channel hooks). Turtle's Player::ProhibitSpellSchool sends the
  lockout as ONE cooldown packet listing every spell of the interrupted
  school at one uniform duration -- a shape nothing else emits (verified
  against the server source) -- so a player-GUID batch of >=2 same-school
  spells with equal duration authoritatively yields the locked school
  (1<<Spell.dbc School) + duration. The interrupter isn't in the packet, so
  spellID=0 / displayText="Interrupted".

- CC types (STUN/FEAR/ROOT/CONFUSE/CHARM/POSSESS/SILENCE/PACIFY/
  PACIFYSILENCE/DISARM): scan the player's debuffs, classify each by its
  EffectApplyAuraName (values authoritative from the server's SPELL_AURA
  table). Timing is best-effort from the Aura::Source cache (nil when the
  applying cast wasn't observed -- the fields are nullable).

- LOSS_OF_CONTROL_ADDED / LOSS_OF_CONTROL_UPDATE: a WorldTick poll-and-diff
  of the active set fires ADDED (with the 1-based eventIndex) per new effect
  and one coalesced UPDATE on any change. Chosen over event-driven aura hooks
  because effect EXPIRY has no engine packet (a tick is needed regardless),
  and the WorldTick tail keeps Lua handlers out of the mid-packet contexts
  the state updates in. Scan is bounded (16 debuff slots) and idle-cheap.

Placeholders with no vanilla equivalent: priority=0, displayType=2,
auraInstanceID=nil; iconTexture is the icon path (no FileDataIDs in vanilla).

Verified in-game: Polymorph -> CONFUSE, correct name/icon/duration, nil once
worn off; a counterspelled heal -> SCHOOL_INTERRUPT (lockoutSchool + 10s) +
SILENCE (4s), with two ADDED at cast and UPDATE as each expires.
2026-07-07 17:28:45 -05:00
Brues 9e03f69e9b xml: add C_XMLUtil.GetTemplateInfo() and DoesTemplateExist()
Both resolve by name through the engine's own template lookup
(FUN_006ee6f0) -- the same path inherits= uses -- so they're
case-insensitive and cover Blizzard FrameXML plus addon templates.

GetTemplateInfo returns { type, width, height, keyValues, inherits } or
nil if the template doesn't exist. type is the element tag; width/height
are the RAW values from a <Size> element declared directly on the
template (0 if none / inherited) -- read straight from the attributes
rather than via the engine's Size parser (FUN_006f1eb0), which applies
UI-scale conversion we don't want here. inherits is the inherits=
attribute or nil. keyValues is always empty (vanilla's XML schema has no
<KeyValues> element) and sourceLocation is omitted (a 10.2 feature;
vanilla records no per-node file/line) -- both parity-safe.

DoesTemplateExist returns a bool.

Verified in-game: DoesTemplateExist true/false as expected; GetTemplate-
Info nil for a missing name; StatFrameTemplate -> 104x13; FriendsFrame-
TabTemplate -> inherits "CharacterFrameTabButtonTemplate".
2026-07-06 22:26:15 -05:00
Brues 694a7415ab xml: add C_XMLUtil.GetTemplates()
Backport the modern C_XMLUtil.GetTemplates() -- enumerate every
registered virtual XML template as { name, type }.

The engine keeps virtual templates (the <Frame virtual="true"> etc. that
inherits= targets) in a Storm hash table. The XML parser (FUN_006ede10)
registers each via FUN_006ee500 and the inherits resolver reads the same
table via FUN_006ee6f0, so it's the authoritative template store. The
new module walks it: each node carries the template name (+0x14) and its
parsed definition node (+0x18), whose element tag string (+0x8) is the
frame type ("Frame" / "Button" / ...).

Covers both Blizzard FrameXML and addon templates; rebuilt on /reload.
Fonts are a separate registry and correctly excluded (matching retail).
Returns an empty table before the first template registers (mask ==
0xFFFFFFFF). Pure read, no hooks. Verified in-game: 212 templates,
entry 1 = { name="AtlasLootDefaultFrameButtonTemplate", type="Button" }.
2026-07-06 21:39:55 -05:00
Brues 23b102c60c spell/Cast: clear player channel on early stop; unify player-object access
UnitChannelInfo showed a stale "Ritual of Summoning" countdown after
helping a warlock summon: g_channel only cleared at its computed endMs
or when a timed cast superseded it, so a ritual that ends early (when the
summon fills) lingered, and a following instant cast -- which supersedes
neither cast timing nor the channel -- couldn't dislodge it.

OnWorldTick now polls the player's UNIT_FIELD_CHANNEL_SPELL (+0x228) and
clears g_channel when it drops to 0, gated by a confirm latch so the
field's ~1-tick start lag can't wipe a freshly stamped channel. Both
channel-stamp sites route through a new StampChannel helper that re-arms
the latch. Also fixes early-ended channels generally (interrupt / cancel
/ LoS break), which previously self-expired only at endMs.

Cleanup: factor the player-object/descriptor read into shared
Unit::Identity::PlayerObject() / PlayerDescriptor(), now used by
State.cpp, Cast.cpp's poll, and ComboDuration. PlayerObject() uses the
object-manager GUID resolve (FUN_OBJECT_RESOLVE_BY_GUID) -- the fastest
player-object path, non-throwing (null pre-world) -- replacing the
duplicated resolves (State.cpp's token resolver, ComboDuration's
hand-rolled GUID plumbing).
2026-07-06 19:52:01 -05:00
Brues e165aa6cd4 README: add Frame backports, RegisterComboDuration, GetMapOverlays rows 2026-07-06 00:56:29 -05:00
Brues 8ed32e6c8e C_Map.GetMapOverlays: zone overlays from WorldMapOverlay.dbc
Every overlay of a zone regardless of exploration, replacing the
hand-measured tables map-reveal addons ship (pfUI pfMapOverlayData).
The engine's GetNumMapOverlays/GetMapOverlayInfo only expose
DISCOVERED overlays -- the exploration gate is a server-fed ID list at
0x00B6E630 -- so unexplored areas were previously undrawable without
per-server data. Reads WorldMapOverlay.dbc directly instead, scoped by
the zone's WorldMapArea row (never by folder contents).

No argument = the world map's currently viewed zone (same
continent/zone resolution GetMapInfo performs, globals 0x0084506C /
0x00845070 / 0x00B6E668); numeric argument = AreaTable areaID, the
same identity C_Map.GetBestMapForUnit returns.

Tile grids are resolved in the DLL rather than left to consumers,
because Octo's shipped data breaks the canonical ceil(w/256) layout
three ways (mirroring the InkLab map generator's empirically-derived
disambiguation):
- sliver columns the DBC rect rounds away (Icepoint's Kaneq'nuun has a
  real 8px third column; DBC w=512 implies 2 -- naive layout shears
  the overlay into misplaced strips, the one visibly broken overlay);
- foreign tiles appended to an overlay's number sequence (Kaneqnuun
  ships 12 tiles, the true grid is 3x2 = 6);
- same-rect higher-resolution re-exports (Stonetalon's WindshearCrag:
  4 real tiles for a 1-tile rect, kept and scaled).
The true grid comes from the actual BLP header dimensions (width @
+0x0C, height @ +0x10, BLP1 and BLP2 alike) read through the engine's
own VFS reader, since only a row's last tile may be narrower than 256.
A sweep of all 707 overlays found 108 art/DBC mismatches; Kaneq'nuun
is the only shear-class one, matching the single visible artifact.
Resolution is cached per overlay per session.

Each overlay returns the DBC fields plus a ready-to-draw `tiles`
array (file / width / height / texCoordX / texCoordY / offsetX /
offsetY) so consumers need no grid math at all. WorldMapArea.dbc and
WorldMapOverlay.dbc schemas verified against fresh extractions and
the engine's own Script_GetMapOverlayInfo (0x004A8A00).
2026-07-06 00:54:57 -05:00
Brues 26da489773 re-enable HookScript 2026-07-05 23:40:49 -05:00
Brues 9c73430ddd temporary disable HookScript 2026-07-05 23:40:04 -05:00
Brues e712af11a5 Drop stale WOW_PROJECT_ID rationale from Frame backport docs 2026-07-05 18:19:34 -05:00
Brues 45291cb16b Modern Region/Frame method backports (Frame::Modern)
ClassicAPI advertises WOW_PROJECT_ID = WOW_PROJECT_CLASSIC, which
steers modern addon ports onto their Classic Era code paths -- where
they also assume Era's C-level frame behaviors. Supply the missing
ones (surfaced by AI_VoiceOver's Era path, but generic to every port):

- region:SetPoint("RIGHT") -- the one-arg form. Vanilla's parser
  accepts every other modern shape but raises the usage error when
  arg 3 is fully omitted (LUA_TNONE fails its type validation); a
  co-hook on Script_SetPoint (0x007A2540, the ONE implementation in
  the Region base table) normalizes it to (point, 0, 0) -- vanilla's
  parent-relative form, exactly the modern semantic.
- region:SetSize / region:GetSize -- registered on the REGION base
  method registry (ctx 0x00CF54B4), so they resolve on frames,
  buttons, textures, fontstrings -- including engine-created textures
  (GetNormalTexture():SetSize(...)).
- frame:SetShown -- Show/Hide are per-branch script functions with
  their own object typechecks, so it's registered separately on
  Frame / Texture / FontString with matching delegates.
- frame:SetResizeBounds -- modern rename of SetMinResize/SetMaxResize.
- frame:HookScript -- absent in vanilla entirely. Chains the old
  handler (invoked vanilla-style, reading the this/event/argN
  globals), then invokes the hook handler with modern positional args
  ((frame, event, arg1..arg9) for OnEvent, (frame, arg1..arg9)
  otherwise) via a C closure over (handler, old, isOnEvent).

All methods delegate to the engine's own Script_* implementations by
reshaping the Lua stack -- no re-derived setters, so UI-scale
conversion, object resolution, and type checking stay engine code.
Method-registry contexts beyond GameTooltip's (Region / Frame /
Texture / FontString) are now in Offsets.h per the
docs/BlizzardScriptAPI.md catalogue.

Verified in-game: GameTooltip:GetSize() returns real dimensions,
CreateFrame + SetSize(50,50) reads back 50, and AI_VoiceOver's Era
path runs through its entire frame construction (SetPoint one-arg,
SetSize, SetShown, SetResizeBounds, 14 HookScript sites) where it
previously errored on the first call.
2026-07-05 18:16:47 -05:00
Brues 9bfbb2e6b0 We have other ways to detect 1.12.1 and this is causing conflict 2026-07-05 18:16:13 -05:00
Brues d6bf05c12c Combo-point finisher durations for C_UnitAuras
Rupture at 4 combo points reported duration=6 (the SpellDuration.dbc
base) instead of the real 14s: finisher durations are computed
server-side at cast time and 1.12 never transmits them for debuffs the
player puts on other units. Verified with a whole-protocol sniff --
SMSG_UPDATE_AURA_DURATION (0x137) is self-scoped (slot+duration for the
player's own aura array), 0x2DD has no client handler and never
arrives, and nothing else carries the value. (pfUI issue #17.)

Mirror the server's computation instead (new Aura::ComboDuration):

- Snapshot combo points ([player+0xE68]+0x1029, the field behind
  Script_GetComboPoints) the instant CMSG_CAST_SPELL leaves, via a
  co-hook on the NetClient send funnel (FUN_NET_SEND 0x005379A0). The
  server consumes CP before SMSG_SPELL_GO returns, so reading at
  SpellGo time is too late; the send funnel also covers
  nampower-queued casts, which bypass any Spell_C_CastSpell co-hook
  via trampoline re-entry.
- Compute base + (max - base) * CP / 5 from the spell's own
  SpellDuration.dbc row ((v)mangos CalculateSpellDuration), then apply
  the player's duration SpellMods, matching server order. DBC-driven
  and spell-agnostic: finishers are exactly the rows with base != max
  (Rupture 6/16s, Kidney Shot 1/6s, r1 0/5s, Slice and Dice 6/21s;
  Expose Armor 30/30 self-disables). Consumed by Aura::Source's
  SpellGo hook; 0 falls through to the existing base path.
- Turtle's reworked Rip (8s + 2s per CP) has NO client data: all six
  ranks' DurationIndex points at SpellDuration row 87, which Turtle
  added server-side only -- the client table jumps 86 -> 105. Built-in
  values cover it, gated on the dangling-row condition itself (the
  only dangling cross-reference in the entire client Spell.dbc, per a
  full DurationIndex/CastingTimeIndex/RangeIndex sweep) rather than on
  realm detection: stock clients resolve Rip's index normally, and if
  Turtle ships the missing row the DBC takes over automatically.
- C_UnitAuras.RegisterComboDuration(spellID, baseSec, maxSec) as an
  escape hatch for other servers with the same class of client-data
  gap; a registered override beats DBC and built-ins.

Verified in-game: Rupture at 4 CP now reads duration=14.
2026-07-05 17:12:10 -05:00
Brues 07a4cbde43 Evict remote casts via CGUnit_C::ClearCastingSpell choke point
Turtle's core doesn't broadcast SMSG_SPELL_FAILURE or
SMSG_SPELL_FAILED_OTHER on interrupt (verified by logging both
handlers through live tests), so the SPELL_FAILED_OTHER co-hook never
fired and interrupted enemy casts (Kick / Earth Shock / Counterspell)
lingered as ghost bars. What actually stops the cast visual on Turtle
is SuperWoW calling CGUnit_C::ClearCastingSpell (0x0060D040) directly.

Co-hook that choke point instead: every unit-stopped-casting path
funnels through it (failure packets, SPELL_GO completion, movement,
death, animation events, local cancel), gated on the unit's live
current-cast field at +0xC8C, so eviction is independent of which
signal the server sends. Both failure-packet handlers stay co-hooked
as backstops for servers that do broadcast them. Verified in-game:
all three interrupts evict; non-interrupting damage doesn't
false-evict.

Also confirmed via a whole-protocol sniff on the NetClient message
dispatch (FUN_NET_MESSAGE_DISPATCH = 0x00537AA0, documented for
future use): SMSG_SPELL_DELAYED goes only to the affected caster --
observers receive no packet of any kind for pushback, so remote bars
can't stretch. Documented as an inherent 1.12 protocol limitation.
2026-07-05 01:57:18 -05:00
Brues 017dc5b6f1 Add UPDATE_INVENTORY_DURABILITY (TODO #89)
Fires (no payload) when any equipped item's durability changes -
combat damage, death, repairs - via a co-hook on the engine's
inventory-alerts recompute (FUN_004C7EE0, the routine behind vanilla's
UPDATE_INVENTORY_ALERTS). The engine calls it whenever an owned item's
fields change (the item post-update handler FUN_005D9A90 routes every
item values-update through it) plus equip/enter-world paths, so it is
the engine's own durability-may-have-changed signal. Post-original we
diff a per-slot {itemGuid, durability} snapshot and fire once per
recompute with a real change - a death dinging all armor produces one
event, not nineteen.

The snapshot seeds silently on the first recompute of the process and
is deliberately NOT re-seeded per enter-world: death/release/graveyard
teleports re-run the setup, and a per-setup reset swallowed the
death's own durability loss (verified in-game; repairs fired, deaths
didn't until this was fixed).

Dead end, kept documented at OFF_DESC_ITEM_DURABILITY: bank-1 (ITEM
field) descriptor observers register without error but are never
invoked - item values-updates demonstrably arrive and player-bank
observers fire in the same session; registration arithmetic fully
verified (bank base FUN_00465690(1)=0x18, bank walk item 0->1->2).
Prime suspect is the registrar's internal wrapper lookup
(FUN_00464890, the observer registry, distinct from the object
manager) silently no-op'ing for item objects.

Also in Player::Equipment: resolve the player via the non-throwing
GUID resolver (FUN_00468460, what the engine's own bag observer uses)
instead of FUN_RESOLVE_UNIT_TOKEN, which raises a Lua error when the
token doesn't resolve - unsafe inside SMSG_UPDATE_OBJECT processing
where the player can be mid-create.
2026-07-05 00:23:01 -05:00
Brues ddcb6ba9f0 Add C_QuestLog.GetNumQuestObjectives(questID)
The quest's objective count — the length GetQuestDetails(questID)
.requirements would have — read straight from the cache block with no
Lua tables or strings materialized. nil when the quest isn't cached
(same contract as GetQuestDetails).

Exists because materializing full detail tables in a login-path loop
is expensive at warm cache: GetQuestDetails copies description /
objectives / completion text into Lua strings (~5 KB per quest).
TurtleGuide's guide-load OIDX validation did exactly that for every
tagged step — fine on the first (cold-cache) session, but once the
quest cache was warm from questcache.wdb every subsequent login
exhausted vanilla's fixed Lua memory pool and crashed the client in
lmemPool.cpp:53. With this accessor the same validation costs a few
dozen byte reads regardless of cache temperature.
2026-07-04 20:02:25 -05:00
Brues 17cf323a52 Add PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent) (TODO #88)
The earlier investigation tried to hook a firer of
UNIT_INVENTORY_CHANGED; none of the three known 0xBA fire sites
trigger on equip because equipment descriptor writes are silent — the
engine registers descriptor-field observers (the __AUCMirrorHandler__
system: registrar 0x00467E70, dispatcher 0x00465570 with per-node
mirror + memcmp change detection) for the bag/backpack/bank/keyring
GUID ranges but never for equipment 0x4A8..0x538.

So register our own: co-hook the engine's inventory observer setup
(FUN_004F8CC0, once per enter-world) and, post-original, register one
observer per equipment GUID field through the engine's registrar —
same bank/size/GUID shape as the engine's bag observers, engine-
managed lifetime, no polling, no hot-function hook. The dispatcher
invokes us only on a real change.

Callback ABI verified from the dispatcher call site at 0x004655DC and
the engine callback FUN_004F8DB0's RET 0x10:
  __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
             oldValue*, userArg) -> ret 0x10

equipmentSlot is the 1-based Lua inventory slot (1 head .. 19 tabard);
hasCurrent (1/nil) is read from the player invMgr GUID array — the
same post-change truth the engine's bag observer reads.
2026-07-04 17:01:01 -05:00
Brues a0b7d03c28 C_GossipInfo: session gating + quest-greeting support
Vanilla has two disjoint questgiver paths. Gossip NPCs send
SMSG_GOSSIP_MESSAGE into the gossip arrays this namespace reads; but
quest-only NPCs send SMSG_QUESTGIVER_QUEST_LIST (the QUEST_GREETING
event / QuestFrameGreetingPanel), whose data lands in entirely
separate storage C_GossipInfo never read — every getter returned
empty for those NPCs. GetText was also ungated: the gossip text
buffer persists after close, so it served the previous gossip NPC's
greeting.

- Gate all gossip reads on the live gossip NPC GUID (0x00BC3F58,
  cleared by the close worker at 0x004E1FA0 before GOSSIP_CLOSED
  fires). Stale text/options/quests are no longer visible, and
  selectors can't send a CMSG from stale slots.
- Serve the quest-greeting session when one is live instead: the
  questgiver GUID (0x00BE0810) must match the greeting-fill GUID
  (0x00C4D740) so leftovers can't leak when a quest session starts
  from a gossip-menu click. GetText returns the greeting text;
  Get{Available,Active}Quests / GetNum* read the greeting arrays
  (32 x 0x4C entries {questID, level, title}) with isComplete
  recovered from the packet handler's scratch status array
  (status 4 = turn-in-ready); Select{Available,Active}Quest route to
  the engine's greeting selectors (0x005012A0 / 0x00501320).
- Neither session live: empty tables, 0 counts, empty text.

GetOptions/GetNumOptions stay gossip-only — greetings have no
options. Retail keeps the two APIs disjoint; merging is deliberate so
modern-addon ports work on greeting-path NPCs (common on 1.12
servers).
2026-07-04 14:27:06 -05:00
Brues 15cae8a474 Add QUEST_REMOVED event; refactor quest log hook
Introduce QUEST_REMOVED (fires with questID) and document it in API.md and README. Rename Quest::Accepted → Quest::LogEvents and update the quest-log rebuild hook to compute pre/post deltas for both QUEST_ACCEPTED (questLogIndex, questID) and QUEST_REMOVED (questID). Add suppression rules to avoid firing on login/resync bulk changes. Update Offsets.h commentary to reflect the new LogEvents module and its responsibilities.
2026-07-04 12:04:27 -05:00
Brues 49865e9b9e Fix cast-then-channel spells showing the channel bar at cast start
Mind Control is a 3 s cast on a spell flagged CHANNELED with a 60 s
duration. HandleSpellStart branched on the channel attribute alone, so
at cast start it stamped g_channel with the 60 s duration and killed
the client hook's correct 3 s cast stamp — the bar showed a premature
60 s countdown (pfUI #9). Same story remotely: an observed MC stored a
60 s channel entry instead of the 3 s cast.

- Treat a channeled spell with castTime > 0 as its CAST phase in
  HandleSpellStart (player and remote paths); instant channels keep
  the immediate stamp.
- Co-hook the MSG_CHANNEL_START handler (0x006E7550, layout from
  nampower's SpellChannelStartHandlerHook: spellId u32, duration u32).
  The server sends it to the caster the moment the channel begins —
  after MC's cast completes — with the server-authoritative duration.
  Stamp g_channel there, before the original fires
  SPELLCAST_CHANNEL_START, so pollers see fresh data. For instant
  channels it re-stamps what the SpellStart path just wrote.

Verified: MC shows 3 s cast then 60 s channel; Mind Flay-style instant
channels unaffected.
2026-07-03 21:36:42 -05:00
Brues 98d4504b6f CBOR encode: keep encoding through OutOfMemory so the resize tally is complete
tinycbor's grow-and-retry contract: on buffer overflow the encoder
switches to counting mode and encoding must CONTINUE for
cbor_encoder_get_extra_bytes_needed() to be accurate — a nested
container's tally only reaches the parent via close_container. The old
code returned false on the first CborErrorOutOfMemory, so a mid-
container overflow could report extra == 0 and be misread as a genuine
"value not representable" failure (returning nil), or undercount and
churn through multiple resize passes.

Add EncOk() (NoError or OutOfMemory = keep going) around every encode
call; the retry loop now distinguishes real failures (return nil) from
overflow (single complete-tally resize, retry).
2026-07-03 20:48:39 -05:00
Brues 597530d558 Evict interrupted casts via SMSG_SPELL_FAILED_OTHER (TODO #98)
Remote casts cached from SMSG_SPELL_START were expired only by their
computed end time — an interrupted remote cast lingered as a ghost bar
for every UnitCastingInfo consumer (pfUI #11). The interrupt signal
does exist at the packet layer: the server broadcasts
SMSG_SPELL_FAILED_OTHER to observers whenever a started cast aborts
(interrupt, death, movement, fizzle).

Co-hook its handler (0x006E8E40, same __stdcall shape as
FUN_SPELL_DELAYED; address + layout mirrored from nampower's
SpellFailedOtherHandlerHook — body is plain casterGuid(u64),
spellId(u32)) and:

- evict the matching g_remoteCasts entry (guid + spellID), so remote
  cast/channel bars drop the same tick the cast dies;
- clear the player's own g_cast on a guid match — covers the
  server-stamped chained recast that the WorldTick VAR==0 poll can't
  clear (self-receipt is server-dependent; the endMs self-expiry
  backstop remains if it never fires).

Update the stale 'no per-unit interrupt signal in 1.12' notes in
Cast.cpp and docs/API.md.
2026-07-03 20:24:14 -05:00
Brues 2165a8e986 Restore TTS with VanillaTTS coexistence
Re-add the C_VoiceChat / C_TTSSettings SAPI module removed in 6f543ec,
gated so it coexists with the standalone VanillaTTS DLL: when
VanillaTTS.dll is loaded in the process, the module stands down
entirely — no Lua functions, no ttsVoice/ttsSpeed/ttsVolume cvars, no
VOICE_CHAT_TTS_* event reservations, no SAPI init. Without the gate,
each DLL would claim its own event-table slot for the same event names
(listeners bind to one slot while the other DLL fires the other) and
the cvar registrar would replace the first DLL's clamp callbacks.

The check (GetModuleHandleA at RegisterLuaFunctions time) is
load-order-independent — VanillaFixes loads every DLL at process
attach, long before FUN_LOAD_SCRIPT_FUNCTIONS fires. The event
AutoReserves move from file scope into the gated registration as
function-local statics so a stand-down leaves no reservations behind.

Restore the ole32 link, the README VoiceChat/API rows + event table,
and the docs/API.md VoiceChat section (with a coexistence note).
2026-07-03 19:00:17 -05:00
34 changed files with 6695 additions and 245 deletions
+1
View File
@@ -35,5 +35,6 @@ Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
+28
View File
@@ -8,5 +8,33 @@ if C_AddOns.DoesAddOnExist('pfUI') then
unknownColor.colorStr = unknownColor:GenerateHexColor()
return unknownColor
end})
-- Compatibility with OLDER forks of pfUI. Our DLL adds the modern
-- HookScript widget method; older pfUI forks branch on
-- `if button.HookScript` and take a modern code path they were never
-- written for on their pfActionBar buttons, which breaks them. While
-- the actionbar module builds those buttons, temporarily wrap
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The maintained "brues"
-- fork handles HookScript itself, so it's detected via the TOC
-- X-Website tag and left alone. The wrapper is removed as soon as the
-- actionbar module finishes loading so it affects nothing else.
local website = GetAddOnMetadata("pfUI", "X-Website")
if not website or not string.find(GetAddOnMetadata("pfUI", "X-Website") or '', 'brues') then
local _createFrame = CreateFrame
CreateFrame = function(frameType, name, parent, template)
if frameType == "Button" and string.find(name or "", "pfActionBar") then
local frame = _createFrame(frameType, name, parent, template)
frame.HookScript = false
return frame
end
return _createFrame(frameType, name, parent, template)
end
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
if moduleName == "actionbar" then
CreateFrame = _createFrame
end
end)
end
end)
end
-8
View File
@@ -123,14 +123,6 @@ PLAYER_FACTION_COLORS =
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
};
WOW_PROJECT_MAINLINE = 1;
WOW_PROJECT_CLASSIC = 2;
WOW_PROJECT_BURNING_CRUSADE_CLASSIC = 5;
WOW_PROJECT_WRATH_CLASSIC = 11;
WOW_PROJECT_CATACLYSM_CLASSIC = 14;
WOW_PROJECT_MISTS_CLASSIC = 19;
WOW_PROJECT_ID = WOW_PROJECT_CLASSIC;
--
-- Glyph
--
@@ -0,0 +1,855 @@
-- Trade-skill *list* links (`|Htrade:...|h`) — backport of the 2.x+ shareable
-- profession link. The link is built entirely in the DLL
-- (`GetTradeSkillListLink` / `GetCraftListLink`, see src/tradeskill/Link.cpp);
-- this file only intercepts clicks on received links and renders the linker's
-- recipe list in a scroll frame (the UI half).
--
-- link: |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
--
-- "Which recipes are known" is a bitfield over the skill line's canonical
-- recipe list (deterministic per DBC). The viewer lists the recipes the linker
-- knows (with an "X of Y" summary of the whole skill line), regardless of what
-- the reader can craft themselves.
-- Frame geometry mirrors the real TradeSkillFrame. Turtle ships an HD
-- 768x512 two-column frame (TW-TradeSkill art); stock 1.12.1 only has the
-- vanilla 384x512 single-column art, so on that client we fall back to the
-- native Blizzard_TradeSkillUI geometry (list on top, detail below) rather
-- than stretch the wide art. LO holds every layout value that differs.
local TURTLE = (TURTLE_WOW_VERSION ~= nil);
local FRAME_W = TURTLE and 768 or 384;
local FRAME_H = 512;
local NUM_ROWS = TURTLE and 19 or 8;
local ROW_HEIGHT = 16;
local LO = TURTLE and {
hit = { 2, 82, 2, 73 },
quads = {
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopLeft", 512, 256, "TOPLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopRight", 256, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotLeft2", 512, 256, "BOTTOMLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotRight2", 256, 256, "BOTTOMRIGHT" },
},
close = { -77, -8 },
bar = { 596, 15, 76, -44 },
barBdr = { 604, 22 },
summary = { "TOPRIGHT", -110, -70 },
drop = { 40, -60 },
scroll = { 296, 330, 37, -98 },
-- detail scroll frame: { x, y, width, height }. Tall enough that the
-- content fits (scrollbar auto-hides) — Turtle's right column is roomy.
detail = { 371, -110, 340, 290 },
exit = { "CENTER", "BOTTOMRIGHT", -128, 92 },
} or {
hit = { 0, 34, 0, 75 },
quads = {
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft", 256, 256, "TOPLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight", 128, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\UI-TradeSkill-BotLeft", 256, 256, "BOTTOMLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight", 128, 256, "BOTTOMRIGHT" },
},
close = { -29, -8 },
bar = { 268, 15, 73, -37 },
barBdr = { 278, 22 },
summary = { "TOPRIGHT", -40, -58 },
drop = { 12, -62 },
scroll = { 296, 130, 21, -96 },
-- detail scroll frame { x, y, width, height } — native TradeSkillFrame's
-- TradeSkillDetailScrollFrame values. Small enough that many-reagent
-- recipes scroll (clipping partial rows), like native.
detail = { 20, -234, 297, 176 },
exit = { "CENTER", "TOPLEFT", 305, -422 },
sep = { 15, -221, 331 },
};
-- Reagents render in a 2-column grid inside the detail scroll child (see
-- the detail pane below). The detail is a real ScrollFrame like the
-- native TradeSkillFrame, so the whole pane pixel-scrolls and clips
-- partial reagent rows — `REAGENT_ROW_HEIGHT` is just the vertical stride
-- of one reagent row.
local REAGENT_ROW_HEIGHT = 43;
-- Scroll-frame templates. Stock mimics the native TradeSkillFrame, whose
-- list/detail scrolls use the ClassTrainer templates (they add the
-- decorative UI-ClassTrainer-ScrollBar track). Turtle's HD frame art
-- doesn't want that groove, so it keeps the plain templates. Both detail
-- templates are real ScrollFrames (ClassTrainerDetail... inherits
-- UIPanelScrollFrameTemplate), so the pixel-scroll logic is identical.
local LIST_SCROLL_TEMPLATE = TURTLE and "FauxScrollFrameTemplate"
or "ClassTrainerListScrollFrameTemplate";
local DETAIL_SCROLL_TEMPLATE = TURTLE and "UIPanelScrollFrameTemplate"
or "ClassTrainerDetailScrollFrameTemplate";
local PROFESSION_ICONS = {
[171] = "Trade_Alchemy",
[164] = "Trade_BlackSmithing",
[185] = "INV_Misc_Food_15", -- Cooking
[333] = "Trade_Engraving", -- Enchanting
[202] = "Trade_Engineering",
[129] = "Spell_Holy_SealOfSacrifice", -- First Aid
[165] = "INV_Misc_ArmorKit_17", -- Leatherworking
[197] = "Trade_Tailoring",
[186] = "Trade_Mining",
[182] = "Trade_Herbalism",
[393] = "INV_Misc_Pelt_Wolf_01", -- Skinning
[356] = "Trade_Fishing",
};
if TURTLE then
PROFESSION_ICONS[142] = "Trade_Survival"
PROFESSION_ICONS[755] = "INV_Misc_Gem_01" -- Jewelcrafting
end
local function ProfessionIcon(skillLineID)
local icon = PROFESSION_ICONS[skillLineID];
return icon and ("Interface\\Icons\\" .. icon)
or "Interface\\Icons\\INV_Misc_QuestionMark";
end
local function DifficultyBand(skill, hi, lo)
if not hi or hi <= 0 then
return 3;
end
if not lo or lo == 0 then
lo = (hi ~= 25) and (hi - 25) or 0;
end
local yellow = (hi + lo) / 2;
if skill < lo then
return 0; -- orange (optimal)
elseif skill < yellow then
return 1; -- yellow (medium)
elseif skill < hi then
return 2; -- green (easy)
end
return 3; -- grey (trivial)
end
local BAND_COLOR = {
[0] = DIFFICULT_DIFFICULTY_COLOR, -- orange
[1] = FAIR_DIFFICULTY_COLOR, -- yellow
[2] = EASY_DIFFICULTY_COLOR, -- green
[3] = TRIVIAL_DIFFICULTY_COLOR, -- grey
};
local function CraftableCount(entry)
local reagents = entry.reagents;
if not reagents or table.getn(reagents) == 0 then
return nil;
end
local best;
for i = 1, table.getn(reagents) do
local r = reagents[i];
local need = r.count or 1;
local n = math.floor((C_Item.GetItemCount(r.itemID) or 0) / need);
if not best or n < best then
best = n;
end
end
return best;
end
local function ParseTradeLink(link)
-- Current format, with the linker's name: trade:id:cur:max:Name:bits
local _, _, id, cur, max, who, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([^:]+):([%w%+/]*)$");
if not id then
-- Legacy format (no name): trade:id:cur:max:bits
who = nil;
_, _, id, cur, max, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([%w%+/]*)$");
end
if not id then
return nil;
end
return tonumber(id), tonumber(cur), tonumber(max), who, bits or "";
end
------------------------------------------------------------------------------
-- Display frame (built lazily on first use)
------------------------------------------------------------------------------
local frame;
local function ResolveSubclasses(list, onLoaded)
local present, subs, anyItem = {}, {}, false;
for i = 1, table.getn(list) do
local r = list[i];
if r.createdItem and r.createdItem > 0 then
anyItem = true;
if not r.subclass then
-- GetItemInfoInstant's 3rd return is the localized subclass
-- name (or nil when uncached).
local _, _, subType = C_Item.GetItemInfoInstant(r.createdItem);
if subType and subType ~= "" then
r.subclass = subType;
elseif onLoaded then
-- Not cached — re-derive when the item loads.
Item:CreateFromItemID(r.createdItem):ContinueOnItemLoad(onLoaded);
end
end
if r.subclass and not present[r.subclass] then
present[r.subclass] = true;
table.insert(subs, r.subclass);
end
end
end
table.sort(subs);
return subs, anyItem;
end
local SUBCLASS_ALL = {};
local function SubClassButton_OnClick()
local v = this.value;
if v == SUBCLASS_ALL then
v = nil;
end
frame.subclassFilter = v;
UIDropDownMenu_SetText(v or ALL_SUBCLASSES, frame.dropdown);
frame:ApplyFilter();
end
local function SubClassDropDown_Initialize()
local info = {};
info.text = ALL_SUBCLASSES;
info.value = SUBCLASS_ALL;
info.checked = (frame.subclassFilter == nil);
info.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(info);
local subs = frame.subclasses or {};
for i = 1, table.getn(subs) do
local b = {};
b.text = subs[i];
b.value = subs[i];
b.checked = (frame.subclassFilter == subs[i]);
b.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(b);
end
end
local function RefreshSubclasses()
if not (frame and frame:IsShown() and frame.recipes) then
return;
end
local subs, anyItem = ResolveSubclasses(frame.recipes, RefreshSubclasses);
frame.subclasses = subs;
if anyItem then
UIDropDownMenu_Initialize(frame.dropdown, SubClassDropDown_Initialize);
UIDropDownMenu_SetWidth(120, frame.dropdown);
UIDropDownMenu_SetText(frame.subclassFilter or ALL_SUBCLASSES, frame.dropdown);
frame.dropdown:Show();
else
frame.dropdown:Hide();
end
if frame.subclassFilter then
frame:ApplyFilter();
end
end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
f:RegisterForDrag("LeftButton");
f:SetScript("OnDragStart", function() this:StartMoving(); end);
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); end);
f:SetFrameStrata("DIALOG");
f:Hide();
tinsert(UISpecialFrames, f:GetName())
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
f.portrait = portrait;
for i = 1, table.getn(LO.quads) do
local q = LO.quads[i];
local tx = f:CreateTexture(nil, "BORDER");
tx:SetTexture(q[1]);
tx:SetSize(q[2], q[3]);
tx:SetPoint(q[4]);
end
-- Horizontal divider between the recipe list and the detail pane.
-- Stock only (Turtle's HD frame art already has its own).
if LO.sep then
-- Decoded the BLP: it's a thin divider rod whose graphic lives in the
-- top quarter of the 256x64 texture (a 2nd rod fills the next quarter,
-- bottom half empty). So sample the top quarter, stretched to width —
-- the same region the native TradeSkillFrame uses for this divider.
local bar = f:CreateTexture(nil, "ARTWORK");
bar:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-HorizontalBar");
bar:SetTexCoord(0, 1, 0, 0.25);
bar:SetSize(LO.sep[3], 16);
bar:SetPoint("TOPLEFT", LO.sep[1], LO.sep[2]);
end
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal");
title:SetPoint("TOP", 0, -18);
f.title = title;
local close = CreateFrame("Button", nil, f, "UIPanelCloseButton");
close:SetPoint("TOPRIGHT", LO.close[1], LO.close[2]);
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:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
bar:SetSize(LO.bar[1], LO.bar[2]);
bar:SetPoint("TOPLEFT", LO.bar[3], LO.bar[4]);
bar:SetStatusBarTexture("Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar");
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5);
bar:SetMinMaxValues(0, 1);
local barBg = bar:CreateTexture(nil, "BACKGROUND");
barBg:SetAllPoints(bar);
barBg:SetTexture(1, 1, 1, 0.2);
barBg:SetVertexColor(0.0, 0.0, 0.75, 0.5);
-- Border: tooltip edge around the bar (TradeSkillRankFrameBorder).
local border = CreateFrame("Frame", nil, bar);
border:SetSize(LO.barBdr[1], LO.barBdr[2]);
border:SetPoint("LEFT", -5, 1);
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 16,
});
border:SetBackdropBorderColor(0.4, 0.4, 0.4);
local barName = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
barName:SetPoint("LEFT", 6, 1);
local barRank = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
barRank:SetJustifyH("LEFT");
barRank:SetWidth(128);
barRank:SetPoint("LEFT", barName, "RIGHT", 13, 0);
f.bar = bar;
f.barName = barName;
f.barRank = barRank;
local summary = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
summary:SetPoint(LO.summary[1], LO.summary[2], LO.summary[3]);
f.summary = summary;
local dropdown = CreateFrame("Frame", "ClassicAPITradeSkillLinkSubClass", f,
"UIDropDownMenuTemplate");
dropdown:SetPoint("TOPLEFT", LO.drop[1], LO.drop[2]);
f.dropdown = dropdown;
f.subclassFilter = nil; -- nil = all subclasses
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
f, LIST_SCROLL_TEMPLATE);
scroll:SetSize(LO.scroll[1], LO.scroll[2]);
scroll:SetPoint("TOPLEFT", LO.scroll[3], LO.scroll[4]);
scroll:SetScript("OnVerticalScroll", function()
FauxScrollFrame_OnVerticalScroll(ROW_HEIGHT, function() f:Refresh(); end)
end)
f.scroll = scroll;
f.rows = {};
for i = 1, NUM_ROWS do
local rowName = "ClassicAPITradeSkillLinkRow" .. i;
local row = CreateFrame("Button", rowName, f,
"ClassTrainerSkillButtonTemplate");
row:SetWidth(290); -- height (16) comes from the template
if i == 1 then
row:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0);
else
row:SetPoint("TOPLEFT", f.rows[i - 1], "BOTTOMLEFT", 0, 0);
end
row:SetNormalTexture("");
row:SetDisabledTexture("");
row:SetHighlightTexture("");
local txt = getglobal(rowName .. "Text");
if txt then
txt:ClearAllPoints();
txt:SetPoint("LEFT", row, "LEFT", 4, 0);
end
local sel = row:CreateTexture(nil, "BACKGROUND");
sel:SetTexture("Interface\\Buttons\\UI-Listbox-Highlight2");
sel:SetAllPoints(row);
sel:Hide();
row.select = sel;
-- Override the template's trainer scripts with ours.
row:SetScript("OnClick", function()
if this.entry then
f:SelectRecipe(this.entry);
end
end);
f.rows[i] = row;
end
-- Detail pane: a real ScrollFrame (like the native TradeSkillFrame's
-- TradeSkillDetailScrollFrame) whose scroll child holds the product
-- header + reagent grid, so the whole pane pixel-scrolls — clipping
-- partial reagent rows — with the standard UIPanelScrollBar. The bar
-- auto-hides when the content fits (scrollBarHideable).
local detailScroll = CreateFrame("ScrollFrame",
"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.
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);
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
detailChild:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetScrollChild(detailChild);
f.detailChild = detailChild;
local prodIcon = CreateFrame("Button", nil, detailChild);
prodIcon:SetSize(37, 37); -- native TradeSkillSkillIcon (37x37 @ 8,-3)
prodIcon:SetPoint("TOPLEFT", 8, -3);
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local hdrLeft = detailChild:CreateTexture(nil, "BACKGROUND");
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
hdrRight:SetSize(64, 64);
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
local prodCount = prodIcon:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
prodCount:SetPoint("BOTTOMRIGHT", -5, 2);
prodIcon.count = prodCount;
f.prodIcon = prodIcon;
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(LO.detail[3] - 60);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(LO.detail[3] - 20);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
local reagentLabel = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall");
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
f.reagentLabel = reagentLabel;
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
-- Reagent buttons live in the scroll child (2 per row), built on
-- demand so any reagent count is handled and they scroll/clip with
-- the child.
f.reagents = {};
local function AcquireReagent(i)
if f.reagents[i] then
return f.reagents[i];
end
local rowN = math.floor((i - 1) / 2);
local col = (i - 1) - rowN * 2;
local btn = CreateFrame("Button", nil, detailChild);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
col * 134 - 5, -3 - rowN * REAGENT_ROW_HEIGHT);
-- Extend the hit area rightward over the name plate so the tooltip
-- fires anywhere on the element, not just the icon. Stop short of the
-- next column (pitch 134 − icon 39) so hovers don't bleed across.
btn:SetHitRectInsets(0, -(134 - 39), 0, 0);
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local plate = btn:CreateTexture(nil, "BACKGROUND");
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
plate:SetSize(114, 64);
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
btn.count = cnt;
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
nm:SetSize(80, 36);
nm:SetJustifyH("LEFT");
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemLink then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink(this.itemLink);
GameTooltip:Show();
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
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);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(need) end
local top = getglobal(base .. "Top");
if top then top:SetShown(need) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(need) end
end
function f:RenderDetail(entry)
local function itemInfo(id)
local item = Item:CreateFromItemID(id);
if not item:IsItemDataCached() then
item:ContinueOnItemLoad(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
self:RenderDetail(entry);
end
end);
end
return item:GetItemName(), item:GetItemIcon(), item:GetItemLink();
end
local prodTex, prodText, prodLink;
local created = entry.createdItem or 0;
if created > 0 then
local name, tex, link = itemInfo(created);
prodText = name or entry.name;
prodTex = tex;
prodLink = link;
else
prodText = entry.name;
prodTex = C_Spell.GetSpellTexture(entry.spellID);
end
local descText = (created > 0) and ""
or (C_Spell.GetSpellDescription(entry.spellID) or "");
self.prodDesc:SetText(descText);
self.prodIcon:SetNormalTexture(prodTex or
"Interface\\Icons\\INV_Misc_QuestionMark");
self.prodName:SetText(prodText or "");
self.prodName:SetTextColor(NORMAL_FONT_COLOR:GetRGB());
self.prodIcon.itemLink = prodLink; -- item tooltip when created > 0
self.prodIcon.spellID = entry.spellID; -- else spell tooltip
local made = entry.numMade or 0;
self.prodIcon.count:SetText(made > 1 and tostring(made) or "");
self.prodIcon:Show();
-- Fill every reagent slot; the scroll child clips/scrolls the
-- overflow. Buttons past the count are hidden.
local reagents = entry.reagents or {};
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);
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;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
btn:Show();
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
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 sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
self:UpdateDetailScrollbar();
RunNextFrame(function()
if self:IsShown() then
self:UpdateDetailScrollbar();
end
end)
end
function f:SelectRecipe(entry)
self.selectedSpellID = entry.spellID;
self.selectedEntry = entry;
self:RenderDetail(entry); -- also resets the detail scroll to top
self:Refresh();
end
function f:ClearDetail()
self.selectedSpellID = nil;
self.selectedEntry = nil;
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailNeedScroll = false;
self:UpdateDetailScrollbar();
self.detailScroll:SetVerticalScroll(0);
end
function f:Refresh()
local recipes = self.displayed or {};
local total = table.getn(recipes);
FauxScrollFrame_Update(self.scroll, total, NUM_ROWS, ROW_HEIGHT);
local offset = FauxScrollFrame_GetOffset(self.scroll);
for i = 1, NUM_ROWS do
local row = self.rows[i];
local entry = recipes[offset + i];
if entry then
-- Append " [N]" when the reader can craft any (Blizzard's list).
local craftable = CraftableCount(entry);
local label = entry.name or "";
if craftable and craftable > 0 then
label = label .. " [" .. craftable .. "]";
end
row:SetText(label);
local r, g, b = BAND_COLOR[entry.band or 3]:GetRGB();
if self.selectedSpellID and entry.spellID == self.selectedSpellID then
row.select:SetVertexColor(r, g, b);
row.select:Show();
row:SetTextColor(1, 1, 1);
else
row.select:Hide();
row:SetTextColor(r, g, b);
end
row.spellID = entry.spellID;
row.entry = entry;
row:Show();
else
row.spellID = nil;
row.entry = nil;
row.select:Hide();
row:Hide();
end
end
end
function f:ApplyFilter()
local src = self.recipes or {};
if not self.subclassFilter then
self.displayed = src;
else
local out = {};
for i = 1, table.getn(src) do
if src[i].subclass == self.subclassFilter then
table.insert(out, src[i]);
end
end
self.displayed = out;
end
local sb = getglobal("ClassicAPITradeSkillLinkScrollScrollBar");
if sb then
sb:SetValue(0);
end
self:Refresh();
end
f:RegisterEvent("BAG_UPDATE_DELAYED");
f:SetScript("OnEvent", function()
if not f:IsShown() then
return;
end
f:Refresh();
if f.selectedEntry then
f:RenderDetail(f.selectedEntry);
end
end);
return f;
end
local function ShowTradeSkillLink(link, text)
local id, cur, max, who, bits = ParseTradeLink(link);
if not id then
return false;
end
local recipes = C_TradeSkillUI.GetTradeSkillListRecipes(id, bits);
if not recipes then
return false;
end
local prof = text and string.match(text, "|h%[(.+)%]|h") or "Trade Skills";
local total = table.getn(recipes);
local knownList = {};
for i = 1, total do
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
table.sort(knownList, function(a, b)
if a.band ~= b.band then
return a.band < b.band;
end
return a.name < b.name;
end);
local known = table.getn(knownList);
if not frame then
frame = CreateFrame_TradeSkillLink();
end
frame.title:SetText(who and who ~= "" and (who .. " - " .. prof) or prof);
SetPortraitToTexture(frame.portrait, ProfessionIcon(id));
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
frame.bar:SetValue(cur or 0);
frame.barName:SetText(prof);
frame.barRank:SetText(string.format("%d/%d", cur or 0, max or 0));
frame.summary:SetText(string.format("%d of %d recipes known", known, total));
frame.recipes = knownList;
frame.subclassFilter = nil;
frame:ClearDetail();
frame:Show();
RefreshSubclasses();
frame:ApplyFilter();
local first = frame.displayed and frame.displayed[1];
if first then
frame:SelectRecipe(first);
end
return true;
end
------------------------------------------------------------------------------
-- Click interception: handle `trade:` before the engine's SetHyperlink (which
-- in 1.12 only knows item:/enchant: and would reject the link).
------------------------------------------------------------------------------
local originalSetItemRef = SetItemRef;
function SetItemRef(link, text, button, chatFrame)
if type(link) == "string" and string.sub(link, 1, 6) == "trade:" then
if IsShiftKeyDown() and ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(text);
return;
end
if ShowTradeSkillLink(link, text) then
return;
end
end
return originalSetItemRef(link, text, button, chatFrame);
end
local function CreateProfessionLinkButton(parentName, getLink)
local parent = _G[parentName];
local btnName = parentName .. "LinkButton";
if _G[btnName] or not parent then
return;
end
local title = _G[parentName .. "TitleText"];
local btn = CreateFrame("Button", btnName, parent);
btn:SetSize(16, 16);
local function reposition()
if not title then
return;
end
btn:ClearAllPoints();
if title:GetJustifyH() == "LEFT" then
btn:SetPoint("LEFT", title, "LEFT", title:GetStringWidth() + 4, 0);
else
btn:SetPoint("LEFT", title, "CENTER", title:GetStringWidth() / 2 + 4, 0);
end
end
reposition()
btn:SetScript("OnShow", function() RunNextFrame(reposition) end)
hooksecurefunc(title, "SetText", function() RunNextFrame(reposition) end)
local icon = "Interface\\GossipFrame\\PetitionGossipIcon";
btn:SetNormalTexture(icon);
btn:SetHighlightTexture(icon, "ADD");
btn:SetScript("OnClick", function()
local link = getLink();
if not link then
return;
end
if not ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Show();
end
ChatFrameEditBox:Insert(link);
end);
btn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOPLEFT");
GameTooltip:SetText(LINK_TRADESKILL_TOOLTIP, nil, nil, nil, nil, 1);
GameTooltip:Show();
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
end
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
end)
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow", function()
CreateProfessionLinkButton("ATSWFrame", function()
return atsw_oldmode and C_TradeSkillUI.GetCraftListLink() or C_TradeSkillUI.GetTradeSkillListLink()
end)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow2", function()
CreateProfessionLinkButton("ATSWFrame", function()
return ATSW_TradeSkill() and C_TradeSkillUI.GetTradeSkillListLink() or C_TradeSkillUI.GetCraftListLink()
end)
end)
+1
View File
@@ -24,3 +24,4 @@ SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
+4 -1
View File
@@ -99,7 +99,10 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
+21 -4
View File
@@ -18,7 +18,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [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` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
@@ -33,6 +33,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [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` |
@@ -42,13 +43,15 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit`, `C_Map.GetMapOverlays` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
@@ -56,9 +59,12 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [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` |
| [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) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `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` |
| [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` |
### GlueXML calls
@@ -116,11 +122,16 @@ when launching with `-console`), not as Lua functions. See the
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
| `LOOT_SCAN_COMPLETED` | *(none)* |
| `LOSS_OF_CONTROL_ADDED` | `eventIndex` |
| `LOSS_OF_CONTROL_UPDATE` | `unitToken` (always `"player"`) |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
| `PLAYER_FOCUS_CHANGED` | *(none)* |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
@@ -130,8 +141,14 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
+47 -2
View File
@@ -2725,7 +2725,26 @@ matching modern's 3-tuple. `CastSpellNoToggle("<name>")` macros are
also recognized because `Spell::CastNoToggle` already hooks the same
parser to register that pattern.
## 88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event
## ~~88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event~~ — DONE
**Resolution (2026-07-04, src/player/Equipment.cpp):** the 2026-05-22
investigation kept trying to hook a FIRER of UNIT_INVENTORY_CHANGED.
The winning move was the opposite: the engine has a generic
descriptor-field observer system (the "__AUCMirrorHandler__" nodes —
registrar `FUN_00467E70`, dispatcher `FUN_00465570` with per-node
mirror + memcmp change detection). `FUN_004F8CC0` registers observers
for the bag/backpack/bank/keyring GUID ranges but never for equipment
`0x4A8..0x538` — so we co-hook it and REGISTER OUR OWN observer per
equipment field, mirroring the engine's registration exactly (bank 4,
size 8, player GUID). Callback ABI verified from the dispatcher call
site at `0x004655DC` + `FUN_004F8DB0`'s `RET 0x10`:
`__fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
oldValue*, userArg)`. hasCurrent read from the invMgr GUID array (the
same "current truth" the engine's bag callback reads). No polling, no
hot hook, engine-managed lifetime (setup re-runs each enter-world).
Offsets documented at `FUN_DESC_OBSERVER_REGISTER` in Offsets.h.
Original notes below kept for the investigation trail.
WotLK-era event that fires every time a paperdoll slot changes
(equip, unequip, swap). Heavily used by gear-tracking,
@@ -2828,7 +2847,33 @@ Same diff feeds `EQUIPMENT_SETS_CHANGED` /
module — currently those fire only on user-side mutations, not
on inventory changes that affect a set's "equipped count".
## 89. `UPDATE_INVENTORY_DURABILITY` event
## ~~89. `UPDATE_INVENTORY_DURABILITY` event~~ — DONE
**Resolution (2026-07-04, src/player/Equipment.cpp):** co-hook the
engine's inventory-alerts recompute `FUN_004C7EE0` — the routine that
walks equipment slots, reads each item's durability/broken state from
the descriptor, and unconditionally fires vanilla's
`UPDATE_INVENTORY_ALERTS` (event 501). The engine calls it whenever an
owned item's fields change (the item post-update handler `FUN_005D9A90`
routes every item values-update through it, alongside
UNIT_INVENTORY_CHANGED and the bag-update chain) plus equip and
enter-world paths — i.e., it IS the engine's own "durability may have
changed" signal. Post-original we diff a per-slot {guid, durability}
snapshot (seeded silently on the first recompute per enter-world) and
fire once per recompute with a real change. No polling, no hot hook.
**Dead end tried first:** bank-1 (ITEM field) descriptor observers per
equipped item, via the TODO #88 observer system. They register without
error but are NEVER invoked — item values-updates demonstrably arrive
(probed the values pre-pass `FUN_00465330`) and player-bank observers
fire in the same session; registration arithmetic fully verified (bank
base `FUN_00465690(1)=0x18`, bank walk item 0→1→2, anchors match
dispatch). Prime suspect: the registrar's internal wrapper lookup
(`FUN_00464890`, the observer registry — NOT the object manager)
silently no-ops for item objects. See the DEAD END note at
`OFF_DESC_ITEM_DURABILITY` in Offsets.h.
Original notes below kept for the investigation trail.
Fires when any equipped item's durability changes — picks up
combat damage, repairs, item destruction. Used by Repair-O-Matic
+837 -36
View File
File diff suppressed because it is too large Load Diff
+597 -25
View File
@@ -542,7 +542,99 @@ enum Offsets {
// addon's CHAT_MSG_* OnEvent.
FUN_CHAT_DISPATCH = 0x0049A870,
// Per-player inventory manager lives at this offset on the player object.
// +0x00 = u32 slot count (OFF_INVMGR_SLOT_COUNT), +0x04 = u64* GUID
// array (see OFF_INVMGR_GUID_ARRAY below for the slot-range map).
OFF_PLAYER_INVENTORY_MANAGER = 0x1D38,
OFF_INVMGR_SLOT_COUNT = 0x00,
// --- Descriptor-field observer system --------------------------------
// The engine's generic "watch a descriptor field range for changes"
// mechanism (nodes tagged "__AUCMirrorHandler__", 0x30 bytes). The
// registrar attaches a node to the object's per-field observer anchors;
// each node keeps a private MIRROR of the watched bytes, seeded from
// the live value at registration. During SMSG_UPDATE_OBJECT processing
// the dispatcher FUN_00465570 memcmps live vs mirror and invokes the
// callback only on a real change (the mirror is then re-synced by
// FUN_004667A0). Firing engine events from the callback is sanctioned —
// the engine's own bag observer does exactly that.
//
// Registrar — __fastcall(int bank /*ecx*/, uint32_t fieldOffset /*edx*/,
// uint32_t guidLo, uint32_t guidHi, int size, const void *callback,
// void *userArg1, void *userArg2). `guid` selects the watched OBJECT
// (player, item, container, …); `bank` selects its field bank
// (1 = ITEM, 2 = CONTAINER, 4 = PLAYER — the engine's per-bag
// manager FUN_004F91A0 registers bank-2 observers on each equipped
// bag's CGContainer); `fieldOffset` is BANK-relative (container
// SLOT_1 = 8, not its descriptor-absolute 0x20). The engine's
// inventory setup FUN_004F8CC0 registers the player's bag/backpack/
// bank/keyring GUID ranges this way — but NOT equipment.
FUN_DESC_OBSERVER_REGISTER = 0x00467E70,
// Unregister — __fastcall(int bank /*ecx*/, uint32_t fieldOffset
// /*edx*/, uint32_t guidLo, uint32_t guidHi, const void *callback,
// void *userArg1). Removes the node matching (callback, userArg1)
// from that object+field's observer list; harmless no-op when
// nothing matches. What FUN_004F91A0 calls for an unequipped bag.
FUN_DESC_OBSERVER_UNREGISTER = 0x00467FB0,
// Observer callback ABI (verified from the dispatcher's call site at
// 0x004655DC + the engine callback FUN_004F8DB0's RET 0x10):
// int __fastcall cb(uint32_t fieldOffset /*ecx, as registered*/,
// uint32_t size /*edx*/,
// uint32_t guidLo, uint32_t guidHi,
// const uint32_t *oldValue /*the node's mirror*/,
// void *userArg1) → return 1, callee cleans 0x10.
// The live (new) value is NOT passed — read it from the object (the
// engine's bag callback reads the invMgr GUID array).
// 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).
// Player::Equipment co-hooks it to register the equipment-slot
// observers the engine never installs; the co-hook inherits the exact
// engine timing + lifetime (nodes die with the player object).
FUN_INV_OBSERVER_SETUP = 0x004F8CC0,
// PLAYER_FIELD_INV_SLOT_HEAD — first of the 19 equipment-slot u64 GUID
// fields in the player descriptor (0x4A8..0x538, stride 8; 0x540 is
// the first equipped-bag slot). 0-based slot = (offset - 0x4A8) >> 3;
// Lua inventory slot (GetInventoryItemLink etc.) = that + 1.
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
DESC_PLAYER_EQUIP_SLOTS = 19,
DESC_OBSERVER_BANK_PLAYER = 4,
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
// the 0x18-byte OBJECT prefix. u32.
//
// DEAD END — bank-1 (ITEM field) observers register without error but
// are NEVER invoked: item values-updates demonstrably arrive
// (verified by probing the values pre-pass FUN_00465330) and
// player-bank observers fire in the same session, yet item-bank
// callbacks stay silent. Registration arithmetic was fully verified
// (bank base FUN_00465690(1)=0x18, next-bank walk FUN_004656E0
// item: 0→1→2, anchors match dispatch), so the suspect is the
// registrar's internal wrapper lookup (FUN_00464890 — the observer
// registry, NOT the object manager) silently no-op'ing for item
// objects. UPDATE_INVENTORY_DURABILITY is instead backed by
// FUN_INVENTORY_ALERTS_RECOMPUTE below. Constants kept for the
// investigation trail.
DESC_OBSERVER_BANK_ITEM = 1,
OFF_DESC_ITEM_DURABILITY = 0x88,
// The engine's inventory-alerts recompute — walks the equipment
// slots, reads each item's ITEM_FIELD_DURABILITY / MAXDURABILITY /
// broken flag from the descriptor, fills the per-slot alert table at
// [DAT_00B71F60], and UNCONDITIONALLY fires UPDATE_INVENTORY_ALERTS
// (event 501) at the end. The engine calls it whenever an owned
// item's fields change (the item post-update handler FUN_005D9A90 —
// which also fires UNIT_INVENTORY_CHANGED and the bag-update chain —
// routes every item values-update through it) plus equip and
// enter-world paths. That makes it the engine's own "durability may
// have changed" signal: Player::Equipment co-hooks it and diffs a
// {guid, durability} snapshot per slot to back
// UPDATE_INVENTORY_DURABILITY. void __fastcall(), no args.
FUN_INVENTORY_ALERTS_RECOMPUTE = 0x004C7EE0,
// ItemMgr::GetItemBySlot — __thiscall(this, slot) → CGItem* (NULL if empty).
// Slot is the engine's linearized slot index, not bagID/slot tuple.
FUN_ITEMMGR_GET_ITEM_BY_SLOT = 0x006228A0,
@@ -870,12 +962,231 @@ enum Offsets {
// `SMSG_SPELL_DELAYED` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`. Body: `guid(u64), delayMs(u32)`. The server
// only sends this to the affected caster, so in practice it's always
// the local player's cast pushback (taking damage mid-cast). `Spell::
// Cast` co-hooks it to extend the tracked cast's end time so the bar
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
// only sends this to the affected caster — empirically confirmed on
// Turtle (melee-pushback test: only the victim's client received it) —
// so remote units' pushback is NOT recoverable client-side; only the
// local player's cast pushback is trackable. `Spell::Cast` co-hooks it
// to extend the tracked cast's end time so the bar reflects pushback.
// Mirrored from nampower's SpellDelayedHook.
FUN_SPELL_DELAYED = 0x006E74F0,
// `MSG_CHANNEL_START` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`. Body: `spellId(u32), durationMs(u32)`. Sent
// only to the caster when their channel begins, with the server-
// authoritative duration. `Spell::Cast` co-hooks it to stamp the
// player's channel — required for cast-then-channel spells (Mind
// Control: 3 s cast then 60 s channel), whose SMSG_SPELL_START only
// covers the cast phase. Address + layout mirrored from nampower's
// SpellChannelStartHandlerHook (they hook it too — accepted co-hook
// collision, per-channel-start frequency).
FUN_SPELL_CHANNEL_START = 0x006E7550,
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
// `casterGuid(u64, plain), spellId(u32)`. In (v)mangos cores this is
// broadcast to observers when a started cast aborts (interrupt, death,
// movement, fizzle) — but Turtle's core does NOT send it (verified
// in-game: interrupts arrive with neither this nor SMSG_SPELL_FAILURE;
// observers learn of the abort via SuperWoW instead — see
// FUN_UNIT_CLEAR_CASTING_SPELL). `Spell::Cast` co-hooks it anyway as a
// backstop for servers that do send it, invalidating the matching
// remote-cast cache entry (and the player's own server-stamped chained
// recast). Address + packet layout mirrored from nampower's
// SpellFailedOtherHandlerHook.
FUN_SPELL_FAILED_OTHER = 0x006E8E40,
// `SMSG_SPELL_FAILURE` handler — same `int __stdcall(uint32_t *opCode,
// CDataStore *packet)` shape. Body: `casterGuid(u64, plain),
// spellId(u32), result(u8)` — a superset of FAILED_OTHER's with a
// trailing result byte. Decompiled at 0x006E8D80: resolves the caster
// unit (FUN_00468460 typemask 8) and stops its cast visual via the SAME
// unit methods FAILED_OTHER's handler calls (FUN_0060d040 +
// FUN_00614150), plus extra channel/visual cleanup. Servers derived
// from (v)mangos broadcast BOTH packets from Spell::SendInterrupted,
// but which one a given core actually emits per abort path varies —
// `Spell::Cast` co-hooks this one alongside FAILED_OTHER so a remote
// interrupt evicts the cast cache regardless of which sibling arrives.
// nampower calls this SpellFailedHandler (offsets.hpp) but doesn't
// hook it.
FUN_SPELL_FAILURE = 0x006E8D80,
// `CGUnit_C::ClearCastingSpell` — `__thiscall void(CGUnit *unit,
// int spellID, char notify, char cleanup)` (nampower names the offset
// but never hooks it). THE engine choke point for "this unit stopped
// casting": gated on `spellID == [unit+0xC8C]` (the unit's live
// current-cast spellID, set by SMSG_SPELL_START processing), it zeros
// that field and clears the casting flag (bit 0x400 at [unit+0xD58]).
// Ten callers converge here: both failure-packet handlers, the
// SPELL_GO handler (natural completion), the movement handler
// (0x006018f0 — moving units aren't casting), the health-diff handler
// (0x006046f0, death), the unit animation-event dispatcher
// (0x0060e0a0), the local cancel path (0x006e7040, sends
// CMSG_CANCEL_CAST), and Spell_C_SpellFailed. Hooking THIS instead of
// individual packets makes remote-cast eviction independent of which
// packet the server actually sends — load-bearing on Turtle, whose
// core does NOT broadcast SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER
// on interrupt (verified: neither handler fires while the victim's
// cast visual demonstrably stops).
FUN_UNIT_CLEAR_CASTING_SPELL = 0x0060D040,
// CGUnit field: the unit's current-cast spellID (0 = not casting).
// The regular-cast analog of OFF_UNIT_FIELD_CHANNEL_SPELL — but a
// plain CGUnit member, NOT a descriptor UpdateField (client-derived
// from SMSG_SPELL_START, not server-broadcast state).
OFF_UNIT_CAST_SPELL = 0xC8C,
// NetClient message dispatch — `__thiscall void(void *conn, uint32_t
// arg, CDataStore *packet)`. Reads the u16 opcode off the packet
// cursor, then invokes `[conn + opcode*4 + 0x74]` (the handler table
// FUN_005ab650 / FUN_00537a60 register into; per-handler userParam
// parallel table at +0xD64). EVERY server message the client processes
// funnels through here (the raw-ingress sibling at 0x00537B10 only
// special-cases 0x1DD compressed-moves before queueing into this).
// Useful as a temporary whole-protocol sniff point: co-hook, peek the
// u16 at the cursor, restore, log.
FUN_NET_MESSAGE_DISPATCH = 0x00537AA0,
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
// client sends funnels through it (the argless wrapper `FUN_005AB630`,
// nampower's ClientServices_Send, resolves the connection singleton
// with the packet in ECX and calls this). The packet's leading u32 is
// the opcode; `m_size - m_read` is what goes on the wire.
// `Aura::ComboDuration` co-hooks it to snapshot combo points the
// moment CMSG_CAST_SPELL leaves — before the server consumes them —
// which also covers nampower-queued casts (they re-enter the engine's
// cast path and send normally, unlike a Spell_C_CastSpell co-hook,
// which nampower's trampoline re-entry would bypass).
FUN_NET_SEND = 0x005379A0,
// Outgoing opcode: CMSG_CAST_SPELL. Body: `spellId(u32),
// targetFlags(u16), <target per flags>`.
OP_CMSG_CAST_SPELL = 0x12E,
// Spell.dbc DurationIndex (column 30) — row into SpellDuration.dbc.
// Offset = 30*4, consistent with the verified neighbors
// (CastingTimeIndex col 18 = +0x48, PowerType col 31 = +0x7C).
OFF_SPELL_RECORD_DURATION_INDEX = 0x78,
// SpellDuration.dbc — records/count slots per docs/DBCs.md. Record:
// `{id@+0, baseMs@+4 (i32), perLevelMs@+8 (i32), maxMs@+0xC (i32)}`.
// Combo-point finishers are exactly the rows with base != max: the
// server computes `base + (max - base) * comboPoints / 5`
// ((v)mangos CalculateSpellDuration — verified against the DBC:
// Rupture 6000/16000 = 6+2*CP, Kidney Shot 1000/6000 = 1+1*CP,
// Slice and Dice 6000/21000 = 6+3*CP, Expose Armor 30000/30000 =
// fixed). NOTE: rows can dangle — Turtle's client DBC has no record
// for Rip's DurationIndex 87 (records[87] == NULL), which is why
// Rip reads duration 0 client-side there.
VAR_SPELL_DURATION_RECORDS = 0x00C0D828,
VAR_SPELL_DURATION_COUNT = 0x00C0D82C,
// Combo points, within the CGPlayer `+0xE68` sub-struct (see
// OFF_CGPLAYER_INFO). Verified from Script_GetComboPoints
// (0x0051A190): CP count byte at `[player+0xE68]+0x1029`, the combo
// TARGET's guid (u64) at `[player+0xE68]+0x838` (compared against
// the current-target global 0x00B4E2D8 before the Lua function
// reports a non-zero count).
OFF_CGPLAYER_COMBO_POINTS = 0x1029,
OFF_CGPLAYER_COMBO_TARGET = 0x838,
// ---- Frame-method registries beyond GameTooltip's ------------------
// Same per-type registry mechanism as VAR_GAMETOOLTIP_METHOD_REGISTRY
// (docs/BlizzardScriptAPI.md "Frame types & their methods" has the
// full 23-table catalogue). The dispatcher resolves methods through
// the type hierarchy, so a method added to the REGION base registry
// (the 19-entry core-layout table: GetWidth/SetPoint/…) is callable
// on every region type — frames, buttons, textures, fontstrings.
// `Frame::Modern` uses these to backport modern API (SetSize,
// SetShown, SetResizeBounds).
VAR_REGION_METHOD_REGISTRY = 0x00CF54B4,
VAR_FRAME_METHOD_REGISTRY = 0x00CF4D38,
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
// Engine Script_* method implementations `Frame::Modern` delegates to
// (each `int __fastcall(void *L)`, reading self at stack index 1).
// Addresses from the docs/raw_methods.txt catalogue. Show/Hide are
// PER-BRANCH (Frame vs Texture vs FontString each register their own
// script function with its own object typecheck), so SetShown is
// registered per branch with the matching delegates.
FUN_SCRIPT_REGION_SETWIDTH = 0x007A1F20,
FUN_SCRIPT_REGION_SETHEIGHT = 0x007A2150,
FUN_SCRIPT_REGION_GETWIDTH = 0x007A1E00,
FUN_SCRIPT_REGION_GETHEIGHT = 0x007A2030,
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
FUN_SCRIPT_FRAME_SETMAXRESIZE = 0x007762A0,
FUN_SCRIPT_FRAME_GETSCRIPT = 0x00774780,
FUN_SCRIPT_FRAME_SETSCRIPT = 0x007748D0,
FUN_SCRIPT_TEXTURE_SHOW = 0x0079B770,
FUN_SCRIPT_TEXTURE_HIDE = 0x0079B830,
FUN_SCRIPT_FONTSTRING_SHOW = 0x0079CDB0,
FUN_SCRIPT_FONTSTRING_HIDE = 0x0079CE70,
// ---- World map overlay data (WorldMapOverlay.dbc + WorldMapArea.dbc)
// Backs `C_Map.GetMapOverlays`. Instance VAs per docs/DBCs.md;
// schemas verified against the extracted DBCs AND the engine's own
// reader `Script_GetMapOverlayInfo` (0x004A8A00), which reads the
// same fields for its 7 returns.
//
// WorldMapArea.dbc record (8 fields, 0x20 bytes):
// {ID@+0, mapID@+4, areaID@+8 (AreaTable, 0 for continent rows),
// name@+0xC (char*, the map directory name GetMapInfo returns),
// locLeft/locRight/locTop/locBottom floats}
VAR_WORLDMAP_AREA_RECORDS = 0x00C0D5BC,
VAR_WORLDMAP_AREA_COUNT = 0x00C0D5C0,
OFF_WMA_AREA_ID = 0x08,
OFF_WMA_NAME = 0x0C,
// WorldMapOverlay.dbc record (17 fields, 0x44 bytes):
// {ID@+0, worldMapAreaID@+4 (-> WorldMapArea row),
// areaID[4]@+8..+0x14 (AreaTable ids the overlay reveals),
// mapPointX@+0x18, mapPointY@+0x1C, textureName@+0x20 (char*,
// bare name), textureWidth@+0x24, textureHeight@+0x28,
// offsetX@+0x2C, offsetY@+0x30, hitRect@+0x34..+0x40}
// The engine's Lua surface only exposes EXPLORED overlays: the
// discovered-overlay ID list at 0x00B6E630 (count 0x00B6E6B4) gates
// Script_GetMapOverlayInfo. Reading the DBC directly bypasses the
// exploration gate — every overlay of a zone, explored or not.
VAR_WORLDMAP_OVERLAY_RECORDS = 0x00C0D594,
VAR_WORLDMAP_OVERLAY_COUNT = 0x00C0D598,
OFF_WMO_WORLDMAP_AREA = 0x04,
OFF_WMO_MAP_POINT_X = 0x18,
OFF_WMO_MAP_POINT_Y = 0x1C,
OFF_WMO_TEXTURE_NAME = 0x20,
OFF_WMO_TEXTURE_WIDTH = 0x24,
OFF_WMO_TEXTURE_HEIGHT = 0x28,
OFF_WMO_OFFSET_X = 0x2C,
OFF_WMO_OFFSET_Y = 0x30,
// Current world-map view selection — the globals the engine's
// current-map-name resolver (FUN_004a6cf0, behind GetMapInfo) reads:
// continent index, zone index (-1 = whole continent), the default
// WorldMapArea row used when no continent is selected, and the
// per-continent data blob (base ptr; stride 0x10024 per continent,
// continent's own WMA row at +0x04, per-zone WMA row array ptr at
// +0x10).
VAR_WORLDMAP_CONTINENT_INDEX = 0x0084506C,
VAR_WORLDMAP_ZONE_INDEX = 0x00845070,
VAR_WORLDMAP_DEFAULT_AREA_ROW = 0x00845074,
VAR_WORLDMAP_CONTINENT_DATA = 0x00B6E668,
WORLDMAP_CONTINENT_STRIDE = 0x10024,
// `Script_SetPoint` — the ONE SetPoint implementation, registered in
// the Region base table and inherited by every region type. Vanilla's
// parser accepts (point, region), (point, region, relPoint[, x, y]),
// (point, x, y), and even an explicit nil region — but NOT the bare
// one-arg form (`fs:SetPoint("RIGHT")`): a fully-omitted arg 3 is
// LUA_TNONE, which fails the string/table/number/nil check and raises
// the usage error. Modern clients accept it (anchor to parent at the
// same point, zero offsets). `Frame::Modern` co-hooks this and
// normalizes the one-arg call to (point, 0, 0) — the vanilla
// (point, x, y) form, which is parent-relative and thus exactly the
// modern semantic.
FUN_SCRIPT_REGION_SETPOINT = 0x007A2540,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
@@ -907,6 +1218,42 @@ enum Offsets {
// aura that's still inside its computed duration window.
FUN_ON_AURA_REMOVED = 0x00612320,
// Group-loot roll packet handlers (the inner display functions in
// LootRoll.cpp). Each is `__fastcall`; the deserialized 0x48-byte
// PendingLootRoll message struct is fully populated at entry and freed
// at exit, so read its fields BEFORE calling the original. The register
// the message arrives in differs per handler (matched in
// loot/History.cpp): ROLL passes it in ECX, WON / ALL_PASSED in EDX.
// Backs the C_LootHistory backport (loot/History.cpp) — 1.12 has no
// loot-history store, so we reconstruct one from these packets.
FUN_LOOT_ROLL_HANDLER = 0x0061C0B0, // SMSG_LOOT_ROLL (msg=ECX)
FUN_LOOT_ROLL_WON_HANDLER = 0x0061B9E0, // SMSG_LOOT_ROLL_WON (msg=EDX)
FUN_LOOT_ALL_PASSED_HANDLER = 0x0061B640, // SMSG_LOOT_ALL_PASSED (msg=EDX)
// SMSG_LOOT_START_ROLL (opcode 0x2A1) handler — `__fastcall(ECX = packet)`,
// RET 0. The result packets (ROLL/WON/PASSED) carry 0 for the item's
// random suffix/seed; only START_ROLL does. It deserializes a fresh
// PendingLootRoll node (same 0x20/0x24 field offsets as the message) and
// publishes it as the store head at VAR_LOOTROLL_STORE_HEAD, so we read
// the suffix straight off that node right after the original runs.
FUN_LOOT_START_ROLL_HANDLER = 0x0061B310,
VAR_LOOTROLL_STORE_HEAD = 0x00C4DC00, // PendingLootRoll* — newest node
// PendingLootRoll message field offsets, derived from the SMSG_LOOT_ROLL
// deserializer FUN_0061BFA0 (reads GUID/u32/byte fields off the packet).
OFF_LOOTROLL_SOURCE_GUID = 0x10, // u64 looted-object GUID (roll identity)
OFF_LOOTROLL_SLOT = 0x18, // u32 loot slot
OFF_LOOTROLL_ITEM_ID = 0x1C, // u32 itemID
// Random-enchant fields for the item link `item:id:enchant:suffix:unique`.
// +0x20 is the suffix/random-property ID the engine's item-name builder
// FUN_005D8B00 uses (indexes the random-properties DBC for the "of the X"
// name); +0x24 is the paired seed/unique value.
OFF_LOOTROLL_ITEM_SUFFIX = 0x20, // i32 random suffix / property ID
OFF_LOOTROLL_ITEM_SEED = 0x24, // u32 random seed (link unique field)
OFF_LOOTROLL_ROLLER_GUID = 0x30, // u64 roller / winner GUID
OFF_LOOTROLL_ROLL_NUM = 0x38, // u8 roll (1..100); high bit = didn't roll
OFF_LOOTROLL_ROLL_TYPE = 0x40, // u32 rollType: 0 = need, 2 = greed
// CGPlayer-side sub-struct, allocated for any player-controlled
// unit (local self, target, party, raid, inspect targets — all of
// them). Holds player-specific data that's *not* in the broadcast
@@ -1083,6 +1430,7 @@ enum Offsets {
OBJ_TYPE_ITEM = 2,
OBJ_TYPE_CONTAINER = 4,
OBJ_TYPE_UNIT = 8,
OBJ_TYPE_PLAYER = 0x10, // what the engine's bag observer FUN_004F8DB0 passes
OBJ_TYPE_GAMEOBJECT = 0x20, // 1<<5; passed by FUN_0052AA20 (hover-tooltip populator).
// `CGObject::GetName` — returns the display-name `const char *` for
@@ -1872,6 +2220,15 @@ enum Offsets {
OFF_SLA_CLASS_MASK = 0x10,
OFF_SLA_EXCLUDE_RACE = 0x14,
OFF_SLA_EXCLUDE_CLASS = 0x18,
// Skill-up threshold ranks (grey / green) at record fields 10/11.
// A row with either nonzero is a craftable recipe; profession
// rank-up / passive rows have both zero. Empirically: the
// Blacksmithing skill line has 343 SLA rows, 334 with a nonzero
// threshold — the 9 excluded are the Apprentice..Artisan rank
// spells and skill grants. This is the discriminator
// `TradeSkill::Link` uses to build the canonical recipe universe.
OFF_SLA_TRIVIAL_SKILL_HIGH = 0x28,
OFF_SLA_TRIVIAL_SKILL_LOW = 0x2C,
// SkillLine.dbc — the skill/category each SLA row points into via
// its `skillId` field. Each record carries a 9-locale localized
@@ -1904,6 +2261,24 @@ enum Offsets {
OFF_PLAYER_SKILL_LINE_ID = 0x00,
OFF_PLAYER_SKILL_RANK = 0x10,
// Live per-skill current/max rank. These are NOT in
// `VAR_PLAYER_SKILL_LIST` (that entry's `+0x10` is numTempPoints) —
// the real ranks live in the CGPlayer `+0xE68` sub-struct
// (`OFF_CGPLAYER_INFO`). `FUN_SKILL_LINE_TO_SLOT`
// (`__thiscall(player, skillLineID) -> slot`) maps a SkillLine.dbc
// row to its slot; the slot indexes a `SKILL_INFO_STRIDE`-byte
// record at `[player+0xE68] + OFF_SKILL_INFO_TABLE + slot*stride`:
// current `+0x0`, max `+0x2`, rank bonus `+0x6` (all u16 — add the
// bonus when the base value is nonzero). Source: `Script_GetTradeSkillLine`
// (`0x004FDD40`) / `Script_GetSkillLineInfo` (`0x004D3610`). Verified
// in-game vs. those functions (Tailoring 261/300, Enchanting 186/240).
FUN_SKILL_LINE_TO_SLOT = 0x005EA3F0,
OFF_SKILL_INFO_TABLE = 0x84C,
SKILL_INFO_STRIDE = 0xC,
OFF_SKILL_INFO_CUR = 0x0,
OFF_SKILL_INFO_MAX = 0x2,
OFF_SKILL_INFO_BONUS = 0x6,
// Per-itemClass proficiency bitmap maintained by the engine.
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
// entry is a bitmask where bit N is set if the player has
@@ -1940,7 +2315,18 @@ enum Offsets {
// Verified via the `IsAssistingRitual` development session by
// observing `0xEE00 → 0xEE05` on `/sit` over a chair (the medium-
// chair sit value 5).
//
// Byte 3 (the AnimTier / misc-flags byte, mask 0x02000000 on the
// u32) is the **stealth / sneak flag**: set while the unit is in
// the sneaking anim tier (Rogue Stealth, Druid Prowl, Shadowmeld),
// which is what drives the client's semi-transparent render of a
// stealthed unit. Verified via `_classicapi_DiffAll`: a Prowl
// toggle from a Cat-Form baseline flips byte 3 `0x00 → 0x02` while
// every other descriptor change is aura-array slot metadata — so
// this is the only standalone broadcast bit that means "stealthed".
// Read by `IsStealthed()`.
OFF_UNIT_FIELD_BYTES_1 = 0x210,
UNIT_BYTES_1_STEALTH_FLAG = 0x02000000,
// `__fastcall(void)` engine helper that refreshes the bonus
// action bar from the local player's current shapeshift form
@@ -2418,10 +2804,10 @@ enum Offsets {
// walks the player's slot data, repopulates `VAR_QUEST_LOG_ENTRIES`
// / `VAR_QUEST_LOG_ENTRY_COUNT`, then fires QUEST_LOG_UPDATE.
//
// Hooked by `Quest::Accepted` to snapshot the log pre-/post-rebuild
// and fire `QUEST_ACCEPTED(logIndex, questID)` for the diff. Single
// quiet target in the `0x004DExxx` quest region — no known DLL
// collisions.
// Hooked by `Quest::LogEvents` to snapshot the log pre-/post-rebuild
// and fire `QUEST_ACCEPTED(logIndex, questID)` / `QUEST_REMOVED(questID)`
// for the diff. Single quiet target in the `0x004DExxx` quest region —
// no known DLL collisions.
FUN_QUEST_LOG_REBUILD = 0x004DE510,
// Player and pet spellbooks — flat int32 arrays indexed by 0-based slot.
@@ -2850,6 +3236,7 @@ enum Offsets {
SPELLMOD_SLOT_STRIDE = 0x74, // bytes per familyFlagBit row
SPELLMOD_SLOT_COUNT = 64, // SpellFamilyFlags is 64-bit
SPELLMOD_OP_RADIUS = 6, // op index for spell radius
SPELLMOD_OP_DURATION = 1, // op index for aura duration
// SpellDispelType.dbc — maps dispel-type IDs (1..N) to localized
// names like "Magic", "Curse", "Disease", "Poison". Used by
@@ -3112,6 +3499,15 @@ enum Offsets {
OFF_EVENT_ENTRY_NAME = 0x00,
OFF_EVENT_ENTRY_HEAD = 0x0C,
// Subscriber-chain node layout — the linked list hanging off each
// entry's +0x0C head, derived from Frame::RegisterEvent
// (FUN_00702140): each node is `{ ?, next@+0x04, frame@+0x08 }`, where
// `frame` is the CFrameScriptObject* subscriber. A node value with its
// low bit set (or null) is the end/empty sentinel. Walked by
// Frame:IsEventRegistered to test membership.
OFF_EVENT_NODE_NEXT = 0x04,
OFF_EVENT_NODE_FRAME = 0x08,
// `Frame::RegisterEvent` — the C++ helper called by the Lua
// `frame:RegisterEvent(eventName)` method (`Script_RegisterEvent` at
// `0x00774A40`). `__thiscall` with `(this=frame, eventName)`. Walks
@@ -3474,6 +3870,30 @@ enum Offsets {
FUN_ADDON_GET_TITLE_BY_NAME = 0x0051DF20, // (name) → title string or NULL
FUN_ADDON_GET_NOTES_BY_NAME = 0x0051E050, // (name) → notes string or NULL
// `__fastcall(const char *nameOrEntry) → AddOnEntry*+0x48 or NULL`.
// Hash-table lookup by name (accepts an entry pointer too — its
// first 12 bytes are the inline name), returning a pointer to the
// matched record's RequiredDeps descriptor (`record + 0x48`), or
// NULL on a miss. Stock `Script_GetAddOnDependencies` (`0x0048E5E0`)
// calls this then reads count@+4 / data@+8 off the return to walk
// the required deps. We reuse it only to recover the record base for
// string input: `record = resolve(name) - OFF_ADDON_REQDEPS_DESC`.
FUN_ADDON_RESOLVE_REQ_DEPS = 0x0051E350,
// AddOnEntry dependency-array descriptors are `{cap, count, data,
// quantum}` (each field 4 bytes) laid out 0x10 apart, parsed by the
// TOC parser `FUN_0051c9b0`:
// OptionalDeps desc@+0x38 (count@+0x3C, data@+0x40)
// RequiredDeps desc@+0x48 (count@+0x4C, data@+0x50) ← stock walks
// LoadWith desc@+0x58
// Optional offsets verified against the parser's append site at
// `0x0051CDBA` (`mov esi,[rec+0x3C]` count, `lea edi,[rec+0x38]`);
// required desc@+0x48 confirmed by `FUN_ADDON_RESOLVE_REQ_DEPS`'s
// `lea eax,[rec+0x48]` return.
OFF_ADDON_REQDEPS_DESC = 0x48,
OFF_ADDON_OPTIONALDEPS_COUNT = 0x3C,
OFF_ADDON_OPTIONALDEPS_ARRAY = 0x40,
// Per-field accessors `Script_GetAddOnInfo` calls internally for
// returns 5 (loadable), 6 (reason string), 7 (security category).
// We dispatch to these directly so the wrappers don't need to
@@ -3540,23 +3960,15 @@ enum Offsets {
VAR_ADDON_ARRAY = 0x00BE1B94,
VAR_ADDON_COUNT = 0x00BE1B90,
// Player visibility/state byte field, broadcast in UpdateFields.
// Found empirically via _classicapi_DescDump (see src/debug/).
// Treated as a u32 but the meaningful bits all live in byte 0:
// 0x02 — set when the player has any visibility/form-modifying
// state active (stealth, mount, possibly shapeshift —
// set by stealth and mount in our test data).
// 0x08, 0x10 — set together when mounted (alongside 0x02).
//
// Ambiguity: bit 0x02 alone doesn't distinguish stealth from
// other visibility-modifying states. Stealth detection AND-gates
// with `mountDisplayID == 0` to exclude mount; if shapeshift
// also sets bit 0x02 we'd false-positive on druids in form
// (untested). The robust path is to walk the player's aura
// list looking for the actual stealth/prowl spell — left as
// future work if false-positives turn up.
OFF_PLAYER_FIELD_VIS_BYTES = 0x17C,
PLAYER_VIS_BIT_STEALTH = 0x02,
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
// (per-slot caster-level bytes; see OFF_UNIT_FIELD_AURALEVELS above),
// and the old read only appeared to work by coincidence (a caster-
// level byte that happened to have bit 0x02 set on the test char;
// at level 60 = 0x3C it doesn't, so it read false). The real stealth
// flag is UNIT_FIELD_BYTES_1 byte 3 (UNIT_BYTES_1_STEALTH_FLAG); see
// that field above. IsStealthed reads it.
// Mount display ID — the creature display ID the engine renders
// under the mounted player. Zero when not mounted, non-zero
@@ -4221,6 +4633,19 @@ enum Offsets {
// with craft).
VAR_TRADESKILL_ENTRIES = 0x00BDDFC0,
VAR_TRADESKILL_COUNT = 0x00BDE04C,
// SkillLine.dbc row ID of the currently-open trade skill window
// (the profession the player has open). Bounds-checked against
// `VAR_SKILL_LINE_COUNT` and used as `records[id]` by
// `Script_GetTradeSkillLine` (`0x004FDD40`). <= 0 / stale when no
// window is open. `FUN_004FC870` zeroes it on close (and fires
// event `0x13B`), so `> 0` reliably means "trade window open".
VAR_CURRENT_TRADESKILL_LINE = 0x00BDE040,
// Craft window (Enchanting, pet training) open-state flag — nonzero
// (3 for the enchanting-style craft) while open; `FUN_004F5FE0`
// zeroes it on close (fires event `0x169`). The Craft window has no
// skill-line-ID global — `TradeSkill::Link` derives the skill line
// from the first craft recipe — so this is the "craft open" gate.
VAR_CRAFT_WINDOW_STATE = 0x00BDCFB8,
// Spell.dbc "produced item" field for crafting recipes — the
// itemID this spell creates when cast. Reagent itemIDs and
// counts live at `OFF_SPELL_REAGENT_ID` / `OFF_SPELL_REAGENT_COUNT`
@@ -4245,6 +4670,18 @@ enum Offsets {
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
SPELL_RECORD_EFFECT_COUNT = 3,
// SPELL_EFFECT_* id array (`Effect[3]`) — five arrays before
// EffectBasePoints (`+0x130`), so `0x130 - 5*0xC`. Used to tell craft
// recipes from profession abilities (Disenchant etc.) that also carry
// skill-up thresholds. Recipe-producing effects: CREATE_ITEM (items),
// ENCHANT_ITEM / _TEMPORARY (enchants), and LEARN_SPELL (Turtle's custom
// recipes, e.g. "Steel Plate Boots" = effect 36).
OFF_SPELL_RECORD_EFFECT = 0xF4, // int32[3]
SPELL_EFFECT_CREATE_ITEM = 24,
SPELL_EFFECT_LEARN_SPELL = 36,
SPELL_EFFECT_ENCHANT_ITEM = 53,
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY = 54,
// EffectBasePoints[3] — the base magnitude of each effect, stored as
// (value - 1) for the fixed-die spells that back item stats (the
// engine adds a 1..dieSides roll; dieSides == 1 makes it value = base
@@ -4285,9 +4722,33 @@ enum Offsets {
// reads `spellRec[+0x14]` as the spell's mechanic when the per-
// effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID.
OFF_SPELL_RECORD_MECHANIC = 0x14,
SPELL_AURA_MOD_STEALTH = 16,
SPELL_AURA_MOD_SHAPESHIFT = 36,
SPELL_AURA_MOUNTED = 78,
// Control-loss aura effect types (EffectApplyAuraName values), used by
// C_LossOfControl to classify a debuff. Values are authoritative from the
// Turtle server's indexed SPELL_AURA proc-handler table (SharedDefines).
SPELL_AURA_MOD_POSSESS = 2,
SPELL_AURA_MOD_CONFUSE = 5,
SPELL_AURA_MOD_CHARM = 6,
SPELL_AURA_MOD_FEAR = 7,
SPELL_AURA_MOD_STUN = 12,
SPELL_AURA_MOD_PACIFY = 25,
SPELL_AURA_MOD_ROOT = 26,
SPELL_AURA_MOD_SILENCE = 27,
SPELL_AURA_MOD_PACIFY_SILENCE = 60,
SPELL_AURA_MOD_DISARM = 67,
// SMSG_SPELL_COOLDOWN (0x134) handler, registered by the spell-opcode
// boot registrar FUN_006e7150 exactly like the failure/channel handlers
// Spell::Cast co-hooks — same `int __stdcall(uint32_t *opCode,
// CDataStore *packet)` shape. Body: GUID(u64) then (spellID, durationMs)
// u32 pairs. A player-GUID packet carrying >=2 spells that all share one
// duration is a school-interrupt lockout (Player::ProhibitSpellSchool is
// the only sender of that shape); see src/lossofcontrol/Info.cpp.
FUN_SPELL_COOLDOWN_HANDLER = 0x006E9460,
// Gossip menu data. Populated by the SMSG_GOSSIP_MESSAGE packet
// handler at 0x004E26E0 and reset by FUN_004E26A0 each time a new
// gossip window opens. Two parallel storage arrays:
@@ -4337,6 +4798,80 @@ enum Offsets {
FUN_GOSSIP_SELECT_ACTIVE_QUEST = 0x004E2600,
FUN_SCRIPT_CLOSE_GOSSIP = 0x004E2B20,
// Current gossip NPC GUID (u64). Set by the SMSG_GOSSIP_MESSAGE
// handler, cleared by the gossip-close worker at 0x004E1FA0 (which
// then fires GOSSIP_CLOSED, event 0x12B). Non-zero = a gossip
// session is live — the gate that keeps `C_GossipInfo` from serving
// the previous NPC's stale arrays/text after close.
VAR_GOSSIP_NPC_GUID = 0x00BC3F58,
// --- Quest-greeting state (SMSG_QUESTGIVER_QUEST_LIST) -------------
// Vanilla's SECOND questgiver path: NPCs without a gossip menu send
// SMSG_QUESTGIVER_QUEST_LIST, whose handler (FUN_005dbb10) fills a
// storage entirely separate from the gossip arrays and fires
// QUEST_GREETING (event 300 = 0x12C) — the QuestFrameGreetingPanel
// ("Current Quests" / "Available Quests"). Vanilla Lua reads it via
// GetGreetingText / Get{Available,Active}Title / etc.; C_GossipInfo
// merges it in so modern-addon ports work on greeting NPCs too.
//
// Shared quest-session state, written by the panel-begin helper
// FUN_00500bd0(panelType, textID, guidLo, guidHi, ...):
// VAR_QUESTGIVER_GUID — u64, the current questgiver. Cleared by
// the session-close worker FUN_00501130 (fires QUEST_FINISHED,
// event 0x130). Non-zero = quest session live.
// VAR_QUEST_PANEL_TYPE — i32 panel: 0 greeting, 1 detail,
// 2 progress, 3 reward. begin(0) also zeroes both greeting
// arrays + counts + the text buffers.
VAR_QUESTGIVER_GUID = 0x00BE0810,
VAR_QUEST_PANEL_TYPE = 0x00BE0818,
// GUID the greeting arrays were filled for — written ONLY by the
// SMSG_QUESTGIVER_QUEST_LIST handler. The greeting arrays are valid
// iff this equals VAR_QUESTGIVER_GUID (and that is non-zero);
// otherwise they're leftovers from an older NPC (e.g. the current
// quest session came from a gossip-menu quest click, which begins
// at panel 1 without ever clearing the greeting arrays).
VAR_GREETING_NPC_GUID = 0x00C4D740,
// Greeting text (0x200 inline, $N/$C-expanded) — what vanilla's
// Script_GetGreetingText pushes.
VAR_QUEST_GREETING_TEXT = 0x00BDE068,
// The two greeting quest arrays: 32 entries, stride 0x4C =
// { u32 questID @ +0, i32 level @ +4, char title[0x40] @ +8,
// u32 extra @ +0x48 (available: status==0 flag; active: always
// 0 — the appender FUN_00500e90 never stores the status, see
// the scratch arrays below for isComplete) }.
// Appenders: FUN_00500e40 (available), FUN_00500e90 (active).
// Readers: Script_Get{Available,Active}{Title,Level} @ 0x00501AC0/
// 0x00501B30/0x00501BA0/0x00501C20.
VAR_GREETING_AVAILABLE_ENTRIES = 0x00BDFE60,
VAR_GREETING_ACTIVE_ENTRIES = 0x00BDE690,
GREETING_QUESTS_STRIDE = 0x4C,
GREETING_QUESTS_MAX = 32,
OFF_GREETING_QUEST_ID = 0x000,
OFF_GREETING_QUEST_LEVEL = 0x004,
OFF_GREETING_QUEST_TITLE = 0x008,
VAR_GREETING_AVAILABLE_COUNT = 0x00BE0834,
VAR_GREETING_ACTIVE_COUNT = 0x00BE0838,
// Scratch arrays the SMSG_QUESTGIVER_QUEST_LIST handler fills in
// packet order (32 × u32 each, zeroed per packet): questIDs and the
// per-quest dialog status. Status 3|4 = active (same sentinel as
// the gossip quest rows), 4 = complete/turn-in-ready. The canonical
// greeting arrays drop the status, so `isComplete` for an active
// greeting quest is recovered by questID lookup here.
VAR_GREETING_SCRATCH_IDS = 0x00C4D2B0,
VAR_GREETING_SCRATCH_STATUS = 0x00C4D340,
// Engine greeting-quest selectors — the workers vanilla's
// Script_Select{Available,Active}Quest call: `__fastcall(int
// idx0Based)`, index straight into the corresponding greeting
// array. They validate VAR_QUESTGIVER_GUID internally and send
// CMSG_QUESTGIVER_{QUERY_QUEST,COMPLETE_QUEST}.
FUN_GREETING_SELECT_AVAILABLE_QUEST = 0x005012A0,
FUN_GREETING_SELECT_ACTIVE_QUEST = 0x00501320,
// CDataStore primitive readers — `__thiscall void(this, void *out)`.
// Read at the current cursor (`+0x14`) and advance it by the value
// size. We call these directly from `Unit::MirrorTimer` to peek
@@ -4506,4 +5041,41 @@ enum Offsets {
// matching what `FUN_00494B60` and the trade-attached paths pass
// when re-resolving the cursor GUID to a `CGItem *`.
FUN_OBJECT_FROM_GUID = 0x00468460,
// Virtual XML-template registry (a Storm hash table) — the store that
// backs `inherits=` and `C_XMLUtil.GetTemplates`. The XML parser
// (`FUN_006ede10`) registers every `virtual="true"` frame into it via
// `FUN_006ee500`; the inherits resolver reads it via `FUN_006ee6f0`.
// Both operate on these globals, so it's the authoritative template list.
// See src/xml/Templates.cpp.
//
// VAR_XML_TEMPLATE_TABLE holds a POINTER to the bucket array (0 / the
// array unallocated) and VAR_XML_TEMPLATE_MASK the bucket-count mask;
// mask == 0xFFFFFFFF means no template has ever registered (walk = empty).
VAR_XML_TEMPLATE_TABLE = 0x00CEEA1C,
VAR_XML_TEMPLATE_MASK = 0x00CEEA24,
// Bucket struct (0xC bytes): { linkOffset@+0x0, ?@+0x4, chainHead@+0x8 }.
// A node's next pointer is at `*(node + linkOffset + 4)`; the tail
// sentinel has its low bit set (chain ends on `(ptr & 1) != 0`).
XML_TEMPLATE_BUCKET_STRIDE = 0xC,
OFF_XML_TEMPLATE_BUCKET_LINKOFF = 0x0,
OFF_XML_TEMPLATE_BUCKET_HEAD = 0x8,
// Registry node payload: template name string @+0x14, parsed definition
// XML node @+0x18 (hash @+0x0, virtual flag @+0x1C — unused here).
OFF_XML_TEMPLATE_NODE_NAME = 0x14,
OFF_XML_TEMPLATE_NODE_DEF = 0x18,
// Parsed XML element node: its tag-name string ("Frame" / "Button" /
// "CheckButton" / …) lives at +0x8 — this is the frame "type".
OFF_XML_NODE_TAG = 0x8,
// The definition node retains its full parsed DOM subtree — used by
// GetTemplateInfo to read `<Size>` / `inherits=`. First child @+0x4, next
// sibling @+0x1C. Attributes are read by name via the node method
// FUN_006f2cf0 (`__thiscall(node, name) -> value string`, null if absent).
OFF_XML_NODE_CHILD = 0x4,
OFF_XML_NODE_SIBLING = 0x1C,
FUN_XML_NODE_GET_ATTRIBUTE = 0x006F2CF0,
// By-name template lookup — the same one `inherits=` resolves through.
// `__fastcall(const char *name) -> definition node`, 0 if unregistered
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
};
+64
View File
@@ -41,6 +41,11 @@ using CanLoadFn_t = uint8_t(__fastcall *)(const uint8_t *entry, char fullCheck,
int *outStatus, int *outDepHandle,
const char *accountName);
using GetAccountName_t = const char *(*)();
// Hash-lookup by name → the matched record's RequiredDeps descriptor
// (`record + 0x48`), NULL on miss. Accepts an entry pointer too (its
// first bytes are the inline name). We use it only to recover the
// record base for string input; see `Script_GetAddOnOptionalDependencies`.
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
// Resolves the user's arg to a `const uint8_t *` that the engine's
// per-field accessors can consume. They all read the addon name as
@@ -295,6 +300,63 @@ int __fastcall Script_DoesAddOnExist(void *L) {
return 1;
}
// `C_AddOns.GetAddOnOptionalDependencies(indexOrName)` — the addon's
// `## OptionalDeps:` names as multiple return values, in declared order.
// The exact shape of the stock `GetAddOnDependencies`, but reading the
// *optional* dep array instead of the required one. No optional deps →
// returns nothing.
//
// Vanilla parses `## OptionalDeps:` in the TOC parser `FUN_0051c9b0`
// into a `{cap, count, data, quantum}` name-array descriptor at
// `record+0x38` (count@+0x3C, data@+0x40), but ships no getter for it.
//
// Arg/error handling mirrors stock `Script_GetAddOnDependencies`
// (`0x0048E5E0`): numeric out-of-range or a non-string/non-number arg
// raises the usage error; an unknown *name* string returns nothing
// (no error), matching the engine's own not-found path.
//
// Resolution mirrors stock `Script_GetAddOnDependencies` exactly: the
// arg is first turned into a name-usable pointer (`ResolveAddOnName` —
// numeric via `FUN_ADDON_GET_BY_INDEX`, string is the raw string), then
// routed through the by-name hash lookup `FUN_ADDON_RESOLVE_REQ_DEPS`
// to reach the deps-bearing record. `FUN_ADDON_GET_BY_INDEX` returns a
// *name pointer*, NOT the record — reading dep offsets off it lands in
// string memory and crashes (a numeric-path caller like pfUI's addon
// list hit exactly that). The resolver returns the RequiredDeps
// descriptor at `record+0x48`; subtract to recover the base. An unknown
// name yields NULL → return nothing (no error), like stock.
int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
static constexpr const char *kUsage =
"Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")";
// NULL ⇒ out-of-range numeric index or a non-string/non-number arg;
// stock raises the usage error in both cases.
const uint8_t *nameOrEntry = ResolveAddOnName(L);
if (nameOrEntry == nullptr) {
Game::Lua::Error(L, "%s", kUsage);
return 0;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(reinterpret_cast<const char *>(nameOrEntry));
if (reqDesc == nullptr)
return 0; // unknown addon name — no error, push nothing
const uint8_t *record = reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_COUNT);
if (count == 0)
return 0;
auto names = *reinterpret_cast<const char *const *const *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_ARRAY);
if (names == nullptr)
return 0;
for (uint32_t i = 0; i < count; ++i)
Game::Lua::PushString(L, names[i]); // NULL → pushnil, per engine
return static_cast<int>(count);
}
// `Enum.AddOnSecurityStatus` — what `C_AddOns.GetAddOnSecurity`
// returns. Slots 0..2 match the engine's own ordering at
// `VAR_ADDON_SECURITY_TABLE`; slot 3 is our addition for
@@ -323,6 +385,8 @@ static void RegisterLuaFunctions() {
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
&Script_GetAddOnOptionalDependencies);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+259
View File
@@ -0,0 +1,259 @@
// 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/>.
// See ComboDuration.h for the contract and the protocol findings that
// motivate the design (the server never transmits target-debuff
// durations in 1.12).
#include "ComboDuration.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "spell/Mod.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Aura::ComboDuration {
namespace {
using Net::CDataStore;
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
// The player's live combo points, read the same way Script_GetComboPoints
// (0x0051A190) does: the CP byte on the CGPlayer +0xE68 sub-struct. The player
// comes from the shared Unit::Identity::PlayerObject() (the object-manager GUID
// resolve, our fastest player-object path — returns null pre-world rather than
// throwing), so this stays safe from any context the send hook can fire in.
uint8_t CurrentComboPoints() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
const uint8_t *info = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (info == nullptr)
return 0;
return *(info + Offsets::OFF_CGPLAYER_COMBO_POINTS);
}
// One cast in flight at a time is the practical case (a second finisher
// can't be cast until the server consumes the first one's combo points),
// so a single slot with a spellID match and a short TTL suffices.
struct Capture {
uint32_t spellId;
uint8_t comboPoints;
uint32_t capturedMs;
};
Capture g_capture{0, 0, 0};
// Worst-case send -> SMSG_SPELL_GO roundtrip. Past this the snapshot is
// stale (the cast failed / was rejected) and must not scale a later cast.
constexpr uint32_t kCaptureTtlMs = 2000;
int ConsumeCapturedCP(uint32_t spellId) {
const Capture c = g_capture;
g_capture = {0, 0, 0};
if (c.spellId != spellId || c.comboPoints == 0)
return 0;
if (NowMs() - c.capturedMs >= kCaptureTtlMs)
return 0;
return c.comboPoints;
}
// Co-hook on the NetClient send — the one funnel every outgoing CMSG
// passes through, including casts released from nampower's spell queue
// (which bypass any co-hook on Spell_C_CastSpell itself via trampoline
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
// Snapshot the combo points at the moment the cast request leaves: the
// server consumes them before SMSG_SPELL_GO arrives, so reading at
// SpellGo time is too late.
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
CDataStore *packet);
NetSend_t g_origNetSend = nullptr;
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint32_t opcode = Net::Read<uint32_t>(packet);
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
}
packet->m_read = saved;
}
g_origNetSend(conn, edx, packet);
}
const Game::HookAutoRegister _sendHook{
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
reinterpret_cast<void **>(&g_origNetSend)};
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
// Lua-registered overrides for spells whose client DBC row is missing or
// wrong. Turtle's reworked Rip is the motivating case: its DurationIndex
// dangles (records[87] == NULL in Turtle's SpellDuration.dbc) because the
// rework lives entirely server-side, so the values must come from config.
struct Override {
uint32_t spellId;
int32_t baseMs;
int32_t maxMs;
};
constexpr int kMaxOverrides = 64;
Override g_overrides[kMaxOverrides];
int g_overrideCount = 0;
const Override *FindOverride(uint32_t spellId) {
for (int i = 0; i < g_overrideCount; ++i) {
if (g_overrides[i].spellId == spellId)
return &g_overrides[i];
}
return nullptr;
}
// Known values for spells whose duration row DANGLES — a non-zero
// DurationIndex pointing at a SpellDuration row the client doesn't have.
// Consulted ONLY when that exact condition holds, so the gate is the bug
// itself, not a server fingerprint: on a stock client every one of these
// resolves to a valid row and this table is never reached, and if the
// broken client data is ever fixed the DBC row takes over automatically.
//
// Turtle's reworked Rip is the known case: all six ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
// i.e. 8s + 2s per combo point); their client patch never shipped the
// row (the client table jumps 86 -> 105). Verified the only dangling
// duration index in the entire client Spell.dbc.
constexpr Override kDanglingRowKnown[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
// -> true on success. Re-registering a spellID replaces its entry.
// Values are in seconds to match the C_UnitAuras duration convention;
// the formula applied is the server's own:
// `base + (max - base) * comboPoints / 5`.
int __fastcall Script_RegisterComboDuration(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterComboDuration(spellID, "
"baseSeconds, maxSeconds)");
return 0;
}
const auto spellId = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto baseMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 2) * 1000.0);
const auto maxMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 3) * 1000.0);
if (spellId == 0 || baseMs < 0 || maxMs <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
for (int i = 0; i < g_overrideCount; ++i) {
if (g_overrides[i].spellId == spellId) {
g_overrides[i] = {spellId, baseMs, maxMs};
Game::Lua::PushBool(L, true);
return 1;
}
}
if (g_overrideCount >= kMaxOverrides) {
Game::Lua::PushBool(L, false);
return 1;
}
g_overrides[g_overrideCount++] = {spellId, baseMs, maxMs};
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterComboDuration",
&Script_RegisterComboDuration);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
const int cp = ConsumeCapturedCP(spellId);
if (cp <= 0 || cp > 5)
return 0;
int32_t baseMs = 0;
int32_t maxMs = 0;
if (const Override *o = FindOverride(spellId)) {
baseMs = o->baseMs;
maxMs = o->maxMs;
} else {
const int idx = *reinterpret_cast<const int *>(
spellRecord + Offsets::OFF_SPELL_RECORD_DURATION_INDEX);
const uint8_t *row =
DBC::Record(Offsets::VAR_SPELL_DURATION_RECORDS,
Offsets::VAR_SPELL_DURATION_COUNT,
static_cast<uint32_t>(idx));
if (row != nullptr) {
baseMs = *reinterpret_cast<const int32_t *>(row + 0x4);
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Fall back to the built-in known values (Turtle Rip).
const Override *k = FindDanglingRowKnown(spellId);
if (k == nullptr)
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
}
// The server scales only when the row has a combo range: base != max
// ((v)mangos CalculateSpellDuration). base < 0 is the "infinite"
// sentinel; fixed-duration rows (base == max) take the regular path.
if (baseMs < 0 || maxMs <= 0 || baseMs == maxMs)
return 0;
int32_t durationMs = baseMs + (maxMs - baseMs) * cp / 5;
if (durationMs <= 0)
return 0;
// Player duration SpellMods apply on top of the combo-scaled value,
// matching the server's order (scale, then ApplySpellMod).
durationMs = static_cast<int32_t>(Spell::Mod::Apply(
spellRecord, Offsets::SPELLMOD_OP_DURATION,
static_cast<float>(durationMs)));
return durationMs > 0 ? static_cast<uint32_t>(durationMs) : 0;
}
} // namespace Aura::ComboDuration
+50
View File
@@ -0,0 +1,50 @@
// 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>
// `Aura::ComboDuration` — combo-point-scaled durations for finisher
// auras (Rupture, Kidney Shot, Slice and Dice, Rip, …).
//
// The 1.12 server computes a finisher's real duration at cast time as
// `base + (max - base) * comboPoints / 5` from the spell's
// SpellDuration.dbc row and never transmits the result for auras the
// player puts on OTHER units (verified with a whole-protocol sniff:
// SMSG_UPDATE_AURA_DURATION is self-scoped, and nothing else carries
// it). So this module mirrors the computation client-side:
//
// - a co-hook on the NetClient send (`FUN_NET_SEND`) snapshots the
// player's combo points the instant CMSG_CAST_SPELL leaves — the
// one moment the count is still intact (the server clears it
// before SMSG_SPELL_GO comes back);
// - `TryComboScaledMs` consumes that snapshot when `Aura::Source`'s
// SpellGo hook stores the aura, computing the scaled duration from
// SpellDuration.dbc and applying the player's duration SpellMods on
// top, matching the server's order of operations. Spells whose
// duration index DANGLES (client data bug — Turtle's reworked Rip
// is the known case) fall back to built-in values gated on that
// exact condition; a Lua-registered override
// (`C_UnitAuras.RegisterComboDuration`) beats both, as an escape
// hatch for other servers whose values differ from the client DBC.
namespace Aura::ComboDuration {
// Combo-scaled duration (ms) for a player-cast spell, or 0 when it
// doesn't apply: no combo points captured for this cast, the spell's
// duration row isn't combo-scaled (base == max) and has no override,
// or the row is missing. A 0 return means "use the regular base
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
} // namespace Aura::ComboDuration
+33 -4
View File
@@ -492,6 +492,25 @@ void PushFromCache(void *L, const uint8_t *unit,
c.casterGuid);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
// range. The cache fallback is only trustworthy when the descriptor has been
// wholesale-cleared: the two states it exists for — rogue stealth and party
// range fluctuation — clear the *entire* aura array at once. If the unit shows
// even one live aura, the descriptor is authoritative, so a cache entry the
// descriptor lacks is a genuinely-gone aura we never saw removed (e.g. a
// no-duration debuff — Ghost/Spirit — on a unit that died and resurrected out
// of our visibility, so no OnAuraRemoved fired and its infinite entry never
// expired). Surfacing it then produces a stuck phantom.
bool DescriptorHasVisibleAura(const uint8_t *unit) {
if (unit == nullptr)
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
if (IsSlotPopulated(unit, slot))
return true;
}
return false;
}
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
if (unit == nullptr)
@@ -515,12 +534,18 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
// `filter`/`playerOnly`: it must not already be in a populated descriptor slot
// (no double-listing the same live aura) and, when player-filtered, must have
// been cast by us. Superseded / dispelled auras are kept out of the cache by
// `Aura::Source`'s removal eviction, not filtered here.
// `filter`/`playerOnly`: the spell must be a user-visible aura, it must not
// already be in a populated descriptor slot (no double-listing the same live
// aura) and, when player-filtered, must have been cast by us. Superseded /
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
// eviction, not filtered here.
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
Filter filter, bool playerOnly) {
if (!IsVisible(SpellRecord(c.spellId)))
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
// the descriptor path drops these via IsSlotPopulated, so
// the cache path must apply the same engine predicate or
// they leak through as tooltip-less phantom buffs.
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
return false; // still in the descriptor — returned via the slot path
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
@@ -534,6 +559,8 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly) {
if (unit == nullptr)
return false;
if (DescriptorHasVisibleAura(unit))
return false; // descriptor authoritative — see DescriptorHasVisibleAura
// The descriptor held `descCount` matches; the caller already found fewer
// than `oneBasedIndex` there, so the cache supplies index
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
@@ -562,6 +589,8 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
if (DescriptorHasVisibleAura(unit))
return; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return;
+40 -1
View File
@@ -15,6 +15,7 @@
#include "Source.h"
#include "ComboDuration.h"
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
@@ -149,10 +150,40 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
e.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
// records the current map without flushing.
int g_lastMapId = -1;
// Drop entries whose timed aura has elapsed so the table doesn't fill with
// stale combat debuffs. Infinite-duration entries (expirationMs == 0) stay
// until overwritten.
//
// Also flush the entire cache on a map-ID change. A battleground/instance/
// continent transition (including the teleport out of a BG via /afk) bulk-
// clears the player's aura descriptor WITHOUT firing per-aura OnAuraRemoved,
// so entries for auras that are now gone would linger and the
// GetAuraDataByIndex fallback would keep surfacing them — the "buffs/debuffs
// stuck after leaving a battleground" report. Every other unit from the old
// map has despawned too, and the local player's real auras re-sync into the
// descriptor / PLAYER_BUFF tables on entering the new world, so a full flush
// loses nothing legitimate. Crucially, the descriptor drops the fallback
// must survive — rogue stealth and party-range fluctuation — do NOT change
// the map ID, so they're unaffected.
void OnWorldTick() {
const int mapId = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
if (mapId != g_lastMapId) {
if (g_lastMapId != -1)
FlushAll();
g_lastMapId = mapId;
}
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
@@ -203,7 +234,15 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
return;
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
const uint32_t durationMs = SpellDurationMs(rec, casterIsPlayer);
// Combo-point finishers (Rupture, Kidney Shot, …) have their real
// duration computed at cast time from the combo points spent — the
// base-duration helper can't know it. ComboDuration mirrors the
// server's computation from the CP snapshot its send hook captured;
// 0 means "not combo-scaled", fall through to the base path.
uint32_t durationMs =
casterIsPlayer ? ComboDuration::TryComboScaledMs(rec, spellId) : 0;
if (durationMs == 0)
durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
if (numTargets == 0) {
+29 -26
View File
@@ -30,6 +30,16 @@ constexpr int kMaxDepth = 64;
// ---------------- encode ----------------
// tinycbor's grow-and-retry contract: on buffer overflow the encoder
// switches to counting mode (end = NULL, accumulates bytes_needed) and
// encoding must CONTINUE so the final tally is complete — a nested
// container's tally only reaches the parent via close_container. So
// CborErrorOutOfMemory is not a failure here; the caller reads
// cbor_encoder_get_extra_bytes_needed() afterwards, resizes, retries.
bool EncOk(CborError err) {
return err == CborNoError || err == CborErrorOutOfMemory;
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth);
bool IsLuaArray(void *L, int idx, int &outLen) {
@@ -73,8 +83,8 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
int arrayLen = 0;
if (IsLuaArray(L, idx, arrayLen)) {
CborEncoder arr;
if (cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen)) != CborNoError)
if (!EncOk(cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen))))
return false;
for (int i = 1; i <= arrayLen; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i));
@@ -85,12 +95,12 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &arr) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &arr));
}
const int pairs = CountTablePairs(L, idx);
CborEncoder map;
if (cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs)) != CborNoError)
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
return false;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, idx) != 0) {
@@ -101,16 +111,16 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &map) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &map));
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
switch (Game::Lua::Type(L, idx)) {
case Game::Lua::TYPE_NIL:
return cbor_encode_null(enc) == CborNoError;
return EncOk(cbor_encode_null(enc));
case Game::Lua::TYPE_BOOLEAN:
return cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0) == CborNoError;
return EncOk(cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0));
case Game::Lua::TYPE_NUMBER: {
const double n = Game::Lua::ToNumber(L, idx);
// Whole numbers in [-2^53, 2^53] go out as integers so
@@ -118,17 +128,17 @@ bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
if (std::isfinite(n) && n == std::floor(n) &&
n >= -9007199254740992.0 && n <= 9007199254740992.0) {
if (n >= 0.0)
return cbor_encode_uint(enc,
static_cast<uint64_t>(n)) == CborNoError;
return cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)) == CborNoError;
return EncOk(cbor_encode_uint(enc,
static_cast<uint64_t>(n)));
return EncOk(cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)));
}
return cbor_encode_double(enc, n) == CborNoError;
return EncOk(cbor_encode_double(enc, n));
}
case Game::Lua::TYPE_STRING: {
const char *s = Game::Lua::ToString(L, idx);
const unsigned int n = Game::Lua::StrLen(L, idx);
return cbor_encode_text_string(enc, s, n) == CborNoError;
return EncOk(cbor_encode_text_string(enc, s, n));
}
case Game::Lua::TYPE_TABLE:
return EncodeTable(L, idx, enc, depth);
@@ -143,22 +153,15 @@ int __fastcall Script_SerializeCBOR(void *L) {
return 0;
}
// tinycbor encodes into a fixed-size buffer; if it overflows, it
// tracks the extra bytes needed so we can resize and retry.
// tinycbor encodes into a fixed-size buffer; on overflow it keeps
// encoding in counting mode (EncOk treats OutOfMemory as success) so
// the extra-bytes tally is complete, then we resize and retry once.
std::vector<uint8_t> buf(256);
for (;;) {
CborEncoder enc;
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
if (!EncodeValue(L, 1, &enc, 0)) {
// If it was a buffer-size issue we'll catch it via the
// extra-bytes-needed check below; otherwise this is a
// genuine "value not representable" failure.
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra == 0)
return 0;
buf.resize(buf.size() + extra);
continue;
}
if (!EncodeValue(L, 1, &enc, 0))
return 0; // genuine "value not representable" failure
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra > 0) {
buf.resize(buf.size() + extra);
+321
View File
@@ -0,0 +1,321 @@
// 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/>.
// Modern Region/Frame method backports.
//
// Modern addon ports routinely call these methods unconditionally —
// they're the C-level frame surface later clients take for granted.
// This module supplies the ones vanilla lacks (surfaced via
// AI_VoiceOver's modern code path, but generic to every port):
//
// - `region:SetPoint("RIGHT")` — the one-arg form. Vanilla's parser
// accepts every other modern form but errors when arg 3 is fully
// omitted; a co-hook on Script_SetPoint normalizes it to
// `(point, 0, 0)` (parent-relative, zero offsets — the exact modern
// semantic).
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
// REGION base method registry, so they resolve on frames, buttons,
// textures, fontstrings — everything.
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
// (each with its own object typecheck), so SetShown is registered
// separately on Frame / Texture / FontString with matching delegates.
// - `frame:SetResizeBounds(minW, minH [, maxW, maxH])` — the modern
// rename of vanilla's SetMinResize/SetMaxResize pair.
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
// entirely. Chains after any existing handler and invokes `handler`
// with modern positional args (see the implementation comment).
//
// Implementation style: each new method delegates to the ENGINE's own
// Script_* implementations by reshaping the Lua stack and calling them
// (`int __fastcall(void *L)`, self at index 1) — no re-derived setters,
// so pixel/UI-scale conversion, object resolution, and type checking are
// all the engine's own code. Values are read out BEFORE any stack
// reshaping.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace Frame::Modern {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
int CallScript(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// ---- SetSize / GetSize (Region base — all region types) -------------------
// `region:SetSize(width, height)` → SetWidth + SetHeight.
int __fastcall Script_SetSize(void *L) {
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: region:SetSize(width, height)");
return 0;
}
const double h = Game::Lua::ToNumber(L, 3);
Game::Lua::SetTop(L, 2); // (self, width)
CallScript(Offsets::FUN_SCRIPT_REGION_SETWIDTH, L);
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushNumber(L, h);
CallScript(Offsets::FUN_SCRIPT_REGION_SETHEIGHT, L);
return 0;
}
// `region:GetSize()` → width, height.
int __fastcall Script_GetSize(void *L) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(Offsets::FUN_SCRIPT_REGION_GETWIDTH, L); // (self, w)
CallScript(Offsets::FUN_SCRIPT_REGION_GETHEIGHT, L); // (self, w, h)
return 2;
}
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
// matching modern semantics.
template <uintptr_t ShowFn, uintptr_t HideFn>
int __fastcall Script_SetShown(void *L) {
const bool shown = Game::Lua::ToBoolean(L, 2) != 0;
Game::Lua::SetTop(L, 1); // (self) — Show/Hide take no args
CallScript(shown ? ShowFn : HideFn, L);
return 0;
}
// ---- SetResizeBounds (Frame) -----------------------------------------------
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
// modern rename of the vanilla SetMinResize/SetMaxResize pair.
int __fastcall Script_SetResizeBounds(void *L) {
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: frame:SetResizeBounds(minWidth, "
"minHeight [, maxWidth, maxHeight])");
return 0;
}
const bool haveMax = Game::Lua::IsNumber(L, 4) && Game::Lua::IsNumber(L, 5);
const double maxW = haveMax ? Game::Lua::ToNumber(L, 4) : 0.0;
const double maxH = haveMax ? Game::Lua::ToNumber(L, 5) : 0.0;
Game::Lua::SetTop(L, 3); // (self, minW, minH)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMINRESIZE, L);
if (haveMax) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushNumber(L, maxW);
Game::Lua::PushNumber(L, maxH);
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMAXRESIZE, L);
}
return 0;
}
// ---- HookScript (Frame) ----------------------------------------------------
// `frame:HookScript(scriptType, handler)` — vanilla has no HookScript at
// all. Chains onto any existing handler: the old handler runs first,
// vanilla-style (no args — it reads the `this`/`event`/`argN` globals like
// every 1.12 handler), then `handler` is invoked MODERN-style with
// positional arguments `(frame, event, arg1..arg9)` for OnEvent and
// `(frame, arg1..arg9)` otherwise — mirroring the compat convention
// modern addon ports expect (and ship themselves when they detect a
// legacy client). The chain is a C closure over (handler, old, isOnEvent).
// Pushes _G[name].
void PushGlobal(void *L, const char *name) {
Game::Lua::PushString(L, name);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
}
// The chained handler the engine actually invokes (with no args, like any
// vanilla script handler). Upvalues: 1 = hook handler, 2 = old handler
// (or nil), 3 = isOnEvent (number). Errors inside lua_call propagate to
// the engine's own protected script dispatch.
int __fastcall HookThunk(void *L) {
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(2)) ==
Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2));
Game::Lua::Call(L, 0, 0);
}
const bool isOnEvent =
Game::Lua::ToNumber(L, Game::Lua::UpvalueIndex(3)) != 0;
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
PushGlobal(L, "this");
if (isOnEvent)
PushGlobal(L, "event");
static const char *const kArgs[] = {"arg1", "arg2", "arg3", "arg4",
"arg5", "arg6", "arg7", "arg8",
"arg9"};
for (const char *a : kArgs)
PushGlobal(L, a);
Game::Lua::Call(L, (isOnEvent ? 2 : 1) + 9, 0);
return 0;
}
int __fastcall Script_HookScript(void *L) {
if (!Game::Lua::IsString(L, 2) ||
Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "Usage: frame:HookScript(\"type\", function)");
return 0;
}
const char *scriptName = Game::Lua::ToString(L, 2);
const bool isOnEvent =
scriptName != nullptr && std::strcmp(scriptName, "OnEvent") == 0;
Game::Lua::SetTop(L, 3); // (self, name, handler)
// GetScript reads self/name at 1/2 and pushes the current handler
// (or nil) — extra stack entries above are ignored by its reads.
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (.., old)
// Closure upvalues, in upvalue order: handler, old, isOnEvent.
Game::Lua::PushValue(L, 3); // handler
Game::Lua::PushValue(L, 4); // old
Game::Lua::PushNumber(L, isOnEvent ? 1.0 : 0.0); // flag
Game::Lua::PushCClosure(L, &HookThunk, 3); // (self,name,handler,old,closure)
Game::Lua::Remove(L, 4); // drop old
Game::Lua::Remove(L, 3); // drop handler → (self,name,closure)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
return 0;
}
// ---- IsEventRegistered (Frame) ---------------------------------------------
// `frame:IsEventRegistered("event")` — vanilla has RegisterEvent /
// UnregisterEvent but not the query. There's no engine Script_* to
// delegate to, so we replicate the membership check that lives inside
// Frame::RegisterEvent (FUN_00702140): find the event by name in the live
// event table, then walk that entry's subscriber chain for this frame.
// The frame pointer we compare against is the same CFrameScriptObject*
// the engine stores (Game::Lua::ResolveObject resolves self exactly as
// Script_RegisterEvent does).
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
bool FrameHasEvent(void *frame, const char *eventName) {
const uint32_t count = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
auto *base = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
if (count == 0 || base == nullptr)
return false;
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(
static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I));
for (uint32_t i = 0; i < count; ++i) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_EVENT_ENTRY_NAME);
if (name == nullptr)
continue;
if (sstrcmpi(name, eventName, 0x7FFFFFFF) != 0)
continue;
// Names are unique, so this is the one entry — walk its chain.
uintptr_t node = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
while ((node & 1) == 0 && node != 0) {
if (*reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
return true;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT);
}
return false;
}
return false;
}
int __fastcall Script_IsEventRegistered(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:IsEventRegistered(\"event\")");
return 0;
}
void *frame = Game::Lua::ResolveObject(L, 1);
const char *eventName = Game::Lua::ToString(L, 2);
const bool registered =
frame != nullptr && eventName != nullptr && FrameHasEvent(frame, eventName);
Game::Lua::PushBool(L, registered);
return 1;
}
// ---- SetPoint one-arg form (Script_SetPoint co-hook) -----------------------
// Vanilla's Script_SetPoint accepts every modern form except the bare
// one-arg call (`region:SetPoint("RIGHT")`) — a fully-omitted arg 3 is
// LUA_TNONE, which fails its string/table/number/nil validation and
// raises the usage error. Normalize to the vanilla (point, x, y) form
// with zero offsets, which anchors relative to the parent at the same
// point — exactly the modern one-arg semantic. All other call shapes
// pass through untouched.
ScriptFn_t g_origSetPoint = nullptr;
int __fastcall SetPoint_h(void *L) {
if (Game::Lua::GetTop(L) == 2 && Game::Lua::IsString(L, 2)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushNumber(L, 0.0);
}
return g_origSetPoint(L);
}
const Game::HookAutoRegister _setPointHook{
Offsets::FUN_SCRIPT_REGION_SETPOINT,
reinterpret_cast<void *>(&SetPoint_h),
reinterpret_cast<void **>(&g_origSetPoint)};
// ---- Registration ----------------------------------------------------------
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
{"SetSize", &Script_SetSize},
{"GetSize", &Script_GetSize},
};
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FRAME_SHOW,
Offsets::FUN_SCRIPT_FRAME_HIDE>},
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_TEXTURE_SHOW,
Offsets::FUN_SCRIPT_TEXTURE_HIDE>},
};
const Game::Lua::FrameMethodEntry g_fontStringMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FONTSTRING_SHOW,
Offsets::FUN_SCRIPT_FONTSTRING_HIDE>},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY),
g_regionMethods,
static_cast<int>(sizeof(g_regionMethods) / sizeof(g_regionMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
g_frameMethods,
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_TEXTURE_METHOD_REGISTRY),
g_textureMethods,
static_cast<int>(sizeof(g_textureMethods) / sizeof(g_textureMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FONTSTRING_METHOD_REGISTRY),
g_fontStringMethods,
static_cast<int>(sizeof(g_fontStringMethods) /
sizeof(g_fontStringMethods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Frame::Modern
+159 -11
View File
@@ -22,6 +22,24 @@
// SMSG_GOSSIP_MESSAGE handler at `0x004E26E0` and cleared each open.
// We read the same shape the engine's own `Script_GetGossip*` reads.
//
// Vanilla has a SECOND questgiver path this namespace also covers:
// NPCs without a gossip menu send SMSG_QUESTGIVER_QUEST_LIST instead —
// the QUEST_GREETING event and the QuestFrameGreetingPanel ("Current
// Quests" / "Available Quests") — whose data lands in a storage
// entirely separate from the gossip arrays (see the VAR_GREETING_*
// block in Offsets.h). Retail keeps the two APIs disjoint, but for
// backporting it's far more useful for `C_GossipInfo` to serve
// whichever questgiver session is actually live:
// - gossip session live (VAR_GOSSIP_NPC_GUID != 0) → gossip arrays
// - greeting session live (VAR_QUESTGIVER_GUID != 0
// and == VAR_GREETING_NPC_GUID) → greeting arrays
// - neither → empty / ""
// The GUID gates double as staleness protection: both storages persist
// after their frames close (the engine never clears them until the
// next open), so ungated reads would report the previous NPC.
// `GetOptions`/`GetNumOptions` stay gossip-only — greetings have no
// options.
//
// Missing modern fields — vanilla 1.12's server simply never sends
// them, so there's nothing to surface:
// - `rewards`, `spellID` (post-vanilla; merged in with the
@@ -46,6 +64,57 @@ namespace {
using EngineScriptFn = int(__fastcall *)(void *L);
uint64_t ReadU64(uintptr_t va) {
return *reinterpret_cast<const uint64_t *>(va);
}
// A gossip session is live from SMSG_GOSSIP_MESSAGE until the close
// worker (0x004E1FA0) zeroes the GUID and fires GOSSIP_CLOSED.
bool GossipSessionActive() {
return ReadU64(Offsets::VAR_GOSSIP_NPC_GUID) != 0;
}
// A quest-greeting session is live while the quest-session GUID is set
// AND the greeting arrays were filled for that same NPC. The second
// check matters because a gossip-menu quest click opens a quest session
// at panel 1 without ever touching the greeting arrays — without it,
// leftovers from an older greeting NPC would leak through.
bool GreetingSessionActive() {
const uint64_t giver = ReadU64(Offsets::VAR_QUESTGIVER_GUID);
return giver != 0 && giver == ReadU64(Offsets::VAR_GREETING_NPC_GUID);
}
const uint8_t *GreetingEntry(bool active, int slot) {
const uintptr_t base = active ? Offsets::VAR_GREETING_ACTIVE_ENTRIES
: Offsets::VAR_GREETING_AVAILABLE_ENTRIES;
return reinterpret_cast<const uint8_t *>(
base + static_cast<uintptr_t>(slot) * Offsets::GREETING_QUESTS_STRIDE);
}
int GreetingCount(bool active) {
const int count = *reinterpret_cast<const int *>(
active ? Offsets::VAR_GREETING_ACTIVE_COUNT
: Offsets::VAR_GREETING_AVAILABLE_COUNT);
return (count < 0) ? 0
: (count > Offsets::GREETING_QUESTS_MAX) ? Offsets::GREETING_QUESTS_MAX
: count;
}
// The greeting arrays drop the per-quest dialog status, but the packet
// handler's scratch arrays (packet order, zeroed per packet) keep it —
// status 4 = complete/turn-in-ready, same sentinel as the gossip rows.
bool GreetingQuestIsComplete(uint32_t questID) {
const auto *ids = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_IDS);
const auto *status = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_STATUS);
for (int i = 0; i < Offsets::GREETING_QUESTS_MAX; ++i) {
if (ids[i] == questID)
return status[i] == 4;
}
return false;
}
const uint8_t *OptionEntry(int slot) {
return reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_OPTIONS) +
@@ -79,14 +148,21 @@ bool IsActiveQuest(uint32_t status) {
return status == 3 || status == 4;
}
// `C_GossipInfo.GetText()` — pushes the greeting string the engine
// resolved for the gossip-giver's NPC_TEXT.dbc entry. Mirrors what
// `Script_GetGossipText` does (one inline `lua_pushstring` of the
// fixed buffer at `VAR_GOSSIP_GREETING_TEXT`).
// `C_GossipInfo.GetText()` — the greeting string of whichever
// questgiver session is live: the gossip buffer (what
// `Script_GetGossipText` pushes) for a gossip session, the quest
// greeting buffer (what `Script_GetGreetingText` pushes) for a
// greeting session, "" when neither is open. Both buffers persist
// after close, so the gates keep stale text from the previous NPC
// out.
int __fastcall Script_C_GossipInfo_GetText(void *L) {
const char *text = reinterpret_cast<const char *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_GREETING_TEXT));
Game::Lua::PushString(L, text);
uintptr_t buf = 0;
if (GossipSessionActive())
buf = Offsets::VAR_GOSSIP_GREETING_TEXT;
else if (GreetingSessionActive())
buf = Offsets::VAR_QUEST_GREETING_TEXT;
Game::Lua::PushString(
L, (buf != 0) ? reinterpret_cast<const char *>(buf) : "");
return 1;
}
@@ -108,6 +184,8 @@ int __fastcall Script_C_GossipInfo_GetText(void *L) {
int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive())
return 1; // greeting sessions have no options; stale data stays hidden
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
@@ -138,6 +216,36 @@ int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
return 1;
}
// Greeting-session variant: walks the pre-split greeting array for the
// wanted side. Same table shape as the gossip rows (questID / title /
// questLevel / isComplete) so consumers can't tell the sources apart.
int PushGreetingQuestList(void *L, bool wantActive) {
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint8_t *entry = GreetingEntry(wantActive, slot);
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_GREETING_QUEST_ID);
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "questID",
static_cast<double>(questID));
Game::Lua::SetFieldString(L, "title", reinterpret_cast<const char *>(
entry + Offsets::OFF_GREETING_QUEST_TITLE));
const int32_t level = *reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_GREETING_QUEST_LEVEL);
Game::Lua::SetFieldNumber(L, "questLevel",
static_cast<double>(level));
if (wantActive)
Game::Lua::SetFieldBool(L, "isComplete",
GreetingQuestIsComplete(questID));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// Shared body for `GetAvailableQuests` / `GetActiveQuests`. Both walk
// the same backing array and split by the `status` field — `wantActive`
// inverts the filter.
@@ -145,6 +253,12 @@ int PushQuestList(void *L, bool wantActive) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive()) {
if (GreetingSessionActive())
return PushGreetingQuestList(L, wantActive);
return 1; // no questgiver session — empty table, never stale data
}
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -189,15 +303,19 @@ int __fastcall Script_C_GossipInfo_GetActiveQuests(void *L) {
int __fastcall Script_C_GossipInfo_GetNumOptions(void *L) {
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
if (GossipSessionActive()) {
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
}
}
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
int CountQuests(bool wantActive) {
if (!GossipSessionActive())
return GreetingSessionActive() ? GreetingCount(wantActive) : 0;
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -247,6 +365,17 @@ void EngineSelectActiveQuest(int idx0Based) {
fn(idx0Based);
}
// Greeting-session selectors — the workers vanilla's
// Script_Select{Available,Active}Quest call. Index straight into the
// corresponding greeting array (they validate the questgiver GUID
// internally before sending the CMSG).
void EngineSelectGreetingQuest(bool wantActive, int idx0Based) {
auto fn = reinterpret_cast<SelectQuest_t>(
wantActive ? Offsets::FUN_GREETING_SELECT_ACTIVE_QUEST
: Offsets::FUN_GREETING_SELECT_AVAILABLE_QUEST);
fn(idx0Based);
}
// `C_GossipInfo.SelectOption(gossipOptionID[, text])` — wraps vanilla's
// `SelectGossipOption(slotIndex)`. `text` is the boxCoded password,
// passed through to the engine helper which gates on the
@@ -260,6 +389,8 @@ int __fastcall Script_C_GossipInfo_SelectOption(void *L) {
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *password =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
if (!GossipSessionActive())
return 0; // stale option slots must not send a CMSG
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
const uint8_t *entry = OptionEntry(slot);
@@ -284,7 +415,8 @@ int __fastcall Script_C_GossipInfo_SelectOptionByIndex(void *L) {
return 0;
}
const int slot0Based = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX)
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX ||
!GossipSessionActive())
return 0;
EngineSelectOption(slot0Based, nullptr);
return 0;
@@ -301,6 +433,22 @@ int SelectQuestByID(void *L, bool wantActive, const char *errUsage) {
}
const uint32_t target = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
if (!GossipSessionActive()) {
if (!GreetingSessionActive())
return 0;
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
GreetingEntry(wantActive, slot) +
Offsets::OFF_GREETING_QUEST_ID);
if (questID == target) {
EngineSelectGreetingQuest(wantActive, slot);
return 0;
}
}
return 0;
}
int idx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
+466
View File
@@ -0,0 +1,466 @@
// 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_LootHistory backport.
//
// Modern WoW (2.x+, incl. 5.4.8) exposes a `C_LootHistory` namespace backed
// by an engine-maintained ring buffer that a dedicated loot-history server
// packet fills. Vanilla 1.12 has NO such packet or store — but it DOES
// receive the raw group-loot roll traffic (SMSG_LOOT_ROLL per player,
// SMSG_LOOT_ROLL_WON, SMSG_LOOT_ALL_PASSED), which the default UI only turns
// into chat lines. So we reconstruct the history client-side (à la
// Aura::Source): co-hook the three roll-result handlers and accumulate a ring
// of rolled items + per-player results, then expose C_LootHistory reading
// from it.
//
// The handlers run on the deserialized PendingLootRoll message (0x48 bytes,
// LootRoll.cpp) with the fields at the OFF_LOOTROLL_* offsets. The message is
// populated at handler entry and freed at exit, so each co-hook reads its
// fields BEFORE calling the original.
//
// === Handler ABI (verified from disassembly — RET conventions DIFFER) ===
// ROLL FUN_0061C0B0: __fastcall(msg=ECX, ctx=EDX, int stackArg) -> RET 4
// WON FUN_0061B9E0: __fastcall(ctx=ECX, msg=EDX, int stackArg) -> RET 4
// PASSED FUN_0061B640: __fastcall(ctx=ECX, msg=EDX) -> RET 0
// The trailing stack arg (and matching callee cleanup) is LOAD-BEARING: an
// earlier revision declared these as 2-arg hooks (implicit RET 0), so every
// ROLL/WON call left ESP unbalanced by 4 -> stack corruption -> crash on the
// first loot roll. Each hook below matches its handler's exact shape.
//
// Name + class per roller come from the engine NameCache (read at hook time,
// guaranteed loaded then), so they resolve even for someone who has left the
// group by the time their roll packet arrives. Surface: C_LootHistory
// GetNumItems / GetItem / GetPlayerInfo, plus LOOT_HISTORY_ROLL_CHANGED /
// _ROLL_COMPLETE events. The MoP master-loot management functions
// (SetExpiration / GiveMasterLoot / CanMasterLoot) are intentionally omitted
// — they depend on server support 1.12 doesn't have.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
namespace Loot::History {
namespace {
template <typename T>
T Read(const void *base, int off) {
return *reinterpret_cast<const T *>(static_cast<const uint8_t *>(base) + off);
}
// Modern rollType constants (what C_LootHistory.GetPlayerInfo returns).
constexpr int ROLL_PASS = 0;
constexpr int ROLL_NEED = 1;
constexpr int ROLL_GREED = 2;
// The SMSG_LOOT_ROLL packet encodes rollType as 0 = need, 2 = greed; map to
// the modern convention. A "didn't actually roll" (pass) is flagged by the
// high bit of the roll-number byte, handled by the caller.
int MapRollType(int packetType) {
return packetType == 2 ? ROLL_GREED : ROLL_NEED;
}
// GUID -> engine NameCache entry-data block. This is the same primitive the
// engine's own `%N` name substitution uses (FUN_005083A0): for a LOADED entry
// the lookup returns the data block (name[48] at +0, class at +0x140, …),
// regardless of the (null) callback and regardless of current group
// membership — so it resolves a roller who has since left the group, which
// is exactly what a loot roll needs (the roll packet arrives after they may
// have gone). Guaranteed loaded at hook time: the roll handler's wrapper
// gates on this same lookup succeeding. Returns nullptr if not cached.
const uint8_t *LookupPlayerRecord(uint64_t guid) {
if (guid == 0)
return nullptr;
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
uint32_t hi, void *cookie,
void *cb, void *ud, char flag);
auto fn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
uint32_t cookie[2] = {0, 0};
return fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
cookie, nullptr, nullptr, 0);
}
const char *NameFromRecord(const uint8_t *rec) {
return rec != nullptr
? reinterpret_cast<const char *>(rec + Offsets::OFF_PLAYER_INFO_NAME)
: nullptr;
}
uint32_t ClassIdFromRecord(const uint8_t *rec) {
return rec != nullptr
? *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_PLAYER_INFO_CLASS)
: 0;
}
// ChrClasses.dbc record ID -> class token ("WARRIOR", "MAGE", …). The DBC's
// filename field is the token; RAID_CLASS_COLORS et al. key on it. nullptr if
// the ID is out of range or the record is missing.
const char *ClassTokenForId(uint32_t classId) {
if (classId == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
if (records == nullptr || static_cast<int>(classId) > count)
return nullptr;
const uint8_t *rec = records[classId];
if (rec == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_CHRCLASSES_FILENAME);
}
constexpr int kNameLen = 48; // engine NameCache inline name[48]
void CopyName(char *dst, const char *src) {
if (src == nullptr) {
dst[0] = '\0';
return;
}
int i = 0;
for (; i < kNameLen - 1 && src[i] != '\0'; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
struct PlayerRoll {
uint64_t guid;
char name[kNameLen];
uint32_t classId; // ChrClasses.dbc ID (0 = unknown)
int rollType; // ROLL_* (modern); ROLL_PASS when the player passed
int roll; // 1..100, or 0 if they passed / didn't roll
};
constexpr int kMaxPlayers = 40;
struct HistItem {
uint64_t sourceGuid; // looted-object GUID — the roll's identity
uint32_t slot;
uint32_t itemId;
int32_t itemSuffix; // random suffix/property ID (link suffix field)
uint32_t itemSeed; // random seed (link unique field)
uint64_t winnerGuid;
char winnerName[kNameLen];
bool hasWinner;
bool isDone;
PlayerRoll players[kMaxPlayers];
int playerCount;
bool used;
};
// Ring of recent rolled items. g_write is the next slot to fill; g_count
// saturates at kMaxItems. GetNumItems returns g_count; item index i (1-based,
// oldest→newest) maps to ring[(g_write - g_count + i - 1) mod kMaxItems].
// ~3 KB per item (players[40] dominates), so 128 is ~380 KB static — bump
// freely if a longer history is wanted.
constexpr int kMaxItems = 128;
HistItem g_items[kMaxItems];
int g_write = 0;
int g_count = 0;
HistItem *Find(uint64_t sourceGuid, uint32_t slot) {
for (auto &e : g_items) {
if (e.used && e.sourceGuid == sourceGuid && e.slot == slot)
return &e;
}
return nullptr;
}
HistItem *FindOrCreate(uint64_t sourceGuid, uint32_t slot, uint32_t itemId) {
HistItem *e = Find(sourceGuid, slot);
if (e != nullptr) {
if (itemId != 0)
e->itemId = itemId;
return e;
}
e = &g_items[g_write];
g_write = (g_write + 1) % kMaxItems;
if (g_count < kMaxItems)
++g_count;
*e = HistItem{};
e->used = true;
e->sourceGuid = sourceGuid;
e->slot = slot;
e->itemId = itemId;
return e;
}
// 1-based access in oldest→newest order (matching GetNumItems' count).
HistItem *GetEntry(int index1Based) {
if (index1Based < 1 || index1Based > g_count)
return nullptr;
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
return &g_items[(start + index1Based - 1) % kMaxItems];
}
// The 1-based index of an entry in oldest→newest order (0 if not live).
int IndexOf(const HistItem *e) {
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
for (int i = 0; i < g_count; ++i) {
if (&g_items[(start + i) % kMaxItems] == e)
return i + 1;
}
return 0;
}
// Modern loot-history events. ROLL_CHANGED(itemIdx, playerIdx) fires on each
// roll; ROLL_COMPLETE(itemIdx) when the item is decided (winner / all-passed).
// FULL_UPDATE is reserved for API/IsEventValid completeness (the UI fires it
// on open); we don't dispatch it from C.
constexpr const char *kRollChanged = "LOOT_HISTORY_ROLL_CHANGED";
constexpr const char *kRollComplete = "LOOT_HISTORY_ROLL_COMPLETE";
constexpr const char *kFullUpdate = "LOOT_HISTORY_FULL_UPDATE";
const Event::Custom::AutoReserve _evRollChanged{kRollChanged};
const Event::Custom::AutoReserve _evRollComplete{kRollComplete};
const Event::Custom::AutoReserve _evFullUpdate{kFullUpdate};
// Returns the 1-based item index and writes the 1-based player index to
// `*outPlayerIdx` (0 if none), so the hook can fire ROLL_CHANGED.
int RecordRoll(const void *msg, int *outPlayerIdx) {
*outPlayerIdx = 0;
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
const uint64_t roller = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
const uint8_t rollByte = Read<uint8_t>(msg, Offsets::OFF_LOOTROLL_ROLL_NUM);
const int packetType = Read<int32_t>(msg, Offsets::OFF_LOOTROLL_ROLL_TYPE);
const bool didRoll = (rollByte & 0x80) == 0;
const int rollNum = didRoll ? (rollByte & 0x7F) : 0;
const int rollType = didRoll ? MapRollType(packetType) : ROLL_PASS;
HistItem *e = FindOrCreate(src, slot, itemId);
if (roller == 0)
return IndexOf(e);
int slotIdx = -1;
for (int i = 0; i < e->playerCount; ++i) {
if (e->players[i].guid == roller) {
slotIdx = i;
break;
}
}
if (slotIdx < 0 && e->playerCount < kMaxPlayers) {
slotIdx = e->playerCount++;
const uint8_t *rec = LookupPlayerRecord(roller); // cache loaded now
e->players[slotIdx].guid = roller;
CopyName(e->players[slotIdx].name, NameFromRecord(rec));
e->players[slotIdx].classId = ClassIdFromRecord(rec);
}
if (slotIdx >= 0) {
e->players[slotIdx].rollType = rollType;
e->players[slotIdx].roll = rollNum;
*outPlayerIdx = slotIdx + 1;
}
return IndexOf(e);
}
int RecordWinner(const void *msg) {
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
const uint64_t winner = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->winnerGuid = winner;
CopyName(e->winnerName, NameFromRecord(LookupPlayerRecord(winner)));
e->hasWinner = true;
e->isDone = true;
return IndexOf(e);
}
int RecordAllPassed(const void *msg) {
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->isDone = true;
return IndexOf(e);
}
// SMSG_LOOT_START_ROLL carries the item's random suffix/seed; the result
// packets don't. It's read from the freshly-built store-head node (rather
// than the packet) right after the START_ROLL handler runs — the node has
// the same field offsets as the message. This is the only place the random
// fields are populated, so the result recorders leave them alone.
void RecordStart() {
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOOTROLL_STORE_HEAD));
if (node == nullptr)
return;
const uint64_t src = Read<uint64_t>(node, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_ID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->itemSuffix = Read<int32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SUFFIX);
e->itemSeed = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SEED);
}
// ---- Packet co-hooks -------------------------------------------------------
// Signatures match the verified per-handler ABI above (note the differing
// message register, stack arg, and RET). Read the message fields before the
// original runs — it frees the message.
using RollFn_t = void(__fastcall *)(void *msg, void *ctx, int a3); // RET 4
using WonFn_t = void(__fastcall *)(void *ctx, void *msg, int a3); // RET 4
using PassedFn_t = void(__fastcall *)(void *ctx, void *msg); // RET 0
using StartFn_t = void(__fastcall *)(void *packet); // RET 0
RollFn_t g_origRoll = nullptr;
WonFn_t g_origWon = nullptr;
PassedFn_t g_origPassed = nullptr;
StartFn_t g_origStart = nullptr;
// START_ROLL: let the original build the store node, then read the item's
// random suffix off it. Adds the item to the history so a UI can show it as
// soon as the roll opens (before any roll comes in).
void __fastcall Start_h(void *packet) {
g_origStart(packet);
RecordStart();
}
// Record fields (message is freed by the original), run the original, THEN
// fire the event — firing after the original keeps the engine's own handler
// off our re-entrancy (same order Item::Data uses from its packet hook).
void __fastcall Roll_h(void *msg, void *ctx, int a3) {
int itemIdx = 0, playerIdx = 0;
if (msg != nullptr)
itemIdx = RecordRoll(msg, &playerIdx);
g_origRoll(msg, ctx, a3);
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
playerIdx);
}
void __fastcall Won_h(void *ctx, void *msg, int a3) {
int itemIdx = 0;
if (msg != nullptr)
itemIdx = RecordWinner(msg);
g_origWon(ctx, msg, a3);
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
}
void __fastcall Passed_h(void *ctx, void *msg) {
int itemIdx = 0;
if (msg != nullptr)
itemIdx = RecordAllPassed(msg);
g_origPassed(ctx, msg);
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
}
const Game::HookAutoRegister _hookRoll{
Offsets::FUN_LOOT_ROLL_HANDLER, reinterpret_cast<void *>(&Roll_h),
reinterpret_cast<void **>(&g_origRoll)};
const Game::HookAutoRegister _hookWon{
Offsets::FUN_LOOT_ROLL_WON_HANDLER, reinterpret_cast<void *>(&Won_h),
reinterpret_cast<void **>(&g_origWon)};
const Game::HookAutoRegister _hookPassed{
Offsets::FUN_LOOT_ALL_PASSED_HANDLER, reinterpret_cast<void *>(&Passed_h),
reinterpret_cast<void **>(&g_origPassed)};
const Game::HookAutoRegister _hookStart{
Offsets::FUN_LOOT_START_ROLL_HANDLER, reinterpret_cast<void *>(&Start_h),
reinterpret_cast<void **>(&g_origStart)};
// ---- Lua surface -----------------------------------------------------------
// `C_LootHistory.GetNumItems()` -> number of rolled items in the history.
int __fastcall Script_GetNumItems(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(g_count));
return 1;
}
// `C_LootHistory.GetItem(itemIndex)` -> itemLink, numPlayers, isDone, winner.
// itemLink is the `item:id:enchant:suffix:unique` form (valid for GetItemInfo
// / SetHyperlink), carrying the roll's random suffix so "of the X" items keep
// their affix; `winner` is the winner's name, or nil if undecided.
int __fastcall Script_GetItem(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetItem(itemIndex)");
return 0;
}
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
if (e == nullptr)
return 0;
static char link[64];
// Vanilla item link: item:id:enchant:suffix:unique. Carry the roll's
// random suffix/seed so "of the Bear"-style items keep their affix.
std::snprintf(link, sizeof(link), "item:%u:0:%d:%u", e->itemId,
e->itemSuffix, e->itemSeed);
Game::Lua::PushString(L, link); // 1 itemLink
Game::Lua::PushNumber(L, static_cast<double>(e->playerCount)); // 2 numPlayers
Game::Lua::PushBool(L, e->isDone); // 3 isDone
if (e->hasWinner && e->winnerName[0] != '\0')
Game::Lua::PushString(L, e->winnerName); // 4 winner
else
Game::Lua::PushNil(L);
return 4;
}
// `C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)` ->
// name, classToken, rollType, roll, isWinner, isMe. classToken is the
// ChrClasses filename ("WARRIOR", …) for RAID_CLASS_COLORS-style coloring;
// both name and class come from the engine NameCache captured at roll time.
// isMe compares the roller GUID to the local player's.
int __fastcall Script_GetPlayerInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GetPlayerInfo(itemIndex, playerIndex)");
return 0;
}
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
if (e == nullptr)
return 0;
const int p = static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1;
if (p < 0 || p >= e->playerCount)
return 0;
const PlayerRoll &r = e->players[p];
if (r.name[0] != '\0')
Game::Lua::PushString(L, r.name); // 1 name
else
Game::Lua::PushNil(L);
const char *classToken = ClassTokenForId(r.classId); // 2 class token
if (classToken != nullptr)
Game::Lua::PushString(L, classToken);
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(r.rollType)); // 3 rollType
Game::Lua::PushNumber(L, static_cast<double>(r.roll)); // 4 roll
Game::Lua::PushBool(L, e->hasWinner && e->winnerGuid == r.guid); // 5 isWinner
Game::Lua::PushBool(L, r.guid == Unit::Identity::PlayerGuid()); // 6 isMe
return 6;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_LootHistory", "GetNumItems",
&Script_GetNumItems);
Game::Lua::RegisterTableFunction("C_LootHistory", "GetItem",
&Script_GetItem);
Game::Lua::RegisterTableFunction("C_LootHistory", "GetPlayerInfo",
&Script_GetPlayerInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Loot::History
+373
View File
@@ -0,0 +1,373 @@
// 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_LossOfControl` — active loss-of-control effects on the LOCAL PLAYER.
// Vanilla 1.12 has no such aggregation layer (it's a MoP addition), so we
// synthesize it from two sources:
//
// 1. SCHOOL_INTERRUPT — the Counterspell/Kick school lockout. The server
// (Player::ProhibitSpellSchool) delivers it as a single
// SMSG_SPELL_COOLDOWN carrying every spell of the interrupted school,
// all with the same duration. That "player-GUID packet, >=2 spells, one
// uniform duration" shape is produced by nothing else (normal cooldowns
// are single-spell or applied client-side), so co-hooking the cooldown
// handler and matching it yields an authoritative school + duration.
// The interrupting spell isn't in the packet, so spellID is 0 and
// displayText is a fixed "Interrupted" — the mechanically-relevant data
// (school, duration, timing) is exact.
//
// 2. CC debuffs — stun / fear / root / confuse / charm / possess / silence /
// pacify / disarm are real debuff auras. We scan the player's debuffs and
// classify each by its spell's EffectApplyAuraName. Timing is best-effort
// from the Aura::Source cache (nil when the cast wasn't observed — the
// LoC timing fields are nullable, so that's contract-valid).
//
// Placeholders for fields with no vanilla equivalent: priority = 0,
// displayType = 2 (show for full duration), auraInstanceID = nil. iconTexture
// is the spell's icon PATH (vanilla has no FileDataIDs; a path is the
// functional equivalent).
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace LossOfControl {
namespace {
using TickMs_t = uint32_t(__fastcall *)();
int NowMs() {
return static_cast<int>(
reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)());
}
// Spell.dbc field readers (mirrors the small locals in Spell::Cast).
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
const char *n =
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
return (n != nullptr) ? n : "";
}
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + OFF_ICON_ID);
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID));
if (iconRec == nullptr)
return "";
const char *p = *reinterpret_cast<const char *const *>(iconRec + 0x04);
return (p != nullptr) ? p : "";
}
int SpellSchoolIndex(const uint8_t *rec) {
return *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_SCHOOL);
}
// Classify a spell's control-loss type from its EffectApplyAuraName array, or
// null if it applies no control-loss aura. Priority-ordered (strongest control
// first) so a multi-effect spell resolves to one locType, matching retail.
const char *ClassifyControlLoss(const uint8_t *rec) {
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto has = [&](int type) {
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == type)
return true;
return false;
};
if (has(Offsets::SPELL_AURA_MOD_POSSESS)) return "POSSESS";
if (has(Offsets::SPELL_AURA_MOD_CHARM)) return "CHARM";
if (has(Offsets::SPELL_AURA_MOD_STUN)) return "STUN";
if (has(Offsets::SPELL_AURA_MOD_FEAR)) return "FEAR";
if (has(Offsets::SPELL_AURA_MOD_CONFUSE)) return "CONFUSE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY_SILENCE)) return "PACIFYSILENCE";
if (has(Offsets::SPELL_AURA_MOD_SILENCE)) return "SILENCE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY)) return "PACIFY";
if (has(Offsets::SPELL_AURA_MOD_ROOT)) return "ROOT";
if (has(Offsets::SPELL_AURA_MOD_DISARM)) return "DISARM";
return nullptr;
}
// ---- School-interrupt lockout state (fed by the SMSG_SPELL_COOLDOWN hook) --
// One active lockout per school index (0..6); active while now < endMs.
// Self-expiring, per-process — a stale entry from before a relog is already
// in the past on the shared uptime clock, so no reset is needed.
struct SchoolLock {
int startMs;
int endMs;
};
SchoolLock g_schoolLock[Offsets::SPELL_SCHOOL_COUNT] = {};
using CooldownHandler_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
CooldownHandler_t g_origCooldown = nullptr;
int __stdcall Cooldown_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
if (guid != 0 && guid == Unit::Identity::PlayerGuid()) {
int count = 0;
int firstDur = 0;
bool uniform = true;
int schoolIdx = -1;
while (packet->m_read + 8 <= packet->m_size && count < 256) {
const uint32_t spellID = Net::Read<uint32_t>(packet);
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
if (count == 0)
firstDur = dur;
else if (dur != firstDur)
uniform = false;
if (schoolIdx < 0) {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec != nullptr) {
const int s = SpellSchoolIndex(rec);
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
schoolIdx = s;
}
}
++count;
}
// A uniform-duration batch of >=2 same-school spells is a school
// lockout (ProhibitSpellSchool); single/varied packets are normal
// cooldowns and ignored.
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
const int now = NowMs();
g_schoolLock[schoolIdx].startMs = now;
g_schoolLock[schoolIdx].endMs = now + firstDur;
}
}
packet->m_read = saved; // restore before the engine re-parses
}
return g_origCooldown(opCode, packet);
}
const Game::HookAutoRegister _cooldownHook{
Offsets::FUN_SPELL_COOLDOWN_HANDLER, reinterpret_cast<void *>(&Cooldown_h),
reinterpret_cast<void **>(&g_origCooldown)};
// ---- Aggregated active-effect list ----------------------------------------
struct LocEntry {
const char *locType;
int spellID; // 0 = unknown (school interrupt)
const uint8_t *rec; // spell record for name/icon; null for school interrupt
int schoolMask; // lockoutSchool
int startMs; // 0 = unknown
int endMs; // 0 = unknown / no timing
};
int BuildList(LocEntry *out, int maxOut) {
const int now = NowMs();
int n = 0;
// School-interrupt lockouts first.
for (int s = 0; s < Offsets::SPELL_SCHOOL_COUNT && n < maxOut; ++s) {
if (g_schoolLock[s].endMs != 0 && now < g_schoolLock[s].endMs) {
LocEntry &e = out[n++];
e.locType = "SCHOOL_INTERRUPT";
e.spellID = 0;
e.rec = nullptr;
e.schoolMask = 1 << s;
e.startMs = g_schoolLock[s].startMs;
e.endMs = g_schoolLock[s].endMs;
}
}
// CC debuffs on the player.
const uint8_t *player = Unit::Identity::PlayerObject();
if (player != nullptr) {
const uint64_t playerGuid = Unit::Identity::PlayerGuid();
for (int i = 1; n < maxOut; ++i) {
const int slot = Aura::Data::FindNthSlot(
player, i, Aura::Data::Filter::Harmful, false);
if (slot < 0)
break;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
if (spellID == 0)
continue;
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec == nullptr)
continue;
const char *locType = ClassifyControlLoss(rec);
if (locType == nullptr)
continue;
LocEntry &e = out[n++];
e.locType = locType;
e.spellID = static_cast<int>(spellID);
e.rec = rec;
e.schoolMask = 0;
e.startMs = 0;
e.endMs = 0;
uint64_t caster = 0;
uint32_t expMs = 0, durMs = 0;
if (Aura::Source::Get(playerGuid, spellID, &caster, &expMs, &durMs) &&
expMs != 0) {
e.endMs = static_cast<int>(expMs);
if (durMs != 0)
e.startMs = static_cast<int>(expMs - durMs);
}
}
}
return n;
}
// ---- LOSS_OF_CONTROL_ADDED / _UPDATE events (WorldTick poll-and-diff) ------
//
// Effect *expiry* (a school lockout timing out, a CC aura falling off) has no
// engine packet or callback, so a per-frame diff is the only complete signal.
// Firing from the WorldTick tail also keeps the Lua handlers out of the
// mid-packet contexts the state is updated in (the OnTooltipSetItem fire-safety
// lesson). The scan is bounded (16 debuff slots) and early-outs when idle, so
// the per-frame cost is negligible.
constexpr const char *kEventAdded = "LOSS_OF_CONTROL_ADDED";
constexpr const char *kEventUpdate = "LOSS_OF_CONTROL_UPDATE";
// Identity of an active effect for frame-to-frame diffing: a CC is keyed by
// spellID, a school interrupt by its school mask (spellID 0).
struct EffectKey {
int spellID;
int schoolMask;
};
EffectKey g_prev[32];
int g_prevCount = 0;
bool ContainsKey(const EffectKey *list, int count, EffectKey key) {
for (int i = 0; i < count; ++i)
if (list[i].spellID == key.spellID &&
list[i].schoolMask == key.schoolMask)
return true;
return false;
}
void OnWorldTick() {
LocEntry entries[32];
const int n = BuildList(entries, 32);
EffectKey cur[32];
for (int i = 0; i < n; ++i)
cur[i] = EffectKey{entries[i].spellID, entries[i].schoolMask};
bool changed = false;
for (int i = 0; i < n; ++i) {
if (!ContainsKey(g_prev, g_prevCount, cur[i])) {
// A newly-applied effect -> LOSS_OF_CONTROL_ADDED(eventIndex).
Event::Custom::Fire(Event::Custom::Lookup(kEventAdded), "%d", i + 1);
changed = true;
}
}
for (int i = 0; i < g_prevCount && !changed; ++i) {
if (!ContainsKey(cur, n, g_prev[i]))
changed = true; // an effect ended (fell off / lockout expired)
}
// Any add or removal fires one coalesced "re-scan" signal. The payload is
// the affected unit — always "player" here (vanilla only exposes the local
// player's control-loss state), but passing it keeps the event shape
// forward-compatible with per-unit tracking and matches modern WoW, whose
// LOSS_OF_CONTROL_UPDATE carries the unit token.
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEventUpdate), "%s", "player");
for (int i = 0; i < n; ++i)
g_prev[i] = cur[i];
g_prevCount = n;
}
static const Event::Custom::AutoReserve _reserveAdded{kEventAdded};
static const Event::Custom::AutoReserve _reserveUpdate{kEventUpdate};
static const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// `C_LossOfControl.GetActiveLossOfControlDataCount()` -> number.
int __fastcall Script_GetActiveLossOfControlDataCount(void *L) {
LocEntry entries[32];
const int n = BuildList(entries, 32);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 1;
}
// `C_LossOfControl.GetActiveLossOfControlData(index)` -> LossOfControlData, or
// nil if index is out of range.
int __fastcall Script_GetActiveLossOfControlData(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(
L, "Usage: C_LossOfControl.GetActiveLossOfControlData(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
LocEntry entries[32];
const int n = BuildList(entries, 32);
if (index < 1 || index > n)
return 0; // nil
const LocEntry &e = entries[index - 1];
const int now = NowMs();
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "locType", e.locType);
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(e.spellID));
Game::Lua::SetFieldString(
L, "displayText", (e.rec != nullptr) ? SpellName(e.rec) : "Interrupted");
Game::Lua::SetFieldString(
L, "iconTexture", (e.rec != nullptr) ? SpellIconPath(e.rec) : "");
// Timing (seconds, GetTime() epoch). Nullable — leave a field unset (nil)
// when we don't know it, per the modern contract.
if (e.endMs != 0 && now < e.endMs) {
Game::Lua::SetFieldNumber(L, "timeRemaining", (e.endMs - now) * 0.001);
if (e.startMs != 0) {
Game::Lua::SetFieldNumber(L, "startTime", e.startMs * 0.001);
Game::Lua::SetFieldNumber(L, "duration",
(e.endMs - e.startMs) * 0.001);
}
}
Game::Lua::SetFieldNumber(L, "lockoutSchool",
static_cast<double>(e.schoolMask));
Game::Lua::SetFieldNumber(L, "priority", 0);
Game::Lua::SetFieldNumber(L, "displayType", 2); // show for full duration
// auraInstanceID: nil (vanilla has no aura instance IDs) — field omitted.
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction(
"C_LossOfControl", "GetActiveLossOfControlDataCount",
&Script_GetActiveLossOfControlDataCount);
Game::Lua::RegisterTableFunction("C_LossOfControl",
"GetActiveLossOfControlData",
&Script_GetActiveLossOfControlData);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace LossOfControl
+500
View File
@@ -0,0 +1,500 @@
// 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_Map.GetMapOverlays([areaID])` — every WorldMapOverlay.dbc entry for
// a zone, regardless of exploration, with the tile grid fully resolved.
//
// The engine's own surface (GetNumMapOverlays / GetMapOverlayInfo) only
// exposes DISCOVERED overlays — the exploration gate is a server-fed
// list of discovered overlay IDs — which is why map addons (pfUI's
// mapreveal, Cartographer …) ship hand-measured overlay tables to draw
// unexplored areas. This reads the DBC directly instead: live data for
// every zone, on any client, no per-server table drift.
//
// With no argument, returns overlays for the world map's CURRENT view
// (mirroring the resolution the engine's GetMapInfo does: continent /
// zone selection → WorldMapArea row). With a numeric `areaID`
// (AreaTable id — the stable zone identity), resolves the WorldMapArea
// row whose areaID column matches.
//
// ## Why the tile grid is resolved here (Octo data quirks)
//
// An overlay's art is `<textureName>1.blp, 2, …` numbered row-major on a
// 256px grid, canonically `ceil(w/256) x ceil(h/256)` tiles with only
// the last column/row allowed partial. On stock 1.12 data that formula
// is always right; on Octo it is not, in three ways (established
// empirically by the InkLab map generator, mirrored here):
//
// 1. FOREIGN TILES: unrelated art continues an overlay's number
// sequence in the same folder (Icepoint Rock's folder carries
// `kaneqnuun` tiles, Hyjal carries Tel Abim art). Anything derived
// from "what files exist" draws duplicate landmass. The overlay
// list must come ONLY from the DBC (which this module guarantees),
// and tiles past the true grid must be dropped.
// 2. SLIVER COLUMNS: the DBC rect can round away a narrow edge column
// (kaneqnuun's real art has a third, 8px-wide column; its DBC `w`
// yields 2 columns). Trusting ceil(w/256) both loses the sliver
// and — worse — shears the row-major layout into diagonal strips.
// The art itself reveals the truth: only a row's LAST tile may be
// narrower than 256, so the first partial-width tile (searched one
// past the DBC's column count) marks the real column count, and
// the first partial-height tile marks the last row.
// 3. UPSCALED RE-EXPORTS: the same rect re-exported at higher
// resolution (Stonetalon's WindshearCrag: 4 tiles for a 1-tile
// rect). All tiles are real and must be kept, drawn scaled to the
// rect. Only distinguishable from quirk 1 when the art has NO
// partial edge anywhere AND the DBC rect is a whole multiple of
// 256 in both dimensions; the grid shape is then recovered from
// the aspect ratio (cols ≈ round(sqrt(n·w/h))).
//
// Tile dimensions come from the BLP headers via the engine's own VFS
// reader (`FUN_FILE_READ`, so patch-MPQ overrides are honored). Both
// BLP1 and BLP2 keep width/height at header offsets +0x0C/+0x10.
// Resolution runs once per overlay per session (cached).
//
// ## HD map patches (uniform tile upscale)
//
// A custom `patch-*.mpq` can replace the overlay art with higher-res tiles
// (every 256px tile re-exported at 256*scale). Because we read the *file*
// dimensions, that upscale must not be assumed to be 256: the resolver
// detects the full-cell size `S` (the largest tile dimension = 256*scale)
// and expresses both the partial-tile threshold and the texcoords relative
// to it. Draw sizes/positions remain in logical map pixels (the patch changes
// texture resolution, not the map canvas), so an interior tile reads texcoord
// 1.0 at any scale and stock data (S == 256) is byte-identical to before.
// The upscale factor need not be integral; only a uniform per-overlay scale
// is assumed.
//
// ## Return shape
//
// Array of overlay tables:
// textureName — bare DBC name ("GOLDSHIRE")
// texturePath — "Interface\WorldMap\<dir>\GOLDSHIRE" (engine format)
// textureWidth / textureHeight — the DBC placement rect (reliable for
// placement; its IMPLIED tile count is what lies)
// offsetX / offsetY — placement on the zone canvas
// mapPointX / mapPointY
// tileCols / tileRows / upscaled — the resolved grid
// tiles — array of ready-to-draw tiles:
// { file — texture path incl. number (SetTexture-ready)
// width, height — draw size in map pixels
// texCoordX, texCoordY — right/bottom texcoords
// offsetX, offsetY } — absolute canvas position
//
// A consumer draws an overlay with a dumb loop: per tile SetTexture,
// SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <cmath>
namespace Map::Overlays {
namespace {
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
unsigned int *outSize, unsigned int extraBytes,
int flag1, int flag2);
using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line,
int flags);
constexpr int kMaxTiles = 64;
// Smallest power of two >= v (min 16), matching the engine's own
// WorldMapFrame tile-file-size loop. A tile's content fills content/NextPot
// of its (power-of-two) file — a fraction that's identical whether the file
// is stock or HD-upscaled, so texcoords derived from it are scale-invariant.
int NextPot(int v) {
int p = 16;
while (p < v)
p <<= 1;
return p;
}
// Resolved tile grid for one overlay, cached for the session (DBC and
// MPQ contents are immutable per process).
struct ResolvedOverlay {
uint8_t cols;
uint8_t rows;
uint8_t count; // tiles to draw (foreign tail already dropped)
bool upscaled;
uint16_t cell; // full-cell file size (256 on stock, 256*scale on an HD patch)
uint16_t fileW[kMaxTiles]; // BLP file dimensions per tile
uint16_t fileH[kMaxTiles];
};
ResolvedOverlay **g_cache = nullptr;
int g_cacheSize = 0;
// Reads one BLP's header dimensions through the engine VFS. Returns
// false when the file doesn't exist (end of the tile sequence) or isn't
// a BLP.
bool ReadBlpDims(const char *path, uint16_t *outW, uint16_t *outH) {
auto FileRead = reinterpret_cast<FileRead_t>(Offsets::FUN_FILE_READ);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *buf = nullptr;
unsigned int size = 0;
if (FileRead(0, path, &buf, &size, 0, 1, 0) == 0 || buf == nullptr)
return false;
bool ok = false;
const uint8_t *b = static_cast<const uint8_t *>(buf);
if (size >= 0x14 && b[0] == 'B' && b[1] == 'L' && b[2] == 'P') {
const uint32_t w = *reinterpret_cast<const uint32_t *>(b + 0x0C);
const uint32_t h = *reinterpret_cast<const uint32_t *>(b + 0x10);
if (w > 0 && w <= 4096 && h > 0 && h <= 4096) {
*outW = static_cast<uint16_t>(w);
*outH = static_cast<uint16_t>(h);
ok = true;
}
}
SMemFree(buf, __FILE__, __LINE__, 0);
return ok;
}
// The quirk disambiguation (see module comment). `basePath` is the
// numberless texture path; dbcW/dbcH the overlay's DBC rect.
void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
int dbcH) {
char path[0x120];
int n = 0;
while (n < kMaxTiles) {
std::snprintf(path, sizeof(path), "%s%d.blp", basePath, n + 1);
if (!ReadBlpDims(path, &out->fileW[n], &out->fileH[n]))
break;
++n;
}
int dbcCols = (dbcW + 255) / 256;
int dbcRows = (dbcH + 255) / 256;
if (dbcCols < 1)
dbcCols = 1;
if (dbcRows < 1)
dbcRows = 1;
// Full-cell file size. On stock data a full (interior) tile is 256px; an
// HD map patch (patch-*.mpq) upscales every tile uniformly, so a full cell
// is 256*scale. Full tiles are the largest; edges/slivers are smaller — so
// the max tile dimension is the full-cell size. Partial-tile detection
// (and the texcoords in PushTiles) must compare against this S, not a
// hardcoded 256, or an HD edge tile (e.g. a 200px edge upscaled to 400px)
// reads as "full" and shears the row-major grid into diagonal strips.
int S = 256;
for (int i = 0; i < n; ++i) {
if (out->fileW[i] > S)
S = out->fileW[i];
if (out->fileH[i] > S)
S = out->fileH[i];
}
out->cell = static_cast<uint16_t>(S);
// Real column count: the first partial-width tile is a row's right
// edge. Search one past dbcCols so a sliver column the DBC width
// rounds off is still found.
int cols = dbcCols;
bool widthEdge = false;
for (int i = 0; i < n && i <= dbcCols; ++i) {
if (out->fileW[i] < S) {
cols = i + 1;
widthEdge = true;
break;
}
}
// Real row count: the row holding the first partial-height tile is
// the last row.
int rows = dbcRows;
bool heightEdge = false;
for (int i = 0; i < n; ++i) {
if (out->fileH[i] < S) {
rows = i / cols + 1;
heightEdge = true;
break;
}
}
int limit = cols * rows;
bool upscaled = false;
// Upscale exception: no partial edge anywhere and a whole-tile DBC
// rect means the extras are higher-res art for the same rect, not
// foreign — keep them all; recover the grid from the aspect ratio.
if (!widthEdge && !heightEdge && dbcW % 256 == 0 && dbcH % 256 == 0 &&
n > limit) {
upscaled = true;
cols = static_cast<int>(std::floor(
std::sqrt(static_cast<double>(n) * dbcW / dbcH) + 0.5));
if (cols < 1)
cols = 1;
if (cols > n)
cols = n;
limit = n;
rows = (n + cols - 1) / cols;
}
if (limit > n)
limit = n;
if (cols < 1)
cols = 1;
if (rows < 1)
rows = 1;
out->cols = static_cast<uint8_t>(cols);
out->rows = static_cast<uint8_t>(rows);
out->count = static_cast<uint8_t>(limit);
out->upscaled = upscaled;
}
const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
int dbcW, int dbcH) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
if (g_cache == nullptr || g_cacheSize < count + 1) {
auto **fresh = new ResolvedOverlay *[count + 1]();
for (int i = 0; i < g_cacheSize; ++i)
fresh[i] = g_cache[i];
delete[] g_cache;
g_cache = fresh;
g_cacheSize = count + 1;
}
if (overlayId < 0 || overlayId >= g_cacheSize)
return nullptr;
if (g_cache[overlayId] == nullptr) {
auto *r = new ResolvedOverlay();
ResolveTiles(r, basePath, dbcW, dbcH);
g_cache[overlayId] = r;
}
return g_cache[overlayId];
}
// WorldMapArea row id for the world map's current view — the same
// resolution the engine's current-map-name getter (FUN_004a6cf0, behind
// GetMapInfo) performs: continent/zone selection indexes into the
// per-continent data blob; no continent selected falls back to the
// default row global.
int CurrentWorldMapAreaRow() {
const int continent =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
if (continent < 0)
return *reinterpret_cast<const int *>(
Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
const uint8_t *blob = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_WORLDMAP_CONTINENT_DATA);
if (blob == nullptr)
return -1;
const uint8_t *entry = blob + continent * Offsets::WORLDMAP_CONTINENT_STRIDE;
const int zone =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_ZONE_INDEX);
if (zone < 0)
return *reinterpret_cast<const int *>(entry + 0x04);
const int *zoneRows = *reinterpret_cast<const int *const *>(entry + 0x10);
return (zoneRows != nullptr) ? zoneRows[zone] : -1;
}
// WorldMapArea row id for an AreaTable areaID. Linear walk — the table
// has ~175 rows; called once per Lua call.
int WorldMapAreaRowForAreaID(uint32_t areaID) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec != nullptr &&
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_WMA_AREA_ID) ==
areaID)
return id;
}
return -1;
}
// Pushes the `tiles` array for one overlay (leaves it on the stack top).
void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
int dbcW, int dbcH, int offX, int offY) {
Game::Lua::NewTable(L);
if (r == nullptr || r->count == 0)
return;
const int cols = r->cols;
char file[0x120];
for (int i = 0; i < r->count; ++i) {
const int col = i % cols;
const int row = i / cols;
std::snprintf(file, sizeof(file), "%s%d", basePath, i + 1);
double drawW, drawH, tcX, tcY, posX, posY;
if (r->upscaled) {
// Higher-res re-export of the same rect: tiles split the DBC
// rect evenly, full texcoords.
drawW = static_cast<double>(dbcW) / r->cols;
drawH = static_cast<double>(dbcH) / r->rows;
tcX = 1.0;
tcY = 1.0;
posX = offX + col * drawW;
posY = offY + row * drawH;
} else {
// Canonical 256px logical grid. Draw sizes/positions stay in
// logical map pixels; the texcoord is the content fraction of the
// (possibly HD-upscaled) file. `scale` = file px per logical px
// (1 on stock, 2/4 on an HD patch), so a full interior tile reads
// texCoord 1.0 regardless of file size, and an edge's content
// (remainder * scale) is measured against its actual file width.
const int fileW = r->fileW[i];
const int fileH = r->fileH[i];
const double scale = (r->cell > 0) ? (r->cell / 256.0) : 1.0;
double logicalW = 256.0, logicalH = 256.0;
tcX = 1.0;
tcY = 1.0;
if (col == r->cols - 1) {
const int rem = dbcW - 256 * (r->cols - 1);
if (rem <= 0) {
// Sliver the DBC width rounded away: whole file is content
// (its logical width is the file width de-scaled).
logicalW = fileW / scale;
} else {
// Content fills rem/NextPot(rem) of the POT tile file.
// Scale-invariant, so an HD-upscaled file (POT*scale)
// reads the same fraction — using the raw file width here
// would halve the texcoord on any tile the patch doubled.
logicalW = rem;
tcX = rem / static_cast<double>(NextPot(rem));
}
}
if (row == r->rows - 1) {
const int rem = dbcH - 256 * (r->rows - 1);
if (rem <= 0) {
logicalH = fileH / scale;
} else {
logicalH = rem;
tcY = rem / static_cast<double>(NextPot(rem));
}
}
drawW = logicalW;
drawH = logicalH;
posX = offX + col * 256;
posY = offY + row * 256;
}
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "file", file);
Game::Lua::SetFieldNumber(L, "width", drawW);
Game::Lua::SetFieldNumber(L, "height", drawH);
Game::Lua::SetFieldNumber(L, "texCoordX", tcX);
Game::Lua::SetFieldNumber(L, "texCoordY", tcY);
Game::Lua::SetFieldNumber(L, "offsetX", posX);
Game::Lua::SetFieldNumber(L, "offsetY", posY);
Game::Lua::SetTable(L, -3);
}
}
// `C_Map.GetMapOverlays([areaID])` — see the module comment.
int __fastcall Script_GetMapOverlays(void *L) {
int wmaRow = -1;
if (Game::Lua::IsNumber(L, 1)) {
wmaRow = WorldMapAreaRowForAreaID(
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
} else {
wmaRow = CurrentWorldMapAreaRow();
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (wmaRow <= 0)
return 1;
const uint8_t *wmaRec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(wmaRow));
const char *mapDir =
(wmaRec != nullptr)
? *reinterpret_cast<const char *const *>(wmaRec +
Offsets::OFF_WMA_NAME)
: nullptr;
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_OVERLAY_RECORDS,
Offsets::VAR_WORLDMAP_OVERLAY_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr ||
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
wmaRow)
continue;
const char *texName = *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_WMO_TEXTURE_NAME);
if (texName == nullptr || texName[0] == '\0')
continue; // hit-rect-only rows have no drawable texture
// Full path, mirroring the engine's own GetMapOverlayInfo format
// (unresolvable map dir falls back to the "World" folder).
char basePath[0x104];
if (mapDir != nullptr && mapDir[0] != '\0')
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\%s\\%s", mapDir, texName);
else
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\World\\%s", texName);
const auto fieldInt = [rec](uintptr_t off) {
return *reinterpret_cast<const int32_t *>(rec + off);
};
const int dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
const int dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
const int offX = fieldInt(Offsets::OFF_WMO_OFFSET_X);
const int offY = fieldInt(Offsets::OFF_WMO_OFFSET_Y);
const ResolvedOverlay *r = CachedResolve(id, basePath, dbcW, dbcH);
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "textureName", texName);
Game::Lua::SetFieldString(L, "texturePath", basePath);
Game::Lua::SetFieldNumber(L, "textureWidth", dbcW);
Game::Lua::SetFieldNumber(L, "textureHeight", dbcH);
Game::Lua::SetFieldNumber(L, "offsetX", offX);
Game::Lua::SetFieldNumber(L, "offsetY", offY);
Game::Lua::SetFieldNumber(
L, "mapPointX", fieldInt(Offsets::OFF_WMO_MAP_POINT_X));
Game::Lua::SetFieldNumber(
L, "mapPointY", fieldInt(Offsets::OFF_WMO_MAP_POINT_Y));
if (r != nullptr) {
Game::Lua::SetFieldNumber(L, "tileCols", r->cols);
Game::Lua::SetFieldNumber(L, "tileRows", r->rows);
Game::Lua::SetFieldBool(L, "upscaled", r->upscaled);
}
Game::Lua::PushString(L, "tiles");
PushTiles(L, r, basePath, dbcW, dbcH, offX, offY);
Game::Lua::SetTable(L, -3);
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetMapOverlays",
&Script_GetMapOverlays);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Overlays
+218
View File
@@ -0,0 +1,218 @@
// 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/>.
// `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` — the WotLK-era
// per-slot paperdoll event. Vanilla only fires UNIT_INVENTORY_CHANGED for
// the player, which doesn't say WHICH slot changed; gear trackers and stat
// sheets built against 3.3+ gate their refresh on this event.
//
// Fully event-driven via the engine's own descriptor-field observer system
// (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h). The engine's
// inventory setup (FUN_INV_OBSERVER_SETUP, once per enter-world) registers
// change observers for the bag/backpack/bank/keyring GUID fields of the
// player descriptor — but never for the 19 equipment slots (0x4A8..0x538).
// We co-hook that setup and register our own observer per equipment field,
// mirroring the engine's registration shape exactly (bank 4, size 8). The
// dispatcher invokes us only when a slot's item GUID actually changed
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
// on our side, and the nodes share the engine observers' lifetime (they
// die with the player object; the setup re-runs each enter-world).
//
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
// item and nil when it's empty (the engine dispatcher has no `%b`; 1/nil
// keeps the idiomatic `if hasCurrent then` working — see FireIdSuccess).
//
// Also `UPDATE_INVENTORY_DURABILITY` (no payload) — fires when any
// equipped item's durability changes (combat damage, death, repairs) —
// via a co-hook on the engine's inventory-alerts recompute
// (FUN_INVENTORY_ALERTS_RECOMPUTE): the engine itself calls that whenever
// an owned item's fields change (its item post-update handler FUN_005D9A90
// routes every item values-update through it) and on equip/enter-world
// paths, so it IS the engine's own "durability may have changed" signal.
// Post-original we diff a per-slot {itemGuid, durability} snapshot and
// fire only on a real change. NOT done with bank-1 (ITEM field) descriptor
// observers: those register without error but are never invoked — the
// registrar's internal wrapper lookup (FUN_00464890, the observer
// registry, distinct from the object manager) silently no-ops for item
// objects, verified empirically (item values-updates arrive and the
// player-bank observers fire, item-bank ones never do).
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Locations.h"
#include "event/Custom.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Player::Equipment {
namespace {
constexpr const char *kEvtEquipmentChanged = "PLAYER_EQUIPMENT_CHANGED";
const Event::Custom::AutoReserve _r{kEvtEquipmentChanged};
constexpr const char *kEvtDurability = "UPDATE_INVENTORY_DURABILITY";
const Event::Custom::AutoReserve _r2{kEvtDurability};
// Whether the slot currently holds an item — read from the player's
// inventory-manager GUID array, the same source the engine's own bag
// observer (FUN_004F8DB0) treats as the post-change truth when its field
// observer fires. The player is resolved from the GUID the observer
// callback receives, via the NON-THROWING object resolver — the same call
// FUN_004F8DB0 makes. NOT via FUN_RESOLVE_UNIT_TOKEN("player"): that's the
// assert-frame helper that raises a Lua error when the token doesn't
// resolve, and this runs inside SMSG_UPDATE_OBJECT processing, where the
// player object can be mid-create and momentarily unresolvable — a Lua
// error thrown from there unwinds through raw engine code.
bool SlotHasItem(uint32_t guidLo, uint32_t guidHi, int slot0Based) {
using ResolveByGUID_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int line);
auto resolve =
reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
auto *player = static_cast<const uint8_t *>(resolve(
Offsets::OBJ_TYPE_PLAYER, "ClassicAPI", guidLo, guidHi, 0x172));
if (player == nullptr)
return false;
const uint8_t *invMgr = player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
invMgr + Offsets::OFF_INVMGR_SLOT_COUNT);
if (static_cast<uint32_t>(slot0Based) >= count)
return false;
const uint64_t *guids = *reinterpret_cast<const uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
return guids != nullptr && guids[slot0Based] != 0;
}
// --- PLAYER_EQUIPMENT_CHANGED (player-descriptor observers) -------------
// Descriptor-field observer callback — ABI per the Offsets.h note:
// __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi, oldValue*,
// userArg), return 1, callee cleans 0x10. Invoked by the dispatcher only
// when the watched equipment GUID field actually changed.
int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/,
void * /*userArg*/) {
const int slot0 =
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
slot0 + 1,
SlotHasItem(guidLo, guidHi, slot0));
}
return 1;
}
// Co-hook on the engine's inventory observer setup: after it registers the
// bag-range observers, register ours for the equipment range it skips.
// Same registrar, same shape — only the field offsets differ.
using RegisterObserver_t = void(__fastcall *)(
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
const void *callback, void *userArg1, void *userArg2);
using ObserverSetup_t = void(__fastcall *)();
ObserverSetup_t g_origSetup = nullptr;
// One-time (per process) seed latch for the durability diff below — the
// very first recompute seeds the snapshot silently instead of firing for
// the initial gear population. Deliberately NOT reset per enter-world:
// death/release/graveyard teleports re-run the setup, and a per-setup
// reset made the post-teleport recompute re-seed silently — swallowing
// the death's own durability loss (verified in-game: repairs fired,
// deaths didn't). Keeping the snapshot across loading screens means
// changes that land around them diff normally; the cost is at most one
// spurious fire on relog to a different character, which matches
// modern's login-time fire anyway.
bool g_durSnapValid = false;
void __fastcall ObserverSetup_h() {
g_origSetup();
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return;
auto reg = reinterpret_cast<RegisterObserver_t>(
Offsets::FUN_DESC_OBSERVER_REGISTER);
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
nullptr, nullptr);
}
}
static const Game::HookAutoRegister _setupHook{
Offsets::FUN_INV_OBSERVER_SETUP,
reinterpret_cast<void *>(&ObserverSetup_h),
reinterpret_cast<void **>(&g_origSetup)};
// --- UPDATE_INVENTORY_DURABILITY (alerts-recompute co-hook) --------------
struct SlotDurability {
uint64_t guid; // equipped item, 0 = empty slot
uint32_t durability; // ITEM_FIELD_DURABILITY, 0 when empty/unresolved
};
SlotDurability g_durSnap[EquipmentSet::SLOT_COUNT];
uint32_t ItemDurability(uint64_t guid) {
if (guid == 0)
return 0;
const uint8_t *item = EquipmentSet::Locations::ResolveItemByGUID(guid);
if (item == nullptr)
return 0;
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (descriptor == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_DURABILITY);
}
using AlertsRecompute_t = void(__fastcall *)();
AlertsRecompute_t g_origAlertsRecompute = nullptr;
void __fastcall AlertsRecompute_h() {
g_origAlertsRecompute();
uint64_t paperdoll[EquipmentSet::SLOT_COUNT];
EquipmentSet::Locations::SnapshotPaperdoll(paperdoll);
bool changed = false;
for (int i = 0; i < EquipmentSet::SLOT_COUNT; ++i) {
const SlotDurability cur{paperdoll[i], ItemDurability(paperdoll[i])};
if (g_durSnap[i].guid != cur.guid ||
g_durSnap[i].durability != cur.durability) {
changed = true;
g_durSnap[i] = cur;
}
}
if (!g_durSnapValid) {
g_durSnapValid = true; // first recompute of the session seeds only
return;
}
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
}
static const Game::HookAutoRegister _alertsHook{
Offsets::FUN_INVENTORY_ALERTS_RECOMPUTE,
reinterpret_cast<void *>(&AlertsRecompute_h),
reinterpret_cast<void **>(&g_origAlertsRecompute)};
} // namespace
} // namespace Player::Equipment
+39
View File
@@ -134,6 +134,43 @@ void PushRequirements(void *L, const uint8_t *data) {
}
}
// `C_QuestLog.GetNumQuestObjectives(questID)` — the quest's objective
// count (the length `GetQuestDetails(questID).requirements` would have),
// read straight from the cache block with zero Lua table/string
// materialization. Exists so per-step validation loops (guide addons
// bounds-checking objective indices at load time) stay O(1)-cheap even
// with a warm quest cache — materializing full detail tables for hundreds
// of quests inside a login-path loop can exhaust vanilla's fixed Lua
// memory pool. Returns nil when the quest isn't cached (same contract as
// GetQuestDetails).
int __fastcall Script_GetNumQuestObjectives(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_QuestLog.GetNumQuestObjectives(questID)");
return 0;
}
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (questID <= 0)
return 0;
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
if (data == nullptr)
return 0;
int count = 0;
auto *npcOrGo = reinterpret_cast<const int32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
auto *items = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
if (npcOrGo[i] != 0)
++count;
if (items[i] != 0)
++count;
}
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
int __fastcall Script_GetQuestDetails(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
@@ -245,6 +282,8 @@ int __fastcall Script_GetQuestDetails(void *L) {
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
&Script_GetQuestDetails);
Game::Lua::RegisterTableFunction("C_QuestLog", "GetNumQuestObjectives",
&Script_GetNumQuestObjectives);
}
} // namespace
@@ -11,18 +11,29 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `QUEST_ACCEPTED(questLogIndex, questID)` event — fires once per
// new quest entering the local quest log. Polyfills modern WoW's
// event of the same name (added in 3.1.0). Matches the Cata/WotLK
// signature: `(questLogIndex, questID)`, 1-based log index.
// Quest log delta events — polyfills two modern WoW events from one
// pre-/post-rebuild diff:
//
// `QUEST_ACCEPTED(questLogIndex, questID)` — fires once per new quest
// entering the local quest log. Matches the Cata/WotLK signature,
// 1-based log index (event added in 3.1.0).
//
// `QUEST_REMOVED(questID)` — fires once per quest leaving the local
// quest log, for both turn-ins and abandons (event added in 8.0.1;
// Classic-era signature is the bare questID). For turn-ins this fires
// *after* `QUEST_TURNED_IN`, matching retail ordering: the 0x191
// complete packet doesn't touch the log; the removal arrives in the
// follow-up quest-log update packets, whose rebuild we diff here.
// Observed turn-in sequence: QUEST_TURNED_IN → UNIT_QUEST_LOG_CHANGED
// → QUEST_LOG_UPDATE (fired inside the rebuild) → QUEST_REMOVED.
//
// Hook target: `FUN_QUEST_LOG_REBUILD` (`0x004DE510`) — the single
// chokepoint the engine uses to refresh the Lua-visible quest log
// from the player's authoritative slot data at `[CGPlayer + 0xE68 +
// 0x28]`. Every state change that adds, removes, or updates a quest
// flows through here. By snapshotting the log before and after the
// original runs, we can compute the delta and fire QUEST_ACCEPTED
// only for genuine additions.
// original runs, we can compute the delta and fire only for genuine
// additions / removals.
//
// Login suppression: the same function handles the initial bulk-sync
// at character entry, which appears as a single rebuild call adding
@@ -30,7 +41,10 @@
// added in a single rebuild (which can only happen on login / reload
// resync — human input speed can't accept multiple quests in the
// same engine tick). A brand-new character's first accept is
// `0 → 1` and fires correctly.
// `0 → 1` and fires correctly. Removals apply the same rule (a user
// action removes at most one quest per rebuild) and are additionally
// suppressed when the rebuild also added multiple quests — a
// mixed-delta rebuild is a cross-character resync, not gameplay.
#include "Game.h"
#include "Offsets.h"
@@ -39,13 +53,15 @@
#include <cstdint>
#include <unordered_set>
namespace Quest::Accepted {
namespace Quest::LogEvents {
namespace {
constexpr const char *kEventName = "QUEST_ACCEPTED";
constexpr const char *kAcceptedEventName = "QUEST_ACCEPTED";
constexpr const char *kRemovedEventName = "QUEST_REMOVED";
const Event::Custom::AutoReserve _reserve{kEventName};
const Event::Custom::AutoReserve _reserveAccepted{kAcceptedEventName};
const Event::Custom::AutoReserve _reserveRemoved{kRemovedEventName};
// Walk the live quest log and collect questIDs of real (non-header)
// entries. Headers are 16-byte rows with a non-NULL pointer at +0x8;
@@ -80,7 +96,7 @@ QuestLogRebuild_t QuestLogRebuild_o = nullptr;
void __fastcall QuestLogRebuild_h(int param_1) {
// `param_1 == 0` is the engine's "no-op rebuild" signal — just
// refires QUEST_LOG_UPDATE without touching the entry array.
// No diff possible, no accept happening, pass through.
// No diff possible, no accept/remove happening, pass through.
if (param_1 == 0) {
QuestLogRebuild_o(param_1);
return;
@@ -93,14 +109,17 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// Walk the post-state in index order so the fired event's
// `questLogIndex` matches the 1-based slot the engine just
// assigned. Skip questIDs already in `pre`.
// assigned. Collect the full post-set on the way for the
// removal diff.
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
if (count <= 0)
return;
const auto *entries = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
std::unordered_set<int> post;
if (count > 0)
post.reserve(static_cast<size_t>(count));
int newCount = 0;
int firstNewIdx = 0;
int firstNewID = 0;
@@ -114,6 +133,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
if (questID <= 0)
continue;
post.insert(questID);
if (pre.count(questID) != 0)
continue;
++newCount;
@@ -123,14 +143,35 @@ void __fastcall QuestLogRebuild_h(int param_1) {
}
}
int removedCount = 0;
int firstRemovedID = 0;
for (const int questID : pre) {
if (post.count(questID) != 0)
continue;
++removedCount;
if (removedCount == 1)
firstRemovedID = questID;
}
// Single addition → user-driven accept. Multiple → bulk resync
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
// fire on initial sync either; we match that.
if (newCount == 1) {
const int slot = Event::Custom::Lookup(kEventName);
const int slot = Event::Custom::Lookup(kAcceptedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
}
// Single removal → user-driven turn-in or abandon. The extra
// `newCount <= 1` gate rejects the one resync shape the removal
// count alone can't: switching to a character whose log shares
// all but one of the previous character's quests (1 removal, N
// additions in the same rebuild).
if (removedCount == 1 && newCount <= 1) {
const int slot = Event::Custom::Lookup(kRemovedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d", firstRemovedID);
}
}
static const Game::HookAutoRegister _hookreg{
@@ -138,4 +179,4 @@ static const Game::HookAutoRegister _hookreg{
reinterpret_cast<void *>(&QuestLogRebuild_h),
reinterpret_cast<void **>(&QuestLogRebuild_o)};
} // namespace Quest::Accepted
} // namespace Quest::LogEvents
+48 -27
View File
@@ -17,13 +17,12 @@
//
// Implementation chains the engine's existing primitives:
//
// 1. Initiate the cast the same way `Script_CastSpellByName` does:
// resolve `spellID` (via our `Spell::CastByID` numeric-name hook)
// to a spellbook slot through `FUN_RESOLVE_SPELL_NAME_TO_SLOT`,
// then dispatch through `FUN_SPELL_CAST_DISPATCH`. For a ground-
// target spell, the engine enters placement mode at this point
// (sets `VAR_SPELL_PLACEMENT_STATE`); for a normal cast, it
// fires immediately.
// 1. Initiate the cast: resolve the *exact* `spellID` to its spellbook
// slot (`Spell::Lookup::FindSpellbookSlot` — NOT name resolution, which
// would cast the highest rank) and dispatch through
// `FUN_SPELL_CAST_DISPATCH`. For a ground-target spell, the engine
// enters placement mode at this point (sets `VAR_SPELL_PLACEMENT_STATE`);
// for a normal cast, it fires immediately.
//
// 2. `Spell::AtCursor::Resolve()` then refreshes the cursor's world
// raycast via `FUN_REFRESH_CURSOR_RAYCAST` and commits the
@@ -41,10 +40,10 @@
#include "Game.h"
#include "Offsets.h"
#include "spell/Lookup.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::AtCursor {
@@ -141,9 +140,16 @@ bool Resolve() {
namespace {
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
void DispatchSlot(int slot0Based, int bookType) {
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
// (0, 0) GUID = no implicit target; the engine handles placement.
dispatch(static_cast<unsigned>(slot0Based), bookType, 0, 0.0f);
}
} // namespace
@@ -151,39 +157,54 @@ bool DispatchSpellCast(int spellID) {
if (spellID <= 0)
return false;
// Hand the spellID off as a numeric string — our `Spell::CastByID`
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
// input into the locale-resolved spell name internally, so the
// engine treats it just like `CastSpellByName("Blizzard")`.
char numBuf[16];
std::snprintf(numBuf, sizeof(numBuf), "%d", spellID);
// Resolve the EXACT spellID to its spellbook slot. Every rank is its own
// slot, so we must NOT go through name resolution — that walks to the
// highest rank and would cast Blizzard(Rank 6) for a Rank 1 (spellID 10)
// request. `FindSpellbookSlot` returns a 1-based slot; the dispatcher
// wants the 0-based index into `VAR_PLAYER_SPELLBOOK`.
int bookType = 0;
const int slot1 = Spell::Lookup::FindSpellbookSlot(spellID, &bookType);
if (slot1 <= 0)
return false; // not in the player's spellbook
DispatchSlot(slot1 - 1, bookType);
return true;
}
bool DispatchSpellCastByName(const char *name) {
if (name == nullptr || *name == '\0')
return false;
// The engine's resolver strips a trailing `(Rank N)` and returns the
// 0-based spellbook slot (highest rank when no rank is given). This is the
// by-name path — for an exact rank prefer the numeric spellID overload.
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
int bookType = 0;
const int slot = nameToSlot(numBuf, &bookType);
const int slot = nameToSlot(name, &bookType);
if (slot < 0)
return false; // not in the player's spellbook
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
dispatch(static_cast<unsigned>(slot), bookType,
0, 0.0f); // no implicit target unit; engine handles placement
DispatchSlot(slot, bookType);
return true;
}
namespace {
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
// Accept a numeric spellID (exact rank) or a spell name (the engine's
// resolver parses a trailing "(Rank N)"). IsNumber is checked first so a
// numeric string like "10" still takes the exact-spellID path.
bool dispatched = false;
if (Game::Lua::IsNumber(L, 1)) {
dispatched = DispatchSpellCast(static_cast<int>(Game::Lua::ToNumber(L, 1)));
} else if (Game::Lua::IsString(L, 1)) {
dispatched = DispatchSpellCastByName(Game::Lua::ToString(L, 1));
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
// Spell not in the player's spellbook → match `CastSpellByName`'s
// silent failure.
if (!DispatchSpellCast(spellID)) {
// Bad input / spell not in the player's spellbook → match
// `CastSpellByName`'s silent failure.
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
+6
View File
@@ -46,6 +46,12 @@ bool CommitAtCoords(const float coords[3]);
// `CommitAtCoords`); for a normal spell it fires immediately. Returns
// true if the cast was dispatched (spell is in the player's
// spellbook), false otherwise.
//
// The numeric form casts the *exact* spellID (a specific rank). The
// name form defers to the engine's own resolver, which parses a
// trailing `(Rank N)` — `"Blizzard"` casts the highest known rank,
// `"Blizzard(Rank 6)"` casts that rank.
bool DispatchSpellCast(int spellID);
bool DispatchSpellCastByName(const char *name);
} // namespace Spell::AtCursor
+11 -3
View File
@@ -43,11 +43,14 @@ namespace Spell::AtUnit {
namespace {
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
// arg1: numeric spellID (exact rank) or spell name ("(Rank N)"-aware).
// arg2: unit token.
const bool byNumber = Game::Lua::IsNumber(L, 1);
if ((!byNumber && !Game::Lua::IsString(L, 1)) ||
!Game::Lua::IsString(L, 2)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit's position before dispatching — fail fast so a
@@ -61,7 +64,12 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
return 1;
}
if (!Spell::AtCursor::DispatchSpellCast(spellID)) {
const bool dispatched =
byNumber
? Spell::AtCursor::DispatchSpellCast(
static_cast<int>(Game::Lua::ToNumber(L, 1)))
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1));
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
+254 -30
View File
@@ -32,26 +32,37 @@
// time), dedup'd against a just-made client stamp so a normal cast's
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
// since the global was never set for these, the WorldTick VAR==0 clear
// must skip them — they expire on their computed endMs (and pfUI clears
// on nampower's SPELL_FAILED_SELF for interrupts).
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
// channel duration.
// must skip them — they expire on their computed endMs, or earlier via
// the abort co-hooks (ClearCastingSpell / the failure packets) when the
// engine learns the cast died.
// - channel: stamped from `SMSG_SPELL_START` at packet receipt (instant
// channels; engine-computed duration) and from `MSG_CHANNEL_START`
// (server duration — and the ONLY start signal for cast-then-channel
// spells like Mind Control, whose SpellStart covers just the cast
// phase). The broadcast +0x228 field is NOT polled for the player —
// it lags the channel start by ~1 tick.
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
//
// Other units have no engine-stored cast record, so we capture their casts
// from the `SMSG_SPELL_START` packet (the only place the client sees
// another unit's cast + a server-authoritative cast time) into a per-caster
// cache — see the SpellStart co-hook below. Scope:
// Other units have no engine-stored cast TIMES (the engine does keep the
// current-cast spellID at [unit+0xC8C], but nothing temporal), so we
// capture their casts from the `SMSG_SPELL_START` packet (the only place
// the client sees another unit's cast + a server-authoritative cast time)
// into a per-caster cache — see the SpellStart co-hook below. Scope:
// - Player casts/channels: full timing (self-tracked, exact engine math).
// - Other units' regular CASTS: full timing while within the cast window,
// from the SpellStart cache. No clean per-unit interrupt signal exists
// in 1.12, so an interrupted remote cast lingers until its computed end.
// from the SpellStart cache. Aborts (interrupt, death, movement,
// cancel) evict the entry the moment the engine stops the unit's cast
// — the CGUnit_C::ClearCastingSpell co-hook below, backstopped by
// co-hooks on both failure packets — so no lingering ghost bar.
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
// now"; the SpellStart cache adds real start/end times when we observed
// the channel begin (else spellID/name/texture with nil times).
// - Other units' PUSHBACK is invisible: SMSG_SPELL_DELAYED goes only to
// the affected caster (empirically confirmed — during a melee-pushback
// test only the victim's client received it), so a remote bar can't
// stretch. Inherent 1.12 protocol gap, not recoverable client-side.
//
// Modern-signature fields vanilla can't fill are structurally-correct
// placeholders: castID / castBarID = nil, notInterruptible = false,
@@ -104,8 +115,44 @@ TrackedSpell g_channel{0, 0, 0, 0};
// the WorldTick VAR==0 clear must not touch them.
bool g_castFromServer = false;
// Armed once the broadcast UNIT_FIELD_CHANNEL_SPELL (+0x228) has been seen to
// reflect g_channel's spell — the enable for OnWorldTick's channel-stop poll.
// StampChannel re-arms it (false) on every channel stamp so that field's
// ~1-tick start lag can't false-clear a channel we just stamped.
bool g_channelConfirmed = false;
// Stamp the player's channel and re-arm the stop latch. A channel supersedes
// any in-flight cast. Both channel-stamp sites (SMSG_SPELL_START's instant-
// channel path and the MSG_CHANNEL_START handler) route through here so
// neither can forget to reset the latch.
void StampChannel(int spellID, int startMs, int endMs) {
g_channel = TrackedSpell{spellID, startMs, endMs, 0};
g_channelConfirmed = false;
g_cast.spellID = 0;
}
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
// Pushes an engine-ms timestamp to Lua as an UNSIGNED 32-bit value.
//
// `FUN_OS_TICKCOUNT_MS` returns a 64-bit ms tick from one of two backends
// (GetTickCount, or a scaled rdtsc — the latter survives warm reboots, so
// the value can be well past 2^31 even right after a "restart"). We keep
// ticks in a signed int internally, which is fine for the small deltas and
// window compares (they cancel), but a direct int->double push surfaces a
// NEGATIVE startTimeMs/endTimeMs once the tick's high bit is set.
//
// The contract is that these match `GetTime()*1000`. Script_GetTime
// (0x00515EA0) does `(double)(tick & 0xFFFFFFFF) * 0.001` — i.e. it masks
// the tick to the low 32 bits UNSIGNED, then scales. So reinterpreting our
// stored tick as uint32 reproduces GetTime()'s exact value for the whole
// range, including across the ~49.7-day 2^32 wrap (both wrap together).
// Carrying 64-bit precision here would be WRONG: it would diverge from
// GetTime() past 2^32 and break addons' (now-start)/(end-start) math.
void PushMs(void *L, int ms) {
Game::Lua::PushNumber(L, static_cast<double>(static_cast<uint32_t>(ms)));
}
void *Resolve(const char *token) {
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
}
@@ -173,8 +220,8 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
Game::Lua::PushString(L, name); // 1 name
Game::Lua::PushString(L, name); // 2 displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
Game::Lua::PushNumber(L, static_cast<double>(c.startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(c.endMs)); // 5 endTimeMs
PushMs(L, c.startMs); // 4 startTimeMs
PushMs(L, c.endMs); // 5 endTimeMs
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
Game::Lua::PushNil(L); // 7 castID
Game::Lua::PushBool(L, false); // 8 notInterruptible
@@ -203,8 +250,8 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes
Game::Lua::PushString(L, name); // 2 text / displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
if (haveTimes) {
Game::Lua::PushNumber(L, static_cast<double>(startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(endMs)); // 5 endTimeMs
PushMs(L, startMs); // 4 startTimeMs
PushMs(L, endMs); // 5 endTimeMs
} else {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
@@ -332,7 +379,7 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
const bool channel = IsChannelSpell(rec);
if (caster == Unit::Identity::PlayerGuid()) {
if (channel) {
if (channel && castTime == 0) {
// Player channel start. The broadcast +0x228 field that signals
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
// channel actually begins, so a +0x228 poll shows the bar far too
@@ -341,14 +388,19 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// now with the engine-computed duration. We run before the
// original handler that fires SPELLCAST_CHANNEL_START, so the data
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
// Info) at endMs.
// Info) at endMs. (MSG_CHANNEL_START re-stamps moments later with
// the server's duration — see ChannelStart_h.)
const int dur = ChannelDurationMs(spellID);
if (dur > 0) {
g_channel = TrackedSpell{spellID, now, now + dur, 0};
g_cast.spellID = 0; // a channel supersedes any cast
}
if (dur > 0)
StampChannel(spellID, now, now + dur);
return;
}
// channel && castTime > 0 → a CAST-THEN-CHANNEL spell (Mind Control:
// 3 s cast, then a 60 s channel). This packet is the cast phase —
// the channel begins only when the cast completes, signaled by
// MSG_CHANNEL_START (ChannelStart_h). Fall through to regular cast
// handling so the bar shows the 3 s cast, not a premature 60 s
// channel (pfUI #9).
if (castTime == 0)
return; // instant — no cast bar
// Backstop for chained same-spell recasts: the engine short-circuits
@@ -367,9 +419,14 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
g_channel.spellID = 0; // a cast supersedes any channel
return;
}
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
// A channeled spell with a cast time (Mind Control) is in its CAST phase
// here — store it as a regular cast so observers see the 3 s bar. The
// remote channel phase has no packet (MSG_CHANNEL_START is caster-only);
// it surfaces through the unit's broadcast +0x228 field as usual.
const bool instantChannel = channel && castTime == 0;
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
@@ -427,6 +484,151 @@ const Game::HookAutoRegister _spellDelayedHook{
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
reinterpret_cast<void **>(&g_origSpellDelayed)};
// Co-hook on the MSG_CHANNEL_START handler — the server tells the caster a
// channel has begun, with the server-authoritative (modifier-applied)
// duration. Sent only to the caster, so it's always the local player. This
// is what makes CAST-THEN-CHANNEL spells (Mind Control: 3 s cast, then a
// 60 s channel) show both phases: their SMSG_SPELL_START carries the cast
// (castTime > 0, handled as a regular cast in HandleSpellStart) and only
// this packet marks the channel actually starting. For instant channels it
// re-stamps what the SpellStart path just wrote (same moment, server
// duration) — harmless. We stamp before the original runs so the data is
// fresh when the engine fires SPELLCAST_CHANNEL_START. Packet layout
// mirrored from nampower's SpellChannelStartHandlerHook:
// spellId(u32), durationMs(u32).
using ChannelStart_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
ChannelStart_t g_origChannelStart = nullptr;
int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
const uint32_t duration = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (spellID != 0 && duration > 0) {
const int now = NowMs();
StampChannel(spellID, now, now + static_cast<int>(duration));
}
}
return g_origChannelStart(opCode, packet);
}
const Game::HookAutoRegister _channelStartHook{
Offsets::FUN_SPELL_CHANNEL_START,
reinterpret_cast<void *>(&ChannelStart_h),
reinterpret_cast<void **>(&g_origChannelStart)};
// Shared abort handling for the two sibling failure packets. Servers
// derived from (v)mangos broadcast BOTH `SMSG_SPELL_FAILURE` and
// `SMSG_SPELL_FAILED_OTHER` from Spell::SendInterrupted, but which one a
// given core emits per abort path varies — so both handlers are co-hooked
// and both feed this. Idempotent, so the both-arrive case is harmless.
void HandleCastAborted(uint64_t guid, int spellID) {
if (guid == 0 || spellID == 0)
return;
// Remote units: invalidate the cached cast/channel so
// UnitCastingInfo/UnitChannelInfo stop reporting it this tick.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == guid && e.spellID == spellID) {
e.used = false;
break;
}
}
// Local player: a server-stamped chained recast (g_castFromServer)
// never set VAR_CURRENT_CAST_SPELL, so the WorldTick VAR==0 poll can't
// clear it on interrupt — this packet is the only signal. Whether the
// caster receives their own broadcast is server-dependent; if it never
// fires, the endMs self-expiry backstop still applies.
if (guid == Unit::Identity::PlayerGuid() && g_cast.spellID == spellID)
g_cast.spellID = 0;
}
// Co-hook on the SMSG_SPELL_FAILED_OTHER handler — the server broadcasts it
// to observers whenever a started cast aborts (interrupt, death, movement,
// fizzle). It's what lets us drop a remote cast the moment it dies instead
// of letting the ghost bar run to its computed end (the documented
// remote-unit limitation above). Packet body is plain (not packed):
// casterGuid(u64), spellId(u32).
using SpellFailedOther_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailedOther_t g_origSpellFailedOther = nullptr;
int __stdcall SpellFailedOther_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
}
return g_origSpellFailedOther(opCode, packet);
}
const Game::HookAutoRegister _spellFailedOtherHook{
Offsets::FUN_SPELL_FAILED_OTHER,
reinterpret_cast<void *>(&SpellFailedOther_h),
reinterpret_cast<void **>(&g_origSpellFailedOther)};
// Co-hook on the SMSG_SPELL_FAILURE handler — FAILED_OTHER's sibling, with
// a trailing result(u8) after the same casterGuid(u64), spellId(u32) head
// (we don't read the result). This is the packet that actually stops the
// caster's cast VISUAL in the engine's own handler, so it demonstrably
// arrives for interrupts; hooking both siblings makes the eviction
// independent of which one the server chose to send.
using SpellFailure_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailure_t g_origSpellFailure = nullptr;
int __stdcall SpellFailure_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
}
return g_origSpellFailure(opCode, packet);
}
const Game::HookAutoRegister _spellFailureHook{
Offsets::FUN_SPELL_FAILURE, reinterpret_cast<void *>(&SpellFailure_h),
reinterpret_cast<void **>(&g_origSpellFailure)};
// Co-hook on CGUnit_C::ClearCastingSpell — the engine choke point every
// "unit stopped casting" path funnels through (failure packets, SPELL_GO,
// movement, death, animation events, local cancel — see Offsets.h). The
// original is gated on `spellID == [unit+0xC8C]` (the unit's live
// current-cast field), so a real stop only happens when the gate passes;
// we mirror the gate and evict then. This is the PRIMARY interrupt signal
// on Turtle: its core doesn't broadcast either failure packet — interrupt
// propagation to observers happens via SuperWoW, whose code calls this
// method directly (verified by return-address tracing: interrupted remote
// casts clear with a caller inside a sibling DLL, natural completions
// clear from the SPELL_GO handler). Verified in-game: Kick / Earth Shock /
// Counterspell each evict the victim's cast, while non-interrupting damage
// (pushback flinch) does NOT route through here — no false eviction.
using ClearCastingSpell_t = void(__fastcall *)(void *unit, void *edx,
int spellID, char notify,
char cleanup);
ClearCastingSpell_t g_origClearCastingSpell = nullptr;
void __fastcall ClearCastingSpell_h(void *unit, void *edx, int spellID,
char notify, char cleanup) {
if (unit != nullptr && spellID != 0) {
const int current = *reinterpret_cast<const int *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_CAST_SPELL);
if (current != 0 && current == spellID)
HandleCastAborted(Unit::Identity::GuidForObject(unit), spellID);
}
g_origClearCastingSpell(unit, edx, spellID, notify, cleanup);
}
const Game::HookAutoRegister _clearCastingHook{
Offsets::FUN_UNIT_CLEAR_CASTING_SPELL,
reinterpret_cast<void *>(&ClearCastingSpell_h),
reinterpret_cast<void **>(&g_origClearCastingSpell)};
} // namespace
// Per-frame upkeep for the player. The regular cast is stamped by the
@@ -445,10 +647,32 @@ void OnWorldTick() {
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
g_cast.spellID = 0;
// Channels are stamped from SMSG_SPELL_START (HandleSpellStart) and expire
// on their computed endMs (self-expiry in PushChannelInfo). We don't poll
// the broadcast +0x228 field for the player — it lags the channel start by
// ~1 tick, so the bar would show ~1s late.
// Detect a player CHANNEL that ended before its computed endMs — the
// summon-ritual case (UNIT_FIELD_CHANNEL_SPELL = 698, cleared when the
// summon fills), and equally an interrupted / cancelled / LoS-broken
// channel. Without this, g_channel only clears at endMs (self-expiry in
// PushChannelInfo) or when a later TIMED cast supersedes it — so an early
// end followed by an INSTANT cast (which supersedes nothing) leaves a
// ghost bar counting down to a duration that no longer reflects reality.
//
// We deliberately don't use +0x228 to START the bar — it's a broadcast
// UpdateField that lags the channel start by ~1 tick (HandleSpellStart
// stamps from the packet instead). For the STOP that lag is harmless, but a
// naive "clear when field == 0" would wipe a channel we just stamped during
// the lag window. Guard with a latch: only honor the 0 once the field has
// been seen to reflect our channel spell at least once. A channel too short
// to ever appear in the field just self-expires at endMs.
if (g_channel.spellID != 0) {
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (desc != nullptr) {
const int chan = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
if (chan == g_channel.spellID)
g_channelConfirmed = true;
else if (chan == 0 && g_channelConfirmed)
g_channel.spellID = 0;
}
}
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
@@ -458,8 +682,8 @@ static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cas
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
// window). An interrupted remote cast lingers until its computed end time
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
// window; aborted casts are evicted by the ClearCastingSpell / failure-
// packet co-hooks).
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
+449
View File
@@ -0,0 +1,449 @@
// 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/>.
// Trade-skill *list* links (`|Htrade:...|h`) — the shareable "here's my
// profession and which recipes I know" link. Vanilla 1.12 has NO such link
// type (the engine's SetHyperlink parser only knows `item:` and `enchant:`);
// 2.x introduced it. We backport the whole thing: the link is built entirely
// in C here; only the click-to-display viewer lives in the embedded addon
// (Util/TradeSkillLink.lua), since that's UI.
//
// Wire format (a ClassicAPI-private shape — nothing native emits/reads it, so
// we're free to define it; modeled on the 2.x/3.3.5 `trade:` link, with the
// linker's name in the slot 2.x used for the GUID):
//
// |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
//
// - skillLineID — SkillLine.dbc row (e.g. 164 = Blacksmithing)
// - curRank/maxRank — the linker's skill level (cosmetic; viewer title)
// - linkerName — the linking character's name
// - base64 bits — a bitfield over the skill line's *canonical recipe list*:
// one bit per possible recipe, set = the linker knows it.
//
// The canonical recipe list is every SkillLineAbility.dbc row for the skill
// line whose spell actually produces a craft (see IsCraftRecipe — an item,
// enchant, or Turtle LEARN_SPELL recipe; this excludes profession abilities
// like Disenchant), in ascending record-ID order. That ordering is
// deterministic and identical on every client with the same DBC, so builder
// and reader agree on the bit-index space without any handshake. "Which
// recipes does the linker know" comes from the player-spell bitmap
// (VAR_PLAYER_SPELL_BITMAP — the same store IsPlayerSpell reads, which covers
// profession recipes), NOT from the trade-skill window's server list.
//
// The TradeSkill and Craft windows are separate frames with separate storage
// and can both be open at once, so each gets its own builder (matching
// WotLK) — each reads only its own window's open-state global (both zeroed by
// the engine on close), so "both open" is unambiguous and neither guesses:
//
// - `C_TradeSkillUI.GetTradeSkillListLink()` — the TradeSkill window
// - `C_TradeSkillUI.GetCraftListLink()` — the Craft window (Enchanting)
// - `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)` — decoder
// for the viewer.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace TradeSkill::Link {
namespace {
// 6 recipes per character, LSB-first (mirrors 3.x's encoder).
constexpr char kBase64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
constexpr int kRecipesPerChar = 6;
constexpr int kMaxRecipes = 1024; // largest vanilla skill line is ~340
constexpr int kBitsBufSize = kMaxRecipes / kRecipesPerChar + 2;
int Base64Value(char c) {
for (int i = 0; i < 64; ++i)
if (kBase64[i] == c)
return i;
return -1;
}
// A spell is a craftable recipe (vs. a profession ability like Disenchant, or
// a rank-up / skill-grant spell) iff one of its effects produces a craft: an
// item (CREATE_ITEM), an enchant (ENCHANT_ITEM / _TEMPORARY), or Turtle's
// custom recipes (LEARN_SPELL). Filtering on the skill-up threshold alone
// wrongly kept Disenchant (which carries thresholds but isn't a recipe).
bool IsCraftRecipe(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return false;
const int32_t *eff = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int e = eff[i];
if (e == Offsets::SPELL_EFFECT_CREATE_ITEM ||
e == Offsets::SPELL_EFFECT_LEARN_SPELL ||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM ||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY)
return true;
}
return false;
}
// Fills `outSpell` with the recipe spellIDs for `skillLineID`, in canonical
// (ascending record-ID) order, and — when non-null — `outGrey`/`outGreen` with
// the skill levels the recipe turns grey / green at (its difficulty tiers; the
// required-skill field 7 is uselessly 1 for nearly every trainer-taught
// recipe). Returns the count (capped at `maxOut`). Same walk builder and reader
// use, so the bit-index space is identical.
int BuildRecipeList(uint32_t skillLineID, int *outSpell, int *outGrey,
int *outGreen, int maxOut) {
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS));
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT));
if (records == nullptr || count <= 0)
return 0;
int n = 0;
for (int i = 1; i <= count && n < maxOut; ++i) {
const uint8_t *rec = records[i];
if (rec == nullptr)
continue;
if (static_cast<uint32_t>(*reinterpret_cast<const int *>(
rec + Offsets::OFF_SLA_SKILL_ID)) != skillLineID)
continue;
const int spellID =
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SPELL_ID);
if (!IsCraftRecipe(spellID))
continue;
if (outGrey != nullptr)
outGrey[n] = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SLA_TRIVIAL_SKILL_HIGH);
if (outGreen != nullptr)
outGreen[n] = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SLA_TRIVIAL_SKILL_LOW);
outSpell[n++] = spellID;
}
return n;
}
// Same bit the engine (and our IsPlayerSpell) reads — covers profession
// recipes, not just trained abilities.
bool IsKnownSpell(int spellID) {
if (spellID <= 0)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
return (bitmap[static_cast<uint32_t>(spellID) >> 5] &
(1u << (spellID & 31))) != 0;
}
// Skill line (SkillLine.dbc row) of the first SLA row that teaches `spellID`,
// or 0 if none. Used to identify the Craft window's profession, which — unlike
// the TradeSkill window — has no skill-line global (the engine derives it from
// the recipe, see `GetCraftDisplaySkillLine` at `0x004F6CB0`).
uint32_t SkillForSpell(int spellID) {
if (spellID <= 0)
return 0;
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS));
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT));
if (records == nullptr || count <= 0)
return 0;
for (int i = 1; i <= count; ++i) {
const uint8_t *rec = records[i];
if (rec != nullptr &&
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SPELL_ID) ==
spellID)
return static_cast<uint32_t>(
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SKILL_ID));
}
return 0;
}
// Skill line of the open Craft window (Enchanting, pet training) — derived
// from its first real recipe entry, mirroring the engine. 0 if none open.
uint32_t CraftSkillLine() {
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CRAFT_COUNT));
if (count <= 0)
return 0;
const int cap = (count < kMaxRecipes) ? count : kMaxRecipes;
for (int slot = 0; slot < cap; ++slot) {
const int spellID = Spell::Lookup::RecipeSlotSpellID(
Offsets::VAR_CRAFT_ENTRIES, Offsets::VAR_CRAFT_COUNT, slot);
if (spellID > 0) {
const uint32_t skill = SkillForSpell(spellID);
if (skill != 0)
return skill;
}
}
return 0;
}
// Encodes the local player's known-recipe bitfield for `skillLineID` into
// `out` (must hold kBitsBufSize). Returns the recipe count, or -1 if the skill
// line has no recipes.
int EncodeKnownBits(uint32_t skillLineID, char *out) {
int spells[kMaxRecipes];
const int n = BuildRecipeList(skillLineID, spells, nullptr, nullptr,
kMaxRecipes);
if (n <= 0)
return -1;
int nb = 0;
unsigned acc = 0;
int used = 0;
for (int i = 0; i < n; ++i) {
if (IsKnownSpell(spells[i]))
acc |= (1u << used);
if (++used == kRecipesPerChar) {
out[nb++] = kBase64[acc];
acc = 0;
used = 0;
}
}
if (used != 0)
out[nb++] = kBase64[acc];
out[nb] = '\0';
return n;
}
// Current / max skill rank for the local player in `skillLine`, read from the
// CGPlayer `+0xE68` sub-struct exactly as the engine's own accessors do (see
// the FUN_SKILL_LINE_TO_SLOT block in Offsets.h). Leaves 0/0 when the player
// isn't resolvable or doesn't have the skill.
void ReadPlayerSkillRank(int skillLine, int *outCur, int *outMax) {
*outCur = 0;
*outMax = 0;
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
using SlotFn = int(__thiscall *)(const void *, int);
const int slot = reinterpret_cast<SlotFn>(
Offsets::FUN_SKILL_LINE_TO_SLOT)(player, skillLine);
if (slot < 0)
return;
const uint8_t *base = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (base == nullptr)
return;
const uint8_t *rec =
base + Offsets::OFF_SKILL_INFO_TABLE + slot * Offsets::SKILL_INFO_STRIDE;
uint32_t cur = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
uint32_t mx = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_MAX);
const uint32_t bonus =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
if (cur != 0)
cur += bonus;
if (mx != 0)
mx += bonus;
*outCur = static_cast<int>(cur);
*outMax = static_cast<int>(mx);
}
// The local player's character name via the engine's object name getter.
const char *PlayerCharacterName() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return "";
using GetName_t = const char *(__thiscall *)(const void *, int *);
const char *name = reinterpret_cast<GetName_t>(
Offsets::FUN_OBJECT_GET_NAME)(player, nullptr);
return (name != nullptr) ? name : "";
}
// Builds the full `|Htrade:...|h` link for whichever window `useCraft`
// selects. Pushes the link string (returns 1), or nothing (returns 0 → nil)
// when that window is closed or has no recipes.
int BuildLink(void *L, bool useCraft) {
int skillLine;
if (useCraft) {
// Craft-window open-state flag; zeroed by the engine on close.
if (*reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CRAFT_WINDOW_STATE)) == 0)
return 0;
skillLine = static_cast<int>(CraftSkillLine());
} else {
// TradeSkill skill-line global; zeroed by the engine on close.
skillLine = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_TRADESKILL_LINE));
}
if (skillLine <= 0)
return 0;
char bits[kBitsBufSize];
if (EncodeKnownBits(static_cast<uint32_t>(skillLine), bits) < 0)
return 0;
const char *prof = DBC::LocalizedField(
Offsets::VAR_SKILL_LINE_RECORDS, Offsets::VAR_SKILL_LINE_COUNT,
static_cast<uint32_t>(skillLine), Offsets::OFF_SKILL_LINE_NAME);
if (prof == nullptr || *prof == '\0')
return 0;
int cur = 0, mx = 0;
ReadPlayerSkillRank(skillLine, &cur, &mx);
const char *who = PlayerCharacterName();
char link[512];
std::snprintf(link, sizeof link,
"|cffffd000|Htrade:%d:%d:%d:%s:%s|h[%s]|h|r", skillLine, cur,
mx, who, bits, prof);
Game::Lua::PushString(L, link);
return 1;
}
// `C_TradeSkillUI.GetTradeSkillListLink()` -> link for the open TradeSkill
// window, or nil.
int __fastcall Script_GetTradeSkillListLink(void *L) {
return BuildLink(L, /*useCraft=*/false);
}
// `C_TradeSkillUI.GetCraftListLink()` -> link for the open Craft window, or nil.
int __fastcall Script_GetCraftListLink(void *L) {
return BuildLink(L, /*useCraft=*/true);
}
// `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)`
// -> { { spellID, isKnown, trivialLevel, greenLevel, createdItem,
// numMade, reagents = { { itemID, count }, ... } }, ... }
// `trivialLevel` / `greenLevel` are the skill levels the recipe turns
// grey / green at — its difficulty tiers, for sorting and colouring.
// `createdItem` is the crafted item's ID (0 for enchants) — the viewer
// derives its subclass filter from it. `numMade` is how many the recipe
// yields (CREATE_ITEM base points + dice; 0 for enchants). `reagents` lists
// each consumed item + required count in DBC slot order (stopping at the
// first empty slot, the engine's own convention), for the detail pane.
//
// Rebuilds the canonical recipe list for `skillLineID` and decodes `bits`
// against it. Recipes past the end of `bits` (shorter/garbled string) decode
// as not-known rather than erroring. The Lua caller (the viewer) turns each
// spellID into a name/icon via GetSpellInfo and each reagent itemID into
// name/texture/on-hand-count via GetItemInfo / GetItemCount.
//
// The real 3.3.5 reagent API (GetTradeSkillReagentInfo etc.) can't serve this:
// it's indexed by position in the player's *own* open trade window, so it
// can't describe a linked recipe from someone else's profession. We read the
// reagents straight from Spell.dbc instead.
int __fastcall Script_GetTradeSkillListRecipes(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(
L, "Usage: C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)");
return 0;
}
const uint32_t skillLine =
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const char *bits = Game::Lua::ToString(L, 2);
const int blen = (bits != nullptr) ? static_cast<int>(std::strlen(bits)) : 0;
int spells[kMaxRecipes];
int greys[kMaxRecipes];
int greens[kMaxRecipes];
const int n = BuildRecipeList(skillLine, spells, greys, greens, kMaxRecipes);
Game::Lua::NewTable(L);
for (int i = 0; i < n; ++i) {
const int ci = i / kRecipesPerChar;
const int bi = i % kRecipesPerChar;
bool known = false;
if (ci < blen) {
const int v = Base64Value(bits[ci]);
if (v >= 0)
known = (v & (1 << bi)) != 0;
}
// The recipe's crafted item (0 for enchants) — the viewer groups the
// subclass filter by this item's subclass — plus its yield count.
const uint8_t *srec = Spell::Lookup::RecordForID(spells[i]);
int createdItem = 0;
int numMade = 0;
if (srec != nullptr) {
createdItem = *reinterpret_cast<const int *>(
srec + Offsets::OFF_SPELL_RECORD_CREATED_ITEM);
// Yield: the CREATE_ITEM effect's magnitude (base points + dice),
// mirroring the engine's item-creation math. 0 for enchants.
const int32_t *eff = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_RECORD_EFFECT);
const int32_t *basePts = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
const int32_t *baseDice = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
for (int e = 0; e < Offsets::SPELL_RECORD_EFFECT_COUNT; ++e) {
if (eff[e] == Offsets::SPELL_EFFECT_CREATE_ITEM) {
numMade = basePts[e] + baseDice[e];
break;
}
}
}
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(spells[i]));
Game::Lua::SetFieldBool(L, "isKnown", known);
Game::Lua::SetFieldNumber(L, "trivialLevel",
static_cast<double>(greys[i]));
Game::Lua::SetFieldNumber(L, "greenLevel",
static_cast<double>(greens[i]));
Game::Lua::SetFieldNumber(L, "createdItem",
static_cast<double>(createdItem));
Game::Lua::SetFieldNumber(L, "numMade", static_cast<double>(numMade));
// reagents = { {itemID, count}, ... } — nested array under "reagents"
// (no SetField helper takes a table value, so push key + build + SetTable).
Game::Lua::PushString(L, "reagents");
Game::Lua::NewTable(L);
if (srec != nullptr) {
const int32_t *rids = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_REAGENT_ID);
const int32_t *rcounts = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_REAGENT_COUNT);
int outIdx = 0;
for (int r = 0; r < Offsets::SPELL_MAX_REAGENTS; ++r) {
if (rids[r] == 0)
break; // first empty slot ends the list
Game::Lua::PushNumber(L, static_cast<double>(++outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID",
static_cast<double>(rids[r]));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(rcounts[r]));
Game::Lua::SetTable(L, -3); // reagents[outIdx] = {itemID,count}
}
}
Game::Lua::SetTable(L, -3); // entry.reagents = { ... }
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetTradeSkillListLink",
&Script_GetTradeSkillListLink);
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetCraftListLink",
&Script_GetCraftListLink);
Game::Lua::RegisterTableFunction("C_TradeSkillUI",
"GetTradeSkillListRecipes",
&Script_GetTradeSkillListRecipes);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace TradeSkill::Link
+31
View File
@@ -96,6 +96,37 @@ uint64_t PlayerGuid() {
return g_charSelectGuid; // early-login fallback (0 until char-select)
}
const uint8_t *PlayerObject() {
// Resolve the player's CGObject straight from the object manager by GUID —
// one hash lookup, no token-string parsing, and non-throwing: the resolver
// returns null when the GUID isn't loaded (pre-world) instead of raising,
// so this is safe from any context (world tick, packet send hook, Lua
// callback). The GUID comes from PlayerGuid() — 0 before char-select, which
// short-circuits to null here. NOT VAR_LOCAL_PLAYER_PTR: its +0xC0 is only
// the GUID, not the canonical CGPlayer whose descriptor is readable.
const uint64_t guid = PlayerGuid();
if (guid == 0)
return nullptr;
using ResolveByGuid_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int line);
auto resolve =
reinterpret_cast<ResolveByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_PLAYER, "ClassicAPI",
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
/*line*/ 0));
}
const uint8_t *PlayerDescriptor() {
const uint8_t *player = PlayerObject();
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
}
uint64_t GuidForObject(const void *unitObject) {
if (unitObject == nullptr)
return 0;
+16
View File
@@ -28,6 +28,22 @@ namespace Unit::Identity {
// re-dereferencing the globals inline.
uint64_t PlayerGuid();
// The local player's in-world object pointer, resolved straight from the
// object manager by GUID (`FUN_OBJECT_RESOLVE_BY_GUID`) — the fastest path we
// have (one hash lookup, no token-string work) and naturally non-throwing:
// null before the player object exists (pre-world) or before char-select,
// never a raise. Safe from any context — Lua callback, world tick, packet
// hook. Deliberately NOT `VAR_LOCAL_PLAYER_PTR`: that global carries the
// player GUID at +0xC0 but is NOT the CGPlayer whose descriptor and
// CGPlayer-local fields are readable (see Offsets.h).
const uint8_t *PlayerObject();
// The local player's `m_objectFields` descriptor (`PlayerObject() +
// OFF_UNIT_DESCRIPTOR`), or null. This is the broadcast UpdateField block
// (health / power / flags / UNIT_FIELD_CHANNEL_SPELL / …). Prefer this over
// re-deriving the player object + descriptor offset in each module.
const uint8_t *PlayerDescriptor();
// The 64-bit GUID of a resolved CGUnit/CGObject pointer, read through the
// pointer at `+0x08` to an instance block whose first 8 bytes are the GUID
// (verified in `Script_GetInventoryItemLink`; see `OFF_UNIT_GUID_PTR`).
+55 -51
View File
@@ -21,6 +21,7 @@
#include "Offsets.h"
#include "aura/Data.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -28,24 +29,10 @@ namespace Unit::State {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CancelAuraSend_t = void(__fastcall *)(int spellID);
const uint8_t *Player() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn("player"));
}
const uint8_t *PlayerDescriptor() {
auto *player = Player();
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
}
uint32_t PlayerMovementFlags() {
auto *player = Player();
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
@@ -59,11 +46,39 @@ uint32_t MountDisplayID(const uint8_t *desc) {
desc + Offsets::OFF_UNIT_FIELD_MOUNTDISPLAYID);
}
// Scans the player's helpful-aura (buff) slots for the first spell whose
// Spell.dbc record lists `auraEffect` (a `SPELL_AURA_*` code) in any of
// its three effect slots, and returns that spellID (0 if none). Used by
// `Dismount` to find the active mount aura to cancel — the engine's
// `CMSG_CANCEL_AURA` needs the spellID, not a slot index.
uint32_t FindPlayerBuffWithEffect(int auraEffect) {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
if (!Aura::Data::IsSlotPopulated(player, slot))
continue;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (record == nullptr)
continue;
auto *auraTypes = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
if (auraTypes[eff] == auraEffect)
return spellID;
}
}
return 0;
}
// `IsMounted()` — true iff the player is currently mounted. Reads
// `UNIT_FIELD_MOUNTDISPLAYID` from the descriptor; non-zero means
// the engine is rendering a mount under the player.
int __fastcall Script_IsMounted(void *L) {
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
Game::Lua::PushBool(L, MountDisplayID(desc) != 0);
return 1;
}
@@ -80,50 +95,39 @@ int __fastcall Script_IsMounted(void *L) {
// No-op if the player isn't mounted (early-outs on the
// `UNIT_FIELD_MOUNTDISPLAYID` check before walking auras).
int __fastcall Script_Dismount(void *L) {
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
if (MountDisplayID(desc) == 0)
return 0;
auto *player = Player();
if (player == nullptr)
return 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
if (!Aura::Data::IsSlotPopulated(player, slot))
continue;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (record == nullptr)
continue;
auto *auraTypes = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
if (auraTypes[eff] != Offsets::SPELL_AURA_MOUNTED)
continue;
auto fn = reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
fn(static_cast<int>(spellID));
return 0;
}
const uint32_t spellID = FindPlayerBuffWithEffect(Offsets::SPELL_AURA_MOUNTED);
if (spellID != 0) {
auto fn = reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
fn(static_cast<int>(spellID));
}
return 0;
}
// `IsStealthed()` — true iff the player is currently stealthed
// (Rogue Stealth / Druid Prowl). Checks `PLAYER_FIELD_VIS_BYTES`
// bit 0x02. Mount also sets that bit, so we AND-gate with
// `mountDisplayID == 0` to disambiguate. See the comment on
// `OFF_PLAYER_FIELD_VIS_BYTES` in Offsets.h for the full bit map
// and known limitations (untested for shapeshift forms).
// (Rogue Stealth / Druid Prowl / Night Elf Shadowmeld / etc.). Reads
// the sneak flag in `UNIT_FIELD_BYTES_1` byte 3 (mask
// `UNIT_BYTES_1_STEALTH_FLAG`) — the same broadcast bit the client's
// render path uses to draw a stealthed unit semi-transparent.
//
// Derived via `_classicapi_DiffAll`: a Prowl toggle from a Cat-Form
// baseline flips byte 3 `0x00 -> 0x02` and touches nothing else but
// aura-array slot metadata, so this is the one standalone descriptor
// bit that means "stealthed" (the earlier `0x17C & 0x02` read was
// actually AURALEVELS and only worked by coincidence). Being a unit
// anim-tier flag rather than a class field, it covers every stealth
// source and rank without a spellID list or an aura scan.
int __fastcall Script_IsStealthed(void *L) {
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
bool stealthed = false;
if (desc != nullptr) {
const uint32_t visBytes = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_PLAYER_FIELD_VIS_BYTES);
stealthed = (visBytes & Offsets::PLAYER_VIS_BIT_STEALTH) != 0
&& MountDisplayID(desc) == 0;
const uint32_t bytes1 = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_BYTES_1);
stealthed = (bytes1 & Offsets::UNIT_BYTES_1_STEALTH_FLAG) != 0;
}
Game::Lua::PushBool(L, stealthed);
return 1;
@@ -177,12 +181,12 @@ int __fastcall Script_IsSwimming(void *L) {
// vanilla's summoning ritual has no UI surface, which is what
// motivates this function.
int __fastcall Script_IsAssistingRitual(void *L) {
auto *player = Player();
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
if (desc == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
+594
View File
@@ -0,0 +1,594 @@
// 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/>.
// Text-to-speech — a Windows SAPI backend behind modern WoW's C_VoiceChat /
// C_TTSSettings Lua surface. Ported from awesome_wotlk's VoiceChat.cpp (the
// 3.3.5 implementation); the SAPI core is client-version-agnostic, so only
// the three integration seams are ClassicAPI-native:
// - namespaces → Game::Lua::RegisterTableFunction
// - events → Event::Custom (AutoReserve + Fire)
// - settings → CVar::Factory (ttsVoice / ttsSpeed / ttsVolume, persisted
// to Config.wtf, clamped via change callbacks)
//
// Lua surface:
// C_VoiceChat
// GetTtsVoices() -> { {voiceID=, name=}, ... }
// GetRemoteTtsVoices() -> same (vanilla has no real voice chat)
// SpeakText(voiceID, text[, destination[, rate[, volume]]])
// StopSpeakingText()
// C_TTSSettings
// GetSpeechRate() / GetSpeechVolume() / GetSpeechVoiceID()
// GetVoiceOptionName()
// SetDefaultSettings()
// SetSpeechRate(v) / SetSpeechVolume(v)
// SetVoiceOption(id) / SetVoiceOptionByName(name)
// RefreshVoices()
//
// Events: VOICE_CHAT_TTS_PLAYBACK_STARTED/FINISHED/FAILED,
// VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE (reserved, unused),
// VOICE_CHAT_TTS_VOICES_UPDATE.
//
// Threading: SAPI is created on the main (game) thread under an STA, and
// SetNotifyCallbackFunction marshals the notify back to that thread's message
// pump (WoW pumps messages every frame), so OnSpeechNotify runs on the main
// thread and may touch the Lua event dispatcher safely.
//
// Coexistence: the same TTS surface also ships as the standalone VanillaTTS
// DLL (which adds a pluggable backend — software synth under Wine). When
// that DLL is injected alongside us, this module stands down entirely — see
// StandaloneTtsPresent below.
#include "Game.h"
#include "cvar/Factory.h"
#include "event/Custom.h"
#include <initguid.h> // instantiate the SAPI GUIDs in this TU (no sapi.lib)
#include <sapi.h>
#include <windows.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cwctype>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace VoiceChat::Tts {
namespace {
// ---------------------------------------------------------------------------
// Standalone-DLL coexistence
// ---------------------------------------------------------------------------
// The dedicated VanillaTTS DLL provides this exact Lua surface (same
// C_VoiceChat / C_TTSSettings functions, same VOICE_CHAT_TTS_* events, same
// ttsVoice/ttsSpeed/ttsVolume cvars) with a more capable backend. If both
// registered, the damage wouldn't be limited to "last one wins":
// - Each DLL claims its OWN event-table slot for the same event name, so
// the table ends up with duplicate-name entries. frame:RegisterEvent
// binds listeners to whichever slot the engine's scan finds first, while
// the other DLL fires the other slot — events silently lost depending on
// load order.
// - The cvar registrar dedups by name but REPLACES the change callback on
// re-register, so one DLL's clamp callbacks (and userData) go dead.
// - Both would init COM/SAPI and answer SpeakText → double playback risk.
// So when VanillaTTS.dll is in the process, this module registers nothing —
// no Lua functions, no cvars, no event reservations, no SAPI init.
//
// The check runs at RegisterLuaFunctions time (first login), which is
// load-order-independent: VanillaFixes loads every DLL at process attach,
// long before FUN_LOAD_SCRIPT_FUNCTIONS first fires. Latched once — modules
// can't appear or vanish mid-session.
bool StandaloneTtsPresent() {
static const bool present = GetModuleHandleA("VanillaTTS.dll") != nullptr;
return present;
}
// ---------------------------------------------------------------------------
// Event names — reserved lazily in RegisterLuaFunctions (NOT via file-scope
// AutoReserve statics, which would run unconditionally at DLL load and claim
// slots even when we're standing down for VanillaTTS).
// ---------------------------------------------------------------------------
constexpr const char *kEvtPlaybackFailed = "VOICE_CHAT_TTS_PLAYBACK_FAILED";
constexpr const char *kEvtPlaybackFinished = "VOICE_CHAT_TTS_PLAYBACK_FINISHED";
constexpr const char *kEvtPlaybackStarted = "VOICE_CHAT_TTS_PLAYBACK_STARTED";
constexpr const char *kEvtSpeakTextUpdate = "VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE";
constexpr const char *kEvtVoicesUpdate = "VOICE_CHAT_TTS_VOICES_UPDATE";
// ---------------------------------------------------------------------------
// CVar names + handles (registered in RegisterLuaFunctions)
// ---------------------------------------------------------------------------
constexpr const char *kCVarVoice = "ttsVoice";
constexpr const char *kCVarSpeed = "ttsSpeed";
constexpr const char *kCVarVolume = "ttsVolume";
CVar::Factory::Handle g_cvarVoice = nullptr;
CVar::Factory::Handle g_cvarSpeed = nullptr;
CVar::Factory::Handle g_cvarVolume = nullptr;
// Destination constants (kept for API parity; no special handling — SAPI
// queues FIFO either way).
enum : int {
DEST_LOCAL_PLAYBACK = 1,
DEST_QUEUED_LOCAL_PLAYBACK = 4,
};
int ClampDestination(int d) {
return d == DEST_QUEUED_LOCAL_PLAYBACK ? DEST_QUEUED_LOCAL_PLAYBACK
: DEST_LOCAL_PLAYBACK;
}
// ---------------------------------------------------------------------------
// SAPI state
// ---------------------------------------------------------------------------
ISpVoice *g_pVoice = nullptr;
bool g_comInit = false;
int g_nextUtteranceID = 1;
int NextUtteranceID() {
if (g_nextUtteranceID >= 0x7fffffff)
g_nextUtteranceID = 1;
return g_nextUtteranceID++;
}
struct UtteranceMeta {
int id;
int destination;
bool startedEmitted;
};
std::mutex g_streamMx;
std::unordered_map<ULONG, UtteranceMeta> g_streamMap;
struct VoiceEntry {
int voiceID;
std::wstring name;
};
std::vector<VoiceEntry> g_cachedVoices;
// ---------------------------------------------------------------------------
// UTF-8 / wide conversion
// ---------------------------------------------------------------------------
std::string WideToUtf8(const std::wstring &w) {
if (w.empty())
return {};
int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, nullptr, 0, nullptr, nullptr);
if (need <= 1)
return {};
std::string out(static_cast<size_t>(need - 1), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &out[0], need - 1, nullptr, nullptr);
return out;
}
std::wstring Utf8ToWide(const char *s) {
if (s == nullptr || *s == '\0')
return {};
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
if (need <= 1)
return {};
std::wstring out(static_cast<size_t>(need - 1), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
return out;
}
// ---------------------------------------------------------------------------
// Event fire helpers (engine dispatcher via Event::Custom)
// ---------------------------------------------------------------------------
void FireVoicesUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtVoicesUpdate), "");
}
void FirePlaybackStarted(int numConsumers, int utteranceID, int durationMS, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackStarted), "%d%d%d%d",
numConsumers, utteranceID, durationMS, dest);
}
void FirePlaybackFinished(int numConsumers, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFinished), "%d%d%d",
numConsumers, utteranceID, dest);
}
void FirePlaybackFailed(const char *status, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFailed), "%s%d%d",
status, utteranceID, dest);
}
// ---------------------------------------------------------------------------
// COM / SAPI lifecycle
// ---------------------------------------------------------------------------
void InitCom() {
if (g_comInit)
return;
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
// S_FALSE = already initialized on this thread; treat as success.
if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE)
g_comInit = true;
}
void __stdcall OnSpeechNotify(WPARAM, LPARAM) {
if (g_pVoice == nullptr)
return;
SPEVENT ev = {};
ULONG fetched = 0;
while (SUCCEEDED(g_pVoice->GetEvents(1, &ev, &fetched)) && fetched == 1) {
if (ev.eEventId == SPEI_START_INPUT_STREAM) {
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end() && !it->second.startedEmitted) {
FirePlaybackStarted(1, it->second.id, /*durationMS*/ 0,
it->second.destination);
it->second.startedEmitted = true;
}
} else if (ev.eEventId == SPEI_END_INPUT_STREAM) {
UtteranceMeta meta{0, DEST_LOCAL_PLAYBACK, false};
{
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end()) {
meta = it->second;
g_streamMap.erase(it);
}
}
FirePlaybackFinished(1, meta.id, meta.destination);
}
// Only START/END stream events are requested; neither carries an
// allocated lParam, so no SpClearEvent (sphelper) is needed.
ev = SPEVENT{};
}
}
void InitVoice() {
if (g_pVoice != nullptr)
return;
InitCom();
HRESULT hr = CoCreateInstance(CLSID_SpVoice, nullptr, CLSCTX_ALL,
IID_ISpVoice, reinterpret_cast<void **>(&g_pVoice));
if (FAILED(hr) || g_pVoice == nullptr) {
g_pVoice = nullptr;
return;
}
g_pVoice->SetNotifyCallbackFunction(&OnSpeechNotify, 0, 0);
const ULONGLONG interest =
SPFEI(SPEI_START_INPUT_STREAM) | SPFEI(SPEI_END_INPUT_STREAM);
g_pVoice->SetInterest(interest, interest);
}
// ---------------------------------------------------------------------------
// Voice enumeration (CLSID_SpObjectTokenCategory; no sphelper)
// ---------------------------------------------------------------------------
std::vector<VoiceEntry> EnumerateVoices() {
InitCom();
std::vector<VoiceEntry> voices;
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return voices;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
LPWSTR desc = nullptr;
if (SUCCEEDED(tok->GetStringValue(nullptr, &desc)) && desc != nullptr) {
voices.push_back({index, desc});
CoTaskMemFree(desc);
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return voices;
}
int VoiceCount() {
return static_cast<int>(EnumerateVoices().size());
}
void RefreshVoices() {
auto fresh = EnumerateVoices();
bool changed = fresh.size() != g_cachedVoices.size();
if (!changed) {
for (size_t i = 0; i < fresh.size(); ++i) {
if (fresh[i].name != g_cachedVoices[i].name) {
changed = true;
break;
}
}
}
g_cachedVoices = std::move(fresh);
if (changed)
FireVoicesUpdate();
}
// Select the voice at enumeration index `voiceID` on g_pVoice.
bool SelectVoice(int voiceID) {
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return false;
bool set = false;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
if (index == voiceID) {
set = SUCCEEDED(g_pVoice->SetVoice(tok));
tok->Release();
break;
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return set;
}
// ---------------------------------------------------------------------------
// Speak / stop
// ---------------------------------------------------------------------------
void SpeakText(int voiceID, const std::wstring &text, int destination, int rate,
int volume) {
const int utteranceID = NextUtteranceID();
const int dest = ClampDestination(destination);
InitVoice();
if (g_pVoice == nullptr) {
FirePlaybackFailed("EngineAllocationFailed", utteranceID, dest);
return;
}
if (!SelectVoice(voiceID)) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
g_pVoice->SetRate(std::clamp(rate, -10, 10));
g_pVoice->SetVolume(static_cast<USHORT>(std::clamp(volume, 0, 100)));
ULONG streamNum = 0;
if (FAILED(g_pVoice->Speak(text.c_str(), SPF_ASYNC, &streamNum))) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap[streamNum] = UtteranceMeta{utteranceID, dest, false};
}
void StopAll() {
InitVoice();
if (g_pVoice == nullptr)
return;
g_pVoice->Speak(nullptr, SPF_PURGEBEFORESPEAK, nullptr);
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap.clear();
}
// ---------------------------------------------------------------------------
// Lua helpers
// ---------------------------------------------------------------------------
// Push a { {voiceID=, name=}, ... } array built from g_cachedVoices.
void PushVoicesTable(void *L) {
Game::Lua::NewTable(L); // outer array at top
for (size_t i = 0; i < g_cachedVoices.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based array key
Game::Lua::NewTable(L); // sub-table
Game::Lua::SetFieldNumber(L, "voiceID", g_cachedVoices[i].voiceID);
Game::Lua::SetFieldString(L, "name",
WideToUtf8(g_cachedVoices[i].name).c_str());
// stack: [outer, key, sub] → outer[key] = sub
Game::Lua::RawSet(L, -3);
}
}
int OptInt(void *L, int idx, int fallback) {
return Game::Lua::IsNumber(L, idx)
? static_cast<int>(Game::Lua::ToNumber(L, idx))
: fallback;
}
// ---------------------------------------------------------------------------
// C_VoiceChat
// ---------------------------------------------------------------------------
int __fastcall Script_GetTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_GetRemoteTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_SpeakText(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: C_VoiceChat.SpeakText(voiceID, text "
"[, destination, rate, volume])");
return 0;
}
const int voiceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const std::wstring text = Utf8ToWide(Game::Lua::ToString(L, 2));
const int dest = OptInt(L, 3, DEST_LOCAL_PLAYBACK);
const int rate = OptInt(L, 4, 0);
const int volume = OptInt(L, 5, 100);
SpeakText(voiceID, text, dest, rate, volume);
return 0;
}
int __fastcall Script_StopSpeakingText(void *) {
StopAll();
return 0;
}
// ---------------------------------------------------------------------------
// C_TTSSettings
// ---------------------------------------------------------------------------
int __fastcall Script_GetSpeechRate(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarSpeed, 0));
return 1;
}
int __fastcall Script_GetSpeechVolume(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVolume, 100));
return 1;
}
int __fastcall Script_GetSpeechVoiceID(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVoice, 0));
return 1;
}
int __fastcall Script_GetVoiceOptionName(void *L) {
const int id = CVar::Factory::GetInt(g_cvarVoice, 0);
auto voices = EnumerateVoices();
if (id >= 0 && id < static_cast<int>(voices.size()))
Game::Lua::PushString(L, WideToUtf8(voices[id].name).c_str());
else
Game::Lua::PushString(L, "");
return 1;
}
int __fastcall Script_SetSpeechRate(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarSpeed, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetSpeechVolume(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVolume, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOption(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVoice, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOptionByName(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const std::wstring want = Utf8ToWide(Game::Lua::ToString(L, 1));
auto voices = EnumerateVoices();
for (const auto &v : voices) {
if (v.name.size() == want.size()) {
bool eq = true;
for (size_t i = 0; i < want.size(); ++i) {
if (towlower(v.name[i]) != towlower(want[i])) {
eq = false;
break;
}
}
if (eq) {
CVar::Factory::SetInt(g_cvarVoice, v.voiceID);
break;
}
}
}
return 0;
}
int __fastcall Script_SetDefaultSettings(void *) {
const int maxVoice = VoiceCount();
CVar::Factory::SetInt(g_cvarVoice, maxVoice > 1 ? 1 : 0);
CVar::Factory::SetInt(g_cvarSpeed, 0);
CVar::Factory::SetInt(g_cvarVolume, 100);
FireVoicesUpdate();
return 0;
}
int __fastcall Script_RefreshVoices(void *) {
RefreshVoices();
return 0;
}
// ---------------------------------------------------------------------------
// CVar clamp callbacks (re-entrant SetString to clamp; reject out-of-range)
// ---------------------------------------------------------------------------
int __fastcall ClampRange(CVar::Factory::Handle cvar, const char *, const char *next,
int lo, int hi) {
if (next == nullptr)
return 1;
const int v = std::atoi(next);
const int c = std::clamp(v, lo, hi);
if (c != v) {
char buf[16];
std::snprintf(buf, sizeof buf, "%d", c);
CVar::Factory::SetString(cvar, buf);
return 0;
}
return 1;
}
int __fastcall OnSpeedChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, -10, 10);
}
int __fastcall OnVolumeChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, 0, 100);
}
int __fastcall OnVoiceChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
const int maxIdx = VoiceCount() - 1;
return ClampRange(c, p, n, 0, maxIdx < 0 ? 0 : maxIdx);
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void RegisterLuaFunctions() {
if (StandaloneTtsPresent())
return; // VanillaTTS owns the TTS surface this session — see above.
// Reserve the events on first (real) registration. AutoReserve only
// appends to Event::Custom's reservation list — the actual slot claims
// happen later via the usual RetryClaims walk — so reserving here instead
// of at static-init time changes nothing but the conditionality.
static const Event::Custom::AutoReserve _r1{kEvtPlaybackFailed};
static const Event::Custom::AutoReserve _r2{kEvtPlaybackFinished};
static const Event::Custom::AutoReserve _r3{kEvtPlaybackStarted};
static const Event::Custom::AutoReserve _r4{kEvtSpeakTextUpdate};
static const Event::Custom::AutoReserve _r5{kEvtVoicesUpdate};
g_cvarVoice = CVar::Factory::Register(kCVarVoice, "0", 0, &OnVoiceChanged);
g_cvarSpeed = CVar::Factory::Register(kCVarSpeed, "0", 0, &OnSpeedChanged);
g_cvarVolume = CVar::Factory::Register(kCVarVolume, "100", 0, &OnVolumeChanged);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetTtsVoices", &Script_GetTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetRemoteTtsVoices", &Script_GetRemoteTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "SpeakText", &Script_SpeakText);
Game::Lua::RegisterTableFunction("C_VoiceChat", "StopSpeakingText", &Script_StopSpeakingText);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechRate", &Script_GetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVolume", &Script_GetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVoiceID", &Script_GetSpeechVoiceID);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetVoiceOptionName", &Script_GetVoiceOptionName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetDefaultSettings", &Script_SetDefaultSettings);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechRate", &Script_SetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechVolume", &Script_SetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOption", &Script_SetVoiceOption);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOptionByName", &Script_SetVoiceOptionByName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "RefreshVoices", &Script_RefreshVoices);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace VoiceChat::Tts
+232
View File
@@ -0,0 +1,232 @@
// 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_XMLUtil.GetTemplates()` — enumerate every registered virtual XML frame
// template as `{ name = <string>, type = <frameType> }`.
//
// The engine keeps virtual templates (the `<Frame virtual="true">` etc. that
// `inherits=` targets) in a Storm hash table. The XML parser (FUN_006ede10)
// registers each one via FUN_006ee500, and the inherits resolver reads the
// same table via FUN_006ee6f0 — so it's the authoritative template store, and
// walking it yields exactly what modern `C_XMLUtil.GetTemplates` returns.
// Fonts are a separate registry and are (correctly) not included; non-virtual
// named frames take a different instantiate path and never enter this table.
//
// Table shape (see Offsets.h): a POINTER to a bucket array at
// VAR_XML_TEMPLATE_TABLE, a bucket-count mask at VAR_XML_TEMPLATE_MASK
// (0xFFFFFFFF until the first template registers). Each bucket is a Storm
// intrusive list; each node carries the template name (+0x14) and the parsed
// definition node (+0x18), whose element tag string (+0x8) is the frame type.
// Contents reflect the currently-loaded XML and are rebuilt on `/reload`.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstdlib>
namespace Xml::Templates {
namespace {
// ---- Node-DOM helpers (shared by GetTemplateInfo / DoesTemplateExist) -----
using TemplateLookup_t = const uint8_t *(__fastcall *)(const char *name);
using GetAttr_t = const char *(__thiscall *)(const void *node, const char *name);
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
// The engine's by-name template lookup (FUN_006ee6f0) — the same path
// `inherits=` resolves through. Returns the parsed definition node, or null
// if no template of that name is registered (case-insensitive, hashed).
const uint8_t *LookupTemplate(const char *name) {
return reinterpret_cast<TemplateLookup_t>(
Offsets::FUN_XML_TEMPLATE_LOOKUP)(name);
}
// Value of `node`'s attribute `name`, or null if absent.
const char *Attr(const uint8_t *node, const char *name) {
return reinterpret_cast<GetAttr_t>(Offsets::FUN_XML_NODE_GET_ATTRIBUTE)(
node, name);
}
const uint8_t *FirstChild(const uint8_t *node) {
return *reinterpret_cast<const uint8_t *const *>(
node + Offsets::OFF_XML_NODE_CHILD);
}
const uint8_t *NextSibling(const uint8_t *node) {
return *reinterpret_cast<const uint8_t *const *>(
node + Offsets::OFF_XML_NODE_SIBLING);
}
// Case-insensitive tag match, using the engine's own comparator (the same one
// FUN_006f1eb0 uses to recognize "AbsDimension" etc.).
bool TagEquals(const uint8_t *node, const char *tag) {
const char *t = *reinterpret_cast<const char *const *>(
node + Offsets::OFF_XML_NODE_TAG);
if (t == nullptr)
return false;
return reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I)(
t, tag, 0x7FFFFFFF) == 0;
}
const uint8_t *FindChild(const uint8_t *node, const char *tag) {
for (const uint8_t *c = FirstChild(node); c != nullptr; c = NextSibling(c))
if (TagEquals(c, tag))
return c;
return nullptr;
}
// The statically-declared width/height from a `<Size>` child (0 if none).
// Handles the inline `<Size x= y=>` form and the `<Size><AbsDimension x= y=>`
// (or RelDimension) child form. Returns the RAW values from the XML — retail's
// GetTemplateInfo reports the declared value, so unlike the engine's own Size
// parser (FUN_006f1eb0) we deliberately skip the UI-scale conversion.
void ReadSize(const uint8_t *node, double *outW, double *outH) {
*outW = 0.0;
*outH = 0.0;
const uint8_t *size = FindChild(node, "Size");
if (size == nullptr)
return;
const char *sx = Attr(size, "x");
const char *sy = Attr(size, "y");
const uint8_t *dim = FirstChild(size);
if (dim != nullptr &&
(TagEquals(dim, "AbsDimension") || TagEquals(dim, "RelDimension"))) {
const char *dx = Attr(dim, "x");
const char *dy = Attr(dim, "y");
if (dx != nullptr && *dx != '\0')
sx = dx;
if (dy != nullptr && *dy != '\0')
sy = dy;
}
if (sx != nullptr && *sx != '\0')
*outW = std::strtod(sx, nullptr);
if (sy != nullptr && *sy != '\0')
*outH = std::strtod(sy, nullptr);
}
// `C_XMLUtil.GetTemplates()` -> XMLTemplateListInfo[] of { name, type }.
int __fastcall Script_GetTemplates(void *L) {
Game::Lua::NewTable(L); // the result array
const uint32_t mask =
*reinterpret_cast<const uint32_t *>(Offsets::VAR_XML_TEMPLATE_MASK);
if (mask == 0xFFFFFFFFu)
return 1; // no template has ever registered — empty array
const uint8_t *base = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_XML_TEMPLATE_TABLE);
if (base == nullptr)
return 1;
int outIdx = 0;
for (uint32_t b = 0; b <= mask; ++b) {
const uint8_t *bucket = base + b * Offsets::XML_TEMPLATE_BUCKET_STRIDE;
const int linkOff = *reinterpret_cast<const int *>(
bucket + Offsets::OFF_XML_TEMPLATE_BUCKET_LINKOFF);
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
bucket + Offsets::OFF_XML_TEMPLATE_BUCKET_HEAD);
// Walk the intrusive chain; the tail sentinel has its low bit set
// (mirrors the engine's own traversal in FUN_006ee6f0).
while (node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0) {
const char *name = *reinterpret_cast<const char *const *>(
node + Offsets::OFF_XML_TEMPLATE_NODE_NAME);
const uint8_t *def = *reinterpret_cast<const uint8_t *const *>(
node + Offsets::OFF_XML_TEMPLATE_NODE_DEF);
const char *type =
(def != nullptr)
? *reinterpret_cast<const char *const *>(
def + Offsets::OFF_XML_NODE_TAG)
: nullptr;
++outIdx;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", name != nullptr ? name : "");
Game::Lua::SetFieldString(L, "type", type != nullptr ? type : "");
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uint8_t *const *>(node + linkOff + 4);
}
}
return 1;
}
// `C_XMLUtil.GetTemplateInfo(name)` -> XMLTemplateInfo table, or nil if the
// template doesn't exist.
int __fastcall Script_GetTemplateInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_XMLUtil.GetTemplateInfo(\"name\")");
return 0;
}
const char *name = Game::Lua::ToString(L, 1);
const uint8_t *node = (name != nullptr) ? LookupTemplate(name) : nullptr;
if (node == nullptr)
return 0; // nil — no such template
Game::Lua::NewTable(L); // XMLTemplateInfo
const char *type = *reinterpret_cast<const char *const *>(
node + Offsets::OFF_XML_NODE_TAG);
Game::Lua::SetFieldString(L, "type", type != nullptr ? type : "");
double w = 0.0, h = 0.0;
ReadSize(node, &w, &h);
Game::Lua::SetFieldNumber(L, "width", w);
Game::Lua::SetFieldNumber(L, "height", h);
// keyValues: vanilla's XML schema has no <KeyValues> element, so this is
// always an empty table (parity with retail's "empty if none defined").
Game::Lua::PushString(L, "keyValues");
Game::Lua::NewTable(L);
Game::Lua::SetTable(L, -3);
// inherits: the comma-delimited `inherits=` attribute, or nil (field left
// unset) when the template inherits nothing.
const char *inherits = Attr(node, "inherits");
if (inherits != nullptr && *inherits != '\0')
Game::Lua::SetFieldString(L, "inherits", inherits);
// sourceLocation is intentionally omitted (nil): vanilla records no
// file/line for XML nodes — a 10.2.0 addition gated behind the
// enableSourceLocationLookup CVar even on retail.
return 1;
}
// `C_XMLUtil.DoesTemplateExist(name)` -> bool.
int __fastcall Script_DoesTemplateExist(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_XMLUtil.DoesTemplateExist(\"name\")");
return 0;
}
const char *name = Game::Lua::ToString(L, 1);
const uint8_t *node = (name != nullptr) ? LookupTemplate(name) : nullptr;
Game::Lua::PushBool(L, node != nullptr);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_XMLUtil", "GetTemplates",
&Script_GetTemplates);
Game::Lua::RegisterTableFunction("C_XMLUtil", "GetTemplateInfo",
&Script_GetTemplateInfo);
Game::Lua::RegisterTableFunction("C_XMLUtil", "DoesTemplateExist",
&Script_DoesTemplateExist);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Xml::Templates