Compare commits

...
40 Commits
Author SHA1 Message Date
Brues 85c5c924a9 fix(loader): install hooks off the loader lock to survive VanillaFixes' 10s injection deadline
DllMain enabled ~90 hooks individually, and every MH_EnableHook freezes all
process threads (CreateToolhelp32Snapshot + SuspendThread each) under the
Windows loader lock. On machines whose security stack intercepts thread
suspension, 90 serialized freezes ran past VanillaFixes' 10-second
remote-LoadLibrary deadline, so VF read the still-running injection thread as
STILL_ACTIVE (259) and showed its generic "compatible client" error — a false
diagnosis of a slow load that blocked launch entirely for affected users.

Move the whole install off the loader lock. DllMain now only spawns a worker
thread and returns immediately. Hooks install via a latched EnsureInitialized()
reached from two triggers: VanillaFixes' `Load` export (called on the game's
main thread after injection — VF's sanctioned extension point) or the fallback
worker (for any non-VF injector). Whichever arrives first installs; the other
observes the cached result. All hooks are queued and activated in a single
MH_ApplyQueued, so ~90 thread-freezes collapse to one.

- src/DllMain.cpp: minimal DllMain + InstallHooks/EnsureInitialized/Load export
- src/Game.cpp: RunHookRegistrations queues instead of enabling
- src/ClassicAPI.def + CMake /DEF: export `Load` undecorated
- src/Common.h: drop the now-dead HOOK_FUNCTION/UNHOOK_FUNCTION macros
2026-08-19 17:46:59 -05:00
Brues f2af2734f0 docs: trim internal implementation detail from FontString entries
User-facing API docs should describe the contract (behavior, inputs,
outputs, errors, observable caveats), not how a method is built or which
engine path it uses. Drop the "engine already computes this internally /
only adds the binding / comes from the engine's own wrap math / reads
what it last drew and compares" narration from the FontString entries,
keeping every observable behavior and caveat (the setup requirements,
the must-have-rendered-once note, the icon-inclusive widths, the <=1px
rounding note).
2026-08-19 17:04:02 -05:00
Brues dc13d7bbfc fix(fontstring): SetFormattedText uses engine str_format, not _G
SetFormattedText resolved _G.string.format and called it, so an addon
that replaced string.format could hijack or break it. Push the engine's
own str_format C function instead (strlib "format" entry 0x007FD3D0,
verified by decode) and pcall it -- immune to global replacement, and
matching retail, whose SetFormattedText formats in C. Also simpler: one
PushCClosure replaces the _G table walk and its two failure branches.

Verified in-game: with both string.format and the format global replaced
by a stub, SetFormattedText("%d apples", 5) still renders "5 apples".
2026-08-19 16:58:56 -05:00
Brues 99c0ab3ba3 refactor(tradeskill): use SetFormattedText
The trade-skill link frame's rank and summary FontStrings built their
text with SetText(string.format(...)). Use the backported
FontString:SetFormattedText instead -- one call, and it routes through
the engine's own SetText path.
2026-08-19 16:48:38 -05:00
Brues 87acb70c5b feat(fontstring): add IsTruncated, SetMaxLines/GetMaxLines
Backport three FontString methods, each working WITH the engine's own
text-layout machinery instead of reconstructing it:

- IsTruncated(): did the engine cut the text off with an ellipsis?
  Reads render truth -- the built text node keeps a copy of the string
  it actually laid out (node+0x48), the ellipsized form on truncation
  -- and compares it to the source. Same "displayed vs full" check
  retail does (5.4.8 inner fn 0x0045E001). Re-calling the display-text
  resolver from Lua cannot work: it reads the laid-out rect (fs+0x64),
  which is zero between frames, so it never sees the bound.

- SetMaxLines(n)/GetMaxLines(): cap the fontstring to n wrapped lines,
  ellipsizing the rest. Writes the engine's own per-fontstring maxLines
  field (fs+0x128, already honored by the truncation resolver) and
  re-invalidates the layout the way SetText does (drop caches, release
  node, notify region). Vanilla has no SetWordWrap -- the fs->layout
  flag translator exposes no no-wrap bit -- so SetMaxLines(1) is how you
  make a single-line, ellipsized label.

Also correct a mislabeled bit in the inline-texture notes: fs+0x120 bit
0x1000 (gxu/node bit 0x40) is NON-SPACE-WRAP (what SetNonSpaceWrap
toggles), not a dedicated "editable" flag. The inline-texture code uses
it as an editbox proxy, which works because multi-line editors enable
non-space-wrap while display text does not; the comments now say so,
with the rare-edge-case caveat. Documentation only, no logic change.

Verified in-game: IsTruncated true/false across bounded/unbounded boxes;
SetMaxLines caps of 1/2/9/0 with matching GetNumLines and IsTruncated.
2026-08-19 16:46:55 -05:00
Brues 92a959c381 feat(fontstring): backport GetWrappedWidth
The rendered width: the widest wrapped line -- the method that
completes the width trio (GetStringWidth and GetUnboundedStringWidth
both measure the string on ONE line; verified in-game they agree to
float noise). Uses the tooltip auto-size's own recipe: break positions
from FUN_FONTSTRING_BREAK_ARRAY, widest per-segment measure through
FUN_FONTSTRING_MEASURE_SUBSTRING -- both icon-aware through the
existing co-hooks. Segments are trimmed of the whitespace the wrap
consumed (the render never draws it, and a trailing space flips the
last real letter from ink to full-advance treatment, +~1.5px measured).

The wrap budget (shared with GetNumLines' unbuilt fallback via
WrapBudget) is rectWidth / fs+0x7C, calibrated EMPIRICALLY against the
render with a three-point probe on a 100px box at ~0.91 scale, using
text whose candidate lines measure 78.1 / 94.6 / 101.6 px:

  raw rectW    -> effective budget ~91px  -> broke one word EARLY
  rectW/scale^2 -> effective budget ~110px -> broke one word LATE
  rectW/scale  -> effective budget 100px  -> breaks exactly where the
                  render does (94.6 <= 100 < 101.6)

All three observations fit budget_px = pxOf(param) x scale. A paper
derivation from the rebuild's node-budget chain predicted /scale^2 --
one hop of that chain models live behaviour wrong; the probe data is
recorded in the code comment so the broken paper chain doesn't get
re-derived. (FUN_00772B60 multiplying its width arg by fs+0x7C before
gxu, and FUN_0044DB50 normalizing through FUN_0041AE40, are verified
decompiles.)

Verified in-game: 100px box, wrapped 93.8 / one-line 165.7 / 2 lines,
break at the render's own position. Residual <=1px vs a direct measure
of the line text -- the engine's own per-glyph measure conventions
(ink-vs-advance, kerning-variant flag), the same class GetStringWidth
carries; documented in API.md.
2026-08-19 15:04:21 -05:00
Brues efc6b29b6b docs(fontstring): correct GetUnboundedStringWidth vs GetStringWidth
The entry claimed GetStringWidth measures inside the wrap box -- it
does not, on any client: both methods measure the string on ONE line
(verified in-game: a fontstring wrapping in a 100px box reports ~165px
from both). They differ only on modern clients and only for
ellipsis-truncated text, where GetStringWidth reports the displayed
width; 1.12's getter never applies truncation, so the two always agree
here. The as-rendered width (widest wrapped line) is GetWrappedWidth,
which 1.12 lacks.
2026-08-19 14:30:22 -05:00
Brues b09db4d5a5 feat(fontstring): backport GetUnboundedStringWidth, GetNumLines, GetLineHeight, SetFormattedText
Four modern FontString bindings 1.12 never shipped, all thin wrappers
over engine machinery already verified in this codebase
(fontstring/Metrics.cpp grows from the GetStringHeight backport into
the general metrics module):

- GetUnboundedStringWidth(): the substring measure
  (FUN_FONTSTRING_MEASURE_SUBSTRING) over the whole string (len 0).
  Icon-aware for free -- the call routes through the InlineTexture
  co-hook. Verified in-game it agrees with GetStringWidth to float
  noise, which is retail parity: both measure unwrapped (the classic
  gotcha; the rendered width is GetWrappedWidth, a future backport).

- GetNumLines(): render truth from the node's built-line count
  (OFF_TEXT_NODE_BUILT_LINES = 0x9C, the draw builder's own counter /
  early-out gate) once painted; for an unbuilt node, the engine's
  break-array computer (FUN_FONTSTRING_BREAK_ARRAY = 0x00772B60, the
  tooltip auto-size's own segment source -- icon-aware via the
  wrap-stepper hook) against the fs's rect width. Empty text 0; no
  wrap constraint 1. Verified: 165px text in a 100px box reports 2.

- GetLineHeight(): the fs font-height getter (FUN_007727b0) through
  the standard push conversion; excludes spacing, like retail.

- SetFormattedText(fmt, ...): resolves the LIVE string.format (addon
  replacements honoured), pcalls it over the varargs, propagates
  format errors as real Lua errors, and sets through the engine's
  FUN_FONTSTRING_SET_TEXT so sanitizer/escape handling is identical
  to SetText.

New Offsets entries carry the derivations; docs/API.md documents all
four under the FontString section.
2026-08-19 14:26:02 -05:00
Brues 0ce9d159e9 fix(text): wrap-shrink axis error; substring measure; trailing-icon trim
Three inline-|T measure fixes, the first one load-bearing for chat.

1) Wrap shrink was overscaled by the render aspect. The wrap stepper's
inputs are gxu-normalized PER AXIS -- fontH normalized-Y (x rasterY =
pen px), wrapWidth/outWidth normalized-X (x rasterX = pen px); both
caller flavors land there (the draw builder passes node+0x1C/+0x3C,
pre-normalized at block creation via FUN_0041ae50/ae40, and the
fs-level callers x-normalize inside FUN_0044d670 before gxu, verified
by decompile). The hook converted the pen-x icon sum with fontH/fontHPx
-- a Y-AXIS factor -- overshrinking the wrap budget by rasterX/rasterY:
x1.33 at the 4:3 setups it was calibrated on (subtle, and the
glyph-relative tolerances were inflated by the same factor, masking
it), x2.37 at 2560x1080 -- icon-bearing chat lines wrapped a word or
two early per icon. The correct pen-x -> width-units factor is
1/rasterX; the auto-width bail, feasibility band, escalation step and
bisect threshold now derive from fontHPx/rasterX so a "half glyph"
tolerance is an actual glyph width.

2) The substring measure (FUN_00772AE0, new Offsets entry) is now
icon-aware. An earlier note claimed nothing consumed it; its xrefs
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of
a wrap-enabled line through it (breaks from FUN_00772B60, already
icon-aware via the stepper hook) and takes the max as the tooltip width
-- so icon-bearing wrapped tooltip lines undersized their tooltip. Same
pen-exact adjustment as the width hook; the editbox caret/selection
callers (10 of its 11 xrefs) keep measuring raw via the established
editable-bit / focused-buffer gates.

3) A string-final icon now reports its INK edge instead of its full
advance (TrailingIconTrimPen: subtract the trail half-pad + half the
outline ink when the text ends in a well-formed span), matching the
engine's own last-glyph convention -- the measure loop ends on ink, not
advance. Render/justify/wrap keep the full advance (the pen genuinely
moves); only the reported width changes. The visible beneficiary is the
money-string shape ("...|TCoin:0|t"): a right-anchored auto-width money
fs sizes to the coin's drawn edge instead of ~2px past it. Remaining
width residual is sub-pixel (region placement rounding + the base
loop's ~0 payload-0 token contribution).
2026-08-19 14:07:01 -05:00
Brues 23b622acff fix(text): snap inline-icon drawn rects to whole render-target pixels
Icon regions placed at fractional pen positions through the anchor
math, so 1:1-sized art (a 16px emote at :16) sampled between texels and
rendered soft. Snap the drawn rect in the flush, gated on the node's
pixel-snap mode (bit-7 clear -- the same condition the engine's glyphs
snap under): position rounds to whole pen px, and SIZE rounds
independently of position so a :16 icon is exactly 16px, never 15/17
from the two edges rounding apart. Floors at 1px.

An integral pen rect survives to the screen because pen px = render
px, the fsLeft/fsBottom terms cancel exactly through the anchor
round-trip (the engine adds the same rect corner back at resolve), and
the pool's width-convergence multiplier is linear.
2026-08-19 13:48:43 -05:00
Brues f806f17f40 fix(text): snap icon advances to pen pixels; make fs measure hooks render-exact
Two changes to the inline-|T pipeline, both anchored in the engine's
own conventions.

1) Post-icon text rendered on half-pixel positions. Native glyphs
always sit on INTEGER pen pixels -- the origin finalize rounds
(FUN_005cdf70) and every glyph advance is truncated (the emitter's
__ftol when node bit-7 is clear) -- but IconAdvancePen added a
fractional advance (w + 1.5 x fontH x 0.18 + ...), shifting every glyph
AFTER an icon onto half-pixel positions: bilinear-filtered, fuzzy or
ghosted post-icon text, with visibility depending on the machine's
filtering config (the "artifacting only on the text after the icon"
report). IconAdvancePen now truncates when the node snaps, the
centre/right justify pre-shift snaps the same way, and the wrap
stepper's shrink probes carry the real snap flag so the budget matches
the render advance exactly.

2) The fs-level GetStringWidth / GetStringHeight hooks now compute the
icon contribution in TRUE PEN PX -- the exact numbers the emitter
reserves, outline ink and snap truncation included -- and bridge it
into the getter's return space via the font height known in both
spaces (sumPen x fontHInt / fontHPen). ResolveFsPenFont replicates the
render chain hop for hop: nodeFontSize = internal x fs+0x7C /
[0x832A48] (the rebuild feeds internal x 0x7C to FUN_0044d420, which
normalizes it through FUN_0041ae50 -- a hop whose omission on first
flight undersized fontHPen ~40%, growing icon-bearing chat lines with
phantom tall-icon overflow and pushing money-tooltip coins off their
right anchor), floored at [0x801628]/rasterY = 2 pen px (the node
ctor), realized by the emitter's own height helper. The outline ink
resolves through face = [[fs+0xE0]+0x20] -- verified stored verbatim at
node+0x44 by FUN_005c1c30 -> FUN_005cd6d0. fs-built nodes are always in
snap mode (no fs-flag maps to node bit 7 in FUN_0044d420), so the
snapped sums are exact for every fontstring. Replaces the old
SetPoint-push-factor bridge, whose units cancelled for :0 icons but
could not express the truncation or the ink.

New Offsets: OFF_FONTSTRING_FONT_HANDLE (0xE0), OFF_FONT_HANDLE_FACE
(0x20), FUN_TEXT_NODE_INVALIDATE derivation notes extended; the 2.0
outline constant's double duty as the minimum font size is documented.
Remaining measure residual: only the engine's own trailing-icon
ink-vs-advance <=1px.
2026-08-19 13:31:46 -05:00
Brues e9407f6564 fix(text): follow text-colour changes on icon-bearing fontstrings
The glue AddonList greys a disabled addon's title through SetTextColor
RGB alone (gold <-> gray, alpha stays 1.0). An icon-bearing title
followed neither channel: the icons only ever mirrored the colour's
ALPHA byte, and the TEXT was frozen at its bake-time colour -- a
segmented (bit-3) node carries baked per-glyph colours, and the
engine's colour setter (FUN_005CCB40) skips the rebuild-invalidate for
bit-3 nodes because UNIFORM bit-3 text recolours live at paint. Plain
titles swapped fine; icon-bearing ones didn't.

Two mechanisms, matching what each colour channel means:

- Text: re-bake on base-RGB change, everywhere. The emitter stamps the
  node's base colour (node+0x2c) at bake; the flush compares it against
  the live value and, on an RGB divergence, calls the engine's own node
  invalidate (FUN_TEXT_NODE_INVALIDATE = 0x005CDEF0 -- exactly what the
  colour setter does for accumulation nodes: clears the built state and
  frees the page buffers, so the next paint re-emits). The re-bake
  recolours base-coloured glyphs while |c runs keep their own colours
  -- baked per-glyph RGB cannot be patched in place. Alpha-only changes
  still take the cheap in-place alpha mirror (fades animate per frame;
  every baked glyph carries the node alpha, so the byte-patch is safe).

- Icons: brightness-follow on the GLUE screen only. The pool's fade
  signal widens from the colour's alpha byte to the whole folded BGRA
  colors[0] dword (FsColorKey), so a pure-RGB SetTextColor now trips
  the change detector at all; FadedColor then scales the icon by the
  text colour's VALUE (max of R/G/B) when no local player exists.
  Value-follow dims but can never tint -- gold and red titles have a
  255-max channel, factor 1.0 -- and in-world icons keep following
  alpha only (retail semantics: guild-green chat must not tint emotes).

Verified on the glue addon list: toggling now swaps title text AND
icons together (gold + bright <-> gray + dimmed), |c-coloured segments
keep their colours through the re-bake.
2026-08-19 13:10:28 -05:00
Brues f42f7459e2 docs(text): update InlineTextureEscapes.md for the exact placement pipeline
Current design gains the verified coordinate-space story (pen units are
render-target pixels; per-axis K = raster / anchorExtent from four live
globals, with the failed empirical and SetPoint-factor forms recorded),
the glyph baked-alpha mirror, and the outline-ink pads. The bit-3
section documents the per-glyph colour consequence, the positioning
steps reference the per-axis conversion, and the width-hook section
explains why its own ~1468 factor is a different, self-cancelling
conversion from the placement K.
2026-08-19 12:32:15 -05:00
Brues 968335debe fix(text): place inline icons with the engine's exact pen conversion
Inline |T icon regions were positioned right only at the resolution the
placement math was calibrated on. Root cause: the pen->anchor scale (K)
was derived EMPIRICALLY (node origin / fs rect justify edge) -- chat
could never derive its own (its rect edge sits near screen x = 0 and
failed the divide-guard), so it rode a global cache seeded by whichever
fontstring derived first and clamped to +/-10% forever. Wrong seed =
icon size and position drifting together, amplified by screen distance
(the skull rendering lines away from its chat message).

The engine's own conversion chain, decompiled end to end this session:

  FUN_0041ade0: block/node positions are stored NORMALIZED -- fs-local
    anchor x / [0x832A44] (screen width in anchor units), y / [0x832A48]
    (its previously unmapped y sibling).
  FUN_005cdf70: node origin (+0x70/+0x74) = round(normalized x
    [0xC2B9A4 / 0xC2B9A0]) -- integer render-target pixel dimensions,
    justify shift and vertical align folded in.
  FUN_005c2b50: writes those raster ints from the live render-target
    rect; every font re-rasterizes when they change.

So pen units are render-target pixels and K = raster / anchorExtent,
per axis, from four live globals -- no calibration, no cache, no fs
scale term. Verified against a live probe: rasterX/anchorW matched the
measured origin/rect-edge quotient to four digits, and icons now land
correctly at 2560x1080 and 1024x768 (previously 1080p-only).

Also fixes the coin-into-digits clip the exact K exposed: outlined
faces (pfUI money bars) draw INK past the glyph advance, which the old
miscalibrated K had been masking with accidental slack. The engine
quantifies that ink itself (emitter prologue: face+0x180 bit 3 -> 4.0
pen px, bit 0 -> 2.0, both .rdata constants); OutlineInkPen reads the
same flags and constants, half leading and half trailing every icon,
threaded through the shared advance helper (emitter advance, justify
pre-shift, wrap-stepper shrink probes). The fs-level GetStringWidth
adjustment passes ink 0 (it cannot cheaply resolve the gxu face) --
outlined icon text measures 2-4px short per icon there; render-side is
exact.

New Offsets: VAR_TEXT_RASTER_X/Y, VAR_UI_ANCHOR_SCREEN_H,
OFF_FONTFACE_FLAGS, FLOAT_OUTLINE_EXTRA_THICK/THIN, each with the
derivation chain.
2026-08-19 12:28:30 -05:00
Brues 04d4bf3326 fix(text): inset rotated glyph UVs half a texel against font-atlas bleed
An axis-aligned glyph quad samples its atlas cell on exact texel
boundaries, so bilinear filtering never crosses into a neighbouring
glyph. A quad rotated by FontString:SetRotation samples along
diagonals, and at the cell border the filter pulls in texels from
ADJACENT cells -- rendering as dotted fragments of other glyphs strung
along the rotated text's edges. Machine-dependent (renderer, driver
filtering, resolution/UI-scale phase decide whether samples land inside
the cell), which is why it reproduced on a user's 1440p setup but not
locally.

The engine has the same hazard with its bit-7 font nodes and ships the
fix: FUN_TEXT_EMITTER's init-once block (0x005CCBE0) sets four globals
to +/-0.001953125 (half a texel of the 256px font page) and shrinks
each glyph's UV rect by that per edge. Mirror exactly that inset for
quads we rotate: InsetGlyphUVs walks the node's page buffers in
four-vert quads and nudges each vert's u/v toward the cell centre.

The rotation baseline now captures/restores UVs alongside positions so
the inset never compounds across restore->rotate cycles, and
SetRotation(0) puts the untouched cell edges back. Unrotated text is
unaffected.
2026-08-19 11:28:56 -05:00
Brues c54556f6a1 refactor: centralize engine field/global access via Game::Read/Ref/Ptr
Replace the raw `*reinterpret_cast<const T *>(base + Offsets::OFF_X)`
idiom (and its double-cast variant when base wasn't already a byte
pointer) with three typed accessors in Game.h:

  Game::Read<T>(base, offset)  field value read
  Game::Ref<T>(base, offset)   writable field lvalue
  Game::Ptr<T>(base, offset)   pointer to a field (arrays / out-params)
  Game::Read<T>(address) / Ref<T>(address)  fixed-VA globals

Pure mechanical refactor, zero behavior change: ~330 cast sites across
84 files collapse to 651 helper calls. Offsets stay in Offsets.h per the
single-source-of-truth rule — the helpers only remove the cast noise and
fold away the inner `reinterpret_cast<uint8_t*>(obj)` base casts.

Left as raw casts by design: FUN_* function-pointer casts, void*/char*
VA handles passed as arguments (caches, method registries), subobject-
anchor pointers fed to __thiscall helpers, `void *volatile` player-ptr
readiness gates (converting would strip volatile), and VA-as-array-base
captures that are subscripted later.
2026-08-19 11:01:19 -05:00
Brues bd14a93a11 fix(text): keep baked glyph alphas in sync with the fontstring's live opacity
Text with an inline |T...|t icon stopped respecting its parent frame's
alpha: the icon faded, the glyphs stayed fully opaque. Iconless text was
unaffected.

Root cause: a fontstring's live opacity (SetTextColor alpha x the frame
chain's effective alpha, re-folded by FUN_0077fac0 on every alpha
change) lands in the node's uniform colour at node+0x2c, which the
paint consults PER FRAME for uniform text. But a segmented icon line
carries baked PER-GLYPH colours (the emitter hook clears node-flag bit
3), which the paint copy-out FUN_005c8710 uses verbatim with no later
modulation -- and FUN_005ccb40 skips the colour-change rebuild for
bit-3 nodes, so those baked alphas never refreshed. On top of that the
emitter hook force-baked alpha 0xFF: a misdiagnosis of the pfUI-bubble
"black text" bug, where the observed 0x07 was the bubble's live folded
alpha at build time (it built while faded out), not a stale default.

Fix, systemic for both bugs:
- Drop the 0xFF force -- colorState's build-time alpha (a node+0x2c
  copy) is already correct.
- Mirror the node's live alpha byte into every baked glyph colour from
  the flush walk, change-detected per page (a byte compare per frame
  until an actual fade). Same signal and cadence as the icon fade
  mirror; bit-3-clear (chat) nodes excluded -- the engine re-bakes
  those on colour change itself.

Adds OFF_TEXT_PAGE_COLOR_COUNT (0x1C), OFF_TEXT_PAGE_COLORS (0x20) and
OFF_TEXT_NODE_COLOR (0x2C) to Offsets.h with the derivation chain.
2026-08-19 10:17:25 -05:00
fuffcandBrues 5978d7dd62 fix(texture): measure vertex offsets against the untouched layout rect (#27)
* fix(texture): measure vertex offsets against the untouched layout rect

* fix(texture): honor SetTexCoordModifiesRect instead of dropping the crop

The flag is not inert in 1.12. Script_SetTexCoordModifiesRect
(0x0079C080) writes the Lua boolean to region+0x124 and
Script_GetTexCoordModifiesRect (0x0079C120) reads it back (1/nil), and
all three FUN_REGION_TEXCOORD_CROP call sites gate on that field: the
4-arg and 8-arg SetTexCoord writers (FUN_00770410 / FUN_007704C0)
crop+store only when it is set, and the layout-resolve store path
(FUN_00770670) crops when set / stores the raw anchor rect when clear.
The earlier "toggling it changes nothing" probe was a timing artifact:
the crop applies at the next SetTexCoord or layout resolve, never
retroactively.

So instead of removing the crop call, ApplyFromRect now applies it
under the engine's own gate. Default state (flag clear) behaves exactly
as this PR intends -- untouched layout rect, wedges fixed -- while a
region that DID opt in keeps its offsets measured against the same
cropped rect the engine stores, so the immediate apply and the
corner-store co-hook can never disagree about the base rect in either
flag state.

Adds OFF_REGION_TEXCOORD_MODIFIES_RECT (0x124) to Offsets.h with the
derivation, and corrects the FUN_REGION_TEXCOORD_CROP comment that
implied a partial SetTexCoord always shrinks the drawn quad.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-19 09:44:11 -05:00
Brues 67172c95f7 feat(model): add Model:SetCreature; dress character displays natively
SetCreature(creatureID) resolves through the client creature cache to a
display ID, then loads like SetDisplayInfo (uncached entry = no-op,
documented with a RequestLoadCreatureByID recipe).

Character-based displays (extendedDisplayInfoID != 0) are now dressed by
the engine's own CharComponent compositor, mirroring
CGUnit_C::UpdateCharacterCustomization's NPC branch field for field:
baked body texture, hair/facial-hair geosets (CharHairGeosets,
CharacterFacialHairStyles), equipment geosets (sleeves, robe->trousers,
tabard), and helm/shoulder attachment models with HelmetGeosetVisData
hiding. Dress jobs run on WorldTick behind the engine's own
model-loaded gate, one compositor per Model frame, kept until the frame
loads another display or the reload teardown (PrepareForReload).

Attached helm/shoulder children additionally get the frame's light/fog
pre-render callback: the frame only registers it on its own instance,
so the children rendered pure black (verified in-game -- a light tint
colored the body but not the equipment). Weapons cannot show: NPC
weapons are server-pushed per spawned unit, not in any client table.

The manual baked-skin/hair-texture appliers are superseded by the
compositor and removed, along with the geoset debug probes
(_classicapi_ModelInstance/Peek/Poke).

Creature::Info gains a public DisplayID(creatureID) for the cache
resolve.
2026-08-19 09:28:30 -05:00
Brues 59c3bf9e0f feat(text): add FontString:GetGlyphVerts() introspection
The fontstring analog of Texture:GetCorners — returns the first glyph's
four baked vertex positions plus the bounding box of all glyph verts (12
numbers). Because the text paint (FUN_005c8710) is translate-only, these
node-local coords track the on-screen positions directly, so a 0-vs-90
dump reads the rotation direction arithmetically.

Also documents, on RotateVerts, why that dump can NOT settle rotation
CHIRALITY on its own: node-local glyph-vert Y handedness is not
determinable from the verts alone, so the visual comparison against the
retail-verified Texture:SetRotation is the arbiter. The RotateVerts sine
is unchanged (matches Texture:SetRotation / retail CCW-for-positive).
2026-08-18 19:27:11 -05:00
Brues 54782df95e feat(model): add Model:SetDisplayInfo(creatureDisplayID)
Backport the retail Model widget method (present since 5.4.8, string
verified in the 5.4.8 and 1.15.8 binaries) that points a Model frame at
a creature by its display ID rather than a raw .mdx path.

Resolve creatureDisplayID -> CreatureDisplayInfo[+0x04] ModelID ->
CreatureModelData[+0x08] ModelPath, load it through SetModel's own worker
(vtable+0x94), then apply the display's TextureVariation columns as
replaceable-texture skins (MONSTER_1/2/3 = slots 11/12/13). The DBC
column offsets are verified two ways: parsing the extracted 1.12 DBCs and
the engine's own unit-display resolver FUN_0060afb0.

Verified in-game: displays 13/330 (human NPCs) load correctly textured
and lit; missing assets fall back to the engine's error cube.

Only the single-model form is implemented; the optional mountDisplayID
(rider-on-mount) second arg is a separate follow-up.
2026-08-18 19:26:58 -05:00
fuffc 3ab690ac53 fix(texture): correct vertex-offset units and the corner-store contract (#26)
* fix(texture): correct vertex-offset units and the corner-store contract

The SetVertexOffset/SetRotation corner writes were wrong in four coupled
ways, found by driving pixel-ruler and wedge fixtures against a raw
corner-array readback:

- Offsets converted px->anchor with the global 1024-normalized factor
  alone, so a region scaled 0.9 got every offset 1/0.9 too large (a 24px
  request measured at 26.67px). Offsets are now stored in the caller's
  LOCAL pixels and folded through the region's live effective scale
  (+0x7C) inside WriteCorners, which also makes them track later scale
  changes. Verified at two scale points (0.9 and 0.45): a 24px offset
  lands at 24.00px on both.

- The anchor space is Y-DOWN (a rect reads top=0.222 < bottom=0.277),
  so +offsetY -- up, the retail contract -- is numerically negative
  there. It was added positive, sending every vertical offset the wrong
  way.

- The draw consumes the +0xD4 array with y INVERTED about the rect's
  horizontal midline, and CULLS a quad whose slot y-parity flips: quads
  stored at their target positions rendered as exact y-mirrors of the
  stored corners, and mirroring the values in place culled every
  transformed quad, including a plain rotated square. Each corner's
  mirrored data is therefore written into its VERTICAL PARTNER's slot
  (BL<->TL, BR<->TR): positions land on target after the draw's flip,
  and the slot parity keeps the winding the draw accepts. For an
  axis-aligned rect this degenerates to exactly what the engine itself
  writes -- which is why none of it was visible on plain rects or
  centered squares, both mirror-invariant as point sets.

- With that store contract, the y-down-transposed rotation matrix is
  the one that renders a positive angle counter-clockwise (settled with
  a rotated-bar fixture; squares and near-equilateral arrows are
  direction-blind).

Only regions carrying an active transform are affected: the corner-store
co-hook passes untracked regions through unchanged.

* feat(texture): GetCorners debug binding

texture:GetCorners() returns the raw drawn-quad corner array (+0xD4)
and the resolved layout rect -- 12 numbers, no unit conversion.
Diagnostic tooling: comparing stored corners against rendered pixels is
what made the corner-store contract measurable from Lua. Kept for
future transform triage; note that with the mirrored-partner store the
raw values deliberately differ from the on-screen positions.
2026-08-18 17:34:08 -05:00
Brues 6b13368e65 feat(fontstring): backport FontString:SetRotation / GetRotation
Generalizes the region-transform module (formerly texture-only) to serve
the FontString method registry too, backporting the modern
`fontstring:SetRotation(angle [, cx, cy])` and `GetRotation()`.

Rotation is visual only, matching retail: GetStringWidth/GetStringHeight/
GetRect/SetPoint stay axis-aligned. A positive angle is CCW, same
convention as Texture:SetRotation; the optional cx,cy pivot is a superset
of retail (centre-only).

How it works: the text paint copy-out (FUN_005c8710) is translate-only,
so a rotation baked into the node-local glyph verts survives to screen.
SetRotation rotates the live verts immediately (like the texture path)
around the vert bounding-box centre, idempotently via an axis-aligned
baseline snapshot (restore -> rotate). The baseline is re-captured on
every fresh vertex bake, wired through inline-texture's DrawBuilder
co-hook (gated on g_buildEmitSeq > 0 so clean paints never double-rotate,
since inline-texture owns every text-render hook). Node page-buffer
vertex layout added to Offsets.h (cross-verified across the emitter,
FUN_005c8710, and FUN_005ce0c0).

Known limitation: inline |T icons in a rotated fontstring don't turn with
the text (separate anchored regions). Plain text rotates correctly.
2026-08-18 16:54:42 -05:00
Brues 5456295e2e fix(item): throttle item-cache queries; prioritize carried items
Post-settle `WarmCache` fired one CMSG_ITEM_QUERY_SINGLE per item
immediately, and `OnWorldTick` escalation issued every pending item in a
single tick. A `/reload` that makes addons (pfQuest/Questie/Bagnon) look
up hundreds of items in one frame therefore emitted an unbounded packet
burst — a reported 1,325 requests in ~14 ms exhausted a consumer
router's NAT table and dropped the machine's internet connectivity.

Make `OnWorldTick` the single point that issues queries and rate-limit
it. All warmups now route through the pending queue (`WarmCache` ->
`Track`); background misses go out at most one per 50 ms (20/sec). The
timeout now counts only after a query is actually issued, so a throttled
backlog no longer fires spurious failures for items never queried.

To keep the throttle from delaying the player's own inventory on a cold
cache, add a priority tier: the carried items (equipment + backpack +
equipped bags) issue first on a separate budget (up to 6/tick, no
spacing floor). The owned set is a bounded snapshot built by a pure-C
inventory walk (no Lua stack — safe at world-tick time), refreshed at
most once per second and only while there is queued work. Peak issue
rate stays ~7 packets/frame, far below the flood.

Fixes #25
2026-08-18 15:31:33 -05:00
Brues 9b34ceff9e feat(unit): add UnitCreatureID(unit)
The unit-token twin of C_CreatureInfo.GetCreatureID — resolves the token
to a GUID and reads the creature-template entry vanilla packs into bits
24-47. nil for players, empty tokens, and non-creature/pet GUIDs.

Factor the entry extraction + creature/pet gate out of GetCreatureID into
a shared Guid::CreatureEntry so both callers use one source of truth for
the GUID bit layout.
2026-08-18 14:44:06 -05:00
Brues a373a775cf Clarify UnitSpellTargetName docs 2026-08-18 14:34:32 -05:00
Brues e9014801b6 docs: move UnitSpellTargetName to the Unit section
It's a Unit* function taking a unit token, so it belongs with the other
Unit calls, not under Spell. Relocates the API.md section + ToC entry and
the README calls-table row.
2026-08-18 14:28:20 -05:00
Brues dc8a510611 feat(spell): add UnitSpellTargetName(unit)
Returns the name of the unit a caster is casting/channeling a spell at,
or nil. The cast's target rides in SMSG_SPELL_START (SpellCastTargets:
u16 mask + packed GUID when TARGET_FLAG_UNIT) — we already parse that
packet for cast timing and just stopped before the target block. Extend
the parse to capture the target GUID for the player (g_castTargetGuid)
and remote units (RemoteCast.targetGuid), gated on an active cast so a
stale target is never returned.

nil when the unit isn't casting, the spell has no unit target
(self/ground/item), or the target's name can't be resolved.

Factor the GUID->name chain (object manager -> friends -> NameCache) out
of UnitNameFromGUID into a shared Player::Info::NameFromGuid so both
callers use one source of truth.
2026-08-18 14:23:59 -05:00
Brues d514dac465 docs(readme): mention inline-texture rendering in the Behaviors table
Adds a Behaviors row for the inline `|T…|t` texture backport (icons in
FontStrings, chat, and tooltips; icon-inclusive GetStringWidth/Height).
Raid marks were already covered by the `mark1`..`mark8` Unit tokens row.
2026-08-18 13:39:06 -05:00
Brues 621d1a9db9 feat(attributes): cast spell without a unit via CastSpellByName
A `type="spell"` click-cast button with no `unit` attribute did nothing:
the `spell` verb bailed the moment no unit resolved. Now it reads the
`spell` attribute first, then branches — with a `unit` it casts at that
unit via C_Spell.CastAtUnit (unchanged), and with no `unit` it falls back
to the plain global CastSpellByName on the current target, like `/cast`.

Fixes #18.
2026-08-18 13:28:54 -05:00
Brues 7d566f3787 fix(spell): stop false cast interrupts on grenades / movement-immune casts
Throwing a grenade and moving mid-cast fired a false UNIT_SPELLCAST_INTERRUPTED
and cut the bar short, even though the throw completed (issue #23). A cast is
interrupted by movement only if its Spell.dbc InterruptFlags carry
SPELL_INTERRUPT_FLAG_MOVEMENT (verified against the server's HandleMovement-
Opcodes and the client's own CheckCast); thrown items (e.g. spell 4068) lack it,
so the server never interrupts them on movement, but the engine still drops the
client cast-state, which our poll surfaced as an interrupt.

Fix: when the movement path clears a movement-immune cast WHILE THE PLAYER IS
MOVING (PlayerMoving mirrors the engine's 0x200f cast-drop mask), keep it tracked
instead of ending it; it then finishes as SUCCEEDED (no INTERRUPTED) or, if its
SPELL_GO never comes, ends a grace past its computed end. A real cancel/interrupt
happens with the player stationary, so it clears normally. Gated on a per-cast
latch, so normal (movement-interruptible) casts are unchanged.

Also de-dup UNIT_SPELLCAST_INTERRUPTED: the client fires Spell_C_SpellFailed AND
drops the tracked cast for the same interrupt (e.g. an Esc cancel), which fired
the event twice; PollPlayer now skips its own when SpellFailed_h already fired
one for the same spell. SuperWoW-style kicks (no Spell_C_SpellFailed) stay owned
by the poll.

New Offsets.h constants: SPELL_INTERRUPT_FLAG_MOVEMENT (InterruptFlags bit 0),
MOVEFLAG_MASK_CAST_DROP (0x200f). Wrap-safe deadline/window checks use
Time::Clock::Reached / Elapsed.

Fixes #23.
2026-08-18 13:10:11 -05:00
Brues acc9a85bc6 feat(spell): CastSpellNoToggle accepts an optional unit token
CastSpellNoToggle(name | spellID [, unit]) now takes a unit token as an
optional second argument, so the spell fires at that unit without
changing the current target:

    CastSpellNoToggle("Auto Shot", "focus")
    CastSpellNoToggle("Shoot", "targettarget")

The spam-safe toggle gates run first and are unchanged: if the requested
spell is already auto-repeating or its self-aura is already up, the call
stays a no-op and the unit is ignored. When the spell does cast and a
unit is supplied, it dispatches through the existing Spell::AtUnit core
(resolve token -> GUID -> dispatch) instead of the plain CastSpellByName
-- a unit-target/auto-repeat spell fires straight at the unit, a ground
spell lands at its feet, and an unknown token raises the engine's
standard "Unknown unit" error. No new offsets or hooks.

Fixes #22.
2026-08-18 12:02:04 -05:00
Petr KomogortsevandBrues 207d47c8b8 feat(bindings): backport direct-action and override binding APIs (#19)
* feat(bindings): backport direct-action and override binding APIs

Add SetBindingSpell/Item/Macro/Click plus the owner-scoped
SetOverrideBinding family. Store permanent commands through vanilla
SetBinding, execute SPELL/ITEM/MACRO/CLICK in the resolved-command hook,
and layer priority overrides ahead of the native binding table.

Factor raw and saved macro dispatch into Macro::Execute so click
attributes and binding commands share the same parser fallback. Preserve
key-down/key-up pairing when overrides change during an action.

Document return values, persistence, precedence, macro-text usage, and
the deliberate 1.12 differences in README and docs/API.md.

* fix(bindings): normalize override key order and preserve command exec-context

Two fidelity gaps in the override binding layer, both verified against the
1.12 binary:

- Override key matching now normalizes modifier order to the engine's own
  keypress form (ALT-CTRL-SHIFT- before the base key, per FUN_004b6630), so a
  caller passing "CTRL-ALT-F" matches the "ALT-CTRL-F" the engine builds at
  keypress. Prefix-stripping keeps the minus base key intact ("SHIFT--").

- Override-dispatched commands now run inside the same execution context the
  engine wraps around a native keypress (save/zero/restore DAT_00ceeac0, its
  nesting depth DAT_00ceeac4, and the manager +0xD8 mid-command flag, per
  FUN_004b7990). Without it, an ordinary Bindings.xml command routed through
  SetOverrideBinding ran without the context its handler expects. The native
  binding-table path is untouched (the engine already establishes context
  before the co-hooked FUN_004b7b50).

Adds the three verified offsets to Offsets.h and documents the any-order,
case-insensitive key handling in docs/API.md.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-18 11:32:30 -05:00
brues-code 76f48de67f Inline |T…|t texture escapes (with coin strings, texture methods, and TextureUtil) (#24)
* feat(text): inline |T...|t texture escapes in chat and display text

Backport modern inline-texture markup to the 1.12 text engine: |Tpath:h|t
renders an icon inline in FontStrings / chat, for BLP and uncompressed TGA.

- Rendering primitive: resolve a path to a resident CGxTexture via FUN_0044acf0
  and draw it as a textured quad through the UI text VB path.
- Positioning: co-hook the per-line glyph emitter (FUN_005ccbe0); render plain
  runs by delegating to the original per segment (threading the pen through
  linkState[4], read as a float), record icon quads at the pen, and flush them
  in the paint pass (FUN_005c8fe0) translated by each node origin. Handles the
  FontString pipe-doubling (|T -> ||T), the bit-3 per-call batch-clear mode,
  multi-icon and multi-line accumulation, and font-relative vertical centering.
- Measure/wrap: co-hook the shared tokenizer (FUN_005c2810) so an inline-texture
  span is one near-zero-width token, so icons no longer force early line wrap.
- Editbox exclusion: read the focused-editbox global (DAT_00cf4dc8) and suppress
  icon recording while an input field is focused, so editboxes show raw,
  editable markup while cached display text keeps its icons.

Diagnostic scaffolding (stats/capture Lua helpers) is still present. Follow-ups:
exact icon width in measure (hover-box accuracy), texcoords for sprite sheets,
vertex color, and the already-built editbox case. See docs/InlineTextureEscapes.md.

* fix(text): exclude editboxes from inline textures via node flag bit 6

Editable text (node flags [+0x5c] bit 6 / 0x40 -- verified in-game: the macro
editbox is 0x4D vs chat display 0x20D and FontStrings 0x0D) now shows raw,
editable |T...|t markup instead of rendered icons, checked per node in the
tokenizer, emitter, and flush. This is the 1.12 analog of 4.3.4's per-render
texture-disable flag (tokenizer bit 0x1000).

Replaces the earlier focus-global-only exclusion, which only caught single-line
inputs that rebuild on each keystroke; bit 6 also covers multi-line editors
(the macro editor) that build once while un-focused. The keyboard-focus global
(DAT_00cf4dc8) is kept as a fallback. Removes the failed monochrome heuristic.

Diagnostic scaffolding remains; a docs update and cleanup pass follow.

* feat(text): inline-texture texcoords + fix vertical flip

- Add the width / offset / texcoord fields to the escape
  (:width:offsetX:offsetY:texW:texH:left:right:top:bottom), so a sprite sheet
  such as UI-RaidTargetingIcons can be cropped to a single cell.
- Fix a vertical flip: the UI device backend is OpenGL (bottom-left texture
  origin), so top screen corners map to v1 and bottom corners to v0. The flip
  was invisible on symmetric icons but obvious on directional ones (raid-target
  markers, swords).

* docs(text): document the completed inline-texture RE map

Fold this session's findings into InlineTextureEscapes.md now that the feature
ships: the frame -> renderNode -> group -> node -> line render hierarchy;
segmented-delegation positioning (the float linkState[4] pen read, the bit-3
per-call batch-clear handling, first-line multi-line accumulation); font-relative
vertical centring; the FontString pipe-doubling (|T arrives as ||T); the shared
tokenizer measure/wrap fix; editbox exclusion via node flag bit 6 (0x40), with
the failed focus-global and monochrome attempts recorded so they aren't retried;
and the texcoord fields plus the OpenGL bottom-left vertical flip.

* chore(text): enable inline textures by default on this branch

* feat(text): inline-texture vertex colour tint (:r:g:b)

Parse the trailing r:g:b (0-255) positional fields of the inline-texture
escape and apply them as the quad's vertex colour, so |T...:r:g:b|t tints
the icon (the font stage's default MODULATE COLOROP does texture.rgb x
tint; white = untinted). The bytes pack to 0xFFrrggbb -- the same
0xAARRGGBB order the tokenizer builds for |cAARRGGBB text, verified from
FUN_005c2810's colour branch -- so the value flows straight into the
existing vertex colour slot. Threaded parse -> IconDesc -> IconRecord ->
DrawTexturedQuad. Verified in-game (red/green/blue/white diamonds).

Also record the hover-accuracy (measure-width) verdict in the docs: the
tokenizer reports an icon as ~zero width, so GetStringWidth/wrap/hit-test
undercount it. Scoped by disassembly (FUN_005c6940 width backend,
FUN_005c6b70 per-glyph advance, FUN_005cdc20/FUN_005c7260 wrap): the token
contract has no width field, there is no single cold choke, and the only
fixes are hot per-glyph hooks (against the MinHook-collision guidance) or
~6 parallel measure-loop hooks -- for a mostly-cosmetic gain. Deferred.

* docs(text): record editbox/measure findings; strip inline-texture debug scaffolding

No behavior change — functionally identical to the shipped inline-texture
feature. This captures what a long investigation established and removes the
temporary debug instrumentation added during it.

Comments (src/text/InlineTexture.cpp):
- The tokenizer's `flags & 0x40` gate is load-bearing for RENDER, not just
  measure: a suppressed editbox delegates to the ORIGINAL emitter, which
  re-enters the tokenizer to lay out glyphs, so bit 0x40 (EDITABLE) must make
  the tokenizer stand down or the macro editor draws BLANK instead of raw.
- bit 6 (NodeEditable) and the focus global (InputFocused) are COMPLEMENTARY,
  not redundant: bit 6 catches the un-focused macro editor; the focus global is
  the only signal for single-line focused inputs (chat box, pfChatCopyBox),
  whose nodes lack bit 6 (0x0D/0x20D, indistinguishable from display).
- Removed the tokenizer call/pipe/span counters and the ZZWIDTH TokCap capture
  (debug-only), and the now-unused g_lastFocused/g_lastMono stats fields.

Docs (docs/InlineTextureEscapes.md):
- Editbox exclusion: two complementary signals, and VERIFIED against retail —
  typing `|T` into a chat box or macro editor shows raw there too; only
  addon/system text renders. Our editbox-raw + display-renders split matches.
- Measure gap: added the focus-time caveat (icon ~0 width unfocused; counts the
  literal path text while an editbox is focused — the editbox-raw tradeoff).
- Vertex-colour tint documented in the descriptor-fields section.

* feat(chat): strip player-injected |T icon spoofs from chat + bubbles

Vanilla neutralizes player-TYPED escapes in chat (a typed |c/|H/|T shows
raw); only trusted text (addon AddMessage, shift-clicked links) renders
them. Our Text::InlineTexture backport broke that parity for |T alone: it
detects the neutralized ||T form and draws the icon anyway, so a player
could /say |Tpath|t and put a real icon in everyone's chat AND the speech
bubble over their head. This restores vanilla behavior for |T while leaving
|c/|r/|H/|h exactly as vanilla.

It can only be done at the chat source, not the text emitter: addon |T and
player |T reach the emitter as byte-identical ||T (vanilla doubles the
unrecognized |T for both), so the only place they differ is provenance --
server chat flows through the chat dispatcher FUN_0049A870, addon print does
not. That one function copies the message into a single buffer fed to BOTH
the chat frame and the say/yell bubble spawn (FUN_00608AC0), so sanitizing
on entry covers both. (ECX is the MESSAGE, not the "sender name" the old
note claimed -- verified from the call site in FUN_0049D560.)

Structure mirrors Net::PacketDispatch (one hook, fanned out), since MinHook
allows only one hook per target and this choke point already had the
GUID-capture concern on it:
  - Chat::Dispatch      -- single owner of the FUN_0049A870 hook; orchestrates
  - Chat::IconFilter    -- pure Sanitize(); no hook, no engine state
  - Chat::CurrentGUID   -- GUID globals + getter + DispatchScope RAII (no
                           longer owns a hook)

Defang detail (the subtle bit): the message arrives doubled (||T). Blanking
the PIPE would leave a lone `|` + space -- an INVALID escape the chat frame
tolerates but the bubble text setter chokes on, producing an empty bubble
that fades instantly. So we keep the `||` (a valid literal-pipe escape) and
blank the `T`; for a lone |T we blank both chars. No dangling `|`, no |T
opener survives. Verified in-game: typed |T shows raw in chat and bubble,
addon icons still render, colours and links untouched.

* feat(text): per-editbox inline-texture suppression

Inline-texture rendering was globally suppressed whenever any editbox held
focus (VAR_FOCUSED_EDITBOX != 0), so opening the chat box flipped every
rendered icon/emote in chat history to raw |T...|t.

Replace the global check with per-editbox buffer correlation: suppress inline
rendering only for text that belongs to the FOCUSED editbox, matched by
pointer range against its own text buffers. Chat history keeps rendering while
you type.

The editbox keeps its text in two buffers (both verified via Ghidra), and both
must be matched or the field desyncs (raw width but a rendered icon):
- INPUT buffer ([fe+0x32C]/[fe+0x334], selected by [fe+0x318]&8) — the
  caret/width MEASURE path reads it in place (FUN_0077da80 -> FUN_00772ae0 ->
  FUN_005c6940, which loops the tokenizer).
- DISPLAY buffer (*([fe+0x328]+0xF0)) — a SStrDup'd copy the editbox actually
  RENDERS from (FUN_00771d80); the emitter node text and the re-entrant render
  tokenizer see this one.

TextInFocusedEditbox() bounds each buffer by a tight strlen (distinct heap
allocations -> no false-match cap) and runs only on pipe-leading tokens after a
null-focus short-circuit, so it stays off the hot path. A display FontString
measured while an editbox is focused matches neither buffer, so its icons now
measure correctly -- an improvement over the old global.

Editbox struct offsets centralized in Offsets.h (OFF_EDITBOX_BUFFER_SELECT /
BUFFER / BUFFER_MASKED / TEXT_FONTSTRING).

* feat(chat): raid-target marker substitutions ({rt1}/{skull}/…)

Expand the modern raid-target marker tokens in chat into inline icons, backported
onto the inline-texture renderer. {rt1}..{rt8} plus the named aliases ({star},
{circle}/{coin}, {diamond}, {triangle}, {moon}, {square}, {cross}/{x}, {skull};
case-insensitive) each become a |T escape cropping Interface\TargetingFrame\
UI-RaidTargetingIcons to the matching 64x64 cell (mapping verified against the
client's UnitPopup.lua: col=(N-1)%4, row=(N-1)/4).

Chat::RaidMarkers::Substitute runs in Chat::Dispatch AFTER the |T anti-spoof
sanitize, so players get the fixed, safe marker icons without a path to inject an
arbitrary texture. It emits the pipe-DOUBLED ||T…||t form: the inline emitter
renders both, but this is what survives into speech bubbles — the vanilla bubble
text builder (FUN_004b1600) truncates a lone |T while preserving ||, and pfUI's
bubble reskin copies the bubble's FontString text into its own FontString, which
then renders the icon through the normal paint path. (This is the form bubbles
rendered before the anti-spoof filter defanged the ||T opener.)

InlineTexture emitter fixes for the standalone-FontString (bit-3, batch-clear)
render path that bubbles/tooltips use — chat lines are unaffected:
- Colour: clearing bit 3 switches the engine to per-glyph colour from
  colorState=[node+0x2c], whose alpha byte is a stale ~0x07; force the glyph
  alpha opaque (keeping RGB) so a FontString coloured via SetTextColor with plain
  text doesn't render near-transparent (which showed only pfUI's dark outline =
  "black text"). |c-coloured text already has full alpha, so it's a no-op for chat.
- Horizontal justify: the engine positions centre/right-justified text using the
  MEASURED width, which counts inline icons as ~zero (the measure gap), so the
  rendered line overflows its box. Pre-sum the line's icon widths and shift the pen
  (half for centre, full for right) so text+icons justify as a unit.
- Default centerFrac 0.5 -> 0.6 (better vertical centring across chat + bubbles).

* fix(text): suppress inline textures in editboxes by content match

The per-editbox suppression missed the chat input box: a pasted `|T…|t` rendered
as an icon instead of raw markup. An in-game probe showed why — the editbox
renders its text through a TRANSIENT copy: same bytes as the input buffer, but a
distinct heap allocation with no pointer link to the editbox in either direction
(every offset/back-pointer scan of fe / its FontString / the render node came back
-1). So the prior pointer/node-identity checks (input buffer, SetText copy,
FontString +0x48, node == *(fe+0x328)) could never match the render.

Match on CONTENT instead:
- Emitter: EmitLineIsFocusedEditbox compares the render line's bytes against the
  focused editbox's input buffer (the emitter is handed the whole line, so a
  bounded strcmp identifies it). Chat-history lines differ, so their icons keep
  rendering while an editbox is focused.
- Measure (caret/GetStringWidth): unchanged pointer path — the tokenizer reads the
  input buffer in place, so TextInFocusedEditbox still matches by range.
- Re-entrant render: the suppressed emitter brackets its delegated raw layout with
  a text range (g_reentryLo/Hi); the tokenizer stands down across the whole span
  via TextInReentry, so `|T` isn't eaten as a zero-width token (which drew BLANK).

Drops the dead node-identity/second-and-third-buffer heuristics. Covers any focused
input (chat box, pfChatCopyBox, macro editor).

* fix(text): drop stale icon records for reused chat line FontStrings

Inline-texture icons could paint as a ghost strip at the bottom of the screen
after chat had been busy for a while. The chat frame reuses its line
FontStrings: when a line is cleared (text set empty) or reused for markup-free
text, the draw builder early-returns on it, so the emitter never runs — and so
never erases that node's old icon record. The paint flush still walked the node
and drew its now-stale record at the FontString's parked position.

Gate the flush draw on the node's LIVE text still containing `|T`: if a node has
an icon record but its current text has no markup, the record is stale — drop it
and skip drawing. Kills the bottom-of-screen ghost.

(The separate scroll-freeze — icons only refreshed on the chat's dirty repaints —
is a different, deeper issue tracked next.)

* fix(text): stop chat icons vanishing — suppress only input boxes, not editable chat display

pfUI renders the chat scrollback through EDITABLE nodes (flags 0x45: bit 6
editable, no bit 3). The inline-texture emitter's blanket NodeEditable gate
suppressed inline rendering for ALL editable nodes, so those chat icons blanked
whenever that display was live — during scroll and as messages arrived, which
read as icons "disappearing/reappearing."

Gate suppression on a true INPUT box instead: editable (bit 6) AND bit 3 (0x08).
Macro editor 0x4D and name/chat input 0xC8 all carry 0x08; the chat scrollback
0x45 does not — so real input boxes still show raw |T markup while the chat
display keeps its icons. Also drop the flush's NodeEditable draw-gate: a
suppressed input box never records, so any record reaching the flush is a real
display line.

Pointer/content match alone can't cover a focused input box because its render
form is pipe-doubled (||T) while the input buffer is single-pipe, so the compare
misses.

* feat(text): inline |T icons as fontstring-anchored engine regions

Port 4.3.4's CSimpleEmbeddedTexture model. Each icon the emitter records
becomes a pooled CSimpleTexture ANCHORED TO ITS OWNING FONTSTRING,
configured off-render on the WorldTick, and drawn by the engine's own
region pipeline. 1.12 chat lines are real CSimpleFontStrings (the
ScrollingMessageFrame display refresh FUN_00788750 drives one per visible
line), so a co-hook on CSimpleFontString::RebuildString (0x7724A0) maps
every text node to its owner and one mechanism covers chat, tooltips, and
standalone FontStrings.

This replaces the raw-GxU-quad renderer and every overlay attempt:
- engine-drawn regions keep their textures resident: no flicker, verified
  across three concurrent clients
- the anchor system moves icons with their lines: scroll costs nothing
- icons mirror the owner's shown state: faded lines hide their icons
- all engine mutation happens outside the render pass: no pending-layout
  corruption, survives /reload (pointers dropped in PrepareForReload)
- placement verified pixel-exact against owner rects

RE landmarks recorded in Offsets.h:
- fs+0xF8 is an HTEXTBLOCK handle; the text node is at handle+8
- Show (0x77FCB0) no-ops unless desired-shown (+0xC4) is set, and
  SetParentAndLayer(show=0) clears it on reparent
- the renderer draws the corner array (+0xD4), stored from the rect by
  SetTexCoord - realize (0x768060) must run between SetPoint and
  SetTexCoord or the corners go stale (the historical invisible-icon and
  frozen-top-left-icon bugs)
- pen coords are y-down from the owner fontstring's BOTTOM edge; anchor
  offsets use Script_SetPoint's pixel scale (LinePool::PixelToInternal)

* refactor(text): inline |T icons = pixel-perfect quads + residency holders

Replace the fontstring-anchored region approach with a hybrid that
decouples position from residency, because the two live in different
coordinate spaces that no single scale could reconcile:

- POSITION: draw each icon as a raw GxU quad in the text engine's own
  render space (pen + node origin), the same coordinate system and
  dynamic VB the glyphs use. Pixel-exact by construction — no anchor/
  layout-space conversion. This is the original quad math; the region
  detour proved pen-space and anchor-space differ by the text paint's
  projection, not a scalar, so region anchoring could never be exact.

- RESIDENCY: a raw quad binds an unowned texture the engine evicts under
  VRAM pressure (the flicker on scroll / blank on a 2nd client). So for
  each distinct texture path, Text::InlineTexturePool creates ONE managed
  CSimpleTexture, draws it every frame (a ~2px, alpha 1/255 corner box),
  and never touches it again — its engine-managed draw is the live
  reference that keeps the texture resident for the quads to bind.

Verified in-game: icons pixel-perfect across chat / bubbles / wrapped
lines / sizes / tint, no flicker on scroll, and two concurrent clients
both render — the residency-holder premise (untested before: the earlier
"disproof" had non-drawing holders) finally validated with a holder that
actually draws. Non-zero holder alpha is load-bearing: a fully
transparent region gets culled and residency is lost.

Diagnostics (probe, perf counters, scale/bias tune knobs) stripped; the
SEH fault guard on the flush stays.

* feat(text): render inline |T icons as engine regions anchored to their fontstring

Replace the raw-quad + residency-holder hybrid as the default renderer with
the 4.3.4 CSimpleEmbeddedTexture model: one pooled CSimpleTexture per visible
icon, anchored RELATIVE TO THE OWNING FONTSTRING. Engine-drawn, so texture
residency (the chat scroll flicker / blank 2nd client) is impossible by
construction; engine-anchored, so icons track their line for free.

The pen<->anchor bridge that unblocked this: RebuildString (0x7724A0)
multiplies every text-unit quantity by fs+0x7C when creating the text node,
and the node origin is the fontstring's justification anchor point mapped by
a single measurable scale K (verified with two-position probes: origin =
rectJustifyRef x K, K ~ 1470 = physical px per anchor unit, both axes, no
drift). Placement pipeline:

- RebuildString co-hook records node -> owning fontstring (a node only
  speaks for its fs while it IS fs+0xF8->block+8, killing the stale-node
  clear race around rebuilds).
- The paint hook converts each icon's pen-space quad through K and queues
  fs-relative placements; regions are keyed BY FONTSTRING (nodes die on
  every SetText - node keying orphaned regions into an accumulating wall).
- Placements apply on WorldTick, never mid-render. SetPoint offsets are
  divided by the region's effective scale (+0x7C; anchor resolution
  multiplies stored offsets by the scale chain - measured 0.0319 stored ->
  0.0290 resolved = x0.91), then converged exactly by a per-region rect
  readback correction each tick (deterministic, self-healing; a same-call
  readback read stale pooled rects and shipped 7.4px crests).
- Engine SetTexCoord (0x770410) takes the 4-float rect Y-FIRST INTERLEAVED
  {v0,u0,v1,u1} (diagnosed in-game: {u,v,u,v} rendered the raid-mark moon
  as the circle cell); it is absolute, and the +0x124 shrink-by-UV-span
  flag stays clear so crops stretch to the rect like modern clients.
- Expired/hidden lines are swept every tick (regions parent to the chat
  frame, not the line, so they cannot inherit the line's own hide).

The quad renderer and residency holders remain behind
_classicapi_InlineTexMode (0 = quads, 1 = regions [default], 2 = overlay
A/B) as the bring-up oracle, plus diagnostics (_classicapi_InlineTexProbeFS
/ RegionDump / RegionCal / HolderCfg). Calibrated residual: RegionCal
(3, -1) pen units.

* fix(text): apply the inline-icon LEAD pad at draw time

The emitter reserves w + 1.5x pad in every inline icon's advance (lead 1x,
trail 0.5x), but FlushLayout drew the icon at the raw pen - all reserved gap
landed AFTER the icon, none before. Mid-text icons looked balanced only
because their surplus trailing gap padded the following text; a string-FINAL
icon (the money-string copper coin) sat jammed against its digits, and the
locale format strings had grown per-coin offsetX hacks to compensate.

Draw at pen + w x iconPadFrac (quads and regions share the cx), matching the
emitter's reservation. The coin formats drop to zero offsets - verified
against the texture itself: the three coin arts in UI-MoneyIcons are
byte-identical in alpha (same bounds, same center of mass, measured from the
exported BLP's DXT3 data), so no coin ever needed a positional nudge.

RegionCal's baked x constant drops 3 -> 0: the +3 calibrated pre-fix was
exactly this missing lead pad (user re-calibrated (0,-1) post-fix). The 1px
y residual remains the pen->anchor map's one true constant.

* fix(text): make inline-icon state lifetime-exact; fix every scroll-landing loss

Chat icons randomly landed shown-or-hidden when lines scrolled, and stale
icons ghosted at parked positions. Root causes, each found by in-game
triangulation (FsDump/Broken/Stats diagnostics, included), all fixed:

- Placements are FS-RELATIVE, computed at PAINT time in one coherent
  snapshot (icon coords + fs rect in the same flush). The old apply-time fs
  rect read raced the chat relayout after SetText and parked icons off
  their line, where the dedup froze them.
- The queue tail gate now matches the per-icon build gate EXACTLY (incl.
  K); any not-ready input skips-and-retries instead of queueing {} - an
  empty queue froze lines iconless via the dedup (early-session K window).
- K (pen->anchor scale) is derived PER NODE from its own fontstring - pen
  space scales with each fs's frame-scale chain, so a global chat-derived K
  mis-sized icons on differently-scaled fontstrings. Global cache kept as a
  +/-10%-clamped fallback.
- Stuck-blockless fontstrings (RebuildString releases the block on a
  SetText that lands on an unresolved rect and never rebuilds; the zombie
  node keeps painting) are re-dirtied so the engine rebuilds - the icon
  pipeline then resumes naturally.
- First-line-of-build detection comes from a draw-builder co-hook
  (FUN_005CDC20) build stamp - exact - with the old text-pointer heuristic
  only as fallback for emitter paths outside the builder.
- THE LIFETIME FIX: co-hook the gxu text-node free (FUN_005CD950, the
  single site where every node dies and its address becomes reusable) and
  erase ALL per-node state there. Anything in the maps now refers to a
  live node by construction: inherited ghost records on recycled nameplate
  fontstrings, orphaned owners (dropNoFs 61k -> 0), and reload leftovers
  lose their mechanism of existence.
- Ghost guard rescans the FONTSTRING's text (fs+0xF0, proven trustworthy),
  not the node text pointer (which lied on pfUI chat lines and made the old
  scan erase LIVE records every frame - the persistent iconless LFG lines).
  False matches can only false-keep, never false-erase.
- Freshness TTL: regions stay only while the paint keeps re-queueing their
  fontstring (deduped touches count); parked/orphaned fs regions expire in
  ~1s. Do NOT build-version records against the builder counter - the
  builder runs every paint but only emits when dirty, so a version check
  erases good records on every clean paint (the all-icons-gone regression).

Diagnostics added (Lua): _classicapi_InlineTexFsDump (pipeline triangulation
with chain classification), _classicapi_InlineTexBroken (names the broken
lines), _classicapi_InlineTexStats gains queued/drop/nudge counters and the
SEH fault address.

Also removes the FrameTick glue-screen attempt (b164d68): hooking the UI
render root regressed in-world chat; glue-screen icon support will be
re-approached without touching the in-world render path. The
FUN_UI_RENDER_ROOT offset stays documented for that retry.

* fix(text): pointer probes must allow Large-Address-Aware heap (>2GB)

LooksReadable capped plausible pointers at 0x7FFF0000. This client is LAA
(VanillaFixes), so heap allocations above 0x80000000 are valid and routine
once the heap grows - and the flush's layout walk BREAKS on an "unreadable"
node, so a single high-heap node silently truncated the walk and every node
AFTER it in the layout list lost its icons. That was the irreducible random
per-ROW missing-chat-icon bug that survived every renderer architecture
since the guard shipped with the first inline-texture commit (aa94ea5): it
fired only when the allocator placed a node/fontstring above 2GB, varying
per session with addon load and uptime. The same check also rejected
high-heap fontstrings (dropNoFs storms, "unreadable" owner chains).

Bound raised to 0xFFFF0000 in both copies.

Diagnosed with the per-node build/emit instrumentation added here: the
smoking-gun Broken() trio was builtKnown=1 (engine built the node),
emitOutcome=8 (emitter recorded its icons), nodeSeenAge=-1 (our walk never
once saw it). _classicapi_InlineTexBroken now reports both new fields.

* feat(text): render inline |T icons on the glue (login) screen

Inline icons never appeared on the login / character-select screens: the
text-pipeline hooks are engine-generic and fire on glue (the |T spans are
consumed there), but InlineTexturePool::Maintain - the step that creates and
shows the icon regions - was driven by Tick::WorldTick, and FUN_WORLD_TICK
(0x0066FD50) is the world-simulation tick, silent on glue. Queued placements
were never applied.

Add Tick::FrameTick, hooked at FUN_UI_RENDER_ROOT (0x00764330) - the
CSimpleTop singleton's per-frame render callback, registered by both the
world-init (FUN_0048FBF0) and glue-boot (FUN_0046A7B0) paths and drained
every frame by the master scene-render pump. Subscribers run at the tail of
the callback (strata walk complete, no frame mid-draw). The pool's Maintain
moves onto FrameTick as its ONLY tick: one driver, fires wherever the UI
renders.

An earlier version of this change was blamed for an in-world chat regression
and reverted. That regression was the Large-Address-Aware pointer-bound bug
(dc61f77) rolling its allocation dice at the moment the tick moved - the
symptom persisted after the revert, which already exonerated the hook.

Glue boot (LoadGlueScriptFunctions_h) now also clears the inline-icon maps:
the world->glue teardown bulk-frees every world fontstring and text node they
reference (glue->world was already covered by FrameScript_Initialize_h, and
the node-free hook covers individual deaths in both states).

WorldTick keeps its world-only subscribers (aura eviction, cast state, raid
targets) - they read world state that is null on glue; the two ticks stay
semantically distinct: WorldTick = "world advanced", FrameTick = "a UI frame
is rendering".

* feat(text): icon-inclusive GetStringWidth + FontString:GetStringHeight backport

GetStringWidth now counts inline |T...|t icons. The old "deliberate
measure gap" analysis was right that the gxu engine has no cold choke
(parallel measure/wrap/hit-test loops, no width field in the token
contract, hot per-glyph helpers) - but the choke sits one level up:
FUN_00772890 = CSimpleFontString::GetStringWidthInternal, a cold,
MinHook-safe funnel for exactly 4 callers (Script_GetStringWidth,
GameTooltip auto-size, editbox caret positioner, effective-width
vmethod). The co-hook calls the original and re-adds the icon advances
the tokenizer hook hid, so measured width matches rendered width - and
tooltips/auto-width layout now size correctly for icon lines.

Shared-source refactor so measure and render can never disagree:
- InlineInterceptActive: the tokenizer's stand-down set factored into
  ONE predicate (tokenizer passes gxu flag 0x40; the width hook passes
  fs+0x120 & 0x1000, the fs-level bit FUN_0044D670 translates into it,
  so editbox carets keep measuring raw markup).
- IconAdvancePen/SumIconAdvances: the emitter's reserved advance
  (w + 1.5*pad + positive offsetX) factored out and shared by the
  emitter loop, the justify pre-shift, and the width hook. Side fix:
  the justify pre-shift previously omitted the positive-offsetX term.

Units (bit us on first flight): escape sizes are UI pixels; convert
px->anchor by the push factor K = [0x832A4C]*1024/[0x832A44] (~1468),
NEVER by fs+0x7C - that's the layout UI scale (~0.68), and dividing by
it measured a 16px icon as +44k px. The hook never writes the fs+0xFC
cache (original may serve cached; the sum is re-added per call).
Verified in-game: delta +19.5 for a 16px icon = 20.32 exact advance
minus the known 0.8 trailing-icon ink-width residual. Wrap-break,
substring measure, and hit-test stay icon-blind (documented).

FontString:GetStringHeight() (2.3.0+ binding) backported in new
src/fontstring/Metrics.cpp: 1.12 already ships the internal wrap-aware
getter FUN_007729B0 (cache fs+0x100, lines*fontH + (lines-1)*spacing,
same wrap engine the render uses, empty -> 0) - it just was never
bound. Registered on the FontString method registry; mirrors
Script_GetStringWidth's resolve + anchor->px push conversion.
Verified in-game: one-line 11.7, wrapped 46.9 (4 lines), empty 0.

* fix(text): wrap lines account for inline icon advances

Wrap decisions measured inline |T icons as ~0 (the tokenizer hook), so
icon-bearing chat lines broke as if the icons weren't there and the
emitter's real advances ran past the frame's right edge.

Choke: FUN_TEXT_WRAP_STEPPER (0x005C7260), the wrap-stepper dispatcher
that lays out one wrapped line per call. Its exactly 4 callers are
every wrap consumer - the draw builder (render breaks), the height
measure (GetStringHeight), the chars-that-fit counter (ellipsis), and
the break-array computer - so one co-hook keeps render, height,
truncation, and break arrays mutually consistent. outBreak and
outNext-text are byte counts (==len means "all fit"); outWidth is the
measured icons-at-zero line width in the caller's units.

The hook shrinks the wrapWidth argument by a minimal feasible shrink,
found by probing the stepper sandboxed (local out-params + a copy of
the p10 first-line state byte): feasible(s) iff the line's icon
advances fit in the shrink plus the spare the break left
(iconSum <= s + probeW - outWidth). s=0 feasible -> no shrink (the
common fits-anyway case); otherwise escalate by the measured deficit,
then bisect the [infeasible, feasible] bracket to a quarter-glyph
(<=8 probes). Never overflows; packs each line to its physical
capacity. Gated on the shared InlineInterceptActive predicate + the
editbox flag bit 0x40 the dispatcher already carries.

Unit trap #2 (cost one flight): each caller passes fontH/wrapWidth in
its OWN space - node text units from the builder, the small
anchor-converted space from the fs-level callers chat wraps through.
px -> caller units = fontH / FUN_TEXT_FONT_HEIGHT(flag, fontH), the
engine's own pixel realization of the fontH param (FUN_005C6940's
final scale proves the convention for every gxu caller).

Rejected en route (documented in docs/InlineTextureEscapes.md): raw-px
subtraction (shredded lines to 2-glyph fragments), whole-remaining-
text sums (8-icon line -> one word per line), icon-count fixed-point
iteration (oscillates - 55-byte zero-width escapes make the
break<->count coupling discontinuous), max/average tiebreaks.

Cross-checked against 4.3.4 (renders |T natively): Blizzard extended
the token contract instead - texture token type 7 + a shared parser
(FUN_00617ba0) called inline by all five layout loops, fontH passed in
for :0 defaults, plain width, no padding. No single-choke trick exists
at source level; the probe search is the 1.12-shaped equivalent.

Diagnostics: _classicapi_InlineTexWrap(n) dumps the last 8
icon-bearing calls (widths, per-probe sums/breaks/raw out-params,
final shrink, floor hits).

* fix(text): wrap-shrink search refinements from in-game shakeout

Three fixes to the minimal-feasible-shrink search (2450522), each from a
live regression the _classicapi_InlineTexWrap diag pinned down:

- Full bracket bisection (<=8 probes, quarter-glyph epsilon). The first
  cut abandoned the [infeasible, feasible] bracket after one stale
  escalation candidate, costing the Marks harness line a whole
  mark+word pair per line (3+5 instead of the optimal 4+4).

- Half-glyph feasibility tolerance. The width hook and the wrap hook
  derive the icon sum through different unit chains (FUN_007727B0xK vs
  FUN_005C6FA0 ratio, OUTLINE extras, the trailing-glyph ink/advance
  quirk); the drift can read "fits exactly" as "1px over" and shrink a
  zero-slack line. Genuine icon overflow is >= a full icon, so half a
  glyph separates drift from real overflow.

- Auto-width bail (all text fits at s=0 AND slack <= 1.5 glyphs). A
  single-anchor, no-width fontstring (pfUI's addon-list labels) wraps
  at its own text-only measure plus a ~1-glyph engine margin (measured
  1.07xfontH), so icons "never fit" its budget - but the box is
  synthetic, icons draw past it harmlessly, and any shrink FABRICATES a
  wrap ("Twitch Emotes" split across two lines). Real frames stay
  covered: overflowing lines break, fitting lines carry frame-sized
  slack (Marks measures ~3.5xfontH). The narrow give-up band - text
  within 1.5 glyphs of exactly filling a real frame - reverts to the
  pre-hook overflow, bounded by the line's icon sum.

Diag reshaped for /dump's ~30-value print cap: 8 numeric headers plus
ONE pass-summary string (shrink candidate, icon sum, line length, raw
outBreak/outNext/outWidth per probe), so no record truncates.

* feat(texture): add Texture:SetRotation via corner-vertex rotation

Backports the modern texture:SetRotation(angle [, cx, cy]) method, which
vanilla 1.12 lacks entirely.

4.3.4's SetRotation (Script FUN_0052cb50) rotates the four texture
COORDINATES around a centre and feeds the 8-arg SetTexCoord, which clips
the corners of an off-axis square. 1.12 can't mirror that — its
CSimpleTexture::SetTexCoord takes only a min/max UV rect.

1.12 has a cleaner seam: a CSimpleTexture stores its drawn quad as four
independent corner positions at +0xD4, and the draw enqueue (FUN_00772fd0)
hands region+0xD4 straight to the vertex batch as four free vertices — no
axis-aligned rect is re-derived. So rotating those four corner positions
draws a truly rotated quad with no corner clipping.

The engine fills the corners axis-aligned from the layout rect via
FUN_REGION_STORE_CORNERS; a co-hook there re-applies rotation right after
each store, so a static rotation is written once and costs nothing per
frame while moves/resizes re-rotate automatically. Angle is radians
(positive = counter-clockwise); optional (cx, cy) is a normalized pivot
in [0,1], default centre; an angle of 0 clears.

Per-region state keys on the raw region pointer and is cleared on /reload
and world->glue (Texture::Rotation::PrepareForReload). A texture freed and
instantly re-pooled mid-session could briefly carry a stale rotation —
accepted; a destructor-evict hook is the later hardening.

New in Offsets.h (all verified against WoW.exe): OFF_SIMPLETEXTURE_CORNERS,
FUN_REGION_TEXCOORD_CROP, FUN_REGION_GET_RECT.

* feat(texture): add Texture:SetVertexOffset, generalize corner transforms

Backports Texture:SetVertexOffset / GetVertexOffset (plus GetRotation),
generalizing the SetRotation module (texture/Rotation.* -> texture/Transform.*)
into one corner-transform primitive that backs both methods.

Both write the four drawn corner POSITIONS at region+0xD4 — the vertices the
draw enqueue FUN_00772fd0 hands straight to the batch; no stock 1.12 function
exposes them. Per-region state widened from an angle to {rotation, four vertex
offsets}, applied through the same FUN_REGION_STORE_CORNERS co-hook: after the
engine writes the axis-aligned corners, rotate about the pivot, then add the
offsets. The two transforms compose (matching retail) and persist across layout
resolve / move / resize / SetTexture with no per-frame cost.

SetVertexOffset offsets are UI coordinate units (like SetPoint x/y), converted
to the engine's internal layout space with the same factor Tooltip::LinePool
uses; +y is up. vertexIndex maps to a corner via VertexSlot; the
UPPER_LEFT_VERTEX / ... constants are defined FrameXML-style in the embedded
addon's Util/Constants.lua.

Non-inverting warps (perspective, skew, trapezoid, wave) are free; a warp that
mirrors the quad past edge-on inverts its winding and the region draw culls it
(documented).

* feat(texture): add Texture:SetColorTexture

Backports the 7.0 SetColorTexture(colorR, colorG, colorB [, a]) as a second
name on the engine's own Script_Texture_SetTexture handler (0x0079BB40): its
numeric branch already clamps r/g/b/a to [0,1] and fills a solid colour via
FUN_00770360 (the string branch loads a path). So SetColorTexture is exactly
vanilla's numeric SetTexture form under the modern name — zero reimplementation,
with the clamp and opaque-alpha default coming straight from the engine.

The alias also accepts a path string (a harmless superset of the retail
contract). SetDesaturated / IsDesaturated are already native (real Desaturate.bls
pixel shader); SetDesaturation is not addable — vanilla desaturation is a fixed
effect handle with no strength constant in the setter, draw command, or effect
table.

* fix(text): correct the emitter's lazy pen read-back; drop quad render mode

The inline-icon emitter hook threaded the pen between text runs by
reading the engine's linkState[4] after each delegated run. That value
is LAZY (verified in FUN_005ccbe0): the engine places each glyph at
pen + pairAdvance(prev, cur) and its final write re-adds the LAST PAIR
as a stand-in for the last glyph's own advance. For a one-glyph run the
stand-in is 0 (no pair exists), so the run read back as zero-width and
the icon drew on top of the digit — the money-string "coin covers the
lone 0/1" bug. For "] 1" the stand-in was the space's advance, ~3px
short: the single-digit clip. "11" matched by luck (same-glyph pair).

Fix (the 4.3.4 model — FUN_0061ea10 case 7 computes the pending last-
glyph advance explicitly instead of trusting a stored pen): drawRun now
token-walks each run with the engine's own tokenizer and, when it ends
in a glyph, replaces the stand-in with the truth:

  pen = linkState[4] - pairAdvance(prev, last)*scale
                     + baseAdvance(last)*scale

using the engine's own advance functions (FUN_005ca240 base,
FUN_005ca2d0/FUN_005ca4b0 pair, per the node's kerning flag) and its
per-placement __ftol snap. Runs ending in a non-glyph token (trailing
|c/|r) need no correction — the trailing iteration already folded the
last glyph's base advance into the write.

Also:
- The per-icon breathing pad is now FONT-relative (fontH * padFrac),
  not icon-width-relative: the gap no longer scales with icon size, so
  a small icon doesn't jam its neighbour while a big one floats.
- Quad render mode is removed entirely (DrawTexturedQuad, residency
  holders, _classicapi_InlineTexMode/_InlineTexHolders/_InlineTexHolderCfg).
  Engine regions (the 4.3.4 CSimpleEmbeddedTexture model) are the only
  render path.

* feat(currency): GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString

The retail coin-icon money string, backported as a C function
(src/currency/CoinText.cpp) the way retail ships it (behaviour from
3.3.5's Script_GetCoinTextureString FUN_00510d00): split copper into
gold/silver/copper, emit only non-zero denominations in that order,
zero shows "0<copper>". Each denomination formats through the
GOLD/SILVER/COPPER_AMOUNT_TEXTURE GlobalStrings, backported in the
embedded addon's locale layer (stock 1.12 doesn't have them), so
locales can override.

1.12 has no per-coin icon textures — the |T markup crops vanilla's
UI-MoneyIcons 4-cell sheet (gold 0:1, silver 1:2, copper 2:3; the
three coin arts are byte-identical in alpha, verified by decoding the
exported BLP's DXT3 data). height/width 0 = size to the display
font's height (the retail default, honoured by the inline-texture
renderer). No offsetX nudges and no separator spaces: correct spacing
comes from the renderer's font-relative pad plus the lazy-pen fix
(550cf5e) — per-coin offsets were compensations for that bug and must
not return.

FormattingUtil.lua comment updates only (GetMoneyString stays the
text-symbol form; the coin form now exists). docs/API.md: new
Currency section.

* docs(readme): list GetCoinTextureString in the API table

* refactor(text): purge the quad-mode leftovers

Follow-through on 550cf5e (quad render mode removed): delete everything
that existed only for the raw-GxU-quad path and no longer has a single
reference.

- Offsets.h: drop the GxU device primitives (dynamic-VB lock/data/
  submit/unlock, texture bind, vertex stride/selector), the standalone
  path->CGxTexture loader trio (FUN_TEXTURE_LOAD_BY_PATH,
  PTR_TEXLOAD_DESC_VTBL, FUN_GX_TEXFLAGS_INIT), the colorop render-state
  and direct-D3D state setters, FUN_TEXTURE_FORCE_DECODE, and
  FUN_TEXTURE_GET_RENDERABLE. All were quad/residency-era scaffolding;
  the VAs stay recorded in docs/InlineTextureEscapes.md and git history.
  FUN_TEXT_PAINT (the flush co-hook site) and VAR_TEXTURE_BLEND_DEFAULT
  (the pool's SetTexture blend arg) remain, with comments rewritten for
  the region path.
- InlineTexture.cpp: drop IconRecord.z / penZ (only the quad draw read
  them); rewrite the paint-flush section comments to describe what the
  flush actually does now (queue fontstring-relative region placements).

* feat(addon): backport TextureUtil.lua (Blizzard_SharedXMLBase)

The non-atlas slice of the Classic Era reference, ported to Lua 5.0
(string.format instead of string-method syntax):

- SetClampedTextureRotation / ClearClampedTextureRotation — the retail
  90-degree-step texcoord shuffle with width/height swap. Works
  unchanged because 1.12's Texture:GetTexCoord() natively returns the
  8-value corner form and SetTexCoord accepts it (both operate on the
  same engine corner array, verified at Script_Texture_GetTexCoord
  0x0079BDF0). One deviation, commented in-file: Clear also nils
  rotationDegrees — the reference leaves 0 behind, which is truthy in
  Lua and breaks the next Set's origTexCoords re-capture.
- GetTexCoordsByGrid — pure math.
- CreateTextureMarkup / CreateSimpleTextureMarkup — |T markup builders;
  output renders via the inline-texture backport.
- TextureUtil = {} for namespace parity.

Omitted: everything atlas-based (CreateAtlasMarkup*, the TextureKit
family, CheckSetAtlas, GetAtlasSize, SetTextureWithAddressModeOptions —
1.12 has no atlas system), the LFG role-icon tables, and
GetTextureInfo (reads the atlas/fileID getters).

* refactor(text): remove the inline-texture bring-up diagnostics

The pool's _classicapi_InlineTexRegionDump / _InlineTexFsDump /
_InlineTexBroken were bring-up triage for the scroll-landing /
ghost-icon bug class, which the node-free lifetime hook ended
(b5df20d) — they have no remaining job. Removing them also kills their
entire support tree, none of which had another consumer:

- the five Debug* chain-state exports on Text::InlineTexture
  (DebugChainState / NodeFlags / NodeSeenAge / NodeBuiltKnown /
  NodeEmitOutcome),
- g_nodeSeen + g_flushSeq (flush visit stamps),
- g_nodeEmit (per-node emitter-outcome tracking — map churn on every
  emitted line, purely diagnostic),
- g_nodeBuilt + g_buildCounter + NodeIcons.builtSeq (write-only: the
  build-version check they were made for was deliberately REJECTED —
  the builder runs every paint but only emits when dirty, so version
  mismatches on clean paints erased live records). g_buildNode /
  g_buildEmitSeq stay — they are the live firstLine detection.

NodeIcons collapses to a plain std::vector<IconRecord> map value, and
the pool drops its Game.h / InlineTexture.h includes (nothing left
uses them). The stale "versioned by build" struct comment is rewritten
to describe the actual invariant (node-free hook + firstLine erase).

Kept: _classicapi_InlineTexEnable / Suppress / Tune / Stats / ProbeFS /
RegionCal / Wrap — the live control surface and field telemetry.

* refactor(text): reduce the inline-texture Lua surface to the kill switch

Only _classicapi_InlineTexEnable remains — the feature toggle the SEH
latch pairs with. Removed with their entire backing state, none of
which had a non-diagnostic consumer:

- _InlineTexSuppress + g_suppressInline (the manual global override —
  editbox exclusion has been per-node/per-buffer for a while) and the
  now-unreferenced InputFocused() helper
- _InlineTexTune: g_vBias / g_sizeScale / g_centerFrac / g_iconPadFrac
  become constexpr (calibrated during bring-up, fixed now)
- _InlineTexRegionCal: g_regionCalX/Y become constexpr (0 / -1)
- _InlineTexStats + the six flush-gate counters (the queue gate
  if/else collapses to the plain condition; the nudge drops its
  counter-guard and just sets the dirty bit — idempotent)
- _InlineTexWrap + the WrapDiag ring and every capture site in the
  wrap-stepper hook (the minimal-feasible-shrink algorithm is
  untouched — it always ran on locals)
- _InlineTexProbeFS (the pen<->anchor bridge verification probe)
- the SEH filter no longer records fault code/address (nothing read
  them); the latch still disables the feature on a flush fault
- the Arg() Lua helper and <cstdio> (last users removed)

* feat(text): inline icons fade with their chat line

The one visible seam left in the region renderer: icon regions parent
to the CHAT FRAME (regions cannot parent regions), so they inherited
the frame's alpha but not the LINE's — a fading chat line's text
dimmed while its inline icons stayed opaque until the end-of-fade
hide.

RE (verified in the 1.12 binary): chat fading is engine-side in the
ScrollingMessageFrame's fader FUN_00788460 — gated on the SetFading
flag (smf+0x360; the 40-method SMF registry's SetFading 0x007939A0
writes it), it counts down each visible line's timeVisible then
fadeDuration ({rgba@+4, shown@+8, timeLeft@+0xC, fadeLeft@+0x10} in
the stride-0x10 line array at smf+0x3A4) and animates the LINE
FONTSTRING's color alpha byte through the fs SetColor (0x0077F750)
every frame, hiding the fs at zero. SetColor stores the dword at
(*(u32**)(fs+0xB8))[0] (count fs+0xB4; alpha = byte 3), and the
per-line setter FUN_00788af0 resets alpha to 0xFF on message reset —
so that byte IS the line's live fade state, whoever wrote it.

The mirror (InlineTexturePool): Maintain reads FsColorAlpha(fs) each
tick and, on change only, rewrites the shown regions' colors with the
markup tint's alpha modulated by the line's alpha (RGB preserved).
ApplyPlacement folds the live alpha in at placement time, so a line
placed mid-fade starts at the right opacity. Composition is exact by
construction: the fs's on-screen alpha is frameAlpha(+0xC8) ×
fsColorAlpha, and the regions share the same parent frame, so
mirroring the fs byte alone makes icon and text alpha identical.
Steady state costs one byte compare per icon-bearing fontstring per
tick; scroll-back/un-fade restores (the SMF resets the line color and
the mirror follows). Verified in-game: fade lockstep, reappear on
scroll.

* feat(text): line-height growth for tall inline icons

An inline icon taller than the font (a 32px TwitchEmotes emote in 15px
chat) used to overlap its neighbouring lines — the line box stayed
fontH-sized. Retail grows the line: 4.3.4's measure and wrap loops
export a max-texture-height out-param (FUN_00618b90 / FUN_00618e40
case 7) and the layout takes it.

1.12 equivalent, one cold co-hook: FUN_FONTSTRING_STRING_HEIGHT
(GetStringHeightInternal, cache fs+0x100) gains the tallest icon's
overflow past the font height (MaxIconOverflowPx — explicit |T heights
only; the :0 default IS the font height). Its three binary consumers
(verified via xrefs) split exactly right:
- FUN_00772A60, the anchor effective-height vmethod — chat line
  fontstrings get only a SetWidth from the ScrollingMessageFrame
  refresh, so their rect height resolves here -> the rect grows;
- FUN_00788750, the SMF display refresh — budgets vertical space and
  anchors each line to the previous line's rect -> the stack spaces by
  the grown height;
- FUN_0077D4D0, the multi-line editbox self-size — excluded by the
  same editbox gate as the width hook (editboxes render raw markup).
Same rules as the width hook: shared InlineInterceptActive predicate,
px->anchor via the push factor (never fs+0x7C), the fs+0x100 cache is
never written (delta re-derived per call — idempotent). The backported
fontstring:GetStringHeight routes through the hooked VA and becomes
icon-inclusive automatically.

Draw side: tall icons CENTRE on the line like any other icon. The text
centres itself inside the grown rect, so the extra space appears half
above and half below — a centred icon fills exactly that, matching
retail. (A bottom-aligned first cut clipped the neighbouring line;
verified in-game against retail's centred, no-clip rendering.)

v1 limit (documented in-code): the delta is the whole text's max
overflow — exact when a message's tall icons share one wrapped line
(the emote case); tall icons on two different wrapped lines of one
message under-grow.

Verified in-game: 32px emotes between normal chat lines each take
their own vertical room, centred, no neighbour overlap.

* fix(text): full-height hyperlink hover for tall inline icons

With line-height growth (49934b0), a tall emote inside a |H...|h link
was only hoverable across its text-high middle band: the hyperlink hit
rect is registered at layout time (FUN_TEXT_LINK_RECT_ADD 0x005CD310,
the GXUFONTHYPERLINKINFO append — record {y,x,y,x} normalized + link
ptr/len pairs) with the TEXT band's y extent, fontH tall.

Consumer geometry (FUN_00788c00 -> FUN_007a3350 decompile + two in-game
experiments): the ScrollingMessageFrame rebuilds a hit-region frame per
record, anchored to the LINE FONTSTRING's top-left with the record's
band as offset/height. The band's y values are Y-UP (numeric max =
screen top — extending the max visibly grew the region into the line
above), and the band is TEXT-relative — but the grown rect's top sits
half the icon overflow above the text (the text centres in the grown
rect), pre-shifting the whole hit band up. Net: the unmodified band
already covered from the icon's top down to textBottom − overflow/2,
and the single correct fix is pushing the screen bottom DOWN by the
full overflow — decreasing the numeric MIN edge.

Co-hook on the append: when the |H...|h escape span contains a |T icon
taller than the line's font (the same MaxIconOverflowPx the
string-height hook grows the line by), decrease the band's min edge by
the overflow before the original stores it. All consumers of the
record (hover, click) inherit the expansion. Gated on the shared
InlineInterceptActive predicate so raw-rendered contexts stay literal.

Known v1 rough edge (commented in-code): a tall icon outside the link
but on the same line grows the line without this hook firing for that
link, leaving its band half-overflow high.

Verified in-game: a 32px emote link hits across its full height, no
bleed into neighbouring lines.

* docs(text): truth-pass on the inline-texture RE docs

InlineTextureEscapes.md and InlineTextureResidency.md described the
removed raw-quad renderer and a bring-up plan as if current. Bring them
in line with the shipped engine-region design.

InlineTextureEscapes.md:
- New "Current design (shipped)" section up front: region renderer, the
  three fs-level measure hooks (width/height/wrap), the lazy-pen fix,
  chat fade, tall-icon line growth + full-height hover, editbox
  exclusion, the payload, and anti-spoof — each with its commit.
- Banner the quad "rendering primitive" section as REMOVED (bring-up
  record; the address log for code deleted in 56c2670).
- Fix the DrawTexturedQuad / quad-flush references to region-queue
  language; vertical centring is g_centerFrac (0.6) with the tall-icon
  grown-rect note; the flip note becomes the region SetTexCoord
  Y-first order.
- "Remaining/optional": diagnostics removed (only InlineTexEnable
  left), measure/height/wrap DONE, animation is addon-side, the TGA
  convert command is -compress none only.
- "Reality check": correct the prediction that we would reimplement the
  emitter — the shipped design segment-delegates and co-hooks instead.

InlineTextureResidency.md: rewritten from a "not-yet-implemented plan"
into an accurate reference. States the region design, records the
CSimpleTexture entry points / layout the pool uses (VAs verified
against Offsets.h), and corrects the root cause honestly — the flicker
was the LAA pointer-bound truncation (dc61f77), NOT texture residency;
regions are kept for 4.3.4 parity and are resident by construction as a
bonus. Keeps the D3D "ruled out" notes as a do-not-re-investigate
record.

* fix(text): stop inline-icon Maintain from dereferencing stale fontstrings

A destroyed chat-line fontstring left a dangling key in the icon pool's
per-fontstring map. Its heap memory was reused as a string buffer, and
Maintain's weak LooksReadable() range check waved the reused pointer
through; reading the "parent" field (fs+0x9C) then yielded string bytes
(ASCII "-Rac"), which the engine's region attach (FUN_0076a750)
dereferenced at *(parent+0x1B4) -> ACCESS_VIOLATION (ERROR #132 at
0x0076A7CE).

Two compounding defects, both in Maintain:
  - the stale-entry expiry was gated on shown > 0, so a scrolled-off line
    (shown == 0) never expired and its dead key was re-dereferenced every
    frame, waiting for its memory to be reused;
  - LooksReadable is a bare VA-range test that a reused buffer passes.

Fix: verify a key is a LIVE fontstring by its object+0x00 vtable before
using it. The vtable is learned at paint time from fontstrings the engine
is actively drawing (QueuePlacements), so every fontstring class -- chat's
internal ScrollingMessageFrame lines, tooltip/CreateFontString lines,
addon fontstrings -- is recognized without hardcoding (one hardcoded value
filtered live chat icons out entirely). Maintain now iterates with erase:
a key whose memory no longer carries a learned fontstring vtable has its
regions hidden (our tex pointers stay valid, parented to the chat frame)
and the entry erased, so dead keys can neither accumulate nor be
dereferenced.

* fix(text): guard inline-icon pool hide loops against short region vector

CreateRegion can fail when the region pool is exhausted, breaking the
apply loop with fewer regions than `want`. The code still set
`np.shown = want`, so a later tick's freshness-expiry and fs-hidden hide
loops indexed np.regions[i] past regions.size() -- an out-of-bounds
std::vector access.

Clamp np.shown to min(want, regions.size()) so it never exceeds the
vector, and add the same i < regions.size() bound the eviction loop
already carries to the other two hide loops so all three self-guard.

* refactor(text): centralize inline-texture offsets and pointer probe

Enforce the single-source-of-truth offset rule on the inline-texture
files:

- Read the fontstring text pointer via Offsets::OFF_FONTSTRING_TEXT
  instead of a raw `fs + 0xF0` in FlushLayout's ghost guard; the symbol
  already existed and is used elsewhere in the same file.
- Add OFF_TEXT_NODE_JUSTIFY (0x54) to Offsets.h with a verification note
  and use it for the two node-justify reads (DeriveK and the emitter
  pre-shift) that previously hardcoded 0x54 in two places.
- Move the LAA-aware LooksReadable probe into a shared text/PtrProbe.h
  (namespace Text) so InlineTexture.cpp and InlineTexturePool.cpp share
  one definition instead of two copies plus a third raw-bounds copy in
  LearnFontStringVtable. The 0xFFFF0000 bound now lives in exactly one
  place -- this is the constant whose earlier 0x7FFF0000 form caused the
  missing-chat-icon bug, so keeping the copies from drifting matters.

No behavior change; full build compiles and links.

* fix(currency): clamp GetCoinTextureString to the uint32 copper ceiling

An amount above INT_MAX gold copper (e.g. 1e15) made `gold =
(int)(amount / 10000)` overflow: the long long division was truncated to
its low 32 bits, so GetCoinTextureString(1e15) printed a garbage gold
count (1215752192). silver/copper were already bounded by the % math.

Vanilla money is a uint32 copper field, so clamp amount to 0xFFFFFFFF
copper (~429,496 gold) -- the most the game can represent -- which keeps
gold within int range for the "%d" GlobalString format. Can't widen the
type instead: the count is formatted through the retail-compatible "%d"
coin GlobalString, and passing a long long to %d is undefined.

* fix(text): SEH-guard the inline-icon Maintain tick; document region leak

#6 (SEH backstop): Maintain's liveness probe dereferences a fontstring
pointer that may have been freed (the documented IsLiveFontString
residual). The paint path already latches off on a fault via SafeFlush,
but Maintain ran bare under the render-root tick, so a fault there was an
uncaught client crash. Split the body into MaintainImpl (holds the C++
objects) and a thin Maintain() SEH wrapper that latches g_maintainDisabled
on fault -- mirroring the paint latch. Re-armed on /reload.

#7 (region leak): document why the stale-key eviction intentionally drops
np.regions without reclaiming the pooled CSimpleTextures. Any reclaim (a
free list or explicit destroy) would touch a region whose parent frame may
already be gone -- a use-after-free on the very path this liveness check
was added to stop crashing. The leak is bounded and near-zero in practice
(chat/tooltip fontstrings are reused, never evicted).

* fix(chat): stop raid-marker expansion from dropping the message tail

A token inflates ~28x ({rtN} -> ~85-byte |T markup), so a marker-dense
message could fill the 0x800 buffer and silently truncate everything after
it -- a DLL user would see a shorter chat line than a non-DLL user (a mild
content-hiding vector).

Reserve room for the literal tail before expanding a token: only iconify a
marker when the expansion plus the whole remaining input (worst case copied
1:1) still fits. Past that point later tokens stay literal {rtN}, so the
full message text is always preserved -- just with fewer icons.

* fix(text): gate eviction Hide on region liveness to avoid a UAF

The stale-key eviction Hid its regions unconditionally, assuming they
outlive the fontstring. They usually do (regions are parented to the
fontstring's parent FRAME, which persists as lines churn) -- but if the
fontstring became non-live because that parent frame was destroyed, the
frame freed the fontstring AND its child regions together. Hiding a region
pointer there wrote to freed/recycled memory: a use-after-free that could
crash.

Learn the pooled CSimpleTexture vtable in CreateRegion and gate each
eviction Hide on IsLiveRegion (the same vtable-liveness technique
IsLiveFontString already uses). A recycled region is skipped (it needs no
hiding -- it's already gone); a live one is still hidden so it can't ghost.
Factored the shared WoW-image-range check into InWowImage so the two Learn*
functions don't duplicate the constant.
2026-08-18 04:15:54 -05:00
fuffcandBrues 72c4185026 fix(aura): identify a cached aura by its caster and descriptor slot (#21)
* fix(aura): identify a cached aura by its caster and descriptor slot

Two casters of one spell on one target are two auras in two descriptor
slots, but `Aura::Source` held a single entry per `(target, spellId)`.
The second cast overwrote the first caster's `casterGuid` and timing, and
`OnAuraRemoved` for either copy evicted the entry backing the survivor.

Observable from Lua, with a same-class groupmate debuffing your target:
`sourceUnit` names the wrong caster, so a `PLAYER`-filtered query drops
your own debuff; and the base-duration estimate stored for the other
caster elapses while your talent-extended aura is still up, which
`PushEnriched` reports as `expirationTime = 0` — an aura on the unit with
no timer at all.

Entries are now keyed `(target, spellId, caster)`, the identity the server
uses. Recovering which instance a descriptor slot holds needs the two
halves joined: SpellGo knows the caster but no slot, `OnAuraAdded` knows
the slot but no caster, and they arrive in that order, so the application
hook seats its slot on the newest cast capture still awaiting one.
`Get` takes that slot and resolves by it first, falling back to the unit's
sole entry for the spell — every single-caster case, and the paths with no
descriptor to read a slot from. Several entries and no binding resolves to
a miss: the caller's unknown-caster defaults beat a coin flip between two
casters.

`OnAuraRemoved` and `EvictAbsent` retire the instance in the vacated slot
rather than every entry sharing its spell ID.

* fix(aura): compare unbound-entry freshness wrap-safely

FindFreshestUnbound picked the newest unbound capture with a raw
`stampMs >` on two absolute engine ticks. That compare inverts across the
2^32 ms tick wrap (~49.7 days uptime, or the rdtsc backend already past
2^31 after a warm reboot), so the OLDER capture would win and OnAuraAdded
would seat the descriptor slot onto the wrong cast — mis-attributing that
aura's caster and duration.

Pick by smallest Time::Clock::Elapsed(stamp, now) instead, the wrap-safe
signed difference the rest of the codebase uses for tick math.

* fix(aura): keep the observed caster on the aura fallback paths

The cache-fallback push paths (the descriptor-drop fallback and the
out-of-range group-member fallback) already hold each aura instance's
caster and timing — Enumerate copies them straight out of the cache entry.
But PushEnriched then re-resolved them through Get(guid, spell, slot),
which returns a miss for an unbound entry that shares its spell with
another caster's entry. So an aura Enumerate had fully attributed came
back out with no sourceUnit/sourceGUID and no expirationTime.

Hand the entry's own attribution to PushEnriched as `known` so the
fallback reports what the cache already knew instead of re-deriving it.

* refactor(aura): route Get and Evict through one FindInstance resolver

Get and Evict each hand-rolled the same FindBySlot-then-FindSole ladder —
the read-side rule for which instance a slot-bearing query names. Naming it
once keeps the two from drifting into resolving the same query to different
instances (reporting one caster while evicting another). No behavior change.

* perf(aura): bound cache scans to the active high-water prefix

Every Aura::Source lookup linear-scanned all 2048 cache slots. Get runs
per-aura per-query — hundreds of times a frame when addons poll UnitAura in a
raid — so the full scan dominated the read path.

Track a high-water mark that Claim (the sole allocator) extends and FlushAll
resets, and scan only [0, g_usedHigh). Every used entry lives below the mark,
so no lookup can miss one. Outside a raid the working set is a handful of
auras, cutting a ~2048-entry scan to ~30; in a raid it drops to the real
working set. Claim still scans the full array — as the allocator it must hand
out slots beyond the current prefix.

---------

Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
2026-08-16 14:41:49 -05:00
Brues d366fec43e fix(attributes): restore the previous target after a macro click-cast
The `macro` verb selected the clicked unit and left it targeted (Clique's
click-heal minus the restore). Snapshot the current selection GUID, retarget
the clicked unit, run the macro, then restore the prior target via
FUN_TARGET_BY_GUID (which validates the GUID and drops a stale one), or
ClearTarget when there was no prior target. The macro's actions dispatch
synchronously while the clicked unit is selected, so restoring afterward
doesn't affect them.
2026-08-13 00:03:42 -05:00
Brues c6d99cf843 feat(attributes): target the unit for macro click-casts
The `macro` click verb ran the macro text with no unit context, so a
plain `/cast Flash Heal` bound to a raid frame hit the current target
instead of the clicked unit -- a regression from Clique/Healbot-style
click healing. Vanilla macros have no `@unit` conditional, and unlike
the `spell` verb (which feeds a GUID straight to the cast dispatcher)
we can't inject a target into arbitrary macro text.

Emulate pfUIevan's classic click-heal: TargetUnit the clicked unit
before running the macro. Like pfUIevan, the previous target is not
restored -- the click doubles as a target swap. Only the `macro` verb
is affected; `spell`/`item` already act on the unit directly.
2026-08-12 03:48:35 -05:00
Brues 39d22ac124 fix(time): centralize engine ms-tick reads in Time::Clock
The engine's OS millisecond counter (FUN_OS_TICKCOUNT_MS, same epoch
GetTime() scales by 0.001) is unsigned and wraps at 2^32 ms. Nine files
each re-derived the reader; several stored the tick into a signed int,
so past 2^31 ms (~24.9 days uptime) every timestamp went negative and
`startTime + duration` landed in the past -- callers saw every cooldown,
aura, and lockout as already elapsed. The same bug shipped three times
(the cooldown by-ID getters and C_LossOfControl).

Add one source of truth -- Time::Clock (src/time/Clock.h) -- with a
uint32 NowMs() and wrap-safe Reached/Remaining/Elapsed helpers, and route
every tick reader through it. No module re-derives the reader or hand-
rolls a `now < deadline` compare (which inverts across the wrap) anymore.

Also fixes the reported cooldown/LoC getters (unsigned out-params,
wrap-safe compares) and three latent instances of the same `now < end`
inversion found while sweeping: totem/Tracker's two timer getters and
aura/Source's cache-eviction pass.

Named Time::Clock, not Time::Tick, because a Time::Tick inner name
shadows the global Tick namespace (the WorldTick hook) from inside any
Time::* file.

Discovered by fuffc in #17.
2026-08-12 01:58:54 -05:00
Brues b497bb7fb0 fix(attributes): run macrotext as text, not through name-based RunMacro
The secure "macro" action read both `macrotext` (raw text) and the
deprecated `macro` (saved-macro name) into one buffer and ran whichever
through the global RunMacro. RunMacro is name-based -- stock resolves an
index, and SuperCleveRoidMacros' RunMacro calls GetMacroIndexByName --
so a macrotext value like "/cast [hp:<90] Holy Light" resolved to no
macro and silently did nothing. Because the global existed, the native
fallback never ran.

Split the two by their real semantics: macrotext runs through the chat
parser (RunMacroTextC), so each line dispatches via SlashCmdList and
picks up addon slash hooks like SuperCleveRoidMacros' conditional /cast;
the name form keeps preferring RunMacro. Fixes click-cast macros with
conditionals.
2026-08-12 00:32:28 -05:00
Brues 4d5fe8d41b feat(spellbook): add C_SpellBook.GetSpellBookItemInfo
Backport C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank) -> table,
reusing Spell::Info's DBC reader. slotIndex is 1-based across the book;
spellBank is Enum.SpellBookSpellBank (0 = Player, 1 = Pet), resolved via
Spell::Lookup::SpellbookSlotToID. Returns nil for an empty/out-of-range
slot, matching retail.

Fields map from Spell.dbc: itemType is Spell (player) or PetAction (pet)
- vanilla's book holds only real spells, so None/FutureSpell/Flyout never
occur; actionID == spellID (no override system); name/subName from the
localized Name/Rank; isPassive from Attributes; isOffSpec always false.
iconID is the icon PATH string (vanilla has no fileID), the same
deviation as C_Spell.GetSpellInfo.iconID; skillLineIndex is omitted.

Also register the two Enum tables the return contract references
(non-destructively, like the other Enum.* additions) so
info.itemType == Enum.SpellBookItemType.Spell comparisons work:
Enum.SpellBookSpellBank and Enum.SpellBookItemType.

Document in docs/API.md (SpellBook section + both Enum entries) and list
in README.
2026-08-11 01:28:31 -05:00
137 changed files with 10588 additions and 1596 deletions
+1
View File
@@ -45,3 +45,4 @@ Util\Vector4D.lua
Util\UIParent.lua
Util\AuraDurationModifiers.lua
Util\SecureUnitMenu.lua
Util\TextureUtil.lua
+6 -1
View File
@@ -132,4 +132,9 @@ INSCRIPTION_AVAILABLE = false;
EQUIPPED_FIRST = 1;
EQUIPPED_LAST = 19;
BASE_MOVEMENT_SPEED = 7;
BASE_MOVEMENT_SPEED = 7;
UPPER_LEFT_VERTEX = 1;
LOWER_LEFT_VERTEX = 2;
UPPER_RIGHT_VERTEX = 3;
LOWER_RIGHT_VERTEX = 4;
+10 -8
View File
@@ -2,12 +2,15 @@
-- 1.12 / Lua 5.0.
--
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
-- The currency/cost helpers (C_CurrencyInfo etc.) are omitted. GetMoneyString
-- uses text symbols, not coin texture markup (markup doesn't render in 1.12).
-- GetMoneyString gives the text-symbol form ("12g 34s 56c"). The coin-icon form
-- (GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString) is provided by the
-- ClassicAPI DLL in C++ (src/currency/CoinText.cpp) — like retail, where it's a
-- C function — now that the inline-texture backport renders |T markup in 1.12.
--
-- The FrameXML format-string globals these functions consume
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL, …) are
-- defined in the locale layer (locales/enUS.lua), which loads first.
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL,
-- GOLD_AMOUNT_TEXTURE, …) are defined in the locale layer (locales/enUS.lua),
-- which loads first.
COPPER_PER_SILVER = 100;
SILVER_PER_GOLD = 100;
@@ -73,10 +76,9 @@ function SplitTextIntoHeaderAndNonHeader(text)
end
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
-- Text-symbol form ("12g 34s 56c"); the coin-texture form from the
-- reference is dropped since texture markup doesn't render in 1.12. The
-- checkGoldThreshold arg is accepted for signature compatibility but not
-- applied (it relied on modern Constants tables).
-- Text-symbol form ("12g 34s 56c"); for the coin-icon form use
-- GetCoinTextureString below. The checkGoldThreshold arg is accepted for
-- signature compatibility but not applied (it relied on modern Constants tables).
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
local gold = math.floor(money / COPPER_PER_GOLD);
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
+94
View File
@@ -0,0 +1,94 @@
TextureUtil = {};
function SetClampedTextureRotation(texture, rotationDegrees)
if (rotationDegrees ~= 0 and rotationDegrees ~= 90 and rotationDegrees ~= 180 and rotationDegrees ~= 270) then
error("SetRotation: rotationDegrees must be 0, 90, 180, or 270");
return;
end
if not (texture.rotationDegrees) then
texture.origTexCoords = {texture:GetTexCoord()};
texture.origWidth = texture:GetWidth();
texture.origHeight = texture:GetHeight();
end
if (texture.rotationDegrees == rotationDegrees) then
return;
end
texture.rotationDegrees = rotationDegrees;
if (rotationDegrees == 0 or rotationDegrees == 180) then
texture:SetWidth(texture.origWidth);
texture:SetHeight(texture.origHeight);
else
texture:SetWidth(texture.origHeight);
texture:SetHeight(texture.origWidth);
end
if (rotationDegrees == 0) then
texture:SetTexCoord( texture.origTexCoords[1], texture.origTexCoords[2],
texture.origTexCoords[3], texture.origTexCoords[4],
texture.origTexCoords[5], texture.origTexCoords[6],
texture.origTexCoords[7], texture.origTexCoords[8] );
elseif (rotationDegrees == 90) then
texture:SetTexCoord( texture.origTexCoords[3], texture.origTexCoords[4],
texture.origTexCoords[7], texture.origTexCoords[8],
texture.origTexCoords[1], texture.origTexCoords[2],
texture.origTexCoords[5], texture.origTexCoords[6] );
elseif (rotationDegrees == 180) then
texture:SetTexCoord( texture.origTexCoords[7], texture.origTexCoords[8],
texture.origTexCoords[5], texture.origTexCoords[6],
texture.origTexCoords[3], texture.origTexCoords[4],
texture.origTexCoords[1], texture.origTexCoords[2] );
elseif (rotationDegrees == 270) then
texture:SetTexCoord( texture.origTexCoords[5], texture.origTexCoords[6],
texture.origTexCoords[1], texture.origTexCoords[2],
texture.origTexCoords[7], texture.origTexCoords[8],
texture.origTexCoords[3], texture.origTexCoords[4] );
end
end
function ClearClampedTextureRotation(texture)
if (texture.rotationDegrees) then
SetClampedTextureRotation(texture, 0);
texture.origTexCoords = nil;
texture.origWidth = nil;
texture.origHeight = nil;
texture.rotationDegrees = nil;
end
end
function GetTexCoordsByGrid(xOffset, yOffset, textureWidth, textureHeight, gridWidth, gridHeight)
local widthPerGrid = gridWidth / textureWidth;
local heightPerGrid = gridHeight / textureHeight;
return (xOffset - 1) * widthPerGrid, (xOffset) * widthPerGrid, (yOffset - 1) * heightPerGrid, (yOffset) * heightPerGrid;
end
-- left/right/top/bottom are normalized [0..1] coords within the file (the
-- markup's texel fields are produced by multiplying back up by the file size).
function CreateTextureMarkup(file, fileWidth, fileHeight, width, height, left, right, top, bottom, xOffset, yOffset)
return string.format("|T%s:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d|t",
file
, height
, width
, xOffset or 0
, yOffset or 0
, fileWidth
, fileHeight
, left * fileWidth
, right * fileWidth
, top * fileHeight
, bottom * fileHeight
);
end
function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset)
return string.format("|T%s:%d:%d:%d:%d|t",
file
, height or width
, width
, xOffset or 0
, yOffset or 0
);
end
+2 -2
View File
@@ -754,8 +754,8 @@ local function ShowTradeSkillLink(link, text)
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.barRank:SetFormattedText("%d/%d", cur or 0, max or 0);
frame.summary:SetFormattedText("%d of %d recipes known", known, total);
frame.recipes = knownList;
frame.subclassFilter = nil;
+4
View File
@@ -4,6 +4,7 @@ BINDING_NAME_TARGETFOCUS = "Target Focus";
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
COPPER_AMOUNT_SYMBOL = "c";
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
DEBUFF_SYMBOL_CURSE = "Cu";
DEBUFF_SYMBOL_DISEASE = "Di";
DEBUFF_SYMBOL_MAGIC = "Ma";
@@ -31,6 +32,8 @@ FOCUSTARGET = "Focus Target";
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ICON_FILTER_ALL = "All Icons";
ICON_FILTER_ITEM = "Items";
@@ -75,6 +78,7 @@ PERCENTAGE_STRING = "%d%%";
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
SET_NOTE = "Set Note";
SILVER_AMOUNT_SYMBOL = "s";
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/equipset";
+4 -1
View File
@@ -149,5 +149,8 @@ set(CMAKE_SHARED_LINKER_FLAGS
# crash dumps back to functions. Published as a build/release artifact so
# any crash from a shipped DLL can be triaged exactly. (/OPT:ICF folds
# identical functions, so a few map symbols share an address — harmless.)
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP"
# /DEF exports `Load` (VanillaFixes' post-injection entry point) undecorated
# so its GetProcAddress(module, "Load") resolves. See src/ClassicAPI.def and
# the loader-lock rationale in src/DllMain.cpp.
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
)
+21 -4
View File
@@ -20,6 +20,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
@@ -29,13 +30,14 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
@@ -60,7 +62,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [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.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
@@ -70,7 +72,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [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.RegisterAuraDurationModifierByTrigger`, `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` |
@@ -188,6 +190,8 @@ when launching with `-console`), not as Lua functions. See the
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
### Unit tokens
@@ -199,6 +203,17 @@ when launching with `-console`), not as Lua functions. See the
### Bindings
ClassicAPI backports the later-client direct-action and temporary override
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
session-only, frame-owned, and support the usual priority flag.
See the [Bindings API reference](docs/API.md#bindings) for signatures,
precedence, execution behavior, macro-text usage, and 1.12 compatibility
notes.
#### Predefined focus bindings
Injected into the engine's **Targeting Functions** group at FrameXML
Bindings.xml load time, so they appear in the keybind UI alongside
native targeting bindings instead of orphaned at the bottom.
@@ -208,7 +223,8 @@ native targeting bindings instead of orphaned at the bottom.
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
for the implementation note.
### Behaviors
@@ -217,6 +233,7 @@ Transparent engine tweaks — no API to call, they just fix a vanilla limitation
| Tweak | Effect |
|-------|--------|
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
## Installation
+604 -7
View File
@@ -79,6 +79,9 @@ build instructions.
- [`C_CreatureInfo.GetCreatureTypeInfo(creatureTypeID)`](#c_creatureinfogetcreaturetypeinfocreaturetypeid)
- [`C_CreatureInfo.GetCreatureTypeIDs()`](#c_creatureinfogetcreaturetypeids)
- [Currency](#currency)
- [`GetCoinTextureString(amount [, fontHeight])` / `C_CurrencyInfo.GetCoinTextureString(amount [, fontHeight])`](#getcointexturestringamount--fontheight--c_currencyinfogetcointexturestringamount--fontheight)
- [CVar](#cvar)
- [`C_CVar.GetCVarBool(cvar)`](#c_cvargetcvarboolcvar)
@@ -162,6 +165,18 @@ build instructions.
- [`region:IsDragging()`](#regionisdragging)
- [`GetMouseFoci()`](#getmousefoci)
- [`frame:SetShown(shown)`](#framesetshownshown)
- [`fontstring:GetStringHeight()`](#fontstringgetstringheight)
- [`fontstring:GetUnboundedStringWidth()`](#fontstringgetunboundedstringwidth)
- [`fontstring:GetWrappedWidth()`](#fontstringgetwrappedwidth)
- [`fontstring:GetNumLines()`](#fontstringgetnumlines)
- [`fontstring:GetLineHeight()`](#fontstringgetlineheight)
- [`fontstring:IsTruncated()`](#fontstringistruncated)
- [`fontstring:SetMaxLines(maxLines)` / `fontstring:GetMaxLines()`](#fontstringsetmaxlinesmaxlines--fontstringgetmaxlines)
- [`fontstring:SetFormattedText(format [, ...])`](#fontstringsetformattedtextformat--)
- [`texture:SetRotation(angle [, cx, cy])`](#texturesetrotationangle--cx-cy)
- [`texture:SetVertexOffset(vertexIndex, offsetX, offsetY)`](#texturesetvertexoffsetvertexindex-offsetx-offsety)
- [`texture:SetColorTexture(colorR, colorG, colorB [, a])`](#texturesetcolortexturecolorr-colorg-colorb--a)
- [`fontstring:SetRotation(angle [, cx, cy])`](#fontstringsetrotationangle--cx-cy)
- [`frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`](#framesetresizeboundsminwidth-minheight--maxwidth-maxheight)
- [`frame:HookScript(scriptType, handler)`](#framehookscriptscripttype-handler)
- [`frame:IsEventRegistered(event)`](#frameiseventregisteredevent)
@@ -217,6 +232,8 @@ build instructions.
- [`Enum.ItemClass`](#enumitemclass)
- [`Enum.ItemQuality`](#enumitemquality)
- [`Enum.PowerType`](#enumpowertype)
- [`Enum.SpellBookSpellBank`](#enumspellbookspellbank)
- [`Enum.SpellBookItemType`](#enumspellbookitemtype)
- [Glue](#glue)
- [`C_Glue.IsFirstLoadThisSession()`](#c_glueisfirstloadthissession)
@@ -375,6 +392,10 @@ build instructions.
- [`C_MerchantFrame.IsMerchantItemRefundable(slot)`](#c_merchantframeismerchantitemrefundableslot)
- [`C_MerchantFrame.IsSellAllJunkEnabled()`](#c_merchantframeissellalljunkenabled)
- [Model](#model)
- [`model:SetDisplayInfo(creatureDisplayID)`](#modelsetdisplayinfocreaturedisplayid)
- [`model:SetCreature(creatureID)`](#modelsetcreaturecreatureid)
- [ChatBubbles](#chatbubbles)
- [`C_ChatBubbles.GetAllChatBubbles([includeForbidden])`](#c_chatbubblesgetallchatbubblesincludeforbidden)
@@ -456,7 +477,7 @@ build instructions.
- [`IsHarmfulSpell(spell)` / `IsHelpfulSpell(spell)`](#isharmfulspellspell--ishelpfulspellspell)
- [`C_Spell.IsSpellHarmful(spellID)` / `C_Spell.IsSpellHelpful(spellID)`](#c_spellisspellharmfulspellid--c_spellisspellhelpfulspellid)
- [`GetSpellSchool(spellID)`](#getspellschoolspellid)
- [`CastSpellNoToggle(name | spellID)`](#castspellnotogglename--spellid)
- [`CastSpellNoToggle(name | spellID [, unit])`](#castspellnotogglename--spellid--unit)
- [`C_Spell.CastAtCursor(spellIDOrName)`](#c_spellcastatcursorspellidorname)
- [`C_Spell.CastAtUnit(spellIDOrName, unit)`](#c_spellcastatunitspellidorname-unit)
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
@@ -467,6 +488,7 @@ build instructions.
- [SpellBook](#spellbook)
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
- [`C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)`](#c_spellbookgetspellbookiteminfoslotindex-spellbank)
- [`C_SpellBook.GetSpellLevelLearned(spellID)`](#c_spellbookgetspelllevellearnedspellid)
- [`C_SpellBook.GetCurrentLevelSpells([level])`](#c_spellbookgetcurrentlevelspellslevel)
- [`C_SpellBook.GetSkillLineName(skillLineID)`](#c_spellbookgetskilllinenameskilllineid)
@@ -547,6 +569,7 @@ build instructions.
- [`UnitSubName(unit)`](#unitsubnameunit)
- [`UnitCreatureFamilyID(unit)`](#unitcreaturefamilyidunit)
- [`UnitCreatureTypeID(unit)`](#unitcreaturetypeidunit)
- [`UnitCreatureID(unit)`](#unitcreatureidunit)
- [`GetUnitSpeed(unit)`](#getunitspeedunit)
- [`UnitClassBase(unit)`](#unitclassbaseunit)
- [`UnitRaceBase(unit)`](#unitracebaseunit)
@@ -570,6 +593,7 @@ build instructions.
- [`UnitPowerMissing(unit [, powerType [, unmodified]])`](#unitpowermissingunit--powertype--unmodified)
- [`UnitPowerType(unit)`](#unitpowertypeunit)
- [`UnitSpellHaste(unit)`](#unitspellhasteunit)
- [`UnitSpellTargetName(unit)`](#unitspelltargetnameunit)
- [UnitAuras](#unitauras)
- [`C_UnitAuras.GetAuraDataByIndex(unit, index [, filter])`](#c_unitaurasgetauradatabyindexunit-index--filter)
@@ -2008,6 +2032,35 @@ local ids = C_CreatureInfo.GetCreatureTypeIDs()
-- { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }
```
## Currency
### `GetCoinTextureString(amount [, fontHeight])` / `C_CurrencyInfo.GetCoinTextureString(amount [, fontHeight])`
Returns the amount of copper as a money string with real coin icons —
for example `"12<gold> 34<silver> 56<copper>"`. The icons are inline
`|T…|t` markup over vanilla's `UI-MoneyIcons` sprite sheet, rendered by
ClassicAPI's inline-texture backport. Both names call the same C
function, like retail.
Only the non-zero denominations appear, in gold → silver → copper
order. An `amount` of `0` returns `"0<copper>"`. A negative or
fractional `amount` is clamped to `0` / rounded.
`fontHeight` sets the icon height in UI pixels. When omitted (or `0`),
each coin sizes itself to the font of the fontstring that shows the
string — the retail default.
```lua
GetCoinTextureString(123456) -- "12g 34s 56c" with coin icons
GetCoinTextureString(5) -- "5c" with a copper icon
GetCoinTextureString(5, 24) -- same, with a 24px coin
```
The per-denomination formats are the GlobalStrings
`GOLD_AMOUNT_TEXTURE` / `SILVER_AMOUNT_TEXTURE` /
`COPPER_AMOUNT_TEXTURE`, backported in the embedded `!!!ClassicAPI`
addon's locale layer — a locale can override them.
## CVar
### `C_CVar.GetCVarBool(cvar)`
@@ -3760,7 +3813,118 @@ after `"target"` (matching retail order), so a focused unit's GUID
reverse-resolves to `"focus"` only if it isn't already addressable
as `"player"` / `"party*"` / `"raid*"` / `"nameplate*"` / `"target"`.
### Bindings (`FOCUSTARGET` / `TARGETFOCUS`)
## Bindings
Backports the direct-action and temporary override binding APIs added in WoW
2.0. The permanent helpers write later-client action command strings into
vanilla's native binding table; the override helpers add a separate,
session-only layer in front of that table.
### Permanent direct-action bindings
| Function | Equivalent command |
|----------|--------------------|
| `ok = SetBindingSpell(key, spell)` | `SetBinding(key, "SPELL " .. spell)` |
| `ok = SetBindingItem(key, item)` | `SetBinding(key, "ITEM " .. item)` |
| `ok = SetBindingMacro(key, macroNameOrIndex)` | `SetBinding(key, "MACRO " .. macroNameOrIndex)` |
| `ok = SetBindingClick(key, buttonName [, mouseButton])` | `SetBinding(key, "CLICK " .. buttonName .. (mouseButton and ":" .. mouseButton or ""))` |
`key` is any binding string accepted by the client, such as `"CTRL-F"`,
`"SHIFT-BUTTON4"`, or `"F8"`. `spell`, `item`, `macroNameOrIndex`, and
`buttonName` identify the action to perform. `macroNameOrIndex` accepts either
a saved macro's name or its decimal index. `mouseButton` is passed to the
button's click handler and defaults to `"LeftButton"`; when supplied, it is
stored as the `:MouseButton` suffix of the `CLICK` command.
Each helper returns `1` if vanilla's `SetBinding` changed the binding and
`nil` otherwise. It changes whichever binding set is currently loaded, fires
vanilla's normal `UPDATE_BINDINGS` notification, and is not persisted until
the addon calls `SaveBindings`. Use vanilla `SetBinding(key)` to unbind a key;
the typed helpers always require an action argument.
The command hook also understands manually constructed `SPELL`, `ITEM`,
`MACRO`, and `CLICK` strings passed directly to vanilla `SetBinding`.
### Temporary override bindings
```lua
SetOverrideBinding(owner, isPriority, key [, command])
SetOverrideBindingSpell(owner, isPriority, key, spell)
SetOverrideBindingItem(owner, isPriority, key, item)
SetOverrideBindingMacro(owner, isPriority, key, macroNameOrIndex)
SetOverrideBindingClick(owner, isPriority, key, buttonName [, mouseButton])
ClearOverrideBindings(owner)
```
`owner` must be a frame and `isPriority` must be a Boolean. The typed helpers
construct the same four action strings shown above. The generic
`SetOverrideBinding` also accepts ordinary Bindings.xml command names, which
are delegated to vanilla's command executor. Override setters and
`ClearOverrideBindings` return no values.
Passing no `command` (or `nil`) to `SetOverrideBinding` removes that owner's
override for `key`. `ClearOverrideBindings(owner)` removes every override
belonging to that owner. Overrides never change or save the native binding
table and are cleared whenever the FrameScript Lua state is recreated, such
as on `/reload` or logout.
Resolution order is:
1. Priority override bindings.
2. Non-priority override bindings.
3. The normal native binding.
When multiple owners set the same key at the same priority, the most recently
set override wins. Removing it immediately reveals the next matching override
or the unchanged native binding.
`key` is matched case-insensitively, and its modifiers are accepted in any
order: `"ctrl-alt-F"`, `"ALT-CTRL-F"`, and `"Alt-Ctrl-F"` all bind the same
key. The override layer normalizes the key to the engine's own keypress form
(`ALT-CTRL-SHIFT-` before the base key) before matching, so the override fires
regardless of how the caller spelled it.
A generic `SetOverrideBinding` command that is an ordinary Bindings.xml command
runs through the engine's own command executor with the same execution context
a native keypress establishes, so it behaves like the equivalent permanent
binding.
### Action execution
| Command | Execution path |
|---------|----------------|
| `SPELL name` | Calls vanilla `CastSpellByName(name)`. The usual `/cast` spell-name syntax is accepted. |
| `ITEM item` | Calls `C_Item.UseItemByName(item)`, accepting the names, item strings, and item IDs supported by that API. |
| `MACRO nameOrIndex` | Resolves a saved macro by name or decimal index. An addon-provided `RunMacro` is used when available; otherwise ClassicAPI resolves it with `GetMacroInfo` and dispatches its body through `ChatEdit_ParseText`. |
| `CLICK ButtonName[:MouseButton]` | Resolves the named global frame and calls its ordinary `:Click(mouseButton)` method. The default is `LeftButton`. |
`SetBindingMacro` and `SetOverrideBindingMacro` take a saved macro identifier,
not literal macro text. To bind literal macro text, bind a named button whose
ClassicAPI attributes describe a macro action:
```lua
local button = CreateFrame("Button", "MyMacroTextBindingButton")
button:SetAttribute("type", "macro")
button:SetAttribute("macrotext", "/say Bound macro text")
SetBindingClick("CTRL-F7", button:GetName())
```
### 1.12 compatibility notes
- There is no combat lockdown or secure execution. These functions remain
callable in combat, and `CLICK` invokes the frame's ordinary click path.
- Direct actions run on key-down. The corresponding key-up is consumed; a
click binding does not emulate later clients' separate mouse-down and
mouse-up delivery.
- The override layer is not reflected by vanilla `GetBindingAction`.
In particular, the later-client `GetBindingAction(key, true)` override query
is not backported.
- Override ownership is explicit. Call `ClearOverrideBindings(owner)` when the
addon's owner is no longer needed; all remaining overrides are also cleared
when the Lua state is recreated.
### Predefined focus bindings (`FOCUSTARGET` / `TARGETFOCUS`)
Two key bindings appear in the keybind UI (`Esc` → Key Bindings)
under the **Targeting Functions** group, between `PETATTACK` and the
@@ -3879,6 +4043,225 @@ end
frames, textures and fontstrings (each branch has its own engine
Show/Hide implementation).
### `fontstring:GetStringHeight()`
This is the companion to vanilla's `GetStringWidth`. Blizzard first
shipped it in 2.3.0. It returns the height of the rendered text, in
UI pixels. The result includes word wrap: wrapped text measures
`lines × fontHeight + (lines − 1) × spacing` (the `SetSpacing`
value). Empty or unset text returns `0`.
```lua
local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(100)
f:SetText("a long line that wraps a few times")
frame:SetHeight(f:GetStringHeight() + 16) -- size the box to the text
```
Related: the stock `fontstring:GetStringWidth()` now **includes inline
`|T…|t` icon widths**, so the measured width matches the rendered width.
Editbox text is not adjusted — an editbox shows and measures raw markup.
### `fontstring:GetUnboundedStringWidth()`
Returns the width of the text on one line, in UI pixels, with no wrap
or size limit applied. Note: `GetStringWidth` ALSO ignores word wrap —
that is a classic API trap on every client. The two methods differ
only on modern clients, and only for text truncated with an ellipsis:
there `GetStringWidth` reports the displayed width and this method
reports the full string. On this client the two always agree; the
method exists so modern code that calls it works. The width of the
text AS RENDERED (the widest wrapped line) is `GetWrappedWidth`, below.
Inline `|T…|t` icon widths are included. Empty or unset text returns
`0`.
### `fontstring:GetWrappedWidth()`
Returns the width of the text as it renders, in UI pixels: the width
of the widest wrapped line. This is the method to use for the visible
size of wrapping text — `GetStringWidth` and `GetUnboundedStringWidth`
both measure the string on one line and ignore wrapping. Later clients
added the method. Text that does not wrap returns the same value as
`GetStringWidth`. Inline `|T…|t` icons are counted. Empty or unset
text returns `0`.
The wrapped width can carry a ≤1px difference from a direct measure of
the same line text — the same rounding noise `GetStringWidth` itself
carries.
```lua
local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(100)
f:SetText("a long line that wraps a few times")
-- f:GetStringWidth() -> ~165 (one line, ignores wrap)
-- f:GetWrappedWidth() -> ~94 (the widest rendered line)
```
### `fontstring:GetNumLines()`
Returns the number of wrapped lines the text renders as. Later
clients added the method. It works whether or not the text has
rendered yet. Empty or unset text returns `0`.
### `fontstring:GetLineHeight()`
Returns the height of one text line in UI pixels — the font height,
without the `SetSpacing` value. Later clients added the method. Use
it with `GetNumLines` and `GetSpacing` to reconstruct
`GetStringHeight` per line.
### `fontstring:IsTruncated()`
Returns `true` when the engine cut the text off with an ellipsis
(`...`). Later clients added the method. The engine truncates only
when the text does not fit a bounded box. The fontstring needs a
width and a height, and the text must overflow that box. Vanilla
fontstrings always wrap at spaces. So a box that is only one line
tall truncates, but a taller box wraps the text and fits it.
The result reflects what is on screen, so the fontstring must have
drawn at least once. `GetText` still returns the full text, not the
cut-off line.
```lua
local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(60)
f:SetHeight(14) -- one line tall
f:SetText("a name too long to fit")
-- after it draws: f:IsTruncated() -> true (draws "a name too...")
```
### `fontstring:SetMaxLines(maxLines)` / `fontstring:GetMaxLines()`
Caps the fontstring to `maxLines` wrapped lines. Text past the cap
is cut off with an ellipsis (`...`). Pass `0` (or nothing) for no
cap. Later clients added the method for fontstrings.
This is the way to make a truncating label in 1.12. Vanilla
fontstrings always wrap at spaces, and there is no `SetWordWrap`. So
`SetMaxLines(1)` gives a single-line label that ends in `...` when
the text is too wide, even inside a tall box.
The cap needs a width to take effect: the text must have an edge to
overflow. `GetMaxLines` returns the current cap (`0` when unset). Use
`IsTruncated` to check whether the cap actually cut the text.
```lua
local f = frame:CreateFontString(nil, "ARTWORK", "GameFontNormal")
f:SetWidth(120)
f:SetMaxLines(1) -- one line, then "..."
f:SetText("a really long guild or player name that will not fit")
-- draws "a really long gu..." ; f:IsTruncated() -> true
```
### `fontstring:SetFormattedText(format [, ...])`
Sets the text to `string.format(format, ...)`. Later clients added
the method as a convenience over `SetText(format(...))`. A bad format
string raises a normal Lua error. The set goes through the same engine
path as `SetText`, so escape handling is identical.
```lua
f:SetFormattedText("%d/%d (%.1f%%)", cur, max, cur / max * 100)
```
### `texture:SetRotation(angle [, cx, cy])`
Rotates a texture by `angle` radians. A positive angle turns the texture
counter-clockwise. Later clients added this method. Vanilla has no texture
rotation of its own.
The optional `cx, cy` set the pivot point, as a normalized position inside the
texture from `0` to `1`. The default pivot is the center, `(0.5, 0.5)`. An
angle of `0` clears the rotation and returns the texture to upright.
The method turns the four corners of the drawn quad, so the whole texture stays
visible and no corner is clipped. It works on any texture, including
engine-created ones. The rotation holds across moves and resizes, and costs
nothing per frame while it stays still. `GetRotation()` returns the current
angle in radians.
```lua
local t = frame:CreateTexture(nil, "ARTWORK")
t:SetAllPoints(frame)
t:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
t:SetRotation(math.rad(45)) -- 45 degrees counter-clockwise
t:SetRotation(math.pi, 0, 1) -- half turn around the top-left corner
```
### `texture:SetVertexOffset(vertexIndex, offsetX, offsetY)`
Moves one corner of a texture by `offsetX, offsetY` pixels. `vertexIndex` picks
the corner: `UPPER_LEFT_VERTEX`, `LOWER_LEFT_VERTEX`, `UPPER_RIGHT_VERTEX`, or
`LOWER_RIGHT_VERTEX` (values 1 to 4). `+x` is right and `+y` is up. Vanilla has
no way to move a texture's corners. This backport matches retail.
Moving the corners warps the whole quad, so the texture and its background move
together. It suits skew, trapezoid, and fake-perspective effects, and waving
animations. It composes with `SetRotation`: the texture rotates first, then the
offsets apply. The offset holds across moves, resizes, and `SetTexture`, and
costs nothing per frame while it stays still.
`GetVertexOffset(vertexIndex)` returns the current `offsetX, offsetY` for a
corner (`0, 0` if unset).
Note: an offset that turns the quad inside-out — mirroring it past its opposite
edge — is not drawn. The engine skips a back-facing quad, so keep the warp
within a non-mirrored shape.
```lua
local t = frame:CreateTexture(nil, "ARTWORK")
t:SetAllPoints(frame)
t:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
-- pull the top two corners inward: a trapezoid (fake perspective)
t:SetVertexOffset(UPPER_LEFT_VERTEX, 20, 0)
t:SetVertexOffset(UPPER_RIGHT_VERTEX, -20, 0)
```
### `texture:SetColorTexture(colorR, colorG, colorB [, a])`
Fills the texture with a solid color. Each channel is `0` to `1`. The alpha `a`
is optional and defaults to `1` (opaque). This is the 7.0 name for a fill that
vanilla already does — `SetTexture(r, g, b [, a])` with numbers instead of a
path. The backport is the same engine call under a second name, so the clamping
and the opaque default match the engine.
```lua
local t = frame:CreateTexture(nil, "BACKGROUND")
t:SetAllPoints(frame)
t:SetColorTexture(0.1, 0.6, 1.0, 0.8) -- semi-transparent blue fill
```
### `fontstring:SetRotation(angle [, cx, cy])`
Rotates a FontString's text by `angle` radians. A positive angle turns the text
counter-clockwise. Later clients added this method. Vanilla has no text rotation
of its own.
The optional `cx, cy` set the pivot point, as a normalized position inside the
text from `0` to `1`. The default pivot is the center, `(0.5, 0.5)`. An angle of
`0` clears the rotation and returns the text to upright.
The rotation is visual only. `GetStringWidth`, `GetStringHeight`, `GetRect`, and
`SetPoint` all stay axis-aligned, so a rotated label measures and anchors as if
it were upright. This matches retail. `GetRotation()` returns the current angle
in radians.
The method turns the glyph vertices, so the whole text stays visible. It works on
any FontString, and the rotation holds across text changes, moves, and resizes.
It costs nothing per frame while the text stays still.
Inline `|T…|t` icons inside a rotated FontString do not turn with the text. They
draw as separate anchored regions. Plain text rotates correctly.
```lua
local fs = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
fs:SetPoint("CENTER")
fs:SetText("Rotate me")
fs:SetRotation(math.rad(45)) -- 45 degrees counter-clockwise
fs:SetRotation(0) -- back to upright
```
### `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])`
The modern rename of vanilla's `SetMinResize` / `SetMaxResize` pair.
@@ -4034,9 +4417,9 @@ modifier/button-qualified, same precedence as `type`):
| `target` | — | Targets the `unit` (or clears the target if `unit` is `"none"`). Respects the engine's default-interaction precedence: with a spell on the cursor it casts on the unit, with an item on the cursor it drops it on the unit, instead of switching target. |
| `assist` | — | Targets the `unit`'s target. |
| `focus` | — | Sets the ClassicAPI focus to the `unit`. |
| `spell` | `spell` | Casts the `spell` on the `unit` via [`C_Spell.CastAtUnit`](#c_spellcastatunitspellidorname-unit) — the unit's GUID goes straight to the cast dispatcher (no target juggling), and ground-target spells land at the unit's feet. |
| `spell` | `spell` | Casts the `spell`. With a `unit`, it casts on that unit via [`C_Spell.CastAtUnit`](#c_spellcastatunitspellidorname-unit) — the unit's GUID goes straight to the cast dispatcher (no target juggling), and a ground-target spell lands at the unit's feet. With no `unit`, it casts on the current target through `CastSpellByName`, like a plain `/cast`. |
| `item` | `item`, or `bag`+`slot` | Uses an item on the `unit`. `item` may be a name / itemID / link (used via `C_Item.UseItemByName`, unit as the target) or a `"bag slot"` string like `"0 1"` (used via `UseContainerItem`). The deprecated `bag`+`slot` attributes are used when `item` is unset. |
| `macro` | `macrotext` or `macro` | Runs the macro text. Prefers an addon `RunMacro` (e.g. SuperCleveRoidMacros — named macros + extended conditionals); otherwise runs the text natively, line by line, through the stock `ChatEdit_ParseText`. |
| `macro` | `macrotext` or `macro` | Runs the macro text. To give the macro a unit, the handler selects the clicked `unit`, runs the macro, then restores the target from before the click. It prefers an addon `RunMacro` (for example, SuperCleveRoidMacros — named macros and extended conditionals). If no addon `RunMacro` exists, it runs the text natively, line by line, through the stock `ChatEdit_ParseText`. |
| `stopcasting` | — | Stops the current cast. |
| `menu` / `togglemenu` | — | Pops the standard unit dropdown at the cursor (whisper / inspect / trade / invite / …, the same menu `PlayerFrame` / `TargetFrame` / `PartyMemberFrame` show). |
@@ -5333,6 +5716,32 @@ not modern's `ComboPoints` reuse of the same number.
local rage = UnitPower("player", Enum.PowerType.Rage)
```
### `Enum.SpellBookSpellBank`
Selects which spellbook a `C_SpellBook.*` slot query reads. Passed as the
`spellBank` argument to
[`C_SpellBook.GetSpellBookItemInfo`](#c_spellbookgetspellbookiteminfoslotindex-spellbank).
| Value | Field |
|------:|----------|
| `0` | `Player` |
| `1` | `Pet` |
### `Enum.SpellBookItemType`
Categorizes a spellbook slot. Vanilla's spellbook only ever holds real
spells, so `C_SpellBook.GetSpellBookItemInfo` returns `Spell` for a
player-book slot and `PetAction` for a pet-book slot. The other values
exist for signature parity with retail but never occur in 1.12.
| Value | Field | Notes |
|------:|---------------|-------|
| `0` | `None` | Empty slot. Never returned — an empty slot yields `nil` instead. |
| `1` | `Spell` | A player-book spell. |
| `2` | `FutureSpell` | A not-yet-learned trainer spell. Never occurs in 1.12. |
| `3` | `PetAction` | A pet-book spell. |
| `4` | `Flyout` | A flyout group — a later-expansion concept. Never occurs. |
## Glue
Backports of two `C_Glue` session helpers. `C_Glue` is registered on the glue
@@ -9102,6 +9511,79 @@ No retail equivalent (retail ships only the explored getter; its inverse,
`C_Map.GetMapOverlays`, returns *everything*). Same table shape and
`areaID` semantics as the explored getter.
## Model
### `model:SetDisplayInfo(creatureDisplayID)`
Points a Model frame at a creature by its display ID. This is the modern
alternative to `Model:SetModel(path)`, which needs a raw model file path.
The engine resolves the display ID to a model file through two DBC tables:
`CreatureDisplayInfo`, then `CreatureModelData`. It loads the model the same
way `SetModel` does, then applies the creature's skin textures. So the model
shows the correct look for that display, not an untextured base.
A Model frame provides no light of its own. Call `SetLight` after
`SetDisplayInfo`, or the model renders as a black silhouette. Call
`SetCamera(0)` to frame it.
An unknown or missing display ID falls back to the engine's placeholder
model.
A character-based display (an NPC on the shared `Character\<Race>\<Sex>`
base model) is dressed the same way a spawned NPC is: baked body texture,
hair, face, facial hair, and equipment, including the attached helm and
shoulder models. The dressing runs when the model file finishes loading,
a moment after the call. Weapons do not show: the server sends NPC
weapons only for spawned units, and no client table carries them.
This is the single-creature form, the same one MoP shipped. Later retail
clients added an optional second argument `mountDisplayID` that shows the
creature on a mount. That form is not supported here: a mount needs a
second, attached model, and vanilla's Model frame holds one model.
```lua
local m = CreateFrame("Model", "PreviewModel", UIParent)
m:SetSize(200, 260)
m:SetPoint("CENTER")
m:SetDisplayInfo(330) -- a human NPC
m:SetCamera(0)
m:SetLight(1, 0, 0, -1, -1, 1, 1, 1, 1) -- else it is a black silhouette
```
### `model:SetCreature(creatureID)`
Points a Model frame at a creature by its **entry ID** — the creature's
database ID, not a display ID. This is the older companion to `SetDisplayInfo`:
WoW added `SetCreature` in WotLK and `SetDisplayInfo` later, in Cataclysm.
The engine looks the entry ID up in the client creature cache, reads the display
ID, then loads the model exactly like `SetDisplayInfo`. The same light and camera
notes apply.
**Limitation.** The lookup only works for a creature the client has cached. A
creature caches after you see it, target it, or mouse over it, or from
`creaturecache.wdb` at login. Vanilla has no client-side entry-to-display table,
so an uncached creature ID is a no-op — the frame keeps its previous model.
For an uncached creature, request its data first, then set it when the data
arrives:
```lua
local creatureID = 448 -- the creature's entry (database) ID
C_CreatureInfo.RequestLoadCreatureByID(creatureID)
local f = CreateFrame("Frame")
f:RegisterEvent("CREATURE_DATA_LOAD_RESULT")
f:SetScript("OnEvent", function()
if arg1 == creatureID and arg2 then -- (creatureID, success)
model:SetCreature(creatureID)
model:SetCamera(0)
model:SetLight(1, 0, 0, -1, -1, 1, 1, 1, 1)
end
end)
```
## ChatBubbles
### `C_ChatBubbles.GetAllChatBubbles([includeForbidden])`
@@ -11305,7 +11787,7 @@ resistance-aware aura libraries, and damage-meter school tagging.
Previously addons either maintained hardcoded `spellID → school`
tables or scanned tooltips for the first-line color tag.
### `CastSpellNoToggle(name | spellID)`
### `CastSpellNoToggle(name | spellID [, unit])`
Spam-safe variant of `CastSpellByName` that won't toggle off an
already-active spell. Covers both kinds of vanilla-toggle abilities:
@@ -11357,6 +11839,27 @@ CastSpellNoToggle("Aspect of the Hawk")
CastSpellNoToggle("Battle Stance")
```
#### Optional unit target
A second argument casts the spell at a unit, without changing your
current target:
```lua
CastSpellNoToggle("Auto Shot", "focus") -- Auto Shot on your focus
CastSpellNoToggle("Shoot", "targettarget") -- wand your target's target
```
The unit is any standard token — `"focus"`, `"targettarget"`,
`"party1"`, `"mouseover"`, a player name, and so on. A unit-target or
auto-repeat spell fires straight at that unit. A ground-target spell
lands at the unit's feet. This is the same cast-at-unit path as
[`C_Spell.CastAtUnit`](#c_spellcastatunitspellidorname-unit).
The toggle gates run first, so the unit only matters when the spell
actually casts. If the spell is already toggled on, the call stays a
no-op and ignores the unit. An unknown unit token raises the engine's
standard "Unknown unit" error, the same as `UnitHealth`.
String input matches case-insensitively and tolerates a trailing
`(Rank N)` suffix the same way `CastSpellByName` itself does —
`"Shoot"` and `"Shoot(Rank 1)"` both compare equal to a Shoot that's
@@ -11366,8 +11869,9 @@ Reads `[VAR_ACTIVE_AUTO_REPEAT_SPELL]` (`0x00CEAC30`) for the auto-
repeat check, and the engine's `FUN_SPELL_IS_TOGGLE_AURA_ACTIVE`
(`0x004B36F0`) for the aura-active check. Delegates to
`Script_CastSpellByName` (`0x004B4AB0`) for the actual cast — same
resolution semantics (rank picking, target rules, etc.) as the
engine's own global.
resolution semantics (rank picking, target rules, and more) as the
engine's own global. With a unit argument, it dispatches through the
same cast-at-unit path as `C_Spell.CastAtUnit` instead.
Using this from inside a macro action slot? See
[`CastSpellNoToggle` as a macro cast line](#castspellnotoggle-as-a-macro-cast-line) below for the additional
@@ -11697,6 +12201,40 @@ end
Equivalent to the legacy function of the same name introduced in 3.0
(later renamed to `FindSpellBookSlotBySpellID` in 5.x).
### `C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)`
Returns a table describing the spell in a spellbook slot. `slotIndex` is
1-based across the whole book. `spellBank` picks the book —
[`Enum.SpellBookSpellBank.Player`](#enumspellbookspellbank) (`0`) or
`Enum.SpellBookSpellBank.Pet` (`1`).
```lua
local info = C_SpellBook.GetSpellBookItemInfo(1, Enum.SpellBookSpellBank.Player)
-- info.name, info.spellID, info.itemType, info.isPassive, info.iconID, ...
```
Table fields:
| Field | Type | Notes |
|-------|------|-------|
| `itemType` | [`Enum.SpellBookItemType`](#enumspellbookitemtype) | `Spell` (1) for the player book, `PetAction` (3) for the pet book. |
| `actionID` | number | The spellID. Equals `spellID` — vanilla has no spell-override system. |
| `spellID` | number | The spellID in the slot. |
| `name` | string | The localized spell name. |
| `subName` | string | The rank text (e.g. `"Rank 3"`), or `""` when the spell has no rank. |
| `iconID` | string | The icon **path** — feed it straight to `texture:SetTexture(...)`. |
| `isPassive` | boolean | `true` for a passive spell. |
| `isOffSpec` | boolean | Always `false`. |
Returns `nil` for an empty or out-of-range slot, matching the modern API.
> **Deviations from retail, forced by 1.12's data.** `iconID` is a
> texture path, not a `fileID` (vanilla has no fileID system) — the same
> deviation as [`C_Spell.GetSpellInfo`](#c_spellgetspellinfospellid).
> `skillLineIndex` is not returned (`nil`); spellbook tabs aren't
> SkillLines. `isOffSpec` is always `false` — vanilla has no
> specializations.
### `C_SpellBook.GetSpellLevelLearned(spellID)`
Returns the level at which a spell becomes available — the
@@ -13206,6 +13744,31 @@ resolver the engine's `UnitCreatureType` uses, which handles every unit kind
[`C_CreatureInfo.GetCreatureTypeInfo`](#c_creatureinfogetcreaturetypeinfocreaturetypeid)
to get the localized name back from the id.
### `UnitCreatureID(unit)`
Returns the numeric **creature id** — the NPC / creature-template entry — for a
unit, or `nil`. This is the unit-token twin of
[`C_CreatureInfo.GetCreatureID`](#c_creatureinfogetcreatureidguid): it does
`C_CreatureInfo.GetCreatureID(UnitGUID(unit))` for you.
```
creatureID = UnitCreatureID(unit)
```
```lua
/dump UnitCreatureID("target") -- 1842 for Hogger, nil for a player
/dump UnitCreatureID("pet") -- your pet's creature-template id
```
Vanilla packs the entry id into the unit's GUID (bits 24-47), so this reads it
straight from the resolved GUID — no creature cache lookup. Returns `nil` for a
player (a player GUID carries no template), an unresolvable-but-valid token
(`"target"` with nothing targeted, an empty `"partyN"` slot), and any unit whose
GUID isn't a creature or pet. Raises a Lua error on a garbage token — the
standard `UnitX` behavior. Pair with
[`C_CreatureInfo.GetCreatureInfoByID`](#c_creatureinfogetcreatureinfobyidcreatureid)
to get the name from the id.
### `GetUnitSpeed(unit)`
Returns `currentSpeed, runSpeed, flightSpeed, swimSpeed` — all four
@@ -13855,6 +14418,40 @@ This is the Blizzard-shaped surface over the field nampower exposes raw as
Consumers that need the raw multiplier back can recover it as
`1 / (1 + UnitSpellHaste(unit) / 100)`. Returns `0` for invalid units.
### `UnitSpellTargetName(unit)`
Returns the name of the unit that `unit` is casting or channeling a spell
**at**, or `nil`.
```
targetName = UnitSpellTargetName(unit)
```
```lua
UnitSpellTargetName("target") -- "Playername" while the mob casts at you
UnitSpellTargetName("player") -- your current cast's target, or nil
```
Returns `nil` when:
- `unit` is not casting or channeling.
- The spell has no unit target. This covers a self-cast, a ground-target
spell (Blizzard, Rain of Fire), and an item or lock cast.
- The target's name can't be resolved (an off-screen stranger).
The target comes from the cast's `SMSG_SPELL_START` packet. ClassicAPI
captures it for the player and for any remote unit whose cast you observed
since it began. So the same limit as
[`C_Spell.UnitCastingInfo`](#c_spellunitcastinginfounit--c_spellcastinginfo)
applies: a remote unit's target is known only while you saw its cast. The
name is resolved through the object manager (players and creatures), then the
friends list, then the persistent name cache — the same chain as
[`UnitNameFromGUID`](#unitnamefromguidguid).
The player's target lands with the confirming packet, about one round-trip
after the cast starts. So `UnitSpellTargetName("player")` can read `nil` for
the first part of your own cast, then return the name.
## UnitAuras
Backport of the modern `C_UnitAuras` namespace. Returns
+761
View File
@@ -0,0 +1,761 @@
# Backporting inline `|T…|t` texture escape sequences to 1.12
The 1.12 text engine shows `|Tpath:height:width:…|t` markup as literal text.
This project makes it draw the icon inline in FontStrings, chat, and tooltips —
the way 4.3.4 and later clients do. The feature is complete and shipped
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp),
[src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)).
This file is the reverse-engineering map. **Current design** below states what
ships today. Everything from **Goal & spec** down is the historical RE trail —
the 4.3.4 study, the 1.12 injection map, and the bring-up plan. It is kept for
the addresses and the reasoning, not as a description of the current code.
## Current design (shipped)
Each icon renders as an **engine-managed region** (a pooled `CSimpleTexture`)
anchored to its owning FontString. The engine draws the region every frame and
moves it with its line, so texture residency and scroll tracking are correct by
construction. (A raw-GxU-quad renderer existed during bring-up and was removed
in `56c2670`; its RE is the "REMOVED" section below.)
The DLL hooks the engine's own text pipeline — no companion addon:
- **Positioning** — a co-hook on the glyph emitter `FUN_005ccbe0` records each
icon at its pen position and delegates the plain text runs to the engine
(segmented delegation, below). The pen read-back needed a fix: the engine's
pen is lazy and omits the last glyph's own advance, so a coin sat on its
digits; the emitter now adds the true terminal advance from the engine's own
glyph-advance helpers (`550cf5e`).
- **Rendering** — the paint-pass co-hook `FUN_005c8fe0` walks the icon records
and queues each to `Text::InlineTexturePool`, which places the region on the
next frame tick (never mid-render).
- **Placement coordinates** — text pen space IS render-target pixels. The
engine stores node positions NORMALIZED (fs-local anchor ÷
`[0x832A44]`/`[0x832A48]` per axis, `FUN_0041ade0`), and the origin finalize
`FUN_005cdf70` multiplies by the integer raster dimensions
`[0xC2B9A4]`/`[0xC2B9A0]` (rounded, with the justify shift and vertical
align folded in). So the flush converts pen → fs-relative anchor with
`K = raster ÷ anchorExtent`, **per axis**, from those four live globals — no
calibration, no cache, correct at every resolution and UI scale (`968335d`).
Two earlier forms both failed: an empirical K (`origin ÷ rect edge`) that
chat could never self-derive (its rect edge sits near screen x = 0 and
failed the divide guard) and so rode a global cache seeded by whichever
fontstring derived first — right only at the layout it was calibrated on;
then a closed form built from the SetPoint px factor, which used the wrong
global pair. Full derivation chain at `VAR_TEXT_RASTER_X/Y` in `Offsets.h`.
- **Fade / parent alpha** — icons fade with their line (the pool mirrors the
fs's folded colour alpha per frame, `3ca3b7e`), and segmented GLYPHS do too:
a bit-3 (standalone FontString) node's baked per-glyph colours never re-bake
on an alpha change (the engine's colour-change invalidate `FUN_005ccb40`
skips bit-3 nodes), so the flush mirrors the node's live alpha byte
(node+0x2c — SetTextColor alpha × frame-chain alpha, kept folded by the
engine) into every baked colour, change-detected per page (`bd14a93`). An
earlier `0xFF` alpha force at emit was a misdiagnosis of a bubble that built
while faded out; it froze icon-line text fully opaque while everything
around it faded.
- **Outlined fonts** — outline ink extends past the glyph advances; the
engine itself grows line height by exactly 4.0 / 2.0 pen px for thick / thin
outline (`face+0x180` bits 3 / 0, `.rdata` constants). `OutlineInkPen` reads
the same flags and constants, and half the ink leads each icon, half trails
— threaded through the shared advance helper so the emitter advance, the
justify pre-shift, and the wrap-stepper probes all agree. Without it, coins
clipped into outlined digits the moment the exact K removed the old
miscalibration's accidental slack (`968335d`).
- **Measure** — three cold FontString-level co-hooks make measure match the
drawn result: width (`FUN_00772890`), height (`FUN_007729B0`), and wrap
(`FUN_005C7260`). `GetStringWidth`, `fontstring:GetStringHeight`, wrap
breaks, and line height all count the icon.
- **Tall icons** — an icon taller than the font grows its line height, so it
does not overlap its neighbors, and it centers in the grown line. A hyperlink
that contains a tall icon is hoverable across the whole icon
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
- **Editboxes** — an editbox keeps the raw markup, so the caret stays aligned
(two complementary signals, below).
- **Payload** — the full
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
TGA.
- **Anti-spoof** — chat strips player-injected `|T` icons.
Every fs-level measure path now counts icons: width (`FUN_00772890`), height
(`FUN_007729B0`), wrap (`FUN_005C7260`), and the substring measure
(`FUN_00772AE0` — an earlier note claimed nothing consumed it; its xrefs
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of a
wrap-enabled line through it, so icon-bearing wrapped tooltip lines
undersized their tooltip until it was hooked too). The Lua control surface is
one kill switch, `_classicapi_InlineTexEnable` (the SEH latch trips it on a
flush fault); the bring-up tune / stat / probe functions were removed
(`5e5677b`, `6800a9a`).
## REMOVED (bring-up record) — the raw-GxU-quad rendering primitive
This was the FIRST renderer: each icon drawn as a raw textured quad through the
text VB path. It shipped, then was removed in `56c2670` for the engine-region
model (regions are resident by construction; the quad path needed the texture-
residency machinery below and still flickered on VRAM pressure — see
`docs/InlineTextureResidency.md`). This section is the address record for the
removed code, not a description of the current renderer. `LoadTextureByPath`,
`DrawTexturedQuad`, and the GxU device offsets it names are gone from
`Offsets.h`; the values live here and in git history.
An arbitrary texture draws through the text VB path as a coloured, alpha-blended
quad. The end-to-end recipe:
1. **Load by path** — `LoadTextureByPath` replicates `FUN_00770200`'s load: build
flags via `FUN_0058a980(&flags, DAT_00878cf0, 0,0, 0,0,0, 1, 0)`, then
`FUN_00449d90(path, &desc, flags, 0, 1)` with a byte-identical 5-dword desc
(`{PTR_FUN_007ffa10, 8, &self8, (&self8)|1, 0}`). Returns the **HTEXTURE**
(0x15c-byte struct; path@+0xc, flags@+0x110, refcount@+4).
2. **THE KEY — residency + correct bind via `FUN_0044acf0(HTEXTURE, 1, 0)`.**
This is the engine's own "get renderable texture" call (what the widget
render uses every frame). It returns the bindable **CGxTexture** at
`[HTEXTURE+0x140]` and, with force=1, drives the streaming load
(`FUN_0044ad50` submits/prioritises it via `[HTEXTURE+0x138]`). **Calling it
every frame is the live reference that makes the texture resident** — no
hidden widget, no manual BLP decode. This was the entire multi-session
blocker: WoW streams textures and only loads ones a live frame references; a
standalone `FUN_00449d90` texture never loads on its own. Binding the raw
HTEXTURE is `SetTexture([HTEXTURE+0x48]=0)` = a **white** quad; the D3D
handle lives on the CGxTexture (`[CGxTexture+0x48]`), not the HTEXTURE.
Dead ends that wasted time: manually invoking the decode callback
`FUN_0044a260` (fills `[+0x120]` but not the bindable `+0x140`, and faults);
binding `[HTEXTURE+0x48]` or `[HTEXTURE+0x120]` (both wrong offsets); forcing
the streamable flag `tex[0x3c]|=0x80` (crashes in d3d9 — the bind's stream-in
vfunc corrupts the resource).
3. **Bind** — `FUN_00589e80(0x17, cgxTex)` (selector 0x17 = text texture stage).
4. **Colour** — the font stage's default stage-0 COLOROP is already MODULATE, so
`texture.rgb × diffuse(white)` = the icon's true colours; texture alpha works
too. No colorop override is needed for a plain white vertex. (If a tint is
wanted later: COLOROP is D3DTSS internal state `0x42` via `FUN_005a2570`; the
engine leaves COLORARG1 at the D3D default = TEXTURE. Do NOT poke it directly
mid-draw — it leaks `MODULATE` to the whole UI when a fault skips the restore.
Use the engine's selector `0x1f` (value→COLOROP/ALPHAOP preset table at
`DAT_0080a25c`/`DAT_0080a274`; value 1 = MODULATE/MODULATE).)
5. **Quad** — lock the shared dynamic VB `FUN_0058a140(0, 0x18, 0x800)`, get the
write ptr `FUN_0058a080`, write **4 verts** of the format
`{float x,y,z; u32 colorBGRA; float u,v}` (stride 0x18) in corner order
**TL, TR, BL, BR** (matches the shared quad index buffer `{0,1,2, 2,1,3}` and
the device cull winding), submit `FUN_005c8f40(&handle, 4)`, unlock
`FUN_0058a0a0(handle, 0)`.
6. **Where** — co-hook the text paint pass `FUN_005c8fe0` and draw after the
original glyph flush (device is in the correct textured-UI state there).
Coordinate space is **per-layout pixels, y-down**; glyph verts live at small
local coords (a probed glyph was at ~(9,33)–(17,50)). Draw coords must be
within the layout's rendered area (a fixed (100,100) test lands in chat).
The build-side gotcha: the D3D device (default backend, `FUN_0058dd70`) is
actually **OpenGL** (`glMaterialfv`/`glFogi`) — the D3D backend is `FUN_0058ba70`
(`FUN_00598ce0`, vtable `PTR_FUN_00809ef8`); its state applier is `vtable[1]` =
`FUN_005a2f00`, which maps engine selectors to D3D via `FUN_005a2570`
(SetRenderState `+0xe4`, SetTextureStageState `+0x10c`, SetSamplerState `+0x114`,
SetTexture `+0x104`). Selector map (shared across GL/D3D backends): 7=blend,
0x10=depthtest, 0x14=cullface, 0x17-0x1e=texture bind (8 units),
0x1f-0x26=colorop/alphaop preset (unit 0-7).
## SOLVED — positioning, measure/wrap, editbox exclusion, texcoords
The primitive draws at arbitrary coords; the rest is knowing WHERE and WHEN.
Everything is in [src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp);
offsets under the "Inline texture escape" + positioning blocks in
[Offsets.h](../src/Offsets.h).
### The text render hierarchy (verified)
```
frame → renderNode [frame+0x118] → group [renderNode+0x18]
→ node [group+0x18] → line [node+0x24] ← the emitter's `this`
```
- Main UI render `FUN_007657d0(DAT_00cf0bd8)` walks strata → frames, calls
`FUN_0076fb00(renderNode)` per frame's render node.
- `FUN_0076fb00` draws the frame's Texture regions then `FUN_005c1ef0(group)` →
`FUN_005c8b70(group)` → `FUN_005c8fe0(node)` per node.
- `FUN_005c8fe0(node)` = the **paint pass** we co-hook: ensures each line built
(`FUN_005cd6a0` → `FUN_005cdc20` → the emitter), then binds each font page and
copies the line's per-page glyph batches into the dynamic VB, translated by the
line origin `[line+0x70]/[+0x74]`.
- The **emitter** `FUN_005ccbe0(line, text, len, colorState, penXYZ, pageMask,
linkState)` builds one wrapped line's glyph quads. `this` (the "line"/node the
icon list keys on) owns batches `[+0xa0+page*4]` and origin `[+0x70]`. **Note
the render node the emitter sees has NO back-pointer up to the frame** — the
editbox discriminator had to be found another way (see below).
### Positioning — segmented delegation (co-hook the emitter)
We do NOT reimplement the emitter's intricate glyph vertex math. Instead, in the
`FUN_005ccbe0` co-hook, for a line containing an inline texture:
1. Split the line at icon boundaries. Render each **plain run** by calling the
ORIGINAL emitter on just that substring — the engine still draws every glyph.
2. Thread the pen across runs: on return the emitter leaves the final node-local
pen x in **`linkState[4]`**, stored with `FSTP dword` — a **FLOAT bit
pattern**, not an int. Read it as a float (`*(float*)&linkState[4]`); an
int-cast yields garbage (this bug hid the icon off-screen and dropped the
trailing text).
3. Record an `IconRecord` at the pen (node-local `x`,`y`) into `g_nodeIcons[line]`
and advance the pen by the icon width. No draw runs during the build — icons
are recorded and **flushed** in the `FUN_005c8fe0` paint co-hook, which
computes each icon's pen-space position (line origin `[line+0x70]/[+0x74]`
maps node-local → pen, the same translate the glyph copy `FUN_005c8710`
applies), converts pen → anchor with the per-axis K (see **Placement
coordinates** in Current design), and queues it to `Text::InlineTexturePool`
as a placement RELATIVE TO THE OWNING FONTSTRING. The pool applies
placements on the next frame tick.
4. Restore `penXYZ[0]` on exit (the original never writes it; the draw builder
does NOT reset it between left-justified lines, so leaving it mutated
cascade-shifts every following line).
### FontString pipe-doubling — the escape arrives as `||T…||t`
The FontString text sanitizer **doubles any pipe that doesn't begin a recognized
escape** so it renders as a literal `|`. 1.12 doesn't know `|T`, so a caller's
`|Tpath:h|t` reaches the emitter as `||Tpath:h||t` (verified: emitter text length
+2, pipes doubled). The detector therefore matches BOTH the doubled form (the
real-world case) and a clean `|T` (should one ever arrive undoubled). Normal
doubled pipes (`|| `) never match because the next char must be `T`.
### bit-3 batch-clear mode
The emitter clears the line's page batches at its top only when node flag
`[line+0x5c] & 0x08` is set (the FontString/static case). Calling the original
per segment would then wipe all but the last run. Handled by doing the clear
ONCE per build (a `len==0` original call with bit 3 still set), then clearing
bit 3 so the per-segment calls APPEND, restoring flags on exit. Bit 5 (shadow)
isn't set for these nodes, so the only added work is a harmless colour append.
Clearing bit 3 has one more consequence: it switches the engine from the
UNIFORM colour path (node+0x2c, consulted live at every paint) to baked
PER-GLYPH colours the paint uses verbatim. The build-time alpha in colorState
is already correct (the engine folds SetTextColor alpha × frame-chain alpha
into node+0x2c on every alpha change), but the baked copies never refresh —
the flush's alpha mirror keeps them tracking later fades. See **Fade / parent
alpha** in Current design.
### Multi-line / multi-icon accumulation
The draw builder walks a node's wrapped lines by calling the emitter repeatedly
on the SAME node, advancing the text pointer. So the icon list is cleared ONCE
per build — on the first wrapped line, detected by `text == [node+0x48]` (the
node's text start) — and icons ACCUMULATE across lines. Multiple icons per line
fall out of the segmentation loop naturally.
### Vertical centring — font-relative
`penXYZ[1]` sits near the text TOP, so the icon centre = `penY + fontHeight *
g_centerFrac` (0.6, calibrated in-game across chat + pfUI's bubble). Font height
comes from the engine's own `FUN_005c6fa0(flag, [node+0x1c])` (the emitter's
glyph-sizing call), so centring holds across font sizes without a fixed pixel
nudge. A TALL icon (height > font) centres in its GROWN line (see the tall-icon
sections in Current design) — the string-height co-hook adds the overflow, so
the extra room sits half above and half below the text.
### Measure / wrap — co-hook the tokenizer
Line wrap is decided by the measure/fit functions (`FUN_005c6940`, `FUN_005c7470`
via `FUN_005c7260`), which all share the `|`-tokenizer `FUN_005c2810`. Left
alone, they count the hidden path text and wrap early. Fix: co-hook the
tokenizer — when it's at an inline-texture span, consume the whole span as ONE
near-zero-width token so every measure/fit caller ignores the path characters.
The emitter detects icons independently (it scans the raw bytes), so the draw
path is unaffected.
### Measure width: FIXED at the fs-level choke (`GetStringWidth` counts icons)
The tokenizer reports an icon span as a **near-zero-width** token, so at the
gxu loop level every measure caller counts the icon as ~0 pixels wide. (The
emitter still reserves the render width, so text after an icon positions
correctly.) An earlier version of this doc concluded that the gap could not
be closed cleanly, because the gxu engine has **no single cold choke**:
- `FUN_005c6940` (width), `FUN_005c7470`/`FUN_005c7260` (wrap), and the
hit-test loop each tokenize and accrue width independently.
- The token contract has no width field.
- The per-glyph helpers (`FUN_005cabd0`/`FUN_005c6b70`) are the hottest text
functions in the engine.
That analysis was right about the gxu level. The choke sits ONE LEVEL UP:
**`FUN_00772890` = `CSimpleFontString::GetStringWidthInternal`**
(`Offsets::FUN_FONTSTRING_STRING_WIDTH`) is cold, MinHook-safe, and the single
funnel for exactly 4 callers:
- `Script_GetStringWidth` (0x0079E510)
- the GameTooltip auto-size (0x00530640)
- the editbox caret positioner (0x0077DE70)
- the layout effective-width vmethod (`FUN_00772930` = fs+0x24 vtbl +0x1C,
which feeds auto-width fontstring layout)
It lazily computes into the `fs+0xFC` cache (anchor units, 0.0 sentinel) via
the measure core `FUN_0044D670`, then divides the pen-space result by
`fs+0x7C`.
The co-hook lives in `src/text/InlineTexture.cpp`. It calls the original,
then adds `SumIconAdvances(text) / K` when two gates pass:
- `InlineInterceptActive(text)` is true — the **same predicate** the tokenizer
hook stands down on, factored into one function so measure and render can
never disagree.
- The fs is not an editbox: `fs+0x120 & 0x1000` is clear (the fs-level bit
`FUN_0044D670` translates into the gxu editbox flag 0x40).
The icon sum is computed in TRUE PEN PX — the exact numbers the emitter
reserves — and bridged into the getter's return space by the font height
known in both spaces: `sumPen × (fontHInt / fontHPen)`. `ResolveFsPenFont`
replicates the render chain: `fontHPen = round((fontHInt × fs+0x7C, floored
at [0x801628]/rasterY) × rasterY)` (the rebuild's node fontSize + the node
ctor's min clamp + the emitter's own height helper), and the outline ink
resolves through `face = [[fs+0xE0]+0x20]` — the same object the node stores
at +0x44. fs-built nodes are always in snap mode (no fs-flag maps to node
bit 7), so the snap-truncated advances are exact. The per-icon advance comes
from `IconAdvancePen`, the **same helper** the emitter reserves with — so the
measured width matches the rendered width by construction, outline ink and
pixel snapping included. (An earlier bridge used the SetPoint push factor
`div×1024/mul ≈ 1468` as the intermediate — the units cancelled for `:0`
icons, but it couldn't express the truncation or the ink.)
Unit trap (hit on first flight): escape sizes are UI PIXELS. Do NOT divide
the icon sum by `fs+0x7C` — that field is the layout UI *scale* (~0.68–1.0).
The original's own `out / fs+0x7C` applies to a value the measure core
already ran through the `FUN_0041AD80` gxu→internal converter, not to raw
pixels. The `/0x7C` version measured a 16px icon as +44k px.
The hook NEVER writes the `fs+0xFC` cache: the original can serve the cached
value, so the icon sum is re-derived and re-added per call (idempotent).
The same hook also fixes two layout consumers for free: tooltips auto-size
wide enough for icon-bearing lines, and auto-width fontstrings lay out at
their true rendered width.
### Wrap: FIXED at the shared stepper (`FUN_TEXT_WRAP_STEPPER` = 0x005C7260)
Wrap decisions also measured icons as ~0, so a chat line with prefix icons
broke as if the icons were not there and the emitter's real advances ran past
the right edge. The choke is the wrap-stepper dispatcher `FUN_005C7260`: it
lays out one wrapped line per call, and its exactly 4 callers are EVERY wrap
consumer — the draw builder `FUN_005CDC20` (render breaks), the height
measure `FUN_005C2070`, the chars-that-fit counter `FUN_005C21C0` (ellipsis),
and the break-array computer `FUN_005C2430` (behind `FUN_00772B60`).
The co-hook shrinks the `wrapWidth` argument by a **minimal feasible
shrink**, gated on the same `InlineInterceptActive` predicate plus the flags
editbox bit 0x40 the dispatcher already carries. The stepper reports each
probe's measured (icons-at-zero) line width in `outWidth`, which gives a
direct feasibility check: the line renders inside the frame iff its icons
fit in the shrink plus the spare the break left (`iconSum ≤ s + (probeW −
outWidth)`). The hook probes sandboxed (local out-params plus a COPY of the
`p10` in/out state byte, so the caller's first-line state is not consumed):
s = 0 first (feasible → no shrink at all — the common short-message case),
escalate by the measured deficit when infeasible, bisect down after the
first feasible hit; ≤8 probes, and with no feasible hit the last escalation
target wins (wraps early rather than overflowing). The feasibility check
carries a HALF-GLYPH tolerance: an auto-width fontstring (single anchor, no
width — e.g. pfUI's addon-list labels) wraps at the icon-inclusive string
width our GetStringWidth hook feeds the effective-width vmethod, so its line
fits with exactly zero slack, and cross-computation drift between the two
hooks' unit chains (plus OUTLINE extras and the trailing-glyph quirk) read
"fits exactly" as "1px over" — shrinking a zero-slack line forces a wrap
that had no business existing. Genuine icon overflow is ≥ a full icon, so
half a glyph separates drift from real overflow. `outBreak` and
`outNext − text` are byte counts; `outBreak == len` means the whole text
fit. Two rejected earlier schemes, for the record: a naive whole-remaining-
text sum shredded an 8-icon line to one word per line, and a fixed-point
iteration on the icon count oscillated (a ~55-byte zero-width escape makes
the break↔icon-count coupling discontinuous — no fixed point exists).
`text` always points at the REMAINING text, so a continuation line whose
icons are behind it gets sum = 0. Because all four consumers share the hook,
render breaks, `GetStringHeight`, truncation, and break arrays shift
together. (A `_classicapi_InlineTexWrap(n)` ring dumped the last 8
icon-bearing calls during bring-up; removed with the other diagnostics in
`5e5677b`.)
Unit trap #2 (hit TWICE): the stepper's inputs are gxu-normalized, PER AXIS —
fontH is normalized-Y (× rasterY = pen px) and wrapWidth/outWidth are
normalized-X (× rasterX = pen px). Both caller flavors land there: the draw
builder passes node+0x1C/+0x3C (normalized at block creation), and the
fs-level callers (the path chat wraps through) x-normalize their widths
inside `FUN_0044d670` before gxu. First flight subtracted raw pixels and
annihilated the small normalized widths (2-glyph chat fragments). The second
converted with `fontH / fontHPx` — a Y-AXIS factor applied to an X-axis
width — overshrinking the budget by the render aspect (×1.33 at 4:3, ×2.37
at 2560×1080): icon-bearing chat lines wrapped a word or two early, worse per
icon and per aspect. The correct pen-x → width-units factor is `1/rasterX`,
and the glyph-relative tolerances use `fontHPx/rasterX` so "half a glyph"
means an actual glyph width.
**Chat hyperlink hover over inline icons works (verified).** An emote wrapped in a
hyperlink — `|Htel:name|h|T…|t|h`, the TwitchEmotes pattern — pops its tooltip when
you hover the *icon*, not just adjacent text. The hover region is NOT re-measured
on hover: the engine records each hyperlink's screen rect at layout time into a
`GXUFONTHYPERLINKINFO` array (32-byte entries: rect + link data) via `FUN_005cd310`,
called from the draw builder `FUN_005cdc20` — the same layout pass that runs the
line through our co-hooked tokenizer (`FUN_005c2810`) and wrap-stepper
(`FUN_005c7260`). So the recorded rect rides the same icon-aware position
accounting the wrap fix installs; the icon lands inside the hover region for free,
and hovering it fires the frame's `OnHyperlinkEnter`. This is a consequence of the
wrap fix, not a separate feature. (Ghidra caveat: the decompile of the
`FUN_005cd310` call args is mangled — a pointer-as-float + an uninitialized temp —
so this is confirmed by the call path plus in-game test, not the byte-level
formula.)
**Still icon-blind (accepted residuals):**
- Sub-pixel only for a string-final icon: the width hooks report its INK edge
(advance minus the trail half-pad/ink — `TrailingIconTrimPen`), matching the
engine's own last-glyph convention (the gxu width loop ends on ink, not
advance — the source of the old −0.8px measurement, whose previous-glyph
advance treatment is semantically CORRECT once an icon renders after it).
What remains is the icon region's ±0.5px placement rounding and the base
loop's ~0 contribution for the payload-0 icon token.
- An fs whose `fs+0xFC` cache was filled while the feature was toggled
differently (`_classicapi_InlineTexEnable`) keeps its old base width until
the next `SetText`/font change re-dirties it. Debug-toggle-only.
**Editbox measure stays raw (by design).** The caret/measure path reads the
focused editbox's INPUT buffer in place, and the tokenizer stands down on it
per-editbox (`TextInFocusedEditbox` pointer-range test — not a global focus
check), so caret math measures the raw `|T…|t` literal the editbox renders.
The width hook mirrors that with the `fs+0x120 & 0x1000` editable gate. One
marginal wobble: the focused single-line chat editbox's display fontstring
carries editable=0 (only multi-line editors set the bit), so the caret
positioner's line-boundary branch (multi-line-only in practice) can see an
icon-adjusted `FUN_00772890` on it. Closing that costs a per-token content
compare, which is not worth it.
### Editbox exclusion — two COMPLEMENTARY signals (bit 6 + focus global)
Editable text must show raw, editable `|T…|t` markup, not rendered icons — you
edit macros, tweak icon fields, copy/paste texture strings. **This is
retail-correct, verified in-game on a modern client:** typing
`|TInterface\Icons\INV_Misc_Coin_01:16|t` into the chat entry box OR a macro body
shows it **raw** (no icon) — Blizzard neutralizes player-*typed* escape sequences
as anti-abuse — while addon/system text (`AddMessage`, `GetCoinTextureString`)
renders. Retail's discriminator is the text's SOURCE (trusted vs player-typed),
not editbox-vs-display; a shift-clicked item link renders in the chat box because
it's inserted through the trusted path, but a typed `|T`/`|c` does not. Our
editbox-raw + display-renders split reproduces the same observable outcome. (One
mechanism-level divergence we accept: our editbox-raw uses the GLOBAL focus signal
below, so while an editbox is focused OTHER display text that rebuilds also
briefly shows raw; retail suppresses per-source. Cosmetic, and a per-editbox fix
would need hot-path text-buffer matching — not worth it.) 1.12's tokenizer,
unlike 4.3.4's, has no per-render texture-disable bit (4.3.4 uses tokenizer flag
**0x1000** to kill only `|T`, **0x800** to kill all escapes), and editbox text
nodes are NOT distinguishable by class from the render node — the emitter can't
reach the owning frame. It takes **two signals together**, not one (an earlier
version of this note wrongly called the focus global a redundant "belt-and-
suspenders fallback" — removing it renders the chat/copy boxes' `|T` as icons):
- **Node flag `[node+0x5c]` bit 6 (0x40) = EDITABLE** — catches the **macro
editor** (`MacroFrameText`, flags `0x4D`), which builds its layout once,
**un-focused**, so the focus global never sees it. But single-line inputs
(chat entry box, `pfChatCopyBox`) have bit 6 **CLEAR** — their nodes are
`0x0D`/`0x20D`, identical to display FontStrings — so bit 6 can't catch them.
- **Focused-editbox global `DAT_00cf4dc8`** (`InputFocused()`; holds the editbox
with keyboard focus, cleared by `ClearFocus` = `FUN_0077e410`) — catches those
single-line inputs. It's GLOBAL (true UI-wide, not per-node), which is why,
while an editbox is focused, other display text that *rebuilds* also briefly
renders raw. That's an accepted tradeoff: there is no per-node signal for
single-line inputs, and when you're in an editbox seeing raw markup is what you
want anyway.
**Why the tokenizer's bit-6 gate is load-bearing for RENDER, not just measure:**
when the emitter suppresses an editbox it delegates to the ORIGINAL emitter,
which **re-enters the tokenizer** to lay out glyphs (`FUN_005ccbe0` →
`FUN_005c2810`). On that render path `flags` is the node's own flags, so bit
0x40 makes the tokenizer stand down and the `|T` draws as literal text. Drop the
bit-6 gate and the (un-focused) macro editor draws **BLANK** — the tokenizer eats
the span as a zero-width token and the original emitter renders nothing (verified
in-game). Focused single-line inputs get the same "don't intercept" via
`Suppressed()` → `InputFocused()`. So both the emitter and the tokenizer consult
`Suppressed() || bit-6`; the two must agree or you get blank-vs-raw mismatches.
(Dead end: **monochrome render flag `DAT_00c2b9dc`** (`FUN_005c8b70` sets it per
group) — un-set at MacroFrameText's build time and flickered as a flush gate.
Dropped. The editbox class vtable is `0x0081c8c0`, its script-slot resolver
`FUN_0077a310` with focus slots at `+0x438`/`+0x440` — useful if a frame-level
hook is ever needed.)
### Descriptor fields + the vertical flip
`ParseIcon` handles the full positional payload
`path:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b`.
Texcoords crop a sprite sheet to one cell: `u = left/texW, right/texW`,
`v = top/texH, bottom/texH` — e.g. `UI-RaidTargetingIcons` is a 256×256 sheet,
4×2 grid of 64×64, so marker N's cell is `256:256:<col*64>:<+64>:<row*64>:<+64>`.
**Vertex colour (`r:g:b`, 0-255):** the last three positional fields tint the
icon. They pack to `0xFFrrggbb` — the same `0xAARRGGBB` order the tokenizer's
`|cAARRGGBB` colour path builds (verified from `FUN_005c2810`'s colour branch),
so the value flows straight into the quad's vertex colour and the font stage's
default MODULATE COLOROP multiplies `texture.rgb × tint`. White (`255:255:255`,
the default) = untinted. Because parsing is positional, a tint needs all the
preceding fields present; the `TintIcon` helper passes `0`s for width..bottom
(texW/texH `0` disables the texcoord crop → full texture) so the colour lands in
the trailing slots.
**Texcoord order:** the region's 4-float texcoord setter
(`FUN_SIMPLETEXTURE_SET_TEXCOORD`) takes the rect **Y-FIRST INTERLEAVED**
(`{v0, u0, v1, u1}`), matching the engine's `{yA, left, yB, right}` rect
convention — diagnosed in-game when `{u0,v0,u1,v1}` rendered a raid-mark crop as
the wrong cell. v is top-down, same as the `|T` payload's top/bottom fields.
(The removed quad path had the inverse concern: the OpenGL backend put v=0 at
the bottom, so the quad mapped the TOP screen corners to v1 — kept here as the
backend note.)
### Build / deploy / test cadence
- `set -o pipefail; cmake --build build --config Release 2>&1 | tail -5 && cp build/Release/ClassicAPI.dll "C:/WoW/Octo/dll/ClassicAPI.dll"`
— `pipefail` stops a failed build from deploying a stale DLL.
- **The client must be fully EXITED to relink** (it loads a symlink to the
linker's output). DLL changes need a full restart, not `/reload`.
- **The embedded `!!!ClassicAPI` addon is symlinked**, so Lua-only changes (the
`TextureTest()` harness in `Util/AddOnCompat.lua`) need only `/reload`.
### Remaining / optional
- **Measure width, height, wrap** — all DONE (the three fs-level co-hooks in
Current design). Vertex-colour tint (`:r:g:b`) — DONE. The bring-up
diagnostics (`_classicapi_InlineTexSuppress/Tune/Stats/ProbeFS/RegionCal/Wrap`
and the capture globals) were REMOVED (`5e5677b`); only
`_classicapi_InlineTexEnable` remains.
- **Animated emotes** — no DLL work is needed: a TwitchEmotes-style animator
re-`SetText`s each line ~30fps and rewrites the `|T` texcoords to crop the
current film-strip frame. That is addon Lua. RLE-compressed TGAs do not
decode in 1.12 (uncompressed only) — convert with
`magick in.tga -compress none in.tga` (no `-orient`/`-flip`: the region
renderer's texcoord order shows the frame right-side-up).
- **Still icon-blind** (accepted): the hyperlink hit-test past a tall icon
OUTSIDE the link on the same line. The substring measure `FUN_00772AE0` was
hooked once its real consumer surfaced (the tooltip auto-size measures
wrapped SEGMENTS with it), and the trailing-icon width residual is trimmed
to sub-pixel (`TrailingIconTrimPen` — a string-final icon reports its ink
edge, not its advance, per the engine's own last-glyph convention).
## Goal & spec
Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequences#Textures):
```
|Tpath:height[:width[:offsetX:offsetY[:texWidth:texHeight:left:right:top:bottom[:rV:gV:bV]]]]|t
```
Atlas markup (`|A:atlas:…|a`) is Legion+ (needs an atlas DB 1.12 lacks) — out
of scope. FileDataID paths are Legion+ — 1.12 resolves texture PATH strings
only.
## Confirmed: 1.12 has ZERO inline-texture support
In-game test on the target client — all of these render as literal text, no
icon:
```
|TInterface\Icons\INV_Misc_Coin_01:16|t
|TInterface\Icons\INV_Misc_Coin_01:16:16|t
|TInterface\Icons\INV_Misc_Coin_01:16:16:0:0|t
```
Root cause (see 1.12 map below): the shared escape tokenizer has no `T`/`t`
case, so `|T` falls through to "literal `|` + ordinary glyphs".
## Reference: 4.3.4 (`C:\WoW\Proudmoore\apwow.exe`, build 15595)
4.3.4's text pipeline is a shared `|`-tokenizer feeding per-purpose loops that
`switch` on the returned token type. It DOES support `|T`.
- **Tokenizer `FUN_00613980`** — reads one token, returns a type code, bytes
consumed (`*param2`), payload char (`*param5`). `|`-switch token types:
`1`=color(`|c`+8 hex), `2`=reset(`|r`), `3`=break(`|n`/newline), `4`=`||`,
`5`=`|H`, `6`=`|h`, **`7`=texture(`|T`…`|t`)**, `8`=`|t`, `9`/`10`=`|K`/`|k`
callback, `0xb`/`0xc`=`|W`/`|w`, `0`=ordinary glyph. The `T` case scans
forward to the closing `|t` and returns 7 with the whole span's byte length.
- **Field parser `FUN_00617ba0(tokenStart, &desc, size)`** — splits the `|T`
payload on `:` (helpers: `FUN_0040fa50`=strchr, `FUN_004b3d00`=atof,
`FUN_004b3480`=atoi) into a **0x40-byte descriptor**:
| off | field |
|----|-------|
| `+0x10`/`+0x14` | path ptr / length (up to first `:`) |
| `+0x18` | height (font-scaled) — required |
| `+0x1C` | width (defaults to height) |
| `+0x20`/`+0x24` | offsetX / offsetY |
| `+0x28`–`+0x34` | 4 normalized texcoords (texel ÷ texW/texH) |
| `+0x3C` | hasTexCoords flag |
| `+0x38`–`+0x3B` | vertex B/G/R/A |
| `+0x3D` | hasColor flag |
- **Measure loop `FUN_00618430`** — walks tokens; `case 7` calls
`FUN_00617ba0` and adds the texture width to the pen.
- **Draw emitter `FUN_0061ea10`** (analog of 1.12 `FUN_005ccbe0`) — its
`case 7` is the texture draw:
1. `FUN_00617ba0(tokenStart, &desc)` — parse fields into the descriptor
(stored inline on the line-state struct at `param7+0xb`, 0x10 dwords).
2. Compute pen x, width (`desc.width[+0x18 dword = param7[0x12]] * scale`),
and y from the justify mode `[obj+0x58]` (0=baseline, 1=center, 2=top).
3. Copy the descriptor to a stack struct and call **`FUN_0061e820`** — the
inline-texture quad emit. (`FUN_0061dd50` handles a region-state 1→2
transition when a texture interrupts a hyperlink capture.)
- **`FUN_0061e820`** — normalizes the 4 quad corners (÷ screen w/h from
`DAT_00dd9ea0`/`DAT_00dd9ea4`) and calls `FUN_0061e410(1, &verts)`.
- **`FUN_0061e410(count=1, &verts)`** (`__thiscall`, ECX = a vertex-buffer
object) — appends `count` quads of **0x40 bytes** (one quad = pos + texcoords)
to a **dedicated inline-texture vertex buffer**, growing it as needed
(`FUN_0061db40`). So inline textures accumulate in their OWN batch, separate
from the per-font-page glyph batches, each quad carrying its own texture
(bound at flush). This is the concrete confirmation of the "separate quad
path" — the backport must add such a batch + flush on the 1.12 side.
## Target: 1.12 (`C:\WoW\Octo\WoW.exe`, build 5875) injection map
Same architecture as 4.3.4, different addresses and token numbering. Reached
from `Script_GetStringWidth` (`FUN_0079e510`) → `FUN_00772890` →
`FUN_0044d670` → `FUN_005c2050` → the loops.
- **Shared tokenizer `FUN_005c2810`** — `~11 callers`. `|`-switch token types:
`0`=color(`|c`), `1`=reset(`|r`), `2`=break(`|n`/newline), `3`=`||`,
`4`=`|H`, `5`=`|h`, `6`=ordinary glyph. **No `T`/`t` case** → `|T` hits the
fall-through (`*param5=0x7c; return 6`) = literal `|` then glyphs. Adding a
texture token means picking an unused code (e.g. `7`).
- **Measure loop `FUN_005c6940`** — `switch`: `0/1/4/5` zero-width, `2` break,
default glyph (advance via `FUN_005c6b70`; kerning `FUN_005ca2d0`/
`FUN_005ca4b0`). No texture case.
- **Fit/width loops** `FUN_005c6c50` (chars-into-width + per-char x, for
cursor/wrap) and `FUN_005c7470` (truncation/ellipsis). Same switch shape.
- **Draw builder `FUN_005cdc20`** — reads the layout object's text at `[+0x48]`,
handles line-wrap / justify (`[+0x54]`) / color state, per line calls the
glyph emitter and `FUN_005cd310` (hyperlink rect registration).
- **Glyph emitter `FUN_005ccbe0`** — the real quad emitter. Per glyph:
`FUN_005cabd0(fontFace,char)` → glyph record (atlas **page** at `rec[10]`,
advance `rec[0x12]`, texcoords `rec[0x18..0x1b]`), then appends 4 verts to a
**per-atlas-page** batch at `[obj + page*4 + 0xa0]`. `case 0/1` set/reset
color, `4/5` open/close hyperlink region, default = glyph.
### The architectural wrinkle
Glyph quads batch **per font-atlas page**. An inline `|T` texture is an
arbitrary texture, not a font page, so it cannot join that batch — it needs a
**separate textured-quad draw** (its own draw call, correctly ordered against
the glyph batches so it appears inline). This is the substantive work, not the
tokenizing. 4.3.4 solves it exactly this way — captured below: its draw `case 7`
appends the quad to a dedicated inline-texture vertex buffer at
`renderObject+0x9c`, separate from the per-font-page glyph batches (see the
`FUN_0061ea10` / `FUN_0061e820` items under Open items).
## Strategy
The tokenizer is shared by ~11 callers, so DON'T just make it return a new
type — the other callers' `switch`es would hit `default` and mis-handle it.
Instead:
1. **Reimplement (co-hook) the measure loop `FUN_005c6940` and the draw
builder `FUN_005cdc20`/emitter `FUN_005ccbe0`** — copy existing logic
(fully decompiled), delegate every non-texture token to the original
handling, and add an inline `|T…|t` case: measure → advance pen by texture
width; draw → emit the arbitrary-texture quad.
2. **Port `FUN_00617ba0`** (field parse → 0x40 descriptor). Standalone, safe to
write now from the 4.3.4 decompilation; needs 1.12 equivalents of strchr/
atof/atoi (trivial or already in `Offsets.h`).
3. **Leave the shared tokenizer `FUN_005c2810` and the other ~9 callers
untouched** — they keep rendering `|T` literally (wrap/cursor math slightly
off inside a texture in v1 — cosmetic, extend later).
4. **Texture load by path** — reuse the engine's `Texture:SetTexture` loader;
**quad emit** — the primitive 4.3.4's draw case uses (to pin).
Risk: the hot text path (every string every frame). But these are
deterministic C functions, NOT the contended nampower/SuperWoW targets, so the
MinHook-collision concern is low. Reimplementing two complex loops faithfully
is the real cost.
## Incremental slices
1. Fixed-size `|Tpath:height|t` — render + width only (ignore width/offset/
texcoord/color). Proves the end-to-end quad path.
2. width, offsetX/Y.
3. texcoords (`texW:texH:l:r:t:b`).
4. vertex color.
5. correct wrap/cursor (extend `FUN_005c6c50`/`FUN_005c7470`).
## Open items / next steps
- [x] **4.3.4 draw-side `case 7`** — captured: `FUN_0061ea10` case 7 →
`FUN_00617ba0` (parse) + `FUN_0061e820`→`FUN_0061e410` (append quad to a
dedicated inline-texture vertex buffer). Separate batch, per-quad texture.
- [x] **4.3.4 inline-texture buffer located** — `FUN_0061e820` (`RET 0x40`,
i.e. a 0x40-byte quad struct by value) does `ADD ECX,0x9c` before
`FUN_0061e410`, so the dedicated inline-texture vertex buffer is at
**renderObject+0x9c** (glyph batches are the `+0xa0 + page*4` array — the
two are adjacent but distinct). Quad struct: positions at `+0x8..+0x14`,
texcoords at `+0x20..+0x2c` (rest = z / texture handle / color). Appended
one quad at a time via `FUN_0061e410(this=buf@+0x9c, count, &quad)`.
- [x] **Flush + path→texture load** — NOT traced on the 4.3.4 side by design;
its purpose (the flush + per-quad texture bind + path→load mechanism) is
fully answered on the **1.12 side** below: paint/flush `FUN_005c8fe0`,
bind `FUN_00589e80(0x17,tex)` + draw `FUN_005c8f40`, load `FUN_00449d90`.
4.3.4's own `+0x9c`-buffer flush is not needed — we implement against 1.12.
- [x] **1.12 path→texture loader** — `FUN_00770200(textureObj, path, flag,
blend=DAT_00878cf0, flag)`. It's the string-path branch of
`Script_Texture_SetTexture` (`FUN_0079bb40`, method-table entry at
`0x0087c1a0`). This is how a `|T` path becomes a bound texture.
- [x] **1.12 batch render + textured-quad GPU primitive — FOUND.** The paint
pass is `FUN_005c8fe0(layoutObj)`: it ensures each line built
(`FUN_005cd6a0`), then per font page (stride 0x18, indices 0..7 over
`[obj+0x18c+page*0x18]` = the page's texture object) does:
- **bind texture:** `FUN_00589e80(0x17, texturePage)` (0x17 = the texture
render-state selector),
- **lock dynamic VB:** `FUN_0058a140(0, stride=0x18, 0x800)` → handle;
`FUN_0058a080(handle)` → vertex data ptr,
- copy the page's built verts from the per-page batch at
`[lineObj + 0xa0 + page*4]` (`FUN_005ce090`=count, `FUN_005ce0c0`=copy),
- **draw:** `FUN_005c8f40(&handle, vertCount)` (flush/submit),
- **unlock:** `FUN_0058a0a0(handle, 0)`.
**Vertex stride = 0x18** (24 bytes = pos + uv + packed color). So an inline
`|T` quad = 4 (or 6) such verts, bound to its own texture. Reuse path for
the `|T` draw: `FUN_00589e80(0x17, inlineTex)` → write quad verts →
`FUN_005c8f40`. **`FUN_005c8fe0` is the co-hook site for strategy B** —
after the glyph batches are drawn, draw the frame's collected `|T` quads.
- [x] **Path → bindable texture handle — RESOLVED.** `FUN_00770200`
(SetTexture-by-path) internally calls **`FUN_00449d90(path, &desc, flags,
0, 1)`** — the general path→CGxTexture loader (normalizes path, by-name
cache, creates on miss via `FUN_0044a140`/`FUN_0044a310`, returns a
**fallback texture on failure so never null**) — and stores the handle at
`textureObj+0xcc`. That handle is the SAME CGxTexture type
`FUN_00589e80(0x17, tex)` binds (the `tex[0x3c]` streamable flag +
non-null `*tex` that `FUN_00593840` checks are CGxTexture fields; font
pages and Texture widgets bind identically). So a `|T` draws as:
`tex = FUN_00449d90(path,&desc,flags,0,1)` → `FUN_00589e80(0x17,tex)` →
write 4 verts (stride 0x18) → `FUN_005c8f40(&vb,n)`. No scratch widget
needed if we replicate `FUN_00770200`'s descriptor setup
(`FUN_0058a980(&desc, blend, …)` then `FUN_00449d90`); or, simplest, keep
one scratch Texture object, `FUN_00770200(scratch,path,…)`, read
`[scratch+0xcc]`. The internal by-name cache makes repeats cheap.
Two implementation strategies once the primitive is pinned:
- **A (mirror 4.3.4):** own a separate inline-texture vertex buffer, flush it
in the paint pass alongside the glyph batches. Faithful, more integration.
- **B (immediate):** at paint time, draw each collected `|T` quad immediately
via the GxU primitive (bind `FUN_00770200`'d texture, draw one quad). Simpler;
inline textures are few, so per-quad draw calls are fine. Likely the better
first cut.
- [ ] Confirm 1.12's tokenizer flag bits (`param4 & 0x100/0x200/0x400/0x800`)
that gate color/escape handling, so the reimplemented loops pass them
through unchanged.
## Reality check on scope — how it actually landed
The plan above feared reimplementing 1.12's glyph emitter `FUN_005ccbe0` (a
long, intricate function) and the measure loop. It did not come to that. The
shipped design avoids both:
- **Positioning** does NOT reimplement the emitter — it SEGMENTS the line at
icon boundaries and delegates each plain run to the ORIGINAL emitter, so the
engine still lays out every glyph. We only track where the icons go.
- **Rendering** does NOT stand up a separate quad batch — icons are
engine-managed regions anchored to their FontString (the removed quad batch
was strategy B; regions replaced it).
- **Measure** does NOT reimplement the loops — three cold co-hooks on the
fs-level width / height / wrap functions add the icons' contribution and call
the originals.
The result is the largest feature in the project, but built from co-hooks that
delegate to the engine rather than from reimplemented layout code. It was
verified by in-game iteration over many build/test rounds, as expected.
+115
View File
@@ -0,0 +1,115 @@
# Why inline icons render as engine regions
Inline `|T…|t` icons render as pooled `CSimpleTexture` regions
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) — one per
visible icon, anchored to the icon's owning FontString. The engine draws each
region every frame like any UI texture, so the icon is resident by construction
and moves with its line for free. This is 4.3.4's `CSimpleEmbeddedTexture`
model, built from the parts 1.12 ships.
This file records why that design was chosen and the RE behind it. A raw-GxU-
quad renderer came first and was removed (`56c2670`).
## History — a residency misdiagnosis, corrected
The quad renderer flickered: a random subset of chat icons blanked on scroll,
and a second client showed no icons at all. The first diagnosis was **texture
residency** — the theory that a raw quad bound a texture the VRAM manager had
evicted, because a raw quad is owned by no frame object.
That diagnosis was wrong. The real cause was the Large-Address-Aware pointer
bound (`dc61f77`): a 2GB cap in the flush's `LooksReadable` check rejected valid
heap pointers above `0x80000000`, and the flush's node walk BREAKS on a rejected
node. So one high node truncated the walk, and every node after it lost its
icon. That is the "random subset blanks" pattern, and a second client shows none
because it grows the heap past 2GB sooner. With the bound fixed, the raw quads
did not flicker either.
So engine regions are kept for **4.3.4 parity**, not necessity. They are
resident by construction, which closes the residency question for good — but the
question was never the real bug. Do not re-open the residency investigation; the
D3D notes at the end are the record of what was ruled out.
## The region the pool builds
1.12 ships `CSimpleTexture` — the ordinary `Texture` widget
(`frame:CreateTexture()`). It is a managed, reference-counted, `CGxTexCache`-
backed, engine-drawn texture. The only thing 3.3.5's `CSimpleEmbeddedTexture`
adds is automatic inline layout inside the FontString, and the emitter already
computes the icon positions — so the pool supplies them. (`CSimpleEmbeddedTexture`
is compiled into 3.3.5 only; RTTI confirms it is absent from 1.12.)
Per icon, the pool takes a pooled region and sets the texture, texcoords,
colour, and two anchor points relative to the owning FontString's bottom-left.
The engine then owns load, residency, managed-pool reload, clipping, and draw.
### 1.12 entry points (used by the pool)
RTTI name strings: `.?AVCSimpleTexture@@` @ `0x00846588`,
`.?AVCSimpleFontString@@` @ `0x00846544`.
Create a `CSimpleTexture` from C (mirrors `Tooltip::LinePool`'s FontString path):
```
void* mem = FUN_00760450(&DAT_00cf4ce0, 0, /*rtti*/0x00846588, 0xFFFFFFFE); // pool alloc
void* tex = FUN_0076fc40(mem, parentFrame, /*drawLayer*/2, /*sublayer*/1); // ctor
```
Set the texture path (loads through the engine's own loader, owns the handle):
```
FUN_00770200(tex, path, 0, DAT_00878cf0, 0); // CSimpleTexture::SetTexture(path)
```
`FUN_00770200` builds GxTexFlags (`FUN_0058a980`), loads via `FUN_00449d90`,
stores the HTEXTURE at `tex+0xCC` (owned ref), releases the old one
(`FUN_0041aed0`), and marks the region dirty. This ownership is the whole
residency story — the engine holds the texture and reloads it on eviction.
Region methods the pool calls directly:
| method | C++ target |
|---|---|
| SetTexCoord | `FUN_00770410(tex, &{v0,u0,v1,u1})` — Y-FIRST interleaved |
| SetColor | `FUN_0077F750(tex, &BGRA)` — also the chat-fade alpha mirror |
| SetPoint | `FUN_00767C70` — internal units (see LinePool `PixelToInternal`) |
| Show / Hide | `FUN_0077FCB0` / `FUN_0077FC60` (desired-shown `+0xC4`, realized `+0xC8`) |
`CSimpleTexture` layout (from ctor `FUN_0076fc40`): vtable `0x0081c718`;
region-render vtable at `+0x24`; HTEXTURE `+0xCC`; blend `+0xD0`; per-corner
quad position/texcoord arrays `+0xD4..+0x100` (`OFF_SIMPLETEXTURE_CORNERS`);
`+0x128` a flag. The region-render vtable slot 0 (`0x00770670`) computes the
screen rect and calls `FUN_007705b0` to STORE the quad corners; the GPU draw is
deferred to the engine's batched region paint.
## The pen↔anchor bridge
The emitter computes icon positions in PEN units; the pool stores region
`SetPoint` offsets in ANCHOR units, relative to the FontString's rect. The
bridge is one scale `K` (pen units per anchor unit) plus the FontString's own
rect: the flush reads the FontString rect and derives `K` per node, converts
each icon's screen rect to FontString-relative anchor offsets, and queues that.
The pool applies the queued placement on the next frame tick — never mid-render.
`K` derivation and the FontString-relative store are in `FlushLayout`
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp)); the
convergence pass in `Maintain`
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) self-heals
any residual scale from the region's own frame-scale chain.
## What was ruled out during the residency chase (do not re-investigate)
The blank icons had zero CPU-side signature — records present, node chain
contiguous, geometry sane, index buffer ready at submit, batched draw, z-buffer
excluded twice. The full D3D9 render state at the true draw point was benign:
`zFunc=8` always, `aBlend=1`, `colorW=0xF`, scissor and stencil off, texture
stage MODULATE with a valid bound texture every draw. All of this was consistent
with the LAA-bound truncation (the node never reached the draw), not with a lost
texture surface.
D3D9 device access, if it is ever needed again:
- `IDirect3DDevice9* = *(void**)(*(void**)0x00C0ED38 + 0x38a8)` (CGxDeviceD3d).
- `GetRenderState` = device vtable `+0xE8`; `SetRenderState` `+0xE4`.
- The GxU re-applies its cached render states before every draw (`FUN_005a1b20`),
so a raw `SetRenderState` for a GxU-managed state is overwritten; `ZENABLE` is
not in that path, so a direct set survives.
+9
View File
@@ -0,0 +1,9 @@
; Exports for ClassicAPI.dll.
;
; `Load` is VanillaFixes' post-injection entry point: after the launcher
; injects each dlls.txt DLL, the VanillaFixes runtime calls the DLL's `Load`
; export on the game's MAIN thread (GetProcAddress(module, "Load")), outside
; the loader lock and with no timeout. Listing it here exports it undecorated
; as "Load" so GetProcAddress finds it regardless of calling convention.
EXPORTS
Load
-20
View File
@@ -13,26 +13,6 @@
namespace Common {
#define HOOK_FUNCTION(offset, hook, original) \
{ \
auto *target = reinterpret_cast<LPVOID>(offset); \
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(hook), \
reinterpret_cast<LPVOID *>(&original)) != MH_OK) \
return FALSE; \
if (MH_EnableHook(target) != MH_OK) \
return FALSE; \
}
#define UNHOOK_FUNCTION(offset, original) \
{ \
auto *target = reinterpret_cast<LPVOID>(offset); \
if (MH_DisableHook(target) != MH_OK) \
return FALSE; \
if (MH_RemoveHook(target) != MH_OK) \
return FALSE; \
(original) = nullptr; \
}
bool PatchBytes(void *dst, const void *src, size_t len);
} // namespace Common
+148 -36
View File
@@ -16,8 +16,12 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "model/DisplayInfo.h"
#include "nameplate/Walk.h"
#include "player/NameCache.h"
#include "text/InlineTexture.h"
#include "text/InlineTexturePool.h"
#include "texture/Transform.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
@@ -47,6 +51,20 @@ static bool __fastcall FrameScript_Initialize_h() {
// cache to clear here.
NamePlate::Events::PrepareForReload();
// The reload teardown destroys every icon pool texture, fontstring,
// and text node — forget all inline-icon pointers now so nothing
// touches them (see InlineTexture.h / InlineTexturePool.h).
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
// Drop per-region corner transforms (SetRotation / SetVertexOffset) — the
// reload destroys every addon texture and the region pool reuses their pointers.
Texture::Transform::PrepareForReload();
// Drop pending character-dress jobs and their engine compositors — the
// reload destroys every Model frame the jobs point at.
Model::DisplayInfo::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
@@ -71,6 +89,13 @@ static void __fastcall LoadScriptFunctions_h() {
static void __stdcall LoadGlueScriptFunctions_h() {
LoadGlueScriptFunctions_o();
Game::RunGlueModuleRegistrations();
// World→glue teardown destroyed every world fontstring and text node the
// inline-icon maps reference; forget them now that the glue UI is booting.
// (Glue→world is covered by FrameScript_Initialize_h; the node-free hook
// handles individual deaths, but bulk teardown paths may bypass it.)
Text::InlineTexture::PrepareForReload();
Text::InlineTexturePool::PrepareForReload();
Texture::Transform::PrepareForReload();
}
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
@@ -86,47 +111,134 @@ static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
FrameRegisterEvent_o(frame, edx, eventName);
}
// ---------------------------------------------------------------------------
// Hook install — kept OFF the Windows loader lock.
//
// Every MH_EnableHook freezes all process threads (CreateToolhelp32Snapshot +
// SuspendThread/GetThreadContext each). Doing ~90 of those from DllMain, under
// the loader lock, is the pattern MinHook documents as unsafe: on machines
// whose security stack intercepts thread suspension it stalled the remote
// LoadLibrary thread past VanillaFixes' 10-second injection deadline, so VF
// read the still-running thread as STILL_ACTIVE (259) and showed its generic
// "compatible client" error — a false diagnosis of a slow load.
//
// So DllMain installs nothing. The install runs later, on a thread that does
// NOT hold the loader lock, via one of two triggers that both funnel through
// the latched EnsureInitialized():
// * VanillaFixes' `Load` export, called on the game's main thread after
// injection (the sanctioned VF extension point), or
// * a fallback worker thread we spawn from DllMain, for any other injector
// that never calls `Load`.
// Whichever arrives first installs; the other observes the cached result.
// ---------------------------------------------------------------------------
static volatile LONG g_initClaimed = 0; // 0 until a thread takes the installer role
static volatile LONG g_initDone = 0; // 0 until the install has finished
static volatile LONG g_initResult = 1; // 0 = success, 1 = failure (until proven)
static volatile LONG g_mhInitialized = 0; // MH_Initialize succeeded (gates detach teardown)
static bool CreateAndQueue(uintptr_t offset, void *hook, void **original) {
auto *target = reinterpret_cast<LPVOID>(offset);
if (MH_CreateHook(target, hook, original) != MH_OK)
return false;
// Queue only — a single MH_ApplyQueued below applies the whole batch in
// one thread-freeze.
if (MH_QueueEnableHook(target) != MH_OK)
return false;
return true;
}
static bool InstallHooks() {
if (MH_Initialize() != MH_OK)
return false;
InterlockedExchange(&g_mhInitialized, 1);
if (!CreateAndQueue(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK,
reinterpret_cast<void *>(InvalidFunctionPtrCheck_h), nullptr))
return false;
// Four core init hooks — each runs glue logic (PrepareForReload,
// RunModuleRegistrations, RunGlueModuleRegistrations, EnableWrites,
// RetryClaims) tightly coupled to DllMain state, so a declarative
// HookAutoRegister wouldn't simplify them.
if (!CreateAndQueue(Offsets::FUN_FRAME_SCRIPT_INITIALIZE,
reinterpret_cast<void *>(FrameScript_Initialize_h),
reinterpret_cast<void **>(&FrameScript_Initialize_o)))
return false;
if (!CreateAndQueue(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS,
reinterpret_cast<void *>(LoadScriptFunctions_h),
reinterpret_cast<void **>(&LoadScriptFunctions_o)))
return false;
if (!CreateAndQueue(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
reinterpret_cast<void *>(LoadGlueScriptFunctions_h),
reinterpret_cast<void **>(&LoadGlueScriptFunctions_o)))
return false;
if (!CreateAndQueue(Offsets::FUN_FRAME_REGISTER_EVENT,
reinterpret_cast<void *>(FrameRegisterEvent_h),
reinterpret_cast<void **>(&FrameRegisterEvent_o)))
return false;
// All feature hooks declared via `Game::HookAutoRegister` at file scope
// in their respective modules (create + queue-enable, no apply yet).
if (!Game::RunHookRegistrations())
return false;
// One thread-freeze that activates every queued hook at once.
return MH_ApplyQueued() == MH_OK;
}
// Runs InstallHooks exactly once. Returns 0 on success, 1 on failure. If a
// second caller arrives while the install is in flight, it blocks briefly so
// both callers observe the real result.
static DWORD EnsureInitialized() {
if (InterlockedCompareExchange(&g_initClaimed, 1, 0) == 0) {
g_initResult = InstallHooks() ? 0 : 1;
InterlockedExchange(&g_initDone, 1);
} else {
while (InterlockedCompareExchange(&g_initDone, 0, 0) == 0)
Sleep(1);
}
return static_cast<DWORD>(g_initResult);
}
static DWORD WINAPI InitWorker(LPVOID) {
EnsureInitialized();
return 0;
}
// VanillaFixes calls this on the game's MAIN thread after injection
// (InitAdditionalDLLs -> GetProcAddress(module, "Load")), outside the loader
// lock and with no timeout — the sanctioned VF extension point. Returns 0 on
// success; VF reports any non-zero result to the user. Exported undecorated
// as "Load" via src/ClassicAPI.def.
extern "C" DWORD Load() { return EnsureInitialized(); }
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
if (reason == DLL_PROCESS_ATTACH) {
DisableThreadLibraryCalls(hModule);
if (MH_Initialize() != MH_OK)
return FALSE;
auto *target = reinterpret_cast<LPVOID>(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK);
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(InvalidFunctionPtrCheck_h), nullptr) != MH_OK)
return FALSE;
if (MH_EnableHook(target) != MH_OK)
return FALSE;
// Four core init hooks stay here — each runs glue logic
// (PrepareForReload, RunModuleRegistrations,
// RunGlueModuleRegistrations, EnableWrites, RetryClaims)
// that's tightly coupled to DllMain state, so a declarative
// HookAutoRegister wouldn't simplify them.
HOOK_FUNCTION(Offsets::FUN_FRAME_SCRIPT_INITIALIZE, FrameScript_Initialize_h,
FrameScript_Initialize_o);
HOOK_FUNCTION(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS, LoadScriptFunctions_h,
LoadScriptFunctions_o);
HOOK_FUNCTION(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
LoadGlueScriptFunctions_h, LoadGlueScriptFunctions_o);
HOOK_FUNCTION(Offsets::FUN_FRAME_REGISTER_EVENT, FrameRegisterEvent_h,
FrameRegisterEvent_o);
// All feature hooks declared via `Game::HookAutoRegister` at
// file scope in their respective modules.
if (!Game::RunHookRegistrations())
return FALSE;
// Install nothing here (loader lock — see the block comment above).
// Spawn a worker that installs off the lock; the new thread cannot
// run its body until DllMain returns and the loader lock releases,
// and we never wait on it. Under VanillaFixes the `Load` export
// usually wins the latch first; this worker covers every other
// injector. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
if (worker != nullptr)
CloseHandle(worker);
} else if (reason == DLL_PROCESS_DETACH) {
// Clean /quit path. Flush before MH_Uninitialize — the cache's
// file I/O uses Win32 directly (no MinHook involvement), so
// either order works in practice, but flushing first lets us
// bail early if the hook teardown ever grows side effects.
// Hard process termination (task manager kill) bypasses this
// path; that's an inherent OS limitation, and the 5-minute
// backstop flush in Remember() covers the worst case there.
Player::NameCache::Flush();
MH_Uninitialize();
// Clean /quit path — only tear down if we actually initialized.
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
// directly (no MinHook involvement), so either order works in
// practice, but flushing first lets us bail early if the hook
// teardown ever grows side effects. Hard process termination (task
// manager kill) bypasses this path; that's an inherent OS
// limitation, and the 5-minute backstop flush in Remember() covers
// the worst case there.
if (g_mhInitialized) {
Player::NameCache::Flush();
MH_Uninitialize();
}
}
return TRUE;
}
+7 -2
View File
@@ -73,7 +73,7 @@ using RegisterFrameMethods_t = void(__fastcall *)(const FrameMethodEntry *table,
} // namespace
void *State() {
return *reinterpret_cast<void **>(static_cast<uintptr_t>(Offsets::VAR_LUA_STATE));
return Game::Read<void *>(Offsets::VAR_LUA_STATE);
}
namespace {
@@ -320,7 +320,12 @@ bool RunHookRegistrations() {
if (MH_CreateHook(targetPtr, node->hook,
reinterpret_cast<LPVOID *>(node->original)) != MH_OK)
return false;
if (MH_EnableHook(targetPtr) != MH_OK)
// Queue, don't enable. The caller (InstallHooks) applies every
// queued hook with a single MH_ApplyQueued, so all ~90 hooks share
// ONE thread-freeze instead of one freeze per hook. This is the
// difference that keeps install cheap on machines whose security
// stack intercepts SuspendThread / VirtualProtect per call.
if (MH_QueueEnableHook(targetPtr) != MH_OK)
return false;
}
return true;
+54 -8
View File
@@ -17,6 +17,46 @@
namespace Game {
// --- Typed engine-memory access ---------------------------------------------
//
// The canonical way to read/write an engine object field or a fixed-VA global,
// replacing the `*reinterpret_cast<const T *>(base + Offsets::OFF_X)` idiom
// (and its double-cast variant when `base` isn't a byte pointer). Offsets stay
// in Offsets.h per the single-source-of-truth rule — these only centralize the
// cast noise, they add no checking.
//
// Game::Read<int>(obj, Offsets::OFF_FIELD) field read
// Game::Ref<uint32_t>(obj, Offsets::OFF_FIELD) writable field lvalue
// Game::Ptr<float>(obj, Offsets::OFF_FIELD) pointer TO a field (arrays,
// out-params for engine calls)
// Game::Read<float>(Offsets::VAR_GLOBAL) fixed-VA global read
// Game::Ref<int>(Offsets::VAR_GLOBAL) fixed-VA global lvalue
template <typename T>
inline const T *Ptr(const void *base, uintptr_t offset) {
return reinterpret_cast<const T *>(reinterpret_cast<const uint8_t *>(base) + offset);
}
template <typename T>
inline T *Ptr(void *base, uintptr_t offset) {
return reinterpret_cast<T *>(reinterpret_cast<uint8_t *>(base) + offset);
}
template <typename T>
inline T Read(const void *base, uintptr_t offset) {
return *Ptr<T>(base, offset);
}
template <typename T>
inline T &Ref(void *base, uintptr_t offset) {
return *Ptr<T>(base, offset);
}
template <typename T>
inline T Read(uintptr_t address) {
return *reinterpret_cast<const T *>(address);
}
template <typename T>
inline T &Ref(uintptr_t address) {
return *reinterpret_cast<T *>(address);
}
using FrameScript_Initialize_t = bool(__fastcall *)();
using LoadScriptFunctions_t = void(__fastcall *)();
@@ -341,19 +381,24 @@ void RunGlueModuleRegistrations();
// Declarative MinHook registration. Each feature module declares a
// file-scope `static const Game::HookAutoRegister _hookreg{target,
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and DllMain's
// `RunHookRegistrations` walks the list once after `MH_Initialize`,
// installing each hook with `MH_CreateHook` + `MH_EnableHook`.
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and the installer
// (`InstallHooks` in DllMain.cpp) walks the list once after
// `MH_Initialize`, creating each hook and QUEUE-enabling it; a single
// `MH_ApplyQueued` then activates them all in one thread-freeze.
//
// The install runs OFF the loader lock (via the `Load` export or the
// fallback worker thread — see DllMain.cpp), never inside DllMain, so
// the thread-freeze can't deadlock or stall the loader.
//
// Same lifetime rules as ModuleAutoRegister: constructors chain onto
// a static-init list before DllMain runs, the linker keeps the OBJ
// because the constructor has side effects, and order across TUs is
// undefined but doesn't matter here (hooks are independent).
//
// Use only for feature hooks. The three core engine-init hooks in
// Use only for feature hooks. The four core engine-init hooks in
// DllMain (FrameScript_Initialize / LoadScriptFunctions /
// Frame::RegisterEvent) have inline logic that interleaves with the
// hook chain and stays in DllMain.
// LoadGlueScriptFunctions / Frame::RegisterEvent) have inline logic that
// interleaves with the hook chain and stays with the installer.
struct HookAutoRegister {
HookAutoRegister(uintptr_t target, void *hook, void **original);
uintptr_t target;
@@ -362,8 +407,9 @@ struct HookAutoRegister {
HookAutoRegister *next;
};
// Installs every registered hook. Returns `false` and stops on first
// failure — caller (DllMain) should propagate by returning FALSE.
// Creates and QUEUE-enables every registered hook (no `MH_ApplyQueued`
// here — the caller applies the whole batch at once). Returns `false`
// and stops on first failure.
bool RunHookRegistrations();
} // namespace Game
+850 -6
View File
@@ -40,6 +40,47 @@ enum Offsets {
// state, so `FrameScript_RegisterFunction` writes land on it.
FUN_LOAD_GLUE_SCRIPT_FUNCTIONS = 0x0046ABB0,
// Binding manager internals used by the direct-action/override binding backport.
//
// Stock Lua `SetBinding` wrapper (`int __fastcall(lua_State *)`).
// The SetBindingSpell/Item/Macro/Click wrappers delegate to it after
// constructing their action command, preserving punctuation-key
// normalization, current-set selection, the Boolean return value, and the
// native UPDATE_BINDINGS notification.
FUN_SCRIPT_SET_BINDING = 0x004B8000,
// Hardware-key dispatch, before the normal binding table is resolved:
// int __thiscall(manager, key, isDown)
// Hooking here lets owner-scoped override bindings remain a separate
// layer rather than temporarily mutating the character/account binding
// table. The normal dispatcher is called unchanged when no override wins.
FUN_BINDING_KEY_DISPATCH = 0x004B7990,
// Executes a resolved binding command:
// int __thiscall(manager, command, isDown)
// Vanilla has no SPELL/ITEM/MACRO/CLICK direct-action command handlers. The
// hook recognizes those four families and delegates every native command
// to the client.
FUN_BINDING_COMMAND_EXECUTE = 0x004B7B50,
// Binding-manager `this`-relative flag, set to 1 while a resolved binding
// command runs. FUN_BINDING_KEY_DISPATCH writes 1 immediately before its
// call to FUN_BINDING_COMMAND_EXECUTE and 0 immediately after. Override
// commands dispatched outside that engine path must mirror it so native
// command handlers observe the same manager state a real keypress produces.
// Verified in the FUN_BINDING_KEY_DISPATCH disassembly.
OFF_BINDING_MANAGER_EXECUTING = 0xD8,
// Frame-script execution context and its nesting depth (the `DAT_00ceeac0`
// dance referenced below). Shared by the binding dispatcher and the
// tooltip/frame script invokers. FUN_BINDING_KEY_DISPATCH saves the context
// pointer, zeroes it for the nested command, restores it afterward, and
// floors the depth back to 0. `Bindings::Api` mirrors this when it runs an
// override command outside the engine's own dispatch. Verified in that
// disassembly.
VAR_FRAMESCRIPT_EXEC_CONTEXT = 0x00CEEAC0,
VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH = 0x00CEEAC4,
// GameTooltip script-method prologue helpers (used to resolve self → CFrameScriptObject*).
// Underlying Lua C API names per [[docs/LuaCAPI.md]] are `lua_rawgeti`
// (0x6F3BC0) and `lua_touserdata` (0x6F3740). The Set* method prologue's
@@ -264,18 +305,301 @@ enum Offsets {
OFF_REGION_ANCHOR = 0x24, // LayoutFrame anchor sub-object (SetPoint `this` / relativeTo base)
DRAWLAYER_ARTWORK = 2,
FRAMEPOINT_TOPLEFT = 0,
FRAMEPOINT_TOPRIGHT = 2,
FRAMEPOINT_LEFT = 3,
FRAMEPOINT_RIGHT = 5,
FRAMEPOINT_BOTTOMLEFT = 6,
FRAMEPOINT_BOTTOMRIGHT = 8,
// FUN_REGION_SET_POINT stores offsets in *internal* coordinates, not
// pixels. Script_SetPoint converts: `internal = pixel * [0x00832A44] /
// ([0x00832A4C] * 1024)` (FUN_0041ae60's factor × input ÷
// (FUN_0041ad70's return × DAT_007ffd68)). Both globals are runtime
// UI-scale floats; passing raw pixels makes offsets ~1000× too large.
// The REVERSE conversion (internal → UI pixels) is what the Script_*
// measure getters push: Script_GetStringWidth (0x0079E510) — and our
// GetStringHeight backport — compute `px = internal * [0x00832A4C] *
// 1024 / [0x00832A44]` (FUN_0041AE40(FUN_0041AD70() × DAT_007FFD68 × v)).
VAR_UI_COORD_SCALE_MUL = 0x00832A44, // float numerator
VAR_UI_COORD_SCALE_DIV = 0x00832A4C, // float denominator base
UI_COORD_SCALE_UNIT = 1024, // DAT_007ffd68
// Semantically, [0x832A44] is the SCREEN WIDTH IN ANCHOR UNITS (the
// full-screen x extent of the internal layout space) — that's why it's
// the px→internal numerator above — and [0x832A48] is its Y sibling
// (screen HEIGHT in anchor units). Verified via the gxu text position
// converter FUN_0041ade0: block/node positions are stored NORMALIZED,
// x = anchor/[0x832A44], y = anchor/[0x832A48] (the y global was
// previously unmapped — the x/y pair sits at +0x00/+0x04, the SetPoint
// denominator base at +0x08).
VAR_UI_ANCHOR_SCREEN_H = 0x00832A48,
// gxu text-raster dimensions — INT dwords holding the live render-target
// size in PIXELS ({0,0,640,480} fallback), written by FUN_005c2b50 from
// the render-target rect (FUN_0058a240) whenever it changes; every font
// page re-rasterizes on change (the FUN_005ca6f0 walk). These are the
// normalized→pen multipliers: the origin finalize FUN_005cdf70 computes
// node origin (+0x70/+0x74) = round(normalizedPos × [raster]) after
// folding the justify shift (right: +widthLimit, centre: +half) and
// vertical align; the emitter's font-height helper FUN_005C6FA0 is
// round(fontSize × [VAR_TEXT_RASTER_Y]) the same way (x sibling
// FUN_005C7010 uses _X). So TEXT PEN UNITS ARE RENDER-TARGET PIXELS, and
// pen-per-anchor = [VAR_TEXT_RASTER_*] / [VAR_UI_ANCHOR_SCREEN_*] per
// axis — the exact conversion Text::InlineTexture's flush uses to place
// icon regions (verified against a live probe: rasterX/anchorW matched
// the measured origin÷rect-edge quotient to four digits).
VAR_TEXT_RASTER_Y = 0x00C2B9A0,
VAR_TEXT_RASTER_X = 0x00C2B9A4,
// gxu text-node invalidate/reset — `__fastcall(node)`. Zeroes the
// built-line count (+0x9C, the draw builder's early-out gate), resets the
// link-rect state (+0x80/+0x90) and wrap cache (+0x68/+0x6C), and frees
// all 8 page buffers — the next paint's pre-pass re-runs the builder, so
// the emitter re-bakes the node from scratch. This is what the node
// colour setter FUN_005CCB40 calls on a colour change for accumulation
// (bit-3-clear) nodes; InlineTexture's flush calls it for a SEGMENTED
// bit-3 node whose BASE colour RGB changed (glue AddonList toggle), since
// baked per-glyph RGB can't be patched in place (|c runs own theirs).
FUN_TEXT_NODE_INVALIDATE = 0x005CDEF0,
// gxu font-face flags word. Bit 0 (0x1) = thin outline, bit 3 (0x8) =
// thick outline: the emitter's prologue (FUN_005CCBE0) reads
// *(fontFace+0x180) and grows the line height by [FLOAT_OUTLINE_EXTRA_*]
// pen px for outlined faces, because outline INK extends past the glyph
// metrics; the rebuild/color-sync shadow paths test the same bits. The
// inline-texture lead/trail pads read the same flags so an icon clears an
// outlined neighbour's ink (the coin-into-digits clip).
OFF_FONTFACE_FLAGS = 0x180,
// The engine's outline ink allowance, .rdata float constants (4.0 / 2.0
// pen px total, i.e. ~2 / ~1 px per side) — the exact values the emitter
// adds to line height for bit 3 / bit 0 outlined faces. Read live so we
// stay bit-identical with the engine's own compensation. The 2.0 constant
// double-duties as the MINIMUM font size numerator in the node ctor
// (FUN_005cd6d0 / FUN_005ccb80: fontSize floored at [0x801628]/rasterY =
// 2 pen px).
FLOAT_OUTLINE_EXTRA_THICK = 0x0080306C,
FLOAT_OUTLINE_EXTRA_THIN = 0x00801628,
// The node's BUILT line count — incremented once per emitted wrapped line
// by the draw builder (FUN_005CDC20, including the \n break case) and the
// builder's own early-out gate (`if (node+0x9C != 0) return`); zeroed by
// the node invalidate (FUN_TEXT_NODE_INVALIDATE). The render truth for
// FontString:GetNumLines once the node has painted.
OFF_TEXT_NODE_BUILT_LINES = 0x9C,
// Wrap break-array computer: `__thiscall(fs, const char *text, float
// wrapWidth /*fs-internal units*/, int *outBreaks, int cap)` → segment
// count (byte offsets into text, [0]=0). Routes through FUN_005C2430 →
// the wrap stepper, so it is icon-aware via the stepper co-hook. Verified
// from the GameTooltip auto-size (FUN_00530640), which fills a 20-entry
// array and measures each segment via FUN_FONTSTRING_MEASURE_SUBSTRING.
FUN_FONTSTRING_BREAK_ARRAY = 0x00772B60,
// Substring width measure: `__thiscall(fs, const char *text, int len)` →
// ST0 (len 0 = strlen). Same measure-core call + `out / fs+0x7C` shape as
// GetStringWidthInternal, but for an ARBITRARY string in the fs's font —
// no cache. Callers (xrefs): the GameTooltip auto-size FUN_00530640, which
// measures each WRAPPED SEGMENT of a wrap-enabled line between the
// FUN_00772B60 break positions and takes the max as the tooltip width —
// the icon-relevant consumer (an earlier note claimed nothing consumed
// this function; the xref list refutes it) — plus the editbox
// caret/selection cluster (FUN_0077DA80, FUN_0077DE70, FUN_0077D0D0),
// which must stay raw and is excluded by the editable/focused-buffer
// gates in the co-hook.
FUN_FONTSTRING_MEASURE_SUBSTRING = 0x00772AE0,
// FontString → gxu face resolution, for fs-level (measure-hook) callers
// that need the face the RENDER will use. The rebuild (FUN_007724a0)
// passes [fs+0xE0] (the font HANDLE) to the block creator FUN_0044d420,
// which hands [handle+0x20] to FUN_005c1c30 → FUN_005cd6d0, which stores
// it at node+0x44 — the emitter's face (`this` for every glyph call, and
// the +0x180 outline-flags carrier). So face = [[fs+0xE0]+0x20].
OFF_FONTSTRING_FONT_HANDLE = 0xE0,
OFF_FONT_HANDLE_FACE = 0x20,
// CSimpleTexture (`Texture` widget) creation + operate primitives, used by
// Text::InlineTexturePool to render inline |T icons as engine-owned,
// managed-pool, resident-kept textures (the residency fix — see
// docs/InlineTextureResidency.md). Mirrors Script_CreateTexture
// (FUN_00773A20) and the Texture frame-method handlers; the sibling of the
// CSimpleFontString path above. Same FUN_REGION_POOL_ALLOC / SetPoint /
// Show / Hide / SetColor (FUN_FONTSTRING_SET_COLOR) as the FontString pool.
VAR_SIMPLETEXTURE_POOL = 0x00CF4CE0, // &DAT_00cf4ce0 CSimpleTexture free-list pool (`this`)
VAR_SIMPLETEXTURE_CLASS_TAG = 0x00846588, // ".?AVCSimpleTexture@@" (alloc debug tag)
FUN_SIMPLETEXTURE_CTOR = 0x0076FC40, // __thiscall(mem, parent, layer, sublayer) -> tex
// CSimpleTexture::SetTexture(path). Loads via FUN_00449D90, stores the owned
// HTEXTURE at +0xCC (releases the old via DecRef FUN_0041AED0), marks dirty.
// `__thiscall(tex, path, 0, *VAR_TEXTURE_BLEND_DEFAULT, 0) -> u32`. The
// ownership + engine batched region-draw is the residency win. Same-path
// early-out makes pooled reuse cheap.
FUN_SIMPLETEXTURE_SET_TEXTURE = 0x00770200,
// Script_Texture_SetTexture — the Lua `texture:SetTexture(...)` handler. Its
// NUMERIC branch (arg 2 a number) clamps r/g/b/a to [0,1] and fills the
// texture with a solid colour via FUN_00770360; its string branch loads a
// path through FUN_00770200 above. texture/ColorTexture.cpp aliases this whole
// handler as the 7.0 `SetColorTexture` (which IS exactly the numeric form), so
// the clamp and opaque-alpha default come straight from the engine.
FUN_SCRIPT_TEXTURE_SET_TEXTURE = 0x0079BB40,
// CSimpleTexture::SetTexCoord — `__thiscall(tex, float[4])`. Struct field
// order is {top, left, bottom, right} = {v0, u0, v1, u1} (verified from the
// SetTexCoord handler 0x0079BEB0's Lua-arg → struct mapping). Natural
// texcoords (no v-flip — the engine region path is top-left origin).
FUN_SIMPLETEXTURE_SET_TEXCOORD = 0x00770410,
OFF_SIMPLETEXTURE_HTEXTURE = 0xCC, // owned HTEXTURE ref (diagnostic)
// The four drawn-quad corner POSITIONS: 4 vertices of {x, y, z} floats (0xC
// stride), order BL, TL, BR, TR (from FUN_REGION_STORE_CORNERS's writes). The
// region draw enqueue FUN_00772fd0 (0x00772fd0) hands region+0xD4 straight to
// the vertex batch as FOUR INDEPENDENT vertices — and region+0x104 as the
// matching 4 per-corner texcoords — never re-deriving an axis-aligned rect.
// So writing ROTATED x,y here draws a rotated quad with NO corner clipping
// (unlike 4.3.4's SetRotation, which rotates the texcoords instead). Used by
// texture/Rotation.cpp for Texture:SetRotation.
OFF_SIMPLETEXTURE_CORNERS = 0xD4,
// Region corner-store: `__thiscall(region, const float rect[4])` writes the
// drawn quad corners into region+0xD4..+0x100 from the rect ({yA,left,yB,right}
// screen px). The renderer only draws a region whose +0xD4 corners are
// populated (verified: the draw gate tests region+0xD4 != 0), and normally
// SetPoint→layout-resolve calls this. We call it directly to place an icon by
// its screen rect with NO anchors (anchoring 100+ textures/frame corrupts the
// UI-manager pending-layout list — the FUN_00765650 crash). Co-hooked by
// texture/Rotation.cpp, which re-applies rotation right after the engine
// restores the axis-aligned corners (layout resolve / SetTexCoord).
FUN_REGION_STORE_CORNERS = 0x007705B0,
// Texcoord crop applied to a {top,left,bottom,right} screen rect before the
// corner-store: shrinks right→left and bottom→top by the region's per-corner
// texcoord span (ABS of the +0x104 U/V differences). No-op for full 0..1
// texcoords. `__thiscall(region, float rect[4])`, rect in/out. The crop
// itself is unconditional; every engine call site gates it on
// OFF_REGION_TEXCOORD_MODIFIES_RECT below — with the flag clear (the
// default) a partial SetTexCoord draws at the region's FULL size, so
// callers replicating an engine store must apply the same gate.
FUN_REGION_TEXCOORD_CROP = 0x00770570,
// The SetTexCoordModifiesRect flag (int). Written from the Lua boolean by
// Script_SetTexCoordModifiesRect (0x0079C080, `piVar4[0x49]`), read back
// 1/nil by Script_GetTexCoordModifiesRect (0x0079C120). All three
// FUN_REGION_TEXCOORD_CROP call sites test it: the SetTexCoord writers
// (FUN_00770410 4-arg, FUN_007704C0 8-arg corner form) crop+store only
// when set, and the layout-resolve store path (FUN_00770670) crops when
// set / stores the raw anchor rect (+0x40..+0x4C) when clear. Zero by
// default (region ctor), so the crop never runs unless an addon opts in.
OFF_REGION_TEXCOORD_MODIFIES_RECT = 0x124,
// Get the region's resolved screen rect: `__thiscall(region+OFF_REGION_ANCHOR,
// float out[4]) -> int` (1 = valid, 0 = not laid out yet). Reads the anchor
// sub-object's +0x40..+0x4C = {top, left, bottom, right}, gated on the
// rect-valid bit [+0x3c]&1. This is the engine's own source for the corner
// rect (see FUN_00770410's SetTexCoord path); texture/Rotation.cpp reads it to
// rebuild axis-aligned corners before rotating.
FUN_REGION_GET_RECT = 0x00768320,
// __thiscall(region+OFF_REGION_ANCHOR, int flag /*stack; pass 0*/) — force a
// synchronous layout resolve: recomputes the region's rect (+0x64) from its
// anchors right now, instead of waiting for the render's pending-layout pass.
// Verified from Show (FUN_0077FCB0 at 0x0077FCE1: PUSH 0; LEA ECX,[ESI+0x24];
// CALL). Load-bearing for tick-time icon placement: SetTexCoord
// (FUN_00770410) stores the DRAW CORNERS (+0xD4) from the rect as a side
// effect, so without a realize between SetPoint and SetTexCoord the corners
// are stored from the STALE pre-anchor rect — the region's rect then resolves
// correctly (diagnostics look perfect) but the renderer draws the corners,
// which stay zero/stale → invisible icons (and the old build's frozen
// top-left icons: corners stored from the zero rect at first apply).
FUN_REGION_LAYOUT_REALIZE = 0x00768060,
// CSimpleFontString::RebuildString — destroys the old gxu text node and
// creates the fresh one (FUN_0044d420), storing it at fs+0xF8. Gated on the
// fs dirty bit (+0x60 & 1), so it fires on TEXT CHANGES, not per frame.
// Co-hooked by Text::InlineTexture to map text node → owning fontstring —
// the key that lets inline-icon regions anchor to their owning line
// (1.12 chat lines ARE CSimpleFontStrings: the ScrollingMessageFrame's
// display refresh FUN_00788750 SetTexts/anchors/shows one fs per visible
// line — verified against the 4.3.4 CSimpleEmbeddedTexture model, which
// anchors its icon regions to the owning fontstring the same way).
FUN_FONTSTRING_REBUILD_STRING = 0x007724A0,
// fs+0xF8 holds an HTEXTBLOCK handle, NOT the node itself: FUN_0044d420
// allocates the 12-byte handle {vtbl, refcount, node}, FUN_005c1c30 writes
// the created text node into handle+8, and FUN_0041af10 AddRefs and returns
// the handle. The layout's node (what the emitter/paint hooks see) is
// therefore *( *(fs+0xF8) + 8 ).
OFF_FONTSTRING_TEXT_BLOCK = 0xF8, // HTEXTBLOCK handle (0 when dirty/empty)
// CSimpleFontString live text color: count at +0xB4, array pointer at +0xB8
// (uint32 BGRA per slot; slot 0 = the base color, alpha = byte 3; a parallel
// per-glyph alpha-byte array hangs off +0xA8/+0xA4). Written by the fs
// SetColor (FUN_FONTSTRING_SET_COLOR 0x0077F750: stores the dword at
// (*(u32**)(fs+0xB8))[0] + the alpha byte at (*(u8**)(fs+0xA8))[0], then
// fires the color-update vmethod +0x20). Count 0 = never colored = default
// opaque white. THE CHAT-FADE SIGNAL: the ScrollingMessageFrame's fader
// (FUN_00788460, gated on the SetFading flag at smf+0x360; per-line state
// {rgba@+4, shown@+8, timeVisibleLeft@+0xC, fadeLeft@+0x10} in the
// stride-0x10 line array at smf+0x3A4) animates each visible line
// fontstring's alpha byte through this same SetColor every frame, then
// hides the fs at fade end. Text::InlineTexturePool mirrors this byte onto
// the line's icon regions each tick so inline icons fade with their line
// (regions parent to the CHAT FRAME, so the frame's own alpha byte —
// frame+0xC8, per Script_GetAlpha 0x00774DC0 — already modulates them via
// the engine's parent×region product; only the fs's own component needs
// mirroring).
OFF_FONTSTRING_COLOR_COUNT = 0xB4,
OFF_FONTSTRING_COLOR_ARRAY = 0xB8,
OFF_TEXTBLOCK_NODE = 0x8, // the gxu text node inside the handle
// Byte of fontstring state flags; bit 1 = "text block needs rebuild".
// RebuildString (0x7724A0) gates on it at entry, releases the old block
// (+0xF8 = 0), and only CREATES a new one if the fs rect is resolved —
// a SetText during an unresolved rect leaves the fs blockless with the
// refcounted zombie node still painting. If the bit is also clear at that
// point, nothing ever rebuilds (the stuck-blockless state InlineTexture's
// flush nudges by re-setting this bit).
OFF_FONTSTRING_DIRTY_FLAGS = 0x60,
// The per-node draw builder: walks a node's wrapped lines and calls the
// glyph emitter (FUN_TEXT_EMITTER) once per line. Called from the paint's
// per-node pre-pass FUN_005cd6a0 (`__thiscall(node)`, no stack args). We
// co-hook it to stamp exact BUILD BOUNDARIES for the emitter's first-line
// detection — the old `text == node+text-ptr` heuristic silently failed on
// pfUI-processed chat lines (stale/preprocessed pointer), leaving inherited
// records on reused node addresses (ghost icons) or never clearing them.
FUN_TEXT_DRAW_BUILDER = 0x005CDC20,
// The gxu text-node FREE: unlinks the node from its layout lists and pushes
// it onto the node free list (DAT_00c2b98c) for reuse. Single caller —
// FUN_005c1d00, the HTEXTBLOCK handle release (handle vtbl 0x008026e4 dtor
// 0x0044D5C0 → 0x005c1d00 → here). This is the choke point where every
// text node dies and its address becomes reusable: InlineTexture hooks it
// to erase ALL per-node state exactly at death, making stale-record and
// stale-owner bugs (ghost icons, orphaned records) structurally impossible
// instead of heuristically guarded.
FUN_TEXT_NODE_FREE = 0x005CD950,
// THE pen↔anchor unit bridge. RebuildString (0x7724A0) multiplies every
// text-unit quantity by region+0x7C when crossing into node creation:
// nodePos = inset×s + rect corner, nodeFontH = fontPx×s, spacing = +0xF4×s,
// and divides rect extents by s for the text-unit width/height. SetParent
// (FUN_0076AB10) propagates the parent's +0x7C down the frame tree — it's
// the per-object layout/UI-scale chain. This single scalar is what every
// earlier "derive the scale" attempt (13/16, ownerH/fontH) was guessing at.
OFF_LAYOUT_SCALE = 0x7C, // float: anchor units per text/pen unit
OFF_FONTSTRING_INSET_X = 0x110, // float, text units (RebuildString posX term)
OFF_FONTSTRING_INSET_Y = 0x114, // float, text units (RebuildString posY term)
// CSimpleRegion::SetParentAndLayer — __thiscall(region, parentFrame, layer,
// show). Handles old-parent unlink + new-parent region-registry insert +
// conditional Show. Verified from the region base ctor FUN_0077F640 and the
// message frame's per-line setup (FUN_00788750 calls it with (frame, 2, 1)).
FUN_REGION_SET_PARENT_AND_LAYER = 0x0077FD10,
OFF_REGION_PARENT = 0x9C, // region's parent frame ptr (read by Show's gates)
OFF_REGION_DESIRED_SHOWN = 0xC4, // Show (FUN_0077FCB0) NO-OPS unless this is set;
// engine callers always write it before show/hide
OFF_REGION_ACTUALLY_SHOWN = 0xC8, // 1 after Show completed (the realize latch)
// Owning-frame recovery for the inline-icon pool (the 3.3.5 ownership model,
// no overlay). Chat renders through the strata walker FUN_007657d0 →
// per-frame draw-list rebuild FUN_00765920 → per-layer render FUN_0076FB00 →
// text paint (NOT through the child-frame render FUN_0076B3F0). So
// Text::InlineTexturePool co-hooks the two below:
// • FUN_FRAME_DRAWLIST_REBUILD — `__fastcall(frame)`, receives the frame
// cleanly and owns its 5 inline draw layers at frame + OFF_FRAME_LAYER_BASE
// + i*FRAME_LAYER_STRIDE. We record layer→frame so the layer-render hook
// can recover the owning frame with no offset-scan / vtable guessing.
// • FUN_FRAME_LAYER_RENDER — `__fastcall(layer)`, the per-layer paint that
// directly wraps the chat text paint. We bracket the icon pool's pass here
// (the flush runs nested) and look the frame up in the layer→frame map.
FUN_FRAME_DRAWLIST_REBUILD = 0x00765920,
FUN_FRAME_LAYER_RENDER = 0x0076FB00,
OFF_FRAME_LAYER_BASE = 0x1C, // first inline draw layer, region-relative
FRAME_LAYER_STRIDE = 0x30, // per-layer stride
FRAME_LAYER_COUNT = 5, // BACKGROUND..OVERLAY
// Resolved on-screen rect of a region: 4 floats at regionBase+0x64 (=
// LayoutFrame+0x40), in GxU SCREEN PIXELS — verified: FUN_00770670 reads
// them and FUN_007705b0 stores them straight as GxU vertex corners. Layout is
// {yA, left, yB, right} (x at [1]/[3], y at [0]/[2]); use min/max to get
// left/top without pinning which y index is top.
OFF_REGION_RECT = 0x64,
// "Currently displayed thing" state fields on a GameTooltip frame
// instance. Each Set* path writes one of these (and zero or two
@@ -737,9 +1061,13 @@ enum Offsets {
// (opcode 0x96 → FUN_0049D560) parses the wire data. Called with
// the sender GUID as stack args 9 and 10 (lo, hi).
//
// Calling convention: `__fastcall` with 10 args — ECX = sender
// name string, EDX = chat type, then 8 stack args ending in the
// GUID pair. Called from:
// Calling convention: `__fastcall`, `RET 0x28` — ECX = the MESSAGE
// text, EDX = chat type, then 10 stack args ending in the sender
// GUID pair (lo, hi). (ECX was previously mislabeled "sender name";
// verified from the call-site disassembly in FUN_0049D560 —
// `MOV ECX,[EBP-0x10]` is the raw/processed message. The message is
// copied into one buffer that feeds BOTH the chat frame and the
// say/yell bubble spawn FUN_00608AC0.) Called from:
// - FUN_0049D560 directly for live (non-throttled) chat
// - The pending-chat queue processor (`__AUPENDINGCHAT`) for
// messages buffered via FUN_0049CAE0 when the engine flag at
@@ -748,9 +1076,11 @@ enum Offsets {
// (system notifications, arena team membership changes, etc.)
// which pass 0 / NULL for the GUID args
//
// Hooked by `Chat::CurrentGUID::ChatDispatch_h` to capture the
// GUID into a global for `GetCurrentChatGUID()` to read during an
// addon's CHAT_MSG_* OnEvent.
// Hooked (once) by `Chat::Dispatch`, which orchestrates the concerns
// sharing this choke point: `Chat::CurrentGUID` (publish the sender
// GUID for `GetCurrentChatGUID()`), `Chat::IconFilter` (strip
// player-injected `|T` icon spoofs from chat + speech bubbles), and
// `Chat::RaidMarkers` (expand `{rtN}`/`{skull}`/… into inline markers).
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
@@ -1474,6 +1804,81 @@ enum Offsets {
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
// Model frame — backs `Model:SetDisplayInfo(creatureDisplayID)`
// (`Model::DisplayInfo`). Registry ctx per docs/raw_methods.txt (table
// 0x00878948, 23 methods: SetModel/SetSequence/ReplaceIconTexture/…).
VAR_MODEL_METHOD_REGISTRY = 0x00CF0C8C,
// Model frame-script type id, lazily assigned by the engine on the first
// Model method call (`Script_SetModel` FUN_0076d950 prologue:
// `if (id == 0) id = ++counter`). Mirror the lazy-assign so a Model
// type-check works even before any stock Model method has run; the IsA
// check itself is the CFrameScriptObject vmethod at vtable+0x10, shared by
// every frame type.
VAR_MODEL_LUA_TYPE_ID = 0x00CF0C5C,
VAR_FRAMESCRIPT_TYPE_ID_COUNTER = 0x00CEEF6C,
// FUN_0076cfe0(model /*ecx*/, int replaceableType, const char *path) —
// loads `path` as a texture and binds it to the model's replaceable-texture
// slot `replaceableType`. Worker behind `Model:ReplaceIconTexture` (which
// passes type 14); creature skins use MONSTER_1/2/3 = 11/12/13.
FUN_MODEL_SET_REPLACEABLE_TEXTURE = 0x0076CFE0,
// The loaded model instance a CSimpleModelFFX keeps at +0x318. It is the
// SAME CModel class CGUnits keep at unit+0xD8 — verified via
// FUN_007110d0 (the geoset-range setter), whose submesh walk and +0x98
// visibility-array writes match the instance layout probed in-game.
OFF_SIMPLEMODELFFX_MODEL_INSTANCE = 0x318,
// --- Character-model compositor ("CharComponent", `Model::DisplayInfo`) --
// The engine object that dresses a character-based model: resolves the
// hair / facial-hair geosets (CharHairGeosets, CharacterFacialHairStyles —
// beards, earrings, teeth), loads the baked NPC body texture or composites
// the player one, and applies equipment geosets (sleeves / robe→trousers /
// boots / tabard / cape, FUN_00477520). Units keep one at unit+0xD30,
// driven by CGUnit_C::UpdateCharacterCustomization (FUN_005fb200) — the
// single caller-of-record this whole API was derived from — behind the
// poll gate FUN_00607da0 (dress only once FUN_MODEL_INSTANCE_LOADED says
// the model is in). The portrait renderer (Script_SetPortraitTexture →
// FUN_00524f60) waits on FUN_CHARCOMP_PUMP before rendering, which is why
// portraits always show the finished appearance.
FUN_CHARCOMP_CREATE = 0x00475FB0, // () -> builder from the engine pool at 0x00B42720; 0 on exhaustion
FUN_CHARCOMP_DESTROY = 0x00476000, // (builder /*ecx*/) — releases refs (FUN_00476ac0), returns node to pool
// __thiscall(builder, CharCompInfo*) -> bool. Copies 0x5B dwords
// (0x16C bytes) to builder+0x18, validates race against ChrRaces, resolves
// skin/hair/facial sections + geosets. Caller must AddRef the model first
// (FUN_005fb200 does); the builder owns that ref from then on.
FUN_CHARCOMP_SET_INFO = 0x00476B90,
// __thiscall(builder, slot 0..9, itemDisplayInfoID) — feed one NPC
// equipment piece (validated against ItemDisplayInfo.dbc, no-op for 0 /
// out-of-range). Units pass CreatureDisplayInfoExtra +0x20..+0x44.
FUN_CHARCOMP_SET_ITEM = 0x00478AA0,
// __thiscall(builder, int *unused /*engine passes 0*/) -> bool done.
// The per-frame pump: composites pending texture regions; the baked-NPC
// path short-circuits to the geoset apply (FUN_00477520) and returns 1 on
// the first call. Keep calling until it returns 1.
FUN_CHARCOMP_PUMP = 0x00477860,
// Model-instance API used around the compositor:
FUN_MODEL_INSTANCE_ADDREF = 0x00710390, // (model /*ecx*/)
// __thiscall(model, tryLoad, recurseChildren) -> bool loaded. The engine's
// dress gate (FUN_00607da0) passes (0, 0) — a passive check.
FUN_MODEL_INSTANCE_LOADED = 0x007103D0,
// Attached-child list on a model instance (helm/shoulder item models the
// compositor attaches via FUN_00712f70). Head + sibling-next verified from
// FUN_MODEL_INSTANCE_LOADED's recurseChildren walk.
OFF_MODEL_INSTANCE_CHILD_HEAD = 0x1DC,
OFF_MODEL_INSTANCE_CHILD_NEXT = 0x1E4,
// __thiscall(model, callback, userData) — stores the instance's pre-render
// callback pair (instance+0x3BC/+0x3C0). CSimpleModelFFX::SetModelInstance
// (FUN_0076cd30) registers FUN_SIMPLEMODELFFX_LIGHT_FOG_CB with the frame
// as userData on ITS instance only; attached children have an empty slot
// and render UNLIT (pure black — verified in-game: a light tint colored
// the body but not the equipment). `Model::DisplayInfo` fills each child's
// slot with the same pair so equipment lights like the body.
FUN_MODEL_INSTANCE_SET_RENDER_CB = 0x007134B0,
// __fastcall-shaped engine callback (never called by us directly): applies
// the owning frame's fog (+0x3A4/+0x3A8) and light struct (+0x324, written
// by Script_SetLight via FUN_0076cf30) to the render context each time the
// instance is drawn.
FUN_SIMPLEMODELFFX_LIGHT_FOG_CB = 0x0076D680,
// 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
@@ -2640,6 +3045,66 @@ enum Offsets {
VAR_CREATURETYPE_COUNT = 0x00C0DE30,
OFF_CREATURETYPE_NAMES = 0x04,
// CreatureDisplayInfo.dbc / CreatureModelData.dbc — the
// creatureDisplayID → model-file chain behind `Model:SetDisplayInfo`.
// Both standard pointer-array DBCs (records[id], sparse). Column offsets
// verified two independent ways: parsing the extracted 1.12 DBCs
// (C:\WoW\Octo\DBFilesClient) AND the engine's own unit-display resolver
// FUN_0060afb0, which reads displayID from `[descriptor+0x1F4]` →
// CreatureDisplayInfo[id] +0x04 ModelID → CreatureModelData[ModelID].
// CreatureDisplayInfo: +0x04 ModelID (col 1 → CreatureModelData id);
// +0x18/+0x1C/+0x20 TextureVariation[3] (cols 6/7/8 — creature skin,
// bare filenames applied as replaceable-texture MONSTER_1/2/3).
// CreatureModelData: +0x08 ModelPath (col 2, e.g. "Creature\Rat\Rat.mdx").
VAR_CREATUREDISPLAYINFO_RECORDS = 0x00C0DE90,
VAR_CREATUREDISPLAYINFO_COUNT = 0x00C0DE94,
OFF_CREATUREDISPLAYINFO_MODEL_ID = 0x04,
OFF_CREATUREDISPLAYINFO_TEXTURE_VARIATION = 0x18, // char*[3] @ +0x18/+0x1C/+0x20
VAR_CREATUREMODELDATA_RECORDS = 0x00C0DE68,
VAR_CREATUREMODELDATA_COUNT = 0x00C0DE6C,
OFF_CREATUREMODELDATA_MODEL_PATH = 0x08,
// CreatureDisplayInfo.extendedDisplayInfoID (col 3) — nonzero for a
// CHARACTER-based display (shared Character\Race\Sex base model). It indexes
// CreatureDisplayInfoExtra.dbc, whose LAST field (col 18 in the 19-col
// vanilla layout, +0x48) is the pre-composited body-skin filename. That
// texture lives under Textures\BakedNpcTextures\ and applies to the M2
// body-skin replaceable types (1/8) — NOT the monster slots, which a
// character model has no texture units for (hence "renders white" without
// this). The baked skin already carries the equipped body armor. Verified by
// parsing the extracted DBC; C:\Git\Inklab resolves the same chain.
OFF_CREATUREDISPLAYINFO_EXTENDED_ID = 0x0C,
VAR_CREATUREDISPLAYINFOEXTRA_RECORDS = 0x00C0DEA4,
VAR_CREATUREDISPLAYINFOEXTRA_COUNT = 0x00C0DEA8,
OFF_CREATUREDISPLAYINFOEXTRA_BAKE_NAME = 0x48, // col 18 (last), string
OFF_CREATUREDISPLAYINFOEXTRA_RACE = 0x04, // col 1 (ChrRaces id)
OFF_CREATUREDISPLAYINFOEXTRA_SEX = 0x08, // col 2 (0=male, 1=female)
OFF_CREATUREDISPLAYINFOEXTRA_HAIR_COLOR = 0x18, // col 6 (CharSections color)
// Cols 3/4/5/7 + the NPC equipment block — field order verified from
// CGUnit_C::UpdateCharacterCustomization (FUN_005fb200), which reads
// +0x04..+0x1C into the compositor info struct and walks +0x20..+0x44
// as 10 ItemDisplayInfo ids for FUN_CHARCOMP_SET_ITEM.
OFF_CREATUREDISPLAYINFOEXTRA_SKIN = 0x0C, // col 3
OFF_CREATUREDISPLAYINFOEXTRA_FACE = 0x10, // col 4
OFF_CREATUREDISPLAYINFOEXTRA_HAIR_STYLE = 0x14, // col 5
OFF_CREATUREDISPLAYINFOEXTRA_FACIAL_HAIR = 0x1C, // col 7
OFF_CREATUREDISPLAYINFOEXTRA_EQUIP = 0x20, // cols 8..17: u32[10] ItemDisplayInfo ids
// CharSections.dbc — per-race/sex character texture layers. Scanned by
// Model:SetDisplayInfo to resolve a character display's HAIR texture (applied
// to the M2 hair replaceable type 6, else the hair geoset renders white).
// Standard pointer-array DBC; match rows on race/sex/baseSection (3 = Hair) +
// color, then read the first texture column. Verified against the extracted
// DBC: Human Female (race 1, sex 1) hairColor 0 -> "Character\Human\Hair00_00.blp".
VAR_CHARSECTIONS_RECORDS = 0x00C0DF6C,
VAR_CHARSECTIONS_COUNT = 0x00C0DF70,
OFF_CHARSECTIONS_RACE = 0x04, // col 1
OFF_CHARSECTIONS_SEX = 0x08, // col 2
OFF_CHARSECTIONS_BASE_SECTION = 0x0C, // col 3 (3 = Hair)
OFF_CHARSECTIONS_COLOR = 0x14, // col 5
OFF_CHARSECTIONS_TEXTURE = 0x18, // col 6 (texture[0] path)
CHARSECTIONS_BASE_SECTION_HAIR = 3,
// Race → faction group, mirroring the player branch of
// `Script_UnitFactionGroup` (`0x00516630`) — backs
// `C_CreatureInfo.GetFactionInfo(raceID)`. Chain:
@@ -4358,6 +4823,15 @@ enum Offsets {
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
// this function pointer — no wrapper.
FUN_LUA_STR_GFIND = 0x007FCFA0,
// `str_format` — the Lua 5.0 `string.format` C function (`int
// __fastcall(void *L)`), strlib entry at `0x00822dc0`. Verified by decode:
// reads the format at arg 1, handles `%%`, `%d/i/c`, `%e/E/f/g/G`,
// `%o/u/x/X`, `%s`, `%q`, with the "invalid option to 'format'" / "invalid
// format (width or precision too long)" errors. FontString:SetFormattedText
// pushes THIS directly and pcalls it, rather than resolving `_G.string.format`
// — so an addon replacing `string.format` cannot hijack or break it (matches
// retail, whose SetFormattedText formats in C).
FUN_LUA_STR_FORMAT = 0x007FD3D0,
// `math_fmod` — the Lua 5.0 `math.mod` C function (mathlib entry at
// `0x00822cb0`): `luaL_checknumber(1/2)` → `fmod(x, y)` → `lua_pushnumber`.
// 5.1 renamed the Lua-facing name to `math.fmod` with the identical C
@@ -5029,6 +5503,13 @@ enum Offsets {
MOVEFLAG_FALLING = 0x2000,
MOVEFLAG_FALLING_FAR = 0x4000,
MOVEFLAG_SWIMMING = 0x200000,
// The player-movement bits that drop an in-progress cast — the exact mask
// the engine's CheckCast (FUN_006094f0) tests (0x200f). Spell::Cast uses it
// to tell a spurious movement drop of a movement-immune cast (grenades)
// from a real cancel/interrupt, which happens with the player stationary.
MOVEFLAG_MASK_CAST_DROP = MOVEFLAG_FORWARD | MOVEFLAG_BACKWARD |
MOVEFLAG_STRAFE_LEFT | MOVEFLAG_STRAFE_RIGHT |
MOVEFLAG_FALLING, // = 0x200f
// CGPlayer-local pointer to the GameObject the current spell cast
// is targeting. Holds a heap pointer (high bits in the user-mode
@@ -5180,6 +5661,22 @@ enum Offsets {
// `RET 0x4` (callee cleans the one stack arg).
FUN_WORLD_TICK = 0x0066FD50,
// UI render root — the CSimpleTop singleton's (`DAT_00cf0bd8`) per-frame
// render callback. Registered at priority 1.0 into the global HLAYER list
// (`FUN_00442800`) and drained every frame by the master scene-render pump
// `FUN_00442350`; body is just `FUN_00765650(root, ...)` (UI layout pass)
// then `FUN_007657d0(root)` (the strata walk that draws every UI frame).
// Unlike FUN_WORLD_TICK this fires in BOTH glue and world: the root ctor
// `FUN_00764180` runs from the world-init path (`FUN_0048FBF0`) AND the
// glue-boot path (`FUN_0046A7B0`), so the UI renders — and this callback
// fires — on the login/character-select screens too. This is the tick to
// drive UI-object upkeep that must also run on glue (inline-texture icon
// regions), where FUN_WORLD_TICK is silent. `__stdcall(void *bounds,
// int flag)`, `RET 0x8`; clean PUSH EBP / MOV EBP,ESP prologue (MinHook-
// safe). Single caller (the HLAYER drain), quiet render region — not a
// known collision target for the other Octo DLLs.
FUN_UI_RENDER_ROOT = 0x00764330,
// SMSG_BINDPOINTUPDATE handler. The server sends this packet on
// initial login (to sync the player's current bind) and again
// every time the player binds at a new innkeeper. The handler
@@ -5819,6 +6316,12 @@ enum Offsets {
SPELL_ATTR_EX_CHANNELED = 0x4 | 0x40,
SPELL_ATTR_EX2_AUTOREPEAT_FLAG = 0x20,
OFF_SPELL_RECORD_INTERRUPT_FLAGS = 0x54, // u32 (column 21)
// Bit 0 of InterruptFlags: the cast is interrupted when the caster moves.
// Both the server (HandleMovementOpcodes → InterruptSpellsWithInterruptFlags,
// tortoise-wow) and the client's own CheckCast (FUN_006094f0 gates its MOVING
// failure on this bit) use it; thrown items (grenades — e.g. spell 4068)
// lack it, so movement must not end their cast. See Spell::Cast (issue #23).
SPELL_INTERRUPT_FLAG_MOVEMENT = 0x1,
// Remaining Spell.dbc record fields — the single source of truth for the
// record layout (byte = column * 4). Modules must use these rather than
@@ -6207,4 +6710,345 @@ enum Offsets {
// `__fastcall(const char *name) -> definition node`, 0 if unregistered
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
// --- Inline texture escape (`|Tpath:h:w:...|t`) backport -------------------
// See src/text/InlineTexture.cpp and docs/InlineTextureEscapes.md. The
// 1.12 text pipeline is a shared `|`-tokenizer feeding per-purpose loops.
// Slice 1 injects an inline-texture quad into the text PAINT pass.
// Text paint pass — flushes each text line's per-font-page glyph vertex
// batches to the GPU every frame. `__fastcall(layoutObj)`; gated on
// DAT_00c2b9d4. Co-hooked post-original: the flush walks the layout's node
// list and queues each recorded inline icon as a fontstring-anchored region
// placement (Text::InlineTexturePool). See FUN_005c8fe0.
// (The GxU raw-quad primitives this site once drew with — dynamic-VB
// lock/write/submit, texture bind/load/force-decode, colorop state — were
// removed with the quad render mode; VAs are preserved in
// docs/InlineTextureEscapes.md and git history if ever needed again.)
FUN_TEXT_PAINT = 0x005C8FE0,
// Default texture-load blend arg (DAT_00878cf0) — passed to the engine's
// SetTexture-by-path (FUN_SIMPLETEXTURE_SET_TEXTURE) exactly as
// Script_Texture_SetTexture's own call site does.
VAR_TEXTURE_BLEND_DEFAULT = 0x00878CF0,
// --- Inline-texture positioning (slice 1: measure/emit integration) --------
// The 1.12 text pipeline builds a layout as a list of render "nodes" (one
// per wrapped line); each node owns per-font-page glyph vertex batches and a
// screen origin, and the paint pass (FUN_005c8fe0) walks the layout's node
// list drawing each node's batches translated by that origin. See
// docs/InlineTextureEscapes.md and the decompiled map in InlineTexture.cpp.
// Per-line glyph emitter. `__thiscall(node, byte *text, int len, uint
// *colorState, float *penXYZ, uint *pageMask, int *linkState)`. Reads the
// pen start from penXYZ[0..2] (node-local), appends glyph quads to the
// node's page batches, and on return writes the final pen x (node-local,
// as a FLOAT via `FSTP dword` — read linkState[4] as float, not int) into
// linkState[4]. We co-hook it and, for a line that
// contains an inline `|T…|t`, render the plain runs by delegating to the
// original per segment (threading the pen via linkState[4]) and record an
// icon quad at the pen between segments. Safe to call repeatedly per line
// because the batch-clear at its top is gated on OFF_TEXT_NODE_FLAGS bit 3,
// which is clear during normal accumulation draw. See FUN_005ccbe0.
FUN_TEXT_EMITTER = 0x005CCBE0,
// Node flags. Bit 3 (0x08) gates the emitter's per-call batch-clear (set
// only in a rebuild mode we don't take); we segment only when it's clear.
// Bit 6 (0x40) = EDITABLE text (set on editbox content; verified in-game:
// the macro editbox is 0x4D, chat display 0x20D, FontStrings 0x0D). It rides
// in the tokenizer's flags argument, so we suppress inline rendering per node
// for editboxes — they show raw, editable `|T…|t` markup. This is the 1.12
// analog of 4.3.4's per-render texture-disable flag (tokenizer bit 0x1000).
OFF_TEXT_NODE_FLAGS = 0x5C,
// Node horizontal justify (int): 0 = left, 1 = centre, 2 = right — the draw
// builder's encoding. The emitter pre-shifts an inline icon's pen by the
// line's justify offset for centre/right nodes (left needs no shift).
// Verified in-game against centred/right-aligned text layout.
OFF_TEXT_NODE_JUSTIFY = 0x54,
// Node screen origin (float x, float y). The paint pass translates the
// node's node-local glyph verts by this; an inline icon recorded in
// node-local pen coords is drawn at (localX + originX, localY + originY).
OFF_TEXT_NODE_ORIGIN_X = 0x70,
OFF_TEXT_NODE_ORIGIN_Y = 0x74,
// Node → per-font-page vertex buffers. The glyph emitter (FUN_TEXT_EMITTER)
// bakes each glyph as a 4-vertex quad into one of up to TEXT_NODE_PAGE_COUNT
// font-page buffers hanging off the node; the paint copy-out (FUN_005c8710)
// reads them back TRANSLATE-ONLY (gpu.xy = local.xy + node origin). So a
// rotation baked into these node-local verts survives to the screen — the
// basis for fontstring:SetRotation (Texture::Transform::RotateFontStringNode).
// node + OFF_TEXT_NODE_PAGE_BUFFERS + page*4 -> page buffer ptr (may be 0)
// pageBuf + OFF_TEXT_PAGE_VERT_COUNT -> int vertex count
// pageBuf + OFF_TEXT_PAGE_VERTS -> float* vertex array
// each vertex = 5 floats {x, y, z, u, v}, stride TEXT_VERT_STRIDE bytes.
// Cross-verified: the emitter (writes verts here), FUN_005c8710 (copies them
// to the GPU buffer), and FUN_005ce0c0 (indexes the page array) all agree.
OFF_TEXT_NODE_PAGE_BUFFERS = 0xA0,
TEXT_NODE_PAGE_COUNT = 8,
OFF_TEXT_PAGE_VERT_COUNT = 0xC,
OFF_TEXT_PAGE_VERTS = 0x10,
TEXT_VERT_STRIDE = 0x14, // bytes; 5 floats per vertex (x, y, z, u, v)
// Per-page baked PER-GLYPH colours (BGRA dword per vertex), parallel to the
// vertex array above. Written by the emitter's bit-3-clear path
// (`[pageBuf+0x20] + ([pageBuf+0x1C] + i)*4 = colorState` at 0x005CCC0x);
// the paint copy-out FUN_005c8710 uses them VERBATIM — but only when the
// colour count equals the vertex count, else it falls back to the node's
// uniform colour (OFF_TEXT_NODE_COLOR). No later modulation: whatever alpha
// is baked here is final, which is why InlineTexture's flush mirrors the
// node's live alpha into these entries per frame.
OFF_TEXT_PAGE_COLOR_COUNT = 0x1C,
OFF_TEXT_PAGE_COLORS = 0x20,
// Node uniform colour (BGRA). Seeded from fs colors[0] at block creation
// (FUN_007724a0 → FUN_0044d420) and rewritten on every colour/alpha change
// via the fs colour-changed vmethod (FUN_00772180 → FUN_0044d650 →
// FUN_005ccb40). Its alpha byte (+0x2F) is the FOLDED live opacity —
// SetTextColor alpha (fs+0xA8 array) × the owning frame's effective alpha
// (*(fs+0x9C)+0xC8), maintained by FUN_0077fac0 — so it tracks parent
// SetAlpha per change. The paint reads it per frame for uniform text; the
// emitter's |c splice (case 0) and the draw builder's colorState seed both
// copy it at bake time. NOTE: FUN_005ccb40 only invalidates (re-bakes) a
// node on colour change when bit 3 is CLEAR — a bit-3 (standalone
// FontString) node with baked per-glyph colours goes stale instead, hence
// the flush-side alpha mirror.
OFF_TEXT_NODE_COLOR = 0x2C,
// Layout node list — intrusive singly-linked list of render nodes. Head at
// [layout+0x24]; next = *(node + [layout+0x1c] + 4); the tail sentinel has
// its low bit set (mirrors the paint pass's own walk in FUN_005c8fe0).
OFF_TEXT_LAYOUT_NODE_HEAD = 0x24,
OFF_TEXT_LAYOUT_NODE_LINK = 0x1C,
// Node source-text pointer. The draw builder FUN_005cdc20 starts from
// [node+0x48] and advances it per wrapped line, calling the emitter once
// per line on the SAME node — so `emitterText == [node+0x48]` identifies
// the first wrapped line (used to clear the node's icon list once per
// build, then accumulate across lines). This buffer is a node-owned COPY of
// the DISPLAY text (SStrCopy'd in the node init FUN_005cd6d0, capacity
// [node+0x4c]) — the full source when it fits, or the ellipsized
// "<prefix>..." form when the display-text resolver truncated it. So
// comparing it against the fs source (OFF_FONTSTRING_TEXT) is the render-
// truth "displayed vs full" signal FontString:IsTruncated uses.
OFF_TEXT_NODE_TEXT = 0x48,
// Node font-size field (float), fed to the font-height helper.
OFF_TEXT_NODE_FONT_SIZE = 0x1C,
// Node's gxu font-face pointer. The emitter FUN_005ccbe0 reads *(node+0x44)
// as the `this` for every glyph call (glyph lookup FUN_005cabd0, the pair
// advance, the native-height getter below).
OFF_TEXT_NODE_FONT_FACE = 0x44,
// Font pixel-height helper. `__fastcall(int flag /*ecx = (nodeFlags>>7)&1*/,
// float fontSize /*stack = [node+0x1c]*/) -> float` (returns in ST0). The
// emitter calls it to size glyphs; we call it to derive the text's vertical
// extent so an inline icon can be centred on the line independent of font
// size. See FUN_005c6fa0.
FUN_TEXT_FONT_HEIGHT = 0x005C6FA0,
// Font-face NATIVE pixel height getter: `__fastcall(void *fontFace) -> int`
// (returns *(font+0x24C)). The emitter's pen scale is
// FUN_TEXT_FONT_HEIGHT(...) / (float)nativeHeight — glyph-record advances
// are in native-font units and multiply by that scale into pen units.
// See FUN_005cae90.
FUN_TEXT_FONT_NATIVE_HEIGHT = 0x005CAE90,
// Glyph PAIR advance: `__thiscall(void *fontFace, uint prevCh, uint curCh)
// -> float (ST0)`, native-font units = baseAdvance(prevCh) +
// kern(prevCh,curCh)·[font+0x188], memoized per pair. The emitter computes
// this at the top of each token iteration and places the CURRENT glyph at
// pen + pairAdvance(prev, cur) — i.e. the pen is LAZY: a glyph's own
// advance is only consumed when the NEXT token lands. Consequence
// (verified in FUN_005ccbe0's tail): the emitter's final pen write
// (linkState[4]) is x(lastGlyph) + pairAdvance(secondLast, last) — the
// last pair RE-ADDED as a stand-in for the last glyph's own advance. For a
// single-glyph run that stand-in is 0 (no pair ever computed) → the
// read-back reports the run as zero-width (the money-string "coin sits on
// the lone digit" bug). The emitter co-hook corrects the read-back with
// these helpers: pen = linkState[4] − pairAdvance(prev,last)·scale +
// baseAdvance(last)·scale. See FUN_005ca2d0.
FUN_TEXT_GLYPH_PAIR_ADVANCE = 0x005CA2D0,
// The flags&0x10 variant of the pair advance (same shape; the emitter
// selects it when node flags bit 4 is set). See FUN_005ca4b0.
FUN_TEXT_GLYPH_PAIR_ADVANCE_ALT = 0x005CA4B0,
// Glyph BASE advance: `__thiscall(void *fontFace, uint ch) -> float (ST0)`,
// native-font units, from the font's glyph cache (present for any glyph the
// emitter just drew; returns a default for uncached). This is the pair
// advance's first term — the terminal-glyph advance with no following
// kern, exactly what an inline icon following the glyph needs. See
// FUN_005ca240.
FUN_TEXT_GLYPH_BASE_ADVANCE = 0x005CA240,
// Hyperlink hit-rect registration (the GXUFONTHYPERLINKINFO append).
// `__thiscall(node, float yA, float xLeft, float yB, float xRight,
// char *linkStart, uint linkLen, char *escStart, uint escLen)` — 8 stack
// dwords, RET 0x20. Appends a 0x20-byte record to the node's link array
// (cap +0x7C, count +0x80, data +0x84): the four floats normalized to
// screen space ({yA,left,yB,right} — the engine's y-first rect order; the
// emitter's |H open writes linkState[2]=xLeft, |h close linkState[4]=
// xRight, the draw builder provides the line's yA/yB), then the four link
// dwords raw. Called from the emitter's |h close (case 5). The y extent is
// the TEXT band (fontH tall) — co-hooked so a link containing a tall
// inline icon gets its hit band expanded by the icon's overflow (the
// line-height-growth counterpart; without it only the text-high slice of
// a 32px emote link was hoverable). See FUN_005cd310.
FUN_TEXT_LINK_RECT_ADD = 0x005CD310,
// Shared `|`-escape tokenizer. `__fastcall(byte *text /*ecx*/, int
// *bytesConsumed /*edx*/, uint *colorOut, uint flags, uint *payloadOut) ->
// tokenType`. ~11 callers (measure loops, line-fit/wrap, the emitter, the
// draw builder). It has NO `|T` case, so `|T` falls through to a literal
// `|`. We co-hook it so an inline-texture span (`||T…||t` after the
// FontString pipe-doubling, or a clean `|T…|t`) is consumed as ONE
// near-zero-width token — this stops every measure/wrap caller from
// counting the path text and wrapping early. The emitter detects icons on
// its own (and delegates plain segments that never contain `|T`), so the
// draw path is unaffected by this hook. See FUN_005c2810.
FUN_TEXT_TOKENIZER = 0x005C2810,
// Shared wrap-stepper dispatcher — lays out ONE wrapped line per call.
// `__fastcall(gxuFont /*ecx*/, byte *text /*edx*/, float fontH, float
// wrapWidth, int *outBreak, float *outWidth, void *outNext, float indent,
// uint flags, byte *p10)`, callee cleans 0x20 (verified RET 0x20; prologue
// PUSH EBP; MOV EBP,ESP; FLD [EBP+8] — MinHook-safe). Thin validator that
// routes flags&0x80 to the no-wrap path (FUN_005C7300, ignores wrapWidth)
// and everything else to the real stepper FUN_005C7470. Its exactly 4
// callers are EVERY wrap consumer: the draw builder FUN_005CDC20 (render
// breaks), the height measure FUN_005C2070 (GetStringHeight), the
// chars-that-fit counter FUN_005C21C0 (ellipsis truncation), and the
// break-array computer FUN_005C2430 (behind FUN_00772B60) — so a co-hook
// here keeps render, height, truncation, and break arrays mutually
// consistent. Text::InlineTexture shrinks the wrapWidth arg by the line's
// inline-icon advances (the tokenizer hook hides them from the measure, so
// breaks otherwise land as if icons were 0 wide and the emitter's real
// advances overflow the right edge). UNITS: each caller passes
// fontH/wrapWidth in its OWN space (node text units from the builder, the
// small anchor-converted space from the fs-level callers — the path chat
// wraps through); a raw-pixel subtraction annihilates the small spaces
// (shredded chat lines into 2-glyph fragments on first flight). Convert
// px→caller units by (fontH / FUN_TEXT_FONT_HEIGHT(flag, fontH)) — the
// engine's own pixel realization of the fontH param (FUN_005C6940's final
// scale uses exactly that call), so the ratio is valid in every caller's
// space.
FUN_TEXT_WRAP_STEPPER = 0x005C7260,
// Focused-editbox global — holds the CSimpleEditBox that currently has
// keyboard focus (the one showing a cursor), or 0 when no input field is
// active. Written by EditBox SetFocus (`mov [0xcf4dc8],esi` @0x0077e3f8),
// cleared by ClearFocus (FUN_0077e410). We read it (plus the buffer offsets
// below) to identify the focused editbox's OWN text and suppress inline-icon
// rendering for JUST that text, so the input field shows raw, editable
// `|T…|t` markup while everything else (chat history icons/emotes) keeps
// rendering. See the editbox vtable at 0x0081c8c0 / FUN_0077e410.
VAR_FOCUSED_EDITBOX = 0x00CF4DC8,
// CSimpleEditBox text-buffer layout — the editbox lays out and measures its
// text IN PLACE from these buffers (no internal copy), so its layout node's
// text pointer ([node+0x48]) and every wrapped-line text pointer point INTO
// this buffer. Verified from the caret positioner FUN_0077da80: it selects
// the buffer via `(*(byte*)(fe+0x318) & 8) ? *(char**)(fe+0x334) :
// *(char**)(fe+0x32C)` and measures substrings of it directly with
// FUN_00772ae0(fs, feBuf+off, n). Used by Text::InlineTexture to correlate a
// node/token to the focused editbox by pointer range.
OFF_EDITBOX_BUFFER_SELECT = 0x318, // byte; bit 3 (0x08) picks the masked buffer
OFF_EDITBOX_BUFFER = 0x32C, // char* — normal text buffer
OFF_EDITBOX_BUFFER_MASKED = 0x334, // char* — masked/password variant buffer
// The editbox's DISPLAY FontString ([fe+0x328], param_1[0xca] in FUN_0077da80).
// Its rendered text is a COPY of the input buffer, stored at
// *(fs + OFF_FONTSTRING_TEXT) — the editbox lays out (renders) from this copy,
// NOT from the input buffer above (which only the caret/measure path reads in
// place). So identifying the focused editbox's own render nodes needs BOTH
// buffers. Verified: FUN_00771d80 (FontString SetText) writes the copy to
// *(this+0xF0) via SStrDup.
OFF_EDITBOX_TEXT_FONTSTRING = 0x328, // CSimpleFontString* — the display text object
// --- FontString measure internals (GetStringWidth icon fix + GetStringHeight) ---
// CSimpleFontString::GetStringWidthInternal — `float(__fastcall)(fs /*ecx*/)`,
// returns in x87 ST0. Lazily computes into the fs+0xFC cache (ANCHOR units,
// sentinel 0.0f = DAT_007ffd74): display-text getter FUN_00771EC0, then measure
// core FUN_0044D670(fontHandle fs+0xE0, text, len, fontH*[fs+0x7C], &out,
// fs+0x108, 0, flags fs+0x120), then `fs+0xFC = out / [fs+0x7C]`. Exactly 4
// callers: Script_GetStringWidth (0x0079E510), GameTooltip auto-size
// (0x00530640), the editbox caret positioner (0x0077DE70), and the layout
// effective-width vmethod (FUN_00772930 = fs+0x24 vtbl slot +0x1C: explicit
// rect width else string width). Prologue PUSH EBX; PUSH ESI; MOV ESI,ECX;
// FLD [ESI+0xFC] — MinHook-safe. Co-hooked by Text::InlineTexture to add
// inline |T icon advances; the hook must NEVER write the fs+0xFC cache (the
// original may serve the cached value — the icon sum is re-added per call).
FUN_FONTSTRING_STRING_WIDTH = 0x00772890,
// CSimpleFontString::GetStringHeightInternal — `float(__fastcall)(fs)`, ST0.
// Wrap-aware: FUN_0044D750 → FUN_005C2070 → wrap engine FUN_005C7260 (the
// same wrap math the render uses); returns lines*fontH + (lines-1)*spacing
// (spacing = fs+0xF4), cached at fs+0x100 (anchor units, 0.0 sentinel; empty
// text → 0). Wrap width = the effective-width vmethod (fs+0x24 vtbl +0x1C).
// Used internally by the tooltip auto-size (0x00530640), multi-line editbox
// height (0x0077D4D0), and ScrollingMessageFrame line stacking (0x00788750)
// — FontString::Metrics only CALLS it (the GetStringHeight backport), no hook.
FUN_FONTSTRING_STRING_HEIGHT = 0x007729B0,
// Per-fontstring font-height getter, ANCHOR units (reads fs+0xE4; with
// mode=1 and measure-flag 0x200 set, re-derives from the gxu font's native
// height). ECX = fs, mode on the STACK (`PUSH 0x1; MOV ECX,ESI; CALL` at
// 0x007728F4) → declare `__fastcall(fs, edx_unused, int mode)`. The ENGINE's
// width path multiplies its return by [fs+0x7C] (the layout UI SCALE) before
// handing it to the measure core. Our width hook instead converts it to UI
// PIXELS (× the anchor→px push factor VAR_UI_COORD_SCALE_DIV × 1024 /
// VAR_UI_COORD_SCALE_MUL, ≈1468) to resolve `:0` auto-sized icon dims —
// escape sizes are PIXELS, not fs+0x7C-scaled pen units; dividing a pixel
// advance by fs+0x7C (~0.68) inflated a 16px icon to +44k measured px on
// first flight. Distinct from FUN_TEXT_FONT_HEIGHT (0x005C6FA0), the
// node-level gxu helper.
FUN_FONTSTRING_FONT_HEIGHT = 0x007727B0,
// FontString measure-flags word (the `flags` arg the measure paths pass to
// FUN_0044D670/FUN_0044D750, and the justify field: bits 0-2). The measure
// core translates fs-level bits to gxu/tokenizer flags; bit 0x1000 → gxu
// bit 0x40 (OFF_TEXT_NODE_FLAGS bit 6 — the bit the tokenizer co-hook stands
// down on and the width hook tests).
//
// NOTE — bit 0x1000 is NON-SPACE-WRAP, not a dedicated "editable" flag: it is
// exactly what FontString:SetNonSpaceWrap toggles (verified in its setter
// FUN_0079E9F0: `fs+0x120 ^= 0x1000`). Text::InlineTexture uses gxu 0x40 as an
// EDITBOX PROXY — it works because the macro editor (the multi-line editbox
// the focused-buffer pointer test misses) enables non-space-wrap while chat/
// display text does not (observed node flags: macro editor 0x4D, chat 0x20D/
// 0x205). The correlation is not a guarantee: a DISPLAY fontstring with
// SetNonSpaceWrap(true) + inline |T icons would have its icons wrongly
// suppressed. Rare and never observed; the focused-buffer pointer test remains
// the primary editbox guard.
OFF_FONTSTRING_MEASURE_FLAGS = 0x120,
// CSimpleFontString::GetDisplayText — the engine's truncation MECHANISM (the
// reason FontString:IsTruncated has anything to detect). `char* __thiscall(fs
// /*ecx*/, float availW, float availH)` (dummy-EDX __fastcall form; RET 0x8).
// Reads the raw text at fs+0xF0 and, when the box is BOUNDED (availW>0 AND
// (maxLines fs+0x128 != 0 OR availH>0)), measures how much fits via the
// chars-that-fit counter FUN_0044D960; on overflow it writes "<prefix>...\0"
// into the shared scratch buffer VAR_TEXT_ELLIPSIS_BUFFER and returns THAT,
// else the fs+0xF0 pointer (or NULL). The rebuild FUN_007724A0 calls it with
// availW=(rect.right−rect.left)/[fs+0x7C], availH=(rect.bottom−rect.top)/
// [fs+0x7C] and lays out whatever it returns — the node then COPIES that
// string into OFF_TEXT_NODE_TEXT. IsTruncated does NOT call this (the rect at
// fs+0x64 is zero between frames, so a Lua-time re-call always sees an
// unbounded box); it reads the node copy instead. Kept for the availW/availH
// derivation + the bounded-box gate. Verified in the Octo binary: prologue
// 55 8B EC 83 EC 0C 53 56 8B F1, ellipsis literal "..." at 0x00800188.
FUN_FONTSTRING_DISPLAY_TEXT = 0x00771EC0,
// Shared 0x1000-byte BSS scratch buffer the display-text resolver above writes
// the truncated, "..."-suffixed string into (memset FUN_0064A5A0 at 0x00772054,
// suffix append at 0x00772086) and returns on truncation. Transient — every
// layout rebuild overwrites it, and the node keeps its OWN copy (see
// OFF_TEXT_NODE_TEXT), so it is not read directly by any live path.
VAR_TEXT_ELLIPSIS_BUFFER = 0x00CF0CC0,
// Per-fontstring MAX LINE COUNT. 0 = no cap. When non-zero, the display-text
// resolver forces availH = maxLines × lineHeight (verified in FUN_00771EC0:
// `if (fs+0x128 != 0) param2 = fs+0x128 * lineHeight`), so text past that
// many wrapped lines is ellipsized. Backs FontString:SetMaxLines/GetMaxLines
// — the truncation control 1.12 never exposed for fontstrings (SetMaxLines is
// ScrollingMessageFrame-only). Vanilla fontstrings always word-wrap, so this
// is the way to force a single-line, ellipsized label: SetMaxLines(1).
OFF_FONTSTRING_MAX_LINES = 0x128,
// Lazily-computed measure caches (anchor units, 0.0f = dirty sentinel):
// fs+0xFC = string width (GetStringWidth), fs+0x100 = string height
// (GetStringHeight). SetText (FUN_00771D80) zeroes BOTH when the content
// changes; SetMaxLines mirrors that so the getters recompute after the cap.
OFF_FONTSTRING_WIDTH_CACHE = 0xFC,
OFF_FONTSTRING_HEIGHT_CACHE = 0x100,
// Ref-counted handle DecRef (resolve via FUN_0041af30 → --refcount → dtor at
// 0). `__fastcall(void *handle)`. SetText releases the text-block handle
// (fs+0xF8) through it before nulling the field; SetMaxLines does the same to
// force a fresh node rebuild with the new line cap.
FUN_HANDLE_RELEASE = 0x0041AED0,
// VisibleRegion layout invalidate — marks the region (the fs+0x24 layout
// subobject) dirty and propagates to parent/children so it re-lays-out.
// SetText's tail calls it `FUN_007680e0(fs+0x24, 0)`. `__thiscall(region,
// int)` (dummy-EDX form; the int arg is on the stack).
FUN_FONTSTRING_LAYOUT_INVALIDATE = 0x007680E0,
};
+8 -8
View File
@@ -103,14 +103,14 @@ int CGItemCount(const uint8_t *item) {
auto *desc = Item::ObjectFields(item);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
return Game::Read<int>(desc, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
}
uint64_t CGItemGuid(const uint8_t *item) {
auto *inst = Item::InstanceBlock(item);
if (inst == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
return Game::Read<uint64_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID);
}
struct SlotInfo {
@@ -135,18 +135,18 @@ SlotInfo ReadSlot(void *L, int bag, int slot) {
// --- auction-house globals ----------------------------------------------
uint64_t AuctioneerGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
const uint32_t lo = Game::Read<uint32_t>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
const uint32_t hi = Game::Read<uint32_t>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
return (static_cast<uint64_t>(hi) << 32) | lo;
}
bool AHOpen() { return AuctioneerGuid() != 0; }
void SetSellGuid(uint64_t guid) {
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
static_cast<uint32_t>(guid);
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
static_cast<uint32_t>(guid >> 32);
}
@@ -181,8 +181,8 @@ bool BagIsGeneral(int bag) {
const uint8_t *rec = Item::PeekRecord(static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
return *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
return Game::Read<uint32_t>(
rec, Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
}
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
+2 -6
View File
@@ -23,6 +23,7 @@
#include "net/PacketReader.h"
#include "net/SendObserver.h"
#include "spell/Mod.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -32,12 +33,7 @@ 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))();
}
using Time::Clock::NowMs;
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
+99 -76
View File
@@ -21,6 +21,7 @@
#include "guid/Guid.h"
#include "spell/CrowdControl.h"
#include "spell/IsSelfBuff.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -144,12 +145,10 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
// Permanent auras (paladin auras, passives, racials) carry an infinite
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
// cast-time in this slot for them, so it's always "already elapsed" and the
// engine shows no countdown. The signed `(int)` compare also absorbs the
// ~24.86-day GetTickCount wrap (both operands wrap together).
using TickCount_t = uint32_t(__fastcall *)();
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
if (static_cast<int32_t>(nowMs - expirationMs) >= 0)
// engine shows no countdown. `Time::Clock::Reached` takes the signed diff, so
// it also absorbs the ~24.86-day GetTickCount wrap (both operands wrap
// together).
if (Time::Clock::Reached(expirationMs))
return 0.0; // expiration reached/passed → permanent or no timer
return static_cast<double>(expirationMs) * 0.001;
}
@@ -209,12 +208,7 @@ double SpellBaseDurationSeconds(uint32_t spellID, int unitLevel) {
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
// not meaningful — the caster is kept regardless, but the time is reported as
// unknown rather than as a negative remaining.
bool ExpirationElapsed(uint32_t expMs) {
using TickCount_t = uint32_t(__fastcall *)();
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
return static_cast<int32_t>(nowMs - expMs) >= 0;
}
bool ExpirationElapsed(uint32_t expMs) { return Time::Clock::Reached(expMs); }
int PlayerLevel(const uint8_t *player) {
auto *desc = Descriptor(player);
@@ -224,23 +218,33 @@ int PlayerLevel(const uint8_t *player) {
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
}
// The caster GUID attributed to `(guid, spellID)`: the observed caster from
// the Aura::Source cache, else — when we never saw the cast — the unit itself
// if the spell is a self-only-target buff (self-buffs can't be cross-cast).
// 0 when genuinely unknown. Single source of truth for both display
// (sourceGUID/sourceUnit) and the PLAYER caster filter, so a self-buff shows a
// source AND matches HELPFUL|PLAYER instead of the two paths disagreeing.
uint64_t EffectiveCaster(uint64_t guid, uint32_t spellID) {
// Caster + timing attributed to ONE aura instance — the aura `spellID` sitting
// in absolute descriptor `slot` on `guid` (`Aura::Source::SLOT_UNBOUND` when
// the caller has no slot: the out-of-range group array, the cache fallback).
// The slot is what tells two casters' copies of one spell apart; without it
// they are indistinguishable from a spell ID alone.
//
// The caster is the observed one from the Aura::Source cache, else — when we
// never saw the cast — the unit itself if the spell is a self-only-target buff
// (self-buffs can't be cross-cast), else 0. Single source of truth for both
// display (sourceGUID/sourceUnit) and the PLAYER caster filter, so a self-buff
// shows a source AND matches HELPFUL|PLAYER instead of the two paths
// disagreeing.
struct Attribution {
uint64_t caster;
uint32_t expirationMs;
uint32_t durationMs;
};
Attribution Attribute(uint64_t guid, uint32_t spellID, int slot) {
Attribution a = {0, 0, 0};
if (guid == 0 || spellID == 0)
return 0;
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs) && c != 0)
return c; // observed caster
if (Spell::IsSelfBuff::IsSelfBuff(spellID))
return guid; // self-only-target aura → cast by the unit itself
return 0;
return a;
Aura::Source::Get(guid, spellID, slot, &a.caster, &a.expirationMs,
&a.durationMs);
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
a.caster = guid; // self-only-target aura → cast by the unit itself
return a;
}
} // namespace
@@ -277,7 +281,7 @@ bool IsPlayerCast(const uint8_t *unit, int slot) {
if (spellID == 0)
return false;
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 && EffectiveCaster(UnitGuid(unit), spellID) == player;
return player != 0 && Attribute(UnitGuid(unit), spellID, slot).caster == player;
}
// Applies the PLAYER / !PLAYER caster restriction. `isPlayerCast` is the
@@ -327,12 +331,13 @@ bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
CcMatches(match.cc, spellID);
}
// Group-array analog of IsPlayerCast: the group aura carries no caster, so we
// consult the Aura::Source cache by (guid, spellID). A miss counts as "not the
// player" (same as IsPlayerCast).
// Group-array analog of IsPlayerCast: the member has no descriptor, so there is
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
// A miss counts as "not the player" (same as IsPlayerCast).
bool GroupIsPlayerCast(uint64_t guid, uint32_t spellID) {
const uint64_t player = Unit::Identity::PlayerGuid();
return player != 0 && EffectiveCaster(guid, spellID) == player;
return player != 0 &&
Attribute(guid, spellID, Aura::Source::SLOT_UNBOUND).caster == player;
}
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
@@ -527,10 +532,21 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
// cache when it observed the cast — a miss leaves the modern-truthful defaults
// (expiration 0, no sourceUnit/GUID).
// (expiration 0, no sourceUnit/GUID). `slot` is the absolute descriptor slot
// the aura occupies, which is what attributes it to the right caster when two
// of them hold the same spell on the unit; `Aura::Source::SLOT_UNBOUND` for the
// paths with no descriptor to read one from.
//
// `known` short-circuits the by-(guid,spell,slot) resolve: the cache-fallback
// paths already hold the exact entry's attribution (Enumerate copied it out),
// so they pass it straight through. Re-resolving there would call `Get` a second
// time and, for an unbound entry sharing its spell with another caster's entry,
// return a miss — dropping a caster Enumerate already knew. Null for the
// descriptor / group-array paths, which have no per-entry attribution in hand.
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
bool isHelpful, int applications, int unitLevel,
bool isPlayer) {
bool isPlayer, int slot,
const Attribution *known = nullptr) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
@@ -542,36 +558,42 @@ static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
expirationTime = PlayerBuffExpirationSeconds(entry);
}
if (spellID != 0 && guid != 0) {
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
// A cached expiration that already elapsed while the aura is still
// present (non-player casters get an underestimated base duration)
// is not meaningful — report unknown (0) rather than a negative
// remaining time.
if (expirationTime == 0.0 && expMs != 0 && !ExpirationElapsed(expMs))
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0)
duration = static_cast<double>(durMs) * 0.001;
}
// Caster (sourceGUID/sourceUnit): observed caster, else the self-buff
// inference — same resolution the PLAYER filter uses, so a self-buff
// both shows a source and matches HELPFUL|PLAYER. Timing stays unknown
// when we never saw the cast; only the caster is inferred, and it's
// exact.
casterGuid = EffectiveCaster(guid, spellID);
// Caster (sourceGUID/sourceUnit) and timing resolved together, so a
// self-buff both shows a source and matches HELPFUL|PLAYER instead of
// the two paths disagreeing.
Attribution a = (known != nullptr) ? *known : Attribute(guid, spellID, slot);
// Self-buff inference for a pre-resolved entry whose caster the cache
// never learned (idempotent on the Attribute() path, which already
// applied it — the caster is nonzero there when it matched).
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
a.caster = guid;
// A cached expiration that already elapsed while the aura is still
// present (non-player casters get an underestimated base duration)
// is not meaningful — report unknown (0) rather than a negative
// remaining time.
if (expirationTime == 0.0 && a.expirationMs != 0 &&
!ExpirationElapsed(a.expirationMs))
expirationTime = static_cast<double>(a.expirationMs) * 0.001;
if (a.durationMs != 0)
duration = static_cast<double>(a.durationMs) * 0.001;
casterGuid = a.caster;
}
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
}
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
// for the cache-fallback push paths to hand to `PushEnriched` as `known`.
static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
return {c.casterGuid, c.expirationMs, c.durationMs};
}
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unitLevel, unit != nullptr && unit == LocalPlayer());
unitLevel, unit != nullptr && unit == LocalPlayer(), slot);
}
namespace {
@@ -582,12 +604,15 @@ namespace {
// applied (caster-modified) ms when known, else the Spell.dbc base.
void PushFromCache(void *L, const uint8_t *unit,
const Aura::Source::CachedAura &c, bool isHelpful) {
// `PushEnriched` re-reads the same cache entry `c` came from (by
// guid+spellID) to fill caster / expiration / applied duration, so the
// result is identical to reading `c`'s fields directly. Stacks aren't in
// SMSG_SPELL_GO, so `applications` is 1.
// `c` already carries this exact instance's caster + timing (Enumerate
// copied it out), so hand it to `PushEnriched` as `known` rather than
// re-resolving by (guid, spell, slot) — an unbound entry sharing its spell
// with another caster's would otherwise resolve to a miss and drop the
// caster. Stacks aren't in SMSG_SPELL_GO, so `applications` is 1.
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false);
const Attribution a = CachedAttribution(c);
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false,
c.slot, &a);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
@@ -604,15 +629,9 @@ void ReconcileCache(const uint8_t *unit) {
if (unit == nullptr)
return;
uint32_t present[Offsets::UNIT_AURA_TOTAL];
int n = 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = ReadSpellID(unit, slot);
if (id != 0)
present[n++] = id;
}
if (n == 0)
return;
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
present[slot] = ReadSpellID(unit, slot);
Aura::Source::EvictAbsent(UnitGuid(unit), present);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
@@ -818,8 +837,9 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
if (++seen == oneBasedIndex) {
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
GroupMemberLevel(guid), false);
GroupMemberLevel(guid), false, buf[i].slot, &a);
return true;
}
}
@@ -838,8 +858,9 @@ void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
false);
false, buf[i].slot, &a);
Game::Lua::SetTable(L, outerIdx);
}
}
@@ -861,8 +882,9 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
}
if (!GroupFallbackEligible(arr, buf[i], match))
continue;
const Attribution a = CachedAttribution(buf[i]);
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
false);
false, buf[i].slot, &a);
return true;
}
return false;
@@ -887,7 +909,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
false);
false, Aura::Source::SLOT_UNBOUND);
return true;
}
}
@@ -918,7 +940,7 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
@@ -959,7 +981,7 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
if (!GroupSlotEligible(guid, arr, slot, match))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
@@ -992,7 +1014,8 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, level, false);
filter == Filter::Helpful, 1, level, false,
Aura::Source::SLOT_UNBOUND);
Game::Lua::SetTable(L, outerIdx);
}
// Empty array (server delta-resend gap): supplement from the cache.
+293 -111
View File
@@ -26,6 +26,7 @@
#include "spell/CastEvents.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "totem/Tracker.h"
#include "unit/Identity.h"
@@ -54,11 +55,7 @@ bool SpellAppliesAura(const uint8_t *spellRecord) {
return false;
}
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
using Time::Clock::NowMs;
// Server-authoritative duration (ms). When the local player is the caster
// we let the engine fold in the player's duration modifiers (skipMod = 0);
@@ -125,6 +122,18 @@ bool WasRecentPlayerCast(uint32_t spellId) {
// application hook fires for the same aura.
enum Kind : int8_t { KIND_UNKNOWN = -1, KIND_HELPFUL = 0, KIND_HARMFUL = 1 };
// An entry is one AURA INSTANCE, identified the way the server identifies one:
// `(target, spell, caster)`. Two same-class raiders' Corruption on one boss are
// two auras occupying two descriptor slots, so a `(target, spell)` identity
// collapses them into one entry — the later cast overwrites the earlier
// caster's timing, and `OnAuraRemoved` for either copy evicts both.
//
// The descriptor only stores spell IDs, so recovering WHICH instance a slot
// holds needs the slot bound to its entry. SpellGo knows the caster but no
// slot; `OnAuraAdded` knows the slot but no caster. They arrive in that order
// (SpellGo, then the SMSG_UPDATE_OBJECT that seats the aura), so the
// application hook binds the cast capture it belongs to — the newest entry for
// this `(target, spell)` still awaiting a slot.
struct Entry {
uint64_t targetGuid;
uint64_t casterGuid;
@@ -132,12 +141,13 @@ struct Entry {
uint32_t expirationMs; // 0 = infinite / unknown duration
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
uint32_t stampMs; // last write time; EvictAbsent grace (see below)
int16_t slot; // absolute descriptor slot, SLOT_UNBOUND until bound
int8_t kind; // Kind; descriptor-slot-derived, KIND_UNKNOWN if only seen via SpellGo
bool used;
};
// Sized for the realistic worst case: a fully raid-buffed 40-man plus its
// debuff load. We cache one entry per (target, spellId) for EVERY
// debuff load. We cache one entry per (target, spellId, caster) for EVERY
// aura-applying SMSG_SPELL_GO we observe — not just auras on the player, but
// every buff cast on every unit in view — so the live working set in a raid
// is ~40 members × ~30 persistent buffs ≈ 1200, plus debuffs. At the old 256
@@ -145,10 +155,21 @@ struct Entry {
// dropping their source (the "lost sourceGUID on a buff" report). 2048 gives
// a fully-buffed 40-man comfortable headroom; the tick sweep still reclaims
// expired/orphaned entries so it rarely approaches full outside a raid.
// (~40 bytes/entry → ~80 KB static.)
// (~40 bytes/entry → ~80 KB static.) Per-caster identity adds one entry per
// extra caster of the same spell on one target, which is bounded by the 16
// debuff slots that can hold them.
constexpr int kCacheSize = 2048;
Entry g_cache[kCacheSize];
// One past the highest slot ever claimed since the last flush — the active
// prefix `[0, g_usedHigh)`. Every used entry lives below it (Claim is the sole
// allocator and extends it; FlushAll resets it), so all lookups scan only this
// prefix instead of the full 2048. Outside a raid the working set is a handful
// of auras, so this is the difference between a ~30-entry scan and a 2048-entry
// one on the per-aura query path. Never lowered on eviction — an over-estimate
// only costs a few extra skipped (!used) slots, never a missed live entry.
int g_usedHigh = 0;
// SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
// the target's descriptor, so for a brief window a just-captured entry names
// an aura the descriptor doesn't list yet. `EvictAbsent` (run from a query's
@@ -190,48 +211,106 @@ bool DescriptorListsAura(uint64_t guid, uint32_t spellId) {
return false;
}
// `fromCast` true: the SpellGo hook — authoritative caster + caster-modified
// (talented) timing. False: the OnAuraAdded application hook — timing only,
// no caster, and it must not clobber an entry SpellGo already owns (that
// would replace talented timing with the unmodified base), so it skips
// entries that already carry a caster.
void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
uint32_t expirationMs, uint32_t durationMs, bool fromCast,
int8_t kind) {
if (targetGuid == 0 || spellId == 0)
return;
// ---- Entry lookup --------------------------------------------------------
const uint32_t now = NowMs();
// The exact aura instance `caster` has on `targetGuid` — the server's own
// identity for one aura, and what a repeat cast refreshes.
Entry *FindByCaster(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId &&
e.casterGuid == casterGuid)
return &e;
}
return nullptr;
}
// Update an existing entry for this exact aura instance.
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
e.stampMs = now; // refresh EvictAbsent grace on any touch
// Learn the classification whenever a slot-derived kind arrives —
// independent of caster/timing ownership, and never downgrade a
// known kind back to unknown.
if (kind != KIND_UNKNOWN)
e.kind = kind;
if (!fromCast && e.casterGuid != 0)
return; // SpellGo owns this entry; keep its caster + timing
if (casterGuid != 0)
e.casterGuid = casterGuid;
e.expirationMs = expirationMs;
e.durationMs = durationMs;
return;
// The instance seated in descriptor slot `slot`.
Entry *FindBySlot(uint64_t targetGuid, uint32_t spellId, int slot) {
if (slot < 0)
return nullptr;
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId &&
e.slot == slot)
return &e;
}
return nullptr;
}
// The newest cast capture for this aura still awaiting a descriptor slot —
// what an `OnAuraAdded` for `(target, spell)` is seating. Newest wins because
// SpellGo precedes the application by a packet, so the freshest unbound entry
// is the cast that just landed; an older one is a capture that never seated
// (fully resisted, immune) and is left to expire on its own. "Newest" is the
// smallest `Time::Clock::Elapsed(stamp, now)` — a wrap-safe signed difference;
// a raw `stampMs >` compare inverts across the 2^32 ms tick wrap.
Entry *FindFreshestUnbound(uint64_t targetGuid, uint32_t spellId, uint32_t now) {
Entry *best = nullptr;
uint32_t bestElapsed = 0;
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != targetGuid || e.spellId != spellId ||
e.slot != SLOT_UNBOUND)
continue;
const uint32_t elapsed = Time::Clock::Elapsed(e.stampMs, now);
if (best == nullptr || elapsed < bestElapsed) {
best = &e;
bestElapsed = elapsed;
}
}
// Take a free slot, else an expired one whose aura the descriptor no
// longer lists (a still-present aura keeps its slot so its caster isn't
// lost — see DescriptorListsAura), else evict round-robin.
return best;
}
// The one entry for `(target, spell)`, or null when there are none or several.
// Several means two casters and no way to tell their instances apart from a
// spell ID alone, which is exactly when guessing is what produces a wrong
// caster and a wrong timer.
Entry *FindSole(uint64_t targetGuid, uint32_t spellId) {
Entry *found = nullptr;
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != targetGuid || e.spellId != spellId)
continue;
if (found != nullptr)
return nullptr;
found = &e;
}
return found;
}
// The instance a slot-bearing query means: the entry seated in `slot`, else the
// unit's sole entry for the spell — every single-caster case and the slotless
// (out-of-range group array, cache fallback) paths. Two casters' entries with
// no slot binding resolve to null (see FindSole), never to a coin flip. This is
// the read-side identity rule; both `Get` and `Evict` resolve through it so they
// can't drift into naming different instances for the same query.
Entry *FindInstance(uint64_t targetGuid, uint32_t spellId, int slot) {
Entry *e = FindBySlot(targetGuid, spellId, slot);
return e != nullptr ? e : FindSole(targetGuid, spellId);
}
// Marks the slot `e` occupies as within the active prefix, so later lookups
// reach it. Every new entry passes through here (Claim is the only allocator),
// which is what keeps the `[0, g_usedHigh)` invariant true for all writers.
Entry *Register(Entry *e) {
const int idx = static_cast<int>(e - g_cache);
if (idx >= g_usedHigh)
g_usedHigh = idx + 1;
return e;
}
// Takes a free slot, else an expired one whose aura the descriptor no longer
// lists (a still-present aura keeps its slot so its caster isn't lost — see
// DescriptorListsAura), else evicts. Scans the full array — as the allocator it
// must be able to hand out slots beyond the current active prefix (that is how
// the prefix grows), so it can't bound itself by g_usedHigh.
Entry *Claim(uint32_t now) {
for (auto &e : g_cache) {
if (!e.used ||
(e.expirationMs != 0 && now >= e.expirationMs &&
!DescriptorListsAura(e.targetGuid, e.spellId))) {
e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
now, kind, true};
return;
}
!DescriptorListsAura(e.targetGuid, e.spellId)))
return Register(&e);
}
// Saturated. Honor the same invariant as the tick sweep: an entry whose
// aura is still present on a resolvable unit is NEVER evicted — its caster
@@ -256,9 +335,91 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
if (orphan == nullptr || e.stampMs < orphan->stampMs)
orphan = &e;
}
Entry *victim = (orphan != nullptr) ? orphan : lru;
*victim = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
kind, true};
return Register((orphan != nullptr) ? orphan : lru);
}
// ---- Writes --------------------------------------------------------------
// The SpellGo hook: authoritative caster + caster-modified (talented) timing.
// Identity is `(target, spell, caster)`, so a second caster of the same spell
// opens its own entry instead of overwriting the first's, and a recast by the
// same caster refreshes theirs — keeping the descriptor slot already bound to
// it.
void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
uint32_t expirationMs, uint32_t durationMs) {
if (targetGuid == 0 || spellId == 0)
return;
const uint32_t now = NowMs();
Entry *e = FindByCaster(targetGuid, spellId, casterGuid);
// A lone unattributed entry for this aura is the same instance seen without
// a caster (seated by an application hook while we were out of view, or a
// group-array guess), so claim it rather than opening a second entry for
// one aura — the same adoption `ApplyDurationModifiers` does. Only when
// it's the unit's only entry for the spell: with several, "the one this
// cast refreshes" is a guess, and guessing wrong writes this caster's timer
// onto another's aura.
if (e == nullptr) {
Entry *sole = FindSole(targetGuid, spellId);
if (sole != nullptr && sole->casterGuid == 0)
e = sole;
}
if (e == nullptr) {
e = Claim(now);
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
now, SLOT_UNBOUND, KIND_UNKNOWN, true};
return;
}
e->stampMs = now; // refresh EvictAbsent grace on any touch
e->casterGuid = casterGuid;
e->expirationMs = expirationMs;
e->durationMs = durationMs;
}
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
// a classification, and base timing — but no caster (except the local player's
// own recent cast, which `StampApplication` resolves). Seats the slot on the
// cast capture it belongs to, and must not clobber the caster + talented
// timing SpellGo already owns for that instance.
//
// `slot` is SLOT_UNBOUND for the out-of-range group-array path, which has no
// descriptor at all; there the only available identity is `(target, spell)`.
void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
uint64_t casterGuid, uint32_t expirationMs,
uint32_t durationMs, int slot, int8_t kind) {
if (targetGuid == 0 || spellId == 0)
return;
const uint32_t now = NowMs();
// Anything outside the descriptor's slot range can't index it, so it binds
// nothing — `EvictAbsent` indexes the array by a bound entry's slot.
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
slot = SLOT_UNBOUND;
Entry *e = FindBySlot(targetGuid, spellId, slot);
if (e == nullptr)
e = FindFreshestUnbound(targetGuid, spellId, now);
if (e == nullptr && slot < 0)
e = FindSole(targetGuid, spellId);
if (e == nullptr) {
e = Claim(now);
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
static_cast<int16_t>(slot), kind, true};
return;
}
e->stampMs = now;
if (slot >= 0)
e->slot = static_cast<int16_t>(slot);
// Learn the classification whenever a slot-derived kind arrives —
// independent of caster/timing ownership, and never downgrade a known kind
// back to unknown.
if (kind != KIND_UNKNOWN)
e->kind = kind;
if (e->casterGuid != 0)
return; // SpellGo owns this entry; keep its caster + talented timing
if (casterGuid != 0)
e->casterGuid = casterGuid;
e->expirationMs = expirationMs;
e->durationMs = durationMs;
}
// ---- Out-of-range group-member aura snapshots ---------------------------
@@ -296,25 +457,27 @@ void StampGroupGuess(uint64_t guid, uint16_t spellId, int8_t kind, uint32_t now)
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ false);
if (base == 0)
return;
Store(guid, spellId, /*casterGuid*/ 0, now + base, base, /*fromCast*/ false,
kind);
StoreFromApplication(guid, spellId, /*casterGuid*/ 0, now + base, base,
SLOT_UNBOUND, kind);
}
// Evict the entry for an aura the engine reports gone. Keyed by (target,
// spell) like the rest of the cache. Without this, the GetAuraDataByIndex
// fallback would keep surfacing a dropped aura until its computed expiry —
// e.g. a Rank 1 buff replaced by Rank 2 (engine drops Rank 1 from the
// descriptor) would show as a phantom second aura, or a dispelled buff would
// linger.
void Evict(uint64_t targetGuid, uint32_t spellId) {
// Evict the entry for an aura the engine reports gone, identified by the
// descriptor slot it vacated. Without this, the GetAuraDataByIndex fallback
// would keep surfacing a dropped aura until its computed expiry — e.g. a Rank
// 1 buff replaced by Rank 2 (engine drops Rank 1 from the descriptor) would
// show as a phantom second aura, or a dispelled buff would linger.
//
// The slot is what makes this safe when two casters hold the same spell on one
// target: only the instance that actually fell off goes. Falling back to a
// spell-ID match when nothing is bound to the slot keeps the pre-binding
// behaviour, but only while the match is unambiguous — dropping one of two
// casters' entries at random would take a live aura's caster with it.
void Evict(uint64_t targetGuid, uint32_t spellId, int slot) {
if (targetGuid == 0 || spellId == 0)
return;
for (auto &e : g_cache) {
if (e.used && e.targetGuid == targetGuid && e.spellId == spellId) {
e.used = false;
return;
}
}
Entry *e = FindInstance(targetGuid, spellId, slot);
if (e != nullptr)
e->used = false;
}
// ---- Server-side duration modifiers (trigger-driven inference) -----------
@@ -472,7 +635,8 @@ void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
if (!TriggerMatches(m, triggerSpellId, triggerRec))
continue;
for (int t = 0; t < numTargets; ++t) {
for (auto &e : g_cache) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != targets[t])
continue;
// The mechanic acts on the trigger-caster's own aura. Cast-applied
@@ -580,8 +744,9 @@ const Game::ModuleAutoRegister _autoregDurationMod{&RegisterDurationModLua};
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
e.used = false;
for (int i = 0; i < g_usedHigh; ++i)
g_cache[i].used = false;
g_usedHigh = 0; // active prefix is empty again
// Reset transition baselines too: post-transition group auras re-sync and
// should be treated as first-sight (unknown age), not diffed as new.
for (auto &s : g_groupSnaps)
@@ -617,8 +782,9 @@ void OnWorldTick() {
}
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (!e.used || e.expirationMs == 0 || now < e.expirationMs)
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.expirationMs == 0 || !Time::Clock::Reached(now, e.expirationMs))
continue;
// A timer elapse doesn't prove the aura is gone — expirationMs is only
// a base-duration estimate for non-player casters. Keep any entry whose
@@ -705,13 +871,11 @@ void HandleSpellGo(uint64_t caster, uint32_t spellId, const uint64_t *targets,
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
Store(caster, spellId, caster, expirationMs, durationMs, true,
KIND_UNKNOWN);
StoreFromCast(caster, spellId, caster, expirationMs, durationMs);
return;
}
for (int i = 0; i < numTargets; ++i)
Store(targets[i], spellId, caster, expirationMs, durationMs, true,
KIND_UNKNOWN);
StoreFromCast(targets[i], spellId, caster, expirationMs, durationMs);
}
// SMSG_SPELL_GO parse (funnel subscriber). At the leaf handler the engine has
@@ -744,12 +908,19 @@ const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
// Stamp expiration for an aura that just landed/refreshed on `unit`. Used by
// both the add and stack-change hooks. No caster is available from these
// paths, so it stamps timing only with `fromCast=false` — Store skips any
// entry SpellGo already owns, so a directly-cast aura keeps its talented
// timing. Base (unmodified) duration is the best estimate without a caster.
void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
// Classify by the absolute aura slot: 0..BUFF_COUNT-1 = buff (helpful),
// BUFF_COUNT..TOTAL-1 = debuff (harmful).
int8_t KindForSlot(int slot) {
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
}
// Stamp expiration for an aura that just landed/refreshed in `slot` on `unit`.
// Used by both the add and stack-change hooks. No caster is available from
// these paths, so it stamps timing only — StoreFromApplication keeps whatever
// SpellGo already owns, so a directly-cast aura keeps its talented timing.
// Base (unmodified) duration is the best estimate without a caster. The slot
// is what binds this application to the cast capture behind it.
void StampApplication(void *unit, uint32_t spellId, int slot) {
if (spellId == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
@@ -766,14 +937,8 @@ void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
Store(unitGuid, spellId, caster, expirationMs, durationMs,
/*fromCast*/ false, kind);
}
// Classify by the absolute aura slot: 0..BUFF_COUNT-1 = buff (helpful),
// BUFF_COUNT..TOTAL-1 = debuff (harmful).
int8_t KindForSlot(int slot) {
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
StoreFromApplication(unitGuid, spellId, caster, expirationMs, durationMs,
slot, KindForSlot(slot));
}
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
@@ -793,7 +958,7 @@ OnAuraAdded_t g_origOnAuraAdded = nullptr;
void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
StampApplication(unit, spellId, static_cast<int>(slot));
NotifyIfPlayer(unit);
}
@@ -813,8 +978,7 @@ void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
g_origOnAuraStacksChanged(unit, edx, slot, stackCount);
StampApplication(
unit,
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
KindForSlot(slot));
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot), slot);
NotifyIfPlayer(unit);
}
@@ -843,8 +1007,7 @@ OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraRemoved(unit, edx, slot, spellId);
(void)slot;
Evict(Unit::Identity::GuidForObject(unit), spellId);
Evict(Unit::Identity::GuidForObject(unit), spellId, static_cast<int>(slot));
NotifyIfPlayer(unit);
}
@@ -854,19 +1017,21 @@ const Game::HookAutoRegister _hookAuraRemoved{
} // namespace
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs) {
if (unitGuid == 0 || spellId == 0)
return false;
for (const auto &e : g_cache) {
if (e.used && e.targetGuid == unitGuid && e.spellId == spellId) {
*outCaster = e.casterGuid;
*outExpirationMs = e.expirationMs;
*outDurationMs = e.durationMs;
return true;
}
}
return false;
// Slot first, then the sole entry (see FindInstance): several entries and no
// slot binding is a genuine ambiguity, so reporting the miss leaves the
// caller its unknown-caster defaults instead of one caster's timer on the
// other's aura.
const Entry *e = FindInstance(unitGuid, spellId, slot);
if (e == nullptr)
return false;
*outCaster = e->casterGuid;
*outExpirationMs = e->expirationMs;
*outDurationMs = e->durationMs;
return true;
}
bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
@@ -884,7 +1049,8 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
if (unitGuid == 0 || mask == 0 || casterGuid == 0)
return 0;
const uint32_t now = NowMs();
for (auto &e : g_cache) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid)
continue;
// Caller's own aura only, scoped as ApplyDurationModifiers scopes a
@@ -911,11 +1077,20 @@ uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
return 0;
}
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds) {
if (unitGuid == 0 || slotSpellIds == nullptr)
return;
bool anyPresent = false;
for (int s = 0; s < Offsets::UNIT_AURA_TOTAL; ++s)
if (slotSpellIds[s] != 0) {
anyPresent = true;
break;
}
if (!anyPresent)
return; // out of range vs genuinely buffless — see the header
const uint32_t now = NowMs();
for (auto &e : g_cache) {
for (int i = 0; i < g_usedHigh; ++i) {
Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid)
continue;
// Fresh SpellGo capture the descriptor hasn't synced yet — see
@@ -923,11 +1098,18 @@ void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count)
if (now - e.stampMs < kEvictGraceMs)
continue;
bool present = false;
for (int i = 0; i < count; ++i)
if (presentSpellIds[i] == e.spellId) {
present = true;
break;
}
if (e.slot != SLOT_UNBOUND) {
// A bound entry is only backed by the slot it was seated in, so a
// slot now holding a different spell (or nothing) leaves it stale
// even when another caster's copy keeps the spell ID on the unit.
present = slotSpellIds[e.slot] == e.spellId;
} else {
for (int s = 0; s < Offsets::UNIT_AURA_TOTAL; ++s)
if (slotSpellIds[s] == e.spellId) {
present = true;
break;
}
}
if (!present)
e.used = false;
}
@@ -939,14 +1121,14 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
const int8_t want = harmful ? KIND_HARMFUL : KIND_HELPFUL;
const uint32_t now = NowMs();
int n = 0;
for (const auto &e : g_cache) {
if (n >= maxOut)
break;
for (int i = 0; i < g_usedHigh && n < maxOut; ++i) {
const Entry &e = g_cache[i];
if (!e.used || e.targetGuid != unitGuid || e.kind != want)
continue;
if (e.expirationMs != 0 && now >= e.expirationMs)
continue; // expired (infinite-duration entries pass)
out[n++] = {e.spellId, e.casterGuid, e.expirationMs, e.durationMs};
out[n++] = {e.spellId, e.casterGuid, e.expirationMs, e.durationMs,
e.slot};
}
return n;
}
+47 -21
View File
@@ -19,7 +19,15 @@
// the client ever sees the caster + a server-authoritative duration is the
// `SMSG_SPELL_GO` packet at cast time. We parse it (the same packet
// nampower parses for its `AURA_CAST_ON_*` events) and remember, per
// `(targetGuid, spellId)`, who cast it and when it should expire.
// `(targetGuid, spellId, casterGuid)`, who cast it and when it should expire.
//
// The caster belongs in that key because it belongs in the server's: two
// same-class raiders' Corruption on one boss are two auras in two descriptor
// slots, and a `(target, spell)` cache holds one entry for both — the second
// cast overwrites the first caster's timing, and either copy falling off
// evicts the survivor's entry too. The descriptor stores only spell IDs, so
// each entry additionally records the slot its aura was seated in
// (`OnAuraAdded`), which is what lets a per-slot query name the right instance.
//
// `Aura::Data::Push` consults this to fill `sourceUnit` (caster, resolved
// to a unit token) and `expirationTime` for non-player units. The cache is
@@ -31,15 +39,25 @@
namespace Aura::Source {
// Looks up the cached caster + timing for the aura `spellId` currently on
// the unit identified by `unitGuid`. Returns true and fills the out params
// on a hit. `*outExpirationMs` is an absolute `GetTickCount`-epoch timestamp
// (0 = unknown / infinite-duration aura); `*outDurationMs` is the applied
// duration including the caster's modifiers (talents etc.; 0 = none) — use
// it for the `duration` field so it stays consistent with `expirationTime`;
// `*outCaster` is the caster's 64-bit GUID (never 0 on a hit). Returns false
// on a miss or for zero inputs.
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
// `slot` value for a query with no descriptor slot to go on (out-of-range
// group array, cache fallback) and for an entry not yet seated in one.
constexpr int SLOT_UNBOUND = -1;
// Looks up the cached caster + timing for the aura `spellId` occupying
// absolute descriptor `slot` on the unit identified by `unitGuid`. Returns
// true and fills the out params on a hit. `*outExpirationMs` is an absolute
// `GetTickCount`-epoch timestamp (0 = unknown / infinite-duration aura);
// `*outDurationMs` is the applied duration including the caster's modifiers
// (talents etc.; 0 = none) — use it for the `duration` field so it stays
// consistent with `expirationTime`; `*outCaster` is the caster's 64-bit GUID
// (0 when the aura was seen applied but its cast never observed). Returns
// false on a miss or for zero inputs.
//
// Pass `SLOT_UNBOUND` when the caller has no slot. Resolution is by slot
// first, then by spell ID when the unit carries only one entry for it; two
// casters' entries with no slot binding resolve to a miss rather than to a
// coin flip between them.
bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
uint32_t *outExpirationMs, uint32_t *outDurationMs);
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
@@ -76,19 +94,27 @@ struct CachedAura {
uint64_t casterGuid; // 0 = caster unknown (application hook, no SpellGo)
uint32_t expirationMs; // 0 = infinite / unknown
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
int slot; // descriptor slot the aura was seated in; feed it
// back to `Get` to reach this exact entry again
};
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
// authoritative descriptor when it is back in view (a populated aura array
// means the unit is fully synced): an entry the descriptor no longer lists
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
// while out of our range, whose `OnAuraRemoved` we never received — so drop
// it before the descriptor-drop fallback resurfaces it as a phantom. The
// caller must only invoke this when the descriptor is populated (count > 0);
// an empty array can't distinguish "out of range" from "genuinely buffless",
// so reconciling then would wrongly wipe still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// Evicts every cached entry for `unitGuid` the unit's descriptor contradicts.
// `slotSpellIds` is the raw `UNIT_FIELD_AURA` array — exactly
// `Offsets::UNIT_AURA_TOTAL` spell IDs indexed by absolute slot, 0 for empty.
// Used to reconcile the cache against a unit's authoritative descriptor when
// it is back in view (a populated aura array means the unit is fully synced):
// an entry the descriptor no longer backs was removed while we couldn't
// observe it — e.g. a buff the owner cancelled while out of our range, whose
// `OnAuraRemoved` we never received — so drop it before the descriptor-drop
// fallback resurfaces it as a phantom.
//
// An entry seated in a slot is checked against THAT slot, so one caster's
// copy going away retires its own entry even while another caster keeps the
// spell ID present on the unit. Entries never seated are checked against the
// whole array. An all-empty array is ignored: it can't distinguish "out of
// range" from "genuinely buffless", and reconciling then would wrongly wipe
// still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds);
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
// whose helpful/harmful classification matches `harmful`. Returns the count
+620
View File
@@ -0,0 +1,620 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
// A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Backports the direct-action and override binding APIs introduced in 2.0.
// Normal bindings are stored by vanilla's SetBinding implementation using the
// later-client SPELL/ITEM/MACRO/CLICK command strings. The resolved-command
// hook executes those four command families; every native binding command is
// passed through unchanged.
//
// Override bindings live in a separate owner-scoped layer in front of the
// native key lookup. They are deliberately never written to the saved binding
// table, so clearing an owner immediately reveals the next override or the
// unchanged native binding underneath it.
#include "Game.h"
#include "Offsets.h"
#include "macro/Execute.h"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>
#include <unordered_map>
#include <vector>
namespace Bindings::Api {
namespace {
constexpr const char kSpellPrefix[] = "SPELL ";
constexpr const char kItemPrefix[] = "ITEM ";
constexpr const char kMacroPrefix[] = "MACRO ";
constexpr const char kClickPrefix[] = "CLICK ";
constexpr const char kDefaultMouseButton[] = "LeftButton";
using ScriptSetBinding_t = int(__fastcall *)(void *L);
using KeyDispatch_t = int(__thiscall *)(void *mgr, const char *key, int isDown);
using CommandExecute_t = int(__thiscall *)(void *mgr, const char *cmd, int isDown);
KeyDispatch_t KeyDispatch_o = nullptr;
CommandExecute_t CommandExecute_o = nullptr;
enum class CommandType {
None,
Spell,
Item,
Macro,
Click,
};
struct ParsedCommand {
CommandType type = CommandType::None;
const char *argument = nullptr;
};
struct OverrideEntry {
const void *owner = nullptr;
std::string key;
std::string command;
bool priority = false;
uint64_t sequence = 0;
};
std::vector<OverrideEntry> g_overrides;
std::unordered_map<std::string, std::string> g_activeOverrides;
uint64_t g_nextSequence = 0;
// Normalizes a binding key to the engine's own canonical form so a stored
// override matches the string FUN_BINDING_KEY_DISPATCH builds at keypress. The
// engine's key-string builder (FUN_004b6630) always emits modifiers in the
// fixed order ALT-CTRL-SHIFT- ahead of the base key, regardless of the order
// the caller passed them; vanilla SetBinding never reorders, so the override
// layer does it here. Both the store and the lookup run through this, so they
// stay consistent while also matching the engine's keypress form.
std::string CanonicalKey(const char *key) {
std::string result = key ? key : "";
for (char &character : result) {
if (character >= 'a' && character <= 'z')
character = static_cast<char>(character - 'a' + 'A');
}
// Strip the three modifier prefixes in whatever order/repetition they
// appear, then re-emit them in the engine's fixed order. Prefix-stripping
// (rather than splitting on '-') keeps the minus base key correct, e.g.
// "SHIFT--" -> modifier SHIFT + base "-".
bool alt = false, ctrl = false, shift = false;
size_t base = 0;
for (bool matched = true; matched;) {
matched = false;
if (result.compare(base, 4, "ALT-") == 0) {
alt = true;
base += 4;
matched = true;
} else if (result.compare(base, 5, "CTRL-") == 0) {
ctrl = true;
base += 5;
matched = true;
} else if (result.compare(base, 6, "SHIFT-") == 0) {
shift = true;
base += 6;
matched = true;
}
}
if (!alt && !ctrl && !shift)
return result;
std::string canonical;
if (alt)
canonical += "ALT-";
if (ctrl)
canonical += "CTRL-";
if (shift)
canonical += "SHIFT-";
canonical += result.substr(base);
return canonical;
}
std::string MakeCommand(const char *prefix, const char *arg) {
std::string cmd = prefix;
cmd += arg;
return cmd;
}
std::string MakeClickCommand(const char *btnName, const char *mouseBtn) {
std::string cmd = MakeCommand(kClickPrefix, btnName);
if (mouseBtn != nullptr) {
cmd += ':';
cmd += mouseBtn;
}
return cmd;
}
ParsedCommand ParseCommand(const char *cmd) {
if (cmd == nullptr)
return {};
struct PrefixEntry {
const char *prefix;
size_t length;
CommandType type;
};
static constexpr PrefixEntry prefixes[] = {
{kSpellPrefix, sizeof(kSpellPrefix) - 1, CommandType::Spell},
{kItemPrefix, sizeof(kItemPrefix) - 1, CommandType::Item},
{kMacroPrefix, sizeof(kMacroPrefix) - 1, CommandType::Macro},
{kClickPrefix, sizeof(kClickPrefix) - 1, CommandType::Click},
};
for (const PrefixEntry &entry : prefixes) {
if (std::strncmp(cmd, entry.prefix, entry.length) == 0)
return {entry.type, cmd + entry.length};
}
return {};
}
bool CallGlobalString(void *L, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
if (!Game::Lua::PushGlobalFunction(L, func)) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
bool CallTableString(void *L, const char *table, const char *func, const char *arg) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushString(L, table);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return false;
}
const int tableIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, func);
Game::Lua::GetTable(L, tableIndex);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushString(L, arg);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
void InvokeButtonClick(void *L, const char *btnName, size_t btnNameLength, const char *mouseBtn) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushLString(L, btnName, static_cast<unsigned int>(btnNameLength));
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
Game::Lua::SetTop(L, top);
return;
}
const int frameIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "Click");
Game::Lua::GetTable(L, frameIndex);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, frameIndex);
Game::Lua::PushString(L, mouseBtn);
Game::Lua::PCall(L, 2, 0, 0);
}
Game::Lua::SetTop(L, top);
}
void ExecuteClick(void *L, const char *arg) {
const char *separator = std::strrchr(arg, ':');
if (separator == arg || (separator != nullptr && separator[1] == '\0'))
return;
const char *mouseBtn = kDefaultMouseButton;
size_t btnNameLength = std::strlen(arg);
if (separator != nullptr) {
btnNameLength = static_cast<size_t>(separator - arg);
mouseBtn = separator + 1;
}
if (btnNameLength == 0)
return;
InvokeButtonClick(L, arg, btnNameLength, mouseBtn);
}
// Returns true for every recognized direct-action command, including malformed
// or stale commands. Vanilla cannot execute these namespaces, so a bad command
// is consumed as a no-op instead of falling through to an unrelated handler.
bool ExecuteSpecialCommand(const char *cmd, int isDown) {
const ParsedCommand parsed = ParseCommand(cmd);
if (parsed.type == CommandType::None)
return false;
if (!isDown || parsed.argument == nullptr || *parsed.argument == '\0')
return true;
void *L = Game::Lua::State();
if (L == nullptr)
return true;
switch (parsed.type) {
case CommandType::Spell:
CallGlobalString(L, "CastSpellByName", parsed.argument);
break;
case CommandType::Item:
CallTableString(L, "C_Item", "UseItemByName", parsed.argument);
break;
case CommandType::Macro:
Macro::Execute::Saved(L, parsed.argument);
break;
case CommandType::Click:
ExecuteClick(L, parsed.argument);
break;
default:
break;
}
return true;
}
const OverrideEntry *FindEffectiveOverride(const std::string &key) {
const OverrideEntry *best = nullptr;
for (const OverrideEntry &entry : g_overrides) {
if (entry.key != key)
continue;
if (best == nullptr || entry.priority > best->priority ||
(entry.priority == best->priority && entry.sequence > best->sequence))
best = &entry;
}
return best;
}
void SetOverride(const void *owner, bool priority, const std::string &key, const std::string &cmd) {
auto existing =
std::find_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner && entry.key == key;
});
if (existing == g_overrides.end()) {
g_overrides.push_back(
{owner, key, cmd, priority, ++g_nextSequence});
return;
}
existing->command = cmd;
existing->priority = priority;
existing->sequence = ++g_nextSequence;
}
void RemoveOverride(const void *owner, const std::string &key) {
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner &&
entry.key == key;
}),
g_overrides.end());
}
int ExecuteCommand(void *mgr, const char *cmd, int isDown) {
if (ExecuteSpecialCommand(cmd, isDown))
return 1;
return CommandExecute_o(mgr, cmd, isDown);
}
int __fastcall CommandExecute_h(void *mgr, void *, const char *cmd, int isDown) {
return ExecuteCommand(mgr, cmd, isDown);
}
// Runs an override-resolved command with the same execution context the engine
// wraps around a native keypress. FUN_BINDING_KEY_DISPATCH saves the frame-
// script exec-context, zeroes it for the nested command, marks the manager as
// mid-command (`+0xD8`), then restores the context and floors the nesting
// depth. Native-table bindings already run inside this (the engine sets it up
// before calling FUN_BINDING_COMMAND_EXECUTE, which we co-hook); overrides
// bypass that path, so we mirror it here. Without it, a native Bindings.xml
// command dispatched through an override would run without the context its
// handler expects. Special SPELL/ITEM/MACRO/CLICK commands run harmlessly
// inside the same wrapper.
int DispatchOverrideCommand(void *mgr, const char *cmd, int isDown) {
volatile uint32_t *context =
reinterpret_cast<volatile uint32_t *>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
volatile uint32_t *depth = reinterpret_cast<volatile uint32_t *>(
Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
volatile uint32_t *executing =
Game::Ptr<volatile uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
const uint32_t savedContext = *context;
*depth = *depth + 1;
if (*depth != 0)
*context = 0;
*executing = 1;
const int result = ExecuteCommand(mgr, cmd, isDown);
*executing = 0;
if (*depth != 0)
*context = savedContext;
if (static_cast<int32_t>(*depth) - 1 > 0)
*depth = *depth - 1;
else
*depth = 0;
return result;
}
int __fastcall KeyDispatch_h(void *mgr, void *, const char *key, int isDown) {
const std::string canonicalKey = CanonicalKey(key);
// Keep a key-down paired with its override command even if that override is
// cleared while the command executes. The newly revealed native binding
// must not receive only the key-up half of the same physical press.
if (!isDown) {
auto active = g_activeOverrides.find(canonicalKey);
if (active != g_activeOverrides.end()) {
DispatchOverrideCommand(mgr, active->second.c_str(), 0);
g_activeOverrides.erase(active);
return 1;
}
}
const OverrideEntry *entry = FindEffectiveOverride(canonicalKey);
if (entry == nullptr)
return KeyDispatch_o(mgr, key, isDown);
if (isDown)
g_activeOverrides[canonicalKey] = entry->command;
return DispatchOverrideCommand(mgr, entry->command.c_str(), isDown);
}
int SetBindingCommand(void *L, const char *prefix, const char *usage) {
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, usage);
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const std::string command = MakeCommand(prefix, Game::Lua::ToString(L, 2));
// Preserve the stock SetBinding return value, binding-set selection,
// key normalization, UPDATE_BINDINGS event, and SaveBindings behavior.
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
}
const char *OptionalMouseButton(void *L, int index, const char *usage) {
if (Game::Lua::GetTop(L) < index || Game::Lua::Type(L, index) == Game::Lua::TYPE_NIL)
return nullptr;
if (!Game::Lua::IsString(L, index)) {
Game::Lua::Error(L, usage);
return nullptr;
}
return Game::Lua::ToString(L, index);
}
int __fastcall Script_SetBindingSpell(void *L) {
return SetBindingCommand(L, kSpellPrefix, "Usage: SetBindingSpell(key, spell)");
}
int __fastcall Script_SetBindingItem(void *L) {
return SetBindingCommand(L, kItemPrefix, "Usage: SetBindingItem(key, item)");
}
int __fastcall Script_SetBindingMacro(void *L) {
return SetBindingCommand(L, kMacroPrefix, "Usage: SetBindingMacro(key, macro)");
}
int __fastcall Script_SetBindingClick(void *L) {
constexpr const char *usage = "Usage: SetBindingClick(key, buttonName [, mouseButton])";
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, usage);
return 0;
}
const std::string key = Game::Lua::ToString(L, 1);
const char *buttonName = Game::Lua::ToString(L, 2);
const char *mouseButton = OptionalMouseButton(L, 3, usage);
const std::string command = MakeClickCommand(buttonName, mouseButton);
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, key.c_str());
Game::Lua::PushString(L, command.c_str());
auto setBinding =
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
return setBinding(L);
}
bool ReadOverrideHeader(void *L, const char *usage, const void **owner,
bool *priority, std::string *key) {
*owner = Game::Lua::ResolveObject(L, 1);
if (*owner == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_BOOLEAN ||
!Game::Lua::IsString(L, 3)) {
Game::Lua::Error(L, usage);
return false;
}
*priority = Game::Lua::ToBoolean(L, 2) != 0;
*key = CanonicalKey(Game::Lua::ToString(L, 3));
return true;
}
int SetOverrideBindingCommand(void *L, const char *prefix, const char *usage) {
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
SetOverride(owner, priority, key,
MakeCommand(prefix, Game::Lua::ToString(L, 4)));
return 0;
}
int __fastcall Script_SetOverrideBinding(void *L) {
constexpr const char *usage = "Usage: SetOverrideBinding(owner, isPriority, key [, command])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (Game::Lua::GetTop(L) < 4 ||
Game::Lua::Type(L, 4) == Game::Lua::TYPE_NIL) {
RemoveOverride(owner, key);
return 0;
}
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
SetOverride(owner, priority, key, Game::Lua::ToString(L, 4));
return 0;
}
int __fastcall Script_SetOverrideBindingSpell(void *L) {
return SetOverrideBindingCommand(
L, kSpellPrefix,
"Usage: SetOverrideBindingSpell(owner, isPriority, key, spell)");
}
int __fastcall Script_SetOverrideBindingItem(void *L) {
return SetOverrideBindingCommand(
L, kItemPrefix,
"Usage: SetOverrideBindingItem(owner, isPriority, key, item)");
}
int __fastcall Script_SetOverrideBindingMacro(void *L) {
return SetOverrideBindingCommand(
L, kMacroPrefix,
"Usage: SetOverrideBindingMacro(owner, isPriority, key, macro)");
}
int __fastcall Script_SetOverrideBindingClick(void *L) {
constexpr const char *usage =
"Usage: SetOverrideBindingClick(owner, "
"isPriority, key, buttonName [, mouseButton])";
const void *owner = nullptr;
bool priority = false;
std::string key;
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
return 0;
if (!Game::Lua::IsString(L, 4)) {
Game::Lua::Error(L, usage);
return 0;
}
const char *buttonName = Game::Lua::ToString(L, 4);
const char *mouseButton = OptionalMouseButton(L, 5, usage);
SetOverride(owner, priority, key,
MakeClickCommand(buttonName, mouseButton));
return 0;
}
int __fastcall Script_ClearOverrideBindings(void *L) {
const void *owner = Game::Lua::ResolveObject(L, 1);
if (owner == nullptr) {
Game::Lua::Error(L, "Usage: ClearOverrideBindings(owner)");
return 0;
}
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
[&](const OverrideEntry &entry) {
return entry.owner == owner;
}),
g_overrides.end());
return 0;
}
void RegisterLuaFunctions() {
// A fresh FrameScript state makes every owner pointer from the previous
// state stale. Permanent bindings remain in vanilla's binding table.
g_overrides.clear();
g_activeOverrides.clear();
g_nextSequence = 0;
Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
Game::Lua::RegisterGlobalFunction("SetBindingClick", &Script_SetBindingClick);
Game::Lua::RegisterGlobalFunction("SetOverrideBinding",
&Script_SetOverrideBinding);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell",
&Script_SetOverrideBindingSpell);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem",
&Script_SetOverrideBindingItem);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro",
&Script_SetOverrideBindingMacro);
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick",
&Script_SetOverrideBindingClick);
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings",
&Script_ClearOverrideBindings);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::HookAutoRegister _hookKeyDispatch{
Offsets::FUN_BINDING_KEY_DISPATCH, reinterpret_cast<void *>(&KeyDispatch_h),
reinterpret_cast<void **>(&KeyDispatch_o)};
const Game::HookAutoRegister _hookCommandExecute{
Offsets::FUN_BINDING_COMMAND_EXECUTE,
reinterpret_cast<void *>(&CommandExecute_h),
reinterpret_cast<void **>(&CommandExecute_o)};
} // namespace
} // namespace Bindings::Api
+17 -49
View File
@@ -18,29 +18,19 @@
// against an internal cache to identify the player. With this we
// just read the GUID directly.
//
// Implementation: the SMSG_MESSAGECHAT handler at FUN_0049D560 reads
// the senderGUID from the packet (offsets `[ebp-0x10]` / `[ebp-0xC]`
// in that function's stack frame) and forwards it as args 9-10 to
// the chat dispatcher at FUN_0049A870. The dispatcher in turn fires
// the appropriate CHAT_MSG_* event during its execution. We hook the
// dispatcher's entry, stash the GUID into a file-scope global, call
// the original (which fires the event synchronously), then clear the
// global on return. Inside the addon's OnEvent, `GetCurrentChatGUID`
// reads the global.
//
// Why not hook the SMSG handler directly: the GUID isn't in argument
// registers/stack at the handler's entry — it's read mid-function
// into locals. Hooking the dispatcher (which receives the GUID as
// args) is structurally simpler. The trade-off: the dispatcher is
// also called for synthetic chat (system notifications, arena team
// events, etc.) with GUID = 0; for those, `GetCurrentChatGUID()`
// returns nil — matches the modern API's behavior of nil GUID for
// non-player-sourced chat.
// The GUID is published for the duration of a chat dispatch by a
// `DispatchScope` (see the header), which Chat::Dispatch — the single
// owner of the FUN_0049A870 chat-dispatch hook — constructs around the
// original call. The engine fires the CHAT_MSG_* event synchronously
// inside that call, so `GetCurrentChatGUID` reads the right GUID from
// the addon's OnEvent. The dispatcher is also called for synthetic chat
// (system notifications, arena team events, etc.) with GUID = 0; for
// those `GetCurrentChatGUID()` returns nil — matching the modern API's
// behavior of a nil GUID for non-player-sourced chat.
#include "CurrentGUID.h"
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include <cstdint>
@@ -69,39 +59,17 @@ void RegisterLuaFunctions() {
} // namespace
ChatDispatch_t ChatDispatch_o = nullptr;
DispatchScope::DispatchScope(uint32_t guidLo, uint32_t guidHi)
: prevLo_(g_currentGuidLo), prevHi_(g_currentGuidHi) {
g_currentGuidLo = guidLo;
g_currentGuidHi = guidHi;
}
void __fastcall ChatDispatch_h(
const char *sender, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi)
{
// Save before original — the engine's chat event fires DURING
// origCall, and the addon's OnEvent runs synchronously inside
// that window. The GUID must be readable at that point.
const uint32_t prevLo = g_currentGuidLo;
const uint32_t prevHi = g_currentGuidHi;
g_currentGuidLo = static_cast<uint32_t>(senderGuidLo);
g_currentGuidHi = static_cast<uint32_t>(senderGuidHi);
ChatDispatch_o(sender, chatType, arg3, arg4, arg5, arg6, arg7,
targetGuidLo, targetGuidHi, arg10,
senderGuidLo, senderGuidHi);
// Restore prior value rather than clearing to 0, so nested
// dispatch (one chat event firing during another's OnEvent —
// e.g. an addon calling SendChatMessage from its handler) leaves
// the outer context's GUID intact when the inner returns.
g_currentGuidLo = prevLo;
g_currentGuidHi = prevHi;
DispatchScope::~DispatchScope() {
g_currentGuidLo = prevLo_;
g_currentGuidHi = prevHi_;
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_CHAT_DISPATCH,
reinterpret_cast<void *>(&ChatDispatch_h),
reinterpret_cast<void **>(&ChatDispatch_o)};
} // namespace Chat::CurrentGUID
+21 -48
View File
@@ -13,55 +13,28 @@
#pragma once
#include <cstdint>
namespace Chat::CurrentGUID {
// Hook for FUN_CHAT_DISPATCH (0x0049A870). Saves the senderGUID into
// a file-scope global for the duration of the original call, then
// clears it on return. Inside that window the engine fires the
// appropriate CHAT_MSG_* event, so `GetCurrentChatGUID()` called from
// the addon's OnEvent sees the GUID for the message that triggered it.
//
// 12-arg __fastcall signature. The function has a stack-probe
// prologue (`mov eax, 0x1F0C; call _chkstk`) — its frame is ~8KB.
// Verified by disassembling 0x0049A870 in 1.12.1: stack-arg access
// reaches `[ebp+0x2C]` (= param 12) and the function exits with
// `ret 0x28` (cleans 40 bytes = 10 stack args + 2 register args).
//
// ECX = arg1 (sender name string)
// EDX = arg2 (chat type byte)
// [esp+ 4] = arg3
// [esp+ 8] = arg4
// [esp+ c] = arg5
// [esp+10] = arg6
// [esp+14] = arg7
// [esp+18] = arg8 — target GUID lo (for whisper/channel target)
// [esp+1c] = arg9 — target GUID hi
// [esp+20] = arg10
// [esp+24] = arg11 — sender GUID lo ← captured
// [esp+28] = arg12 — sender GUID hi ← captured
//
// Identified args 11/12 as senderGUID by tracing the AFK auto-reply
// path at 0x0049AB39: reads params 11/12, OR's them to check for
// non-zero, pushes them onto the packet stream via FUN_00418370
// (the "write u64" helper). That's the GUID the AFK whisper-back is
// addressed to — i.e. the original message sender.
//
// An earlier 10-arg signature derived from Ghidra (on the wrong
// binary) had `ret 0x20` cleanup. The 8-byte cleanup mismatch with
// the engine's `ret 0x28`-shaped call left 8 bytes of stack
// uncleaned per chat-dispatch invocation, corrupting the engine's
// stack frame and crashing on a later indirect call. See the
// commit message for the full diagnosis.
using ChatDispatch_t = void(__fastcall *)(
const char *sender, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi);
extern ChatDispatch_t ChatDispatch_o;
void __fastcall ChatDispatch_h(
const char *sender, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi);
// RAII scope that publishes the sender GUID of the chat message currently being
// dispatched, for `GetCurrentChatGUID()` to read. Chat::Dispatch constructs one
// around the original chat-dispatch call: the engine fires the CHAT_MSG_* event
// synchronously inside that call, so an addon's OnEvent sees the GUID of the
// message that triggered it. Restores the prior value on destruction rather than
// clearing to 0, so nested dispatch (a chat event firing during another's
// OnEvent — e.g. an addon calling SendChatMessage from its handler) leaves the
// outer context's GUID intact when the inner returns.
class DispatchScope {
public:
DispatchScope(uint32_t guidLo, uint32_t guidHi);
~DispatchScope();
DispatchScope(const DispatchScope &) = delete;
DispatchScope &operator=(const DispatchScope &) = delete;
private:
uint32_t prevLo_;
uint32_t prevHi_;
};
} // namespace Chat::CurrentGUID
+105
View File
@@ -0,0 +1,105 @@
// 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/>.
// Single owner of the chat-dispatch hook (FUN_0049A870).
//
// The engine routes every server-delivered chat message — say/yell/whisper/
// channel/emote and synthetic system notifications — through this one function.
// (Addon `print`/`AddMessage` does NOT pass through it.) Several independent
// concerns need to observe or transform a dispatched message, but MinHook allows
// only ONE hook per target, so this module owns the hook and orchestrates them,
// mirroring Net::PacketDispatch (one hook, fanned out to independent concerns):
//
// - Chat::IconFilter::Sanitize — strip player-injected `|T` icon spoofs from
// the message before the engine builds the chat line AND the speech bubble
// (both come from the same buffer, so one sanitize covers both).
// - Chat::RaidMarkers::Substitute — expand `{rt1}`/`{skull}`/… raid-target
// tokens into inline marker icons. Runs AFTER Sanitize so a player gets the
// fixed marker icons but still can't inject an arbitrary `|T` path.
// - Chat::CurrentGUID::DispatchScope — publish the sender GUID for the
// synchronous CHAT_MSG_* event so `GetCurrentChatGUID()` can read it.
//
// Signature — `__fastcall`, `RET 0x28`:
// ECX = arg1 — the MESSAGE text (verified from the call site in FUN_0049D560:
// `MOV ECX,[EBP-0x10]` = the raw/processed message. An older
// note mislabeled this "sender name".) Copied into one buffer
// fed to both the chat frame and the bubble spawn FUN_00608AC0.
// EDX = arg2 — chat type byte
// [esp+04] = arg3 [esp+08] = arg4 [esp+0c] = arg5
// [esp+10] = arg6 [esp+14] = arg7
// [esp+18] = arg8 — target GUID lo (whisper/channel target)
// [esp+1c] = arg9 — target GUID hi
// [esp+20] = arg10
// [esp+24] = arg11 — sender GUID lo ← published
// [esp+28] = arg12 — sender GUID hi ← published
//
// The `RET 0x28` cleanup (40 bytes = 10 stack args) MUST match the declared
// signature exactly; an earlier 10-arg / `ret 0x20` version left 8 bytes
// uncleaned per call and crashed the engine on a later indirect call.
#include "CurrentGUID.h"
#include "IconFilter.h"
#include "RaidMarkers.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Chat::Dispatch {
namespace {
using ChatDispatch_t = void(__fastcall *)(
const char *message, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi);
ChatDispatch_t g_original = nullptr;
void __fastcall ChatDispatch_h(
const char *message, int chatType,
int arg3, int arg4, int arg5, int arg6, int arg7,
int targetGuidLo, int targetGuidHi, int arg10,
int senderGuidLo, int senderGuidHi)
{
// Strip player-injected `|T` icon escapes (covers chat line + speech bubble;
// both build from this message). The 0x800 buffer matches the engine's own
// message-buffer cap.
char iconBuf[0x800];
const char *msg = Chat::IconFilter::Sanitize(message, iconBuf, sizeof iconBuf);
// Expand raid-target tokens ({rt1}/{skull}/…) into inline marker icons —
// after the sanitize, so only these fixed, safe escapes are added.
char markerBuf[0x800];
msg = Chat::RaidMarkers::Substitute(msg, markerBuf, sizeof markerBuf);
// Publish the sender GUID for the CHAT_MSG_* event the original fires
// synchronously; restored when this scope ends (after the original returns).
Chat::CurrentGUID::DispatchScope guid(static_cast<uint32_t>(senderGuidLo),
static_cast<uint32_t>(senderGuidHi));
g_original(msg, chatType, arg3, arg4, arg5, arg6, arg7,
targetGuidLo, targetGuidHi, arg10,
senderGuidLo, senderGuidHi);
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_CHAT_DISPATCH,
reinterpret_cast<void *>(&ChatDispatch_h),
reinterpret_cast<void **>(&g_original)};
} // namespace
} // namespace Chat::Dispatch
+85
View File
@@ -0,0 +1,85 @@
// 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/>.
// Inline-texture (`|T`) chat anti-spoof.
//
// Vanilla neutralizes player-*typed* escapes in chat (a typed `|cff…|r`,
// `|Hitem…|h` or `|T…|t` shows as raw text) — only *trusted* text renders them
// (addon `AddMessage`, shift-clicked links). Our Text::InlineTexture backport
// breaks that parity for `|T` alone: it detects the neutralized `||T` form and
// draws the icon anyway, so a player could `/say` `|Tpath|t` and have a real
// icon appear in everyone's chat AND in the speech bubble over their head.
//
// This restores vanilla's behavior for `|T` by defanging it in the chat path,
// while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw for typed
// input). It CANNOT be done in the text emitter: addon `|T` and player `|T`
// reach the emitter as byte-identical `||T` (vanilla doubles the unrecognized
// `|T` for both), so the only place they're distinguishable is the source —
// server-delivered chat flows through the chat dispatcher, addon `print` does
// not. Chat::Dispatch owns that hook and calls this on the message.
//
// Defang = replace the `|` of each `|T` with a space, breaking the pipe→T
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
// `|T` substring, so this catches the doubled form too). The path text stays
// readable, the closer `|t` is inert without an opener, and every other escape
// is untouched.
#include "IconFilter.h"
namespace Chat::IconFilter {
namespace {
// True if `s` contains an inline-texture opener (`|T`) anywhere.
bool HasIconEscape(const char *s) {
for (; *s != '\0'; ++s)
if (s[0] == '|' && s[1] == 'T')
return true;
return false;
}
} // namespace
const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
if (msg == nullptr || !HasIconEscape(msg))
return msg; // common case: forward untouched, no copy
// NEVER write `msg` in place: several of the chat handler's ~50 callers pass
// read-only `.rdata` literals (system notifications), so an in-place edit
// would fault. We copy only when a `|T` is actually present.
//
// Defanging must not leave a LONE `|` followed by a non-escape char — that's
// an invalid escape, which the chat frame tolerates but the speech-bubble
// text setter chokes on, producing an empty bubble (verified in-game). By the
// time chat reaches us the escape is usually the doubled `||T` (vanilla's own
// neutralization of typed escapes), where `||` is a VALID literal-pipe escape.
// So: keep the pipe(s) and blank the `T` when the pipe completes a `||`; blank
// BOTH chars for a lone `|T`. Either way no dangling `|` remains, and no `|T`
// opener survives for the inline-texture detector to fire on.
size_t j = 0;
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
if (msg[i] == '|' && msg[i + 1] == 'T') {
const bool doubledPipe = (i > 0 && msg[i - 1] == '|');
buf[j++] = doubledPipe ? '|' : ' '; // keep pipe only if it completes `||`
if (j + 1 < bufSize)
buf[j++] = ' '; // blank the `T`
i += 2; // consumed `|T`
} else {
buf[j++] = msg[i++];
}
}
buf[j] = '\0';
return buf;
}
} // namespace Chat::IconFilter
+28
View File
@@ -0,0 +1,28 @@
// 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 <cstddef>
namespace Chat::IconFilter {
// Strips player-injected inline-texture (`|T…|t`) escapes from a chat message.
// Returns `msg` unchanged when it contains no `|T` (the common case — no copy),
// otherwise a sanitized copy written into `buf`. Called by Chat::Dispatch on the
// message the engine's chat handler is about to display. See IconFilter.cpp for
// the anti-spoof rationale and why sanitizing here (not in the text emitter) is
// the only place player-typed and addon `|T` are distinguishable.
const char *Sanitize(const char *msg, char *buf, size_t bufSize);
} // namespace Chat::IconFilter
+154
View File
@@ -0,0 +1,154 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Raid-target marker chat substitutions ({rt1}..{rt8} / {star} / {skull} / ...).
//
// Modern WoW lets players type `{skull}` (etc.) in chat and everyone sees the
// raid-target icon. Vanilla 1.12 has no such substitution. We backport it onto
// the inline-texture (`|T…|t`) renderer: each recognized token is rewritten into
// a `|T` escape that crops the shared raid-target sprite sheet
// `Interface\TargetingFrame\UI-RaidTargetingIcons` to the matching 64×64 cell.
//
// Runs in Chat::Dispatch AFTER Chat::IconFilter::Sanitize. Order is deliberate:
// sanitize first defangs any player-typed raw `|T` spoof, then we add ONLY the
// fixed, known-safe marker escapes — so players get the markers without a path to
// inject arbitrary textures.
//
// Icon→texcoord mapping verified against the client's own UnitPopup.lua
// (RAID_TARGET_1..8): the sheet is 256×256 with 64-pixel cells, marker N at
// column (N-1)%4, row (N-1)/4. So RT1(star) is the top-left cell and RT8(skull)
// is row 1, column 3. The inline renderer's OpenGL v-flip already draws this sheet
// right-side-up (it's the sheet that flip was verified against), so top<bottom
// pixel coords render correctly.
#include "RaidMarkers.h"
#include <cstdio>
#include <cstring>
namespace Chat::RaidMarkers {
namespace {
// On-screen pixel size of an inline marker. The default chat font is ~14px, so 14
// keeps a marker about one line tall. Fixed (the substitution is a static string
// rewrite and can't see the target font); tunable here if it reads too small/large.
constexpr int kMarkerSizePx = 14;
struct Token {
const char *name; // lowercase alias, no braces
int marker; // raid-target index 1..8
};
// Aliases per the modern client, all case-insensitive. `{coin}` is a colloquial
// alias for `{circle}` (RT2) and `{x}` for `{cross}` (RT7) — both shown in the
// in-game substitution help.
constexpr Token kTokens[] = {
{"rt1", 1}, {"star", 1},
{"rt2", 2}, {"circle", 2}, {"coin", 2},
{"rt3", 3}, {"diamond", 3},
{"rt4", 4}, {"triangle", 4},
{"rt5", 5}, {"moon", 5},
{"rt6", 6}, {"square", 6},
{"rt7", 7}, {"cross", 7}, {"x", 7},
{"rt8", 8}, {"skull", 8},
};
char LowerAscii(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c; }
// Case-insensitive match of the n-char token body [s, s+n) against a lowercase
// literal — true only when `lit` is exactly n chars long.
bool EqCI(const char *s, size_t n, const char *lit) {
for (size_t i = 0; i < n; ++i)
if (lit[i] == '\0' || LowerAscii(s[i]) != lit[i])
return false;
return lit[n] == '\0';
}
// Raid-target index (1..8) for the token body [name, name+n), or 0 if unknown.
int MatchMarker(const char *name, size_t n) {
for (const Token &t : kTokens)
if (EqCI(name, n, t.name))
return t.marker;
return 0;
}
bool HasBrace(const char *s) {
for (; *s != '\0'; ++s)
if (*s == '{')
return true;
return false;
}
} // namespace
const char *Substitute(const char *msg, char *buf, size_t bufSize) {
if (msg == nullptr || !HasBrace(msg))
return msg; // common case: forward untouched, no copy
// A token inflates ~28x (a 3-char `{rtN}` -> ~85-byte `|T` markup), so a
// marker-dense message can exceed `buf`. Never DROP message content to fit:
// only expand a token when the expansion AND the whole remaining input (worst
// case copied 1:1 as literal text) still fit. Past that point later tokens
// stay literal `{rtN}`, so the full message text is always preserved — a DLL
// user never sees a shorter line than a non-DLL user, just fewer icons.
const size_t msgLen = std::strlen(msg);
size_t j = 0;
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
if (msg[i] == '{') {
// Scan the token body to the closing brace (bounded — longest alias
// is 8 chars, so a `{` with no nearby `}` is just literal text).
size_t k = i + 1;
while (msg[k] != '\0' && msg[k] != '}' && k - i <= 12)
++k;
if (msg[k] == '}') {
const int marker = MatchMarker(msg + i + 1, k - (i + 1));
if (marker != 0) {
const int col = (marker - 1) % 4;
const int row = (marker - 1) / 4;
const int l = col * 64, r = l + 64, t = row * 64, b = t + 64;
// Emit the pipe-DOUBLED form `||T…||t`. The inline emitter
// renders both `|T` and `||T`, but the vanilla speech-bubble
// text builder (FUN_004b1600) TRUNCATES at a lone `|T` while
// PRESERVING `||` — so the doubled form is what survives into
// the bubble's FontString (which pfUI's bubble reskin copies
// and renders). Anti-spoof Sanitize runs before this, so these
// are the only `||T` openers a player can produce.
char markup[128];
const int m = std::snprintf(
markup, sizeof markup,
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:%d:%d:0:0:256:256:"
"%d:%d:%d:%d||t",
kMarkerSizePx, kMarkerSizePx, l, r, t, b);
// Reserve room for the literal tail after this token
// (msg[k+1..]) so the rest of the message always fits.
const size_t tail = msgLen - (k + 1);
if (m > 0 && j + static_cast<size_t>(m) + tail + 1 <= bufSize) {
std::memcpy(buf + j, markup, static_cast<size_t>(m));
j += static_cast<size_t>(m);
i = k + 1; // consume through the closing `}`
continue;
}
// No room for the expansion — fall through and copy the
// literal `{` so the message is never left truncated mid-token.
}
}
}
buf[j++] = msg[i++];
}
buf[j] = '\0';
return buf;
}
} // namespace Chat::RaidMarkers
+30
View File
@@ -0,0 +1,30 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstddef>
namespace Chat::RaidMarkers {
// Expands raid-target marker tokens in a chat message into inline raid-target
// icon textures — the modern `{rt1}`..`{rt8}` / `{star}` / `{circle}` / `{skull}`
// (etc.) chat substitutions, backported onto the inline-texture renderer. Returns
// `msg` unchanged when it contains no `{` (the common case — no copy), otherwise a
// rewritten copy in `buf`. Called by Chat::Dispatch AFTER the `|T` anti-spoof
// sanitize, so a player can use the fixed, safe marker icons but still can't
// inject an arbitrary texture path. See RaidMarkers.cpp for the token table and
// the icon→texcoord mapping.
const char *Substitute(const char *msg, char *buf, size_t bufSize);
} // namespace Chat::RaidMarkers
+19 -19
View File
@@ -80,12 +80,12 @@ bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
// readable-text id is non-zero (letters / server-authored notes).
bool ItemReadable(const uint8_t *item, const uint8_t *record) {
if (record != nullptr &&
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
return true;
const uint8_t *descriptor = Item::ObjectFields(item);
return descriptor != nullptr &&
*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
Game::Read<uint32_t>(
descriptor, Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
}
int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
@@ -116,14 +116,14 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
int stackCount = 0;
bool isBound = false;
if (descriptor != nullptr) {
stackCount = *reinterpret_cast<const int *>(
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
isBound = (*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS) &
stackCount = Game::Read<int>(
descriptor, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
isBound = (Game::Read<uint32_t>(
descriptor, Offsets::OFF_DESCRIPTOR_FLAGS) &
Offsets::ITEM_FLAG_SOULBOUND) != 0;
}
const bool isLocked = (*reinterpret_cast<const uint32_t *>(
item + Offsets::OFF_ITEM_CLIENT_LOCK) &
const bool isLocked = (Game::Read<uint32_t>(
item, Offsets::OFF_ITEM_CLIENT_LOCK) &
Offsets::ITEM_CLIENT_LOCK_BIT) != 0;
const bool isReadable = ItemReadable(item, record);
@@ -135,17 +135,17 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
bool hasLoot = false;
bool hasNoValue = false;
if (record != nullptr) {
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
const uint32_t displayInfoID = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
quality = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_QUALITY);
haveQuality = true;
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
const uint32_t flags = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_FLAGS);
hasLoot = (flags & Offsets::ITEM_FLAG_LOOTABLE) != 0;
hasNoValue = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
hasNoValue = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
}
const char *hyperlink = Item::Link::FromCGItem(item);
@@ -155,8 +155,8 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
if (Item::Link::NameFromCGItem(item, nameBuf, sizeof(nameBuf))) {
itemName = nameBuf;
} else if (record != nullptr) {
const char *baseName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
const char *baseName = Game::Read<const char *>(
record, Offsets::OFF_ITEMSTATS_NAME);
if (baseName != nullptr && baseName[0] != '\0')
itemName = baseName;
}
+33 -21
View File
@@ -53,6 +53,7 @@
#include "Game.h"
#include "Offsets.h"
#include "cache/QueryLoad.h"
#include "creature/Info.h"
#include "dbc/Lookup.h"
#include "dbc/Names.h"
#include "event/Custom.h"
@@ -94,14 +95,14 @@ int __fastcall Script_GetCreatureInfoByID(void *L) {
if (rec == nullptr)
return 0; // not cached -> nil
const char *name = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_NAME);
const char *name = Game::Read<const char *>(rec, Offsets::OFF_CREATURE_NAME);
const char *subName =
*reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_SUBNAME);
const uint32_t type = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_TYPE);
const uint32_t family = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_FAMILY);
const uint32_t rank = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_RANK);
Game::Read<const char *>(rec, Offsets::OFF_CREATURE_SUBNAME);
const uint32_t type = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_TYPE);
const uint32_t family = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_FAMILY);
const uint32_t rank = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_RANK);
const uint32_t displayID =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_DISPLAYID);
Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_DISPLAYID);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "creatureID", creatureID);
@@ -212,7 +213,7 @@ int __fastcall Script_GetCreatureFamilyIDs(void *L) {
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATUREFAMILY_COUNT);
Game::Read<int>(Offsets::VAR_CREATUREFAMILY_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
@@ -249,8 +250,8 @@ int __fastcall Script_GetFactionInfo(void *L) {
static_cast<uint32_t>(raceID));
if (crRec == nullptr)
return 0; // unknown race
const int ftId = *reinterpret_cast<const int *>(
crRec + Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
const int ftId = Game::Read<int>(
crRec, Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
if (ftId <= 0)
return 0;
@@ -259,31 +260,31 @@ int __fastcall Script_GetFactionInfo(void *L) {
static_cast<uint32_t>(ftId));
if (ftRec == nullptr)
return 0;
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
ftRec + Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
const uint32_t mask = Game::Read<uint32_t>(
ftRec, Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
// FactionGroup is stored contiguously (records-base + i*stride), not as a
// pointer array — iterate by hand rather than via DBC::Record.
auto *fgBase = *reinterpret_cast<const uint8_t *const *>(
auto *fgBase = Game::Read<const uint8_t *>(
Offsets::VAR_FACTIONGROUP_RECORDS);
const int fgCount =
*reinterpret_cast<const int *>(Offsets::VAR_FACTIONGROUP_COUNT);
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
Game::Read<int>(Offsets::VAR_FACTIONGROUP_COUNT);
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
if (fgBase == nullptr)
return 0;
for (int i = 0; i < fgCount; ++i) {
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
const int bit = *reinterpret_cast<const int *>(
row + Offsets::OFF_FACTIONGROUP_BIT);
const int bit = Game::Read<int>(
row, Offsets::OFF_FACTIONGROUP_BIT);
if ((mask & (1u << (bit & 0x1F))) == 0)
continue;
const char *loc = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
const char *loc = Game::Read<const char *>(
row, Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
if (loc == nullptr || *loc == '\0')
continue; // "Player" / "Monster" have empty localized names
const char *eng = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_ENGLISH);
const char *eng = Game::Read<const char *>(
row, Offsets::OFF_FACTIONGROUP_ENGLISH);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", loc);
@@ -323,7 +324,7 @@ int __fastcall Script_GetCreatureTypeIDs(void *L) {
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATURETYPE_COUNT);
Game::Read<int>(Offsets::VAR_CREATURETYPE_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
@@ -367,4 +368,15 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
// Public accessor for Model::DisplayInfo's SetCreature — the same peek
// GetCreatureInfoByID uses, reduced to the display-ID field. 0 = uncached.
uint32_t DisplayID(uint32_t creatureID) {
if (creatureID == 0)
return 0;
const uint8_t *rec = PeekCreature(creatureID);
if (rec == nullptr)
return 0;
return Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_DISPLAYID);
}
} // namespace Creature::Info
+27
View File
@@ -0,0 +1,27 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Creature::Info {
// The cached model display ID for a creature/NPC entry, or 0 when the creature
// is not in the client creature cache (creaturecache.wdb, or queried this
// session). A synchronous peek — no network query. Backs `Model:SetCreature`:
// the cache being keyed by entry ID and carrying a display ID is exactly the
// entry-ID → model mapping vanilla has no DBC for.
uint32_t DisplayID(uint32_t creatureID);
} // namespace Creature::Info
+144
View File
@@ -0,0 +1,144 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GetCoinTextureString` / `C_CurrencyInfo.GetCoinTextureString` — the coin-icon
// money string (e.g. "12<gold> 34<silver> 56<copper>"), a C++ backport of the
// retail C function.
//
// Behaviour derived from 3.3.5's `Script_GetCoinTextureString` (FUN_00510d00 →
// formatter FUN_007e7e10): split the copper amount into gold / silver / copper,
// emit only the non-zero denominations in that order, space-separated; a zero
// amount shows "0" copper. Each denomination is built from a localized
// GlobalString — `GOLD_AMOUNT_TEXTURE` / `SILVER_AMOUNT_TEXTURE` /
// `COPPER_AMOUNT_TEXTURE` — exactly as the engine does (its formatter
// `FUN_0076f070` is a C-format over the same GlobalString). Those strings don't
// exist in stock 1.12, so they're backported in `locales/enUS.lua`
// (language-independent, so the enUS base covers every locale); the embedded
// `!!!ClassicAPI` addon guarantees they're loaded.
//
// 1.12 has no separate UI-GoldIcon / UI-SilverIcon / UI-CopperIcon textures
// (those are a later-expansion split); vanilla uses ONE sprite sheet
// `Interface\MoneyFrame\UI-MoneyIcons` cropped per coin (gold 0-0.25, silver
// 0.25-0.5, copper 0.5-0.75 across a 4-cell strip — from CoinPickupFrame.xml).
// So the format carries the inline-texture texcoord fields (texW=4 texH=1, then
// the per-coin left:right cell). The `|T` markup renders as real coin icons via
// ClassicAPI's inline-texture backport (`Text::InlineTexture`).
#include "Game.h"
#include <cstdio>
#include <string>
namespace Currency::CoinText {
namespace {
// Icon size. Retail defaults fontHeight to 0 = "use the display font's height",
// and the inline-texture renderer now honours 0 (it resolves the coin to the
// line's font height at draw time), so coins match the surrounding text instead
// of a fixed size. Callers may still pass an explicit height.
constexpr int kDefaultHeight = 0;
// Fallbacks byte-identical to the backported GlobalStrings, used only if the
// locale layer somehow isn't loaded when this is first called.
// Format: count |T sheet : height=0 : width=0 : offsetX=0 : offsetY=0 : texW=4 :
// texH=1 : left : right : top=0 : bottom=1 |t (left:right selects the coin cell).
// height/width 0 = size to the font. offsetX stays 0: the inline-texture
// renderer's font-relative pad spaces the coin from its digits (the historical
// "coin sits on the digit" bug was the emitter's lazy pen read-back, fixed in
// the DLL). Never grow a per-coin offset here.
constexpr const char *kGoldFallback =
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
constexpr const char *kSilverFallback =
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
// The three coin arts occupy IDENTICAL pixels within their sheet cells (x 0-14,
// y 1-14 of 16 — confirmed by decoding the DXT3 alpha of the exported
// UI-MoneyIcons.blp); only the left:right cell select differs (gold 0:1,
// silver 1:2, copper 2:3).
constexpr const char *kCopperFallback =
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
// Formats one denomination from its localized GlobalString and appends it to
// `out`. Reads `_G[globalName]` the way retail reads the coin GlobalStrings; the
// format consumes (count, iconHeight, iconWidth). No separator between
// denominations — the renderer's symmetric per-icon pad already spaces each coin
// from the surrounding digits, so a space here would double the gap.
void AppendDenom(void *L, std::string &out, const char *globalName, const char *fallback,
int count, int height) {
const int top = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedString(L, globalName, fallback); // [.., fmt]
const char *fmt = Game::Lua::ToString(L, -1);
char piece[256];
piece[0] = '\0';
if (fmt != nullptr)
std::snprintf(piece, sizeof piece, fmt, count, height, height);
Game::Lua::SetTop(L, top); // pop the format string
out += piece;
}
// GetCoinTextureString(amount [, fontHeight]) -> string
int __fastcall Script_GetCoinTextureString(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetCoinTextureString(amount [, fontHeight])");
return 0; // unreachable — Error long-jumps
}
const double amt = Game::Lua::ToNumber(L, 1);
long long amount = static_cast<long long>(amt < 0.0 ? amt - 0.5 : amt + 0.5); // round
if (amount < 0)
amount = 0;
// Vanilla money is a uint32 copper field, so 0xFFFFFFFF copper (~429,496
// gold) is the most the game can represent. Clamp to it: an unclamped huge
// input (e.g. 1e15) makes `gold` exceed INT_MAX, and the cast below truncates
// it to the low 32 bits, printing a garbage count through the "%d" format.
constexpr long long kMaxCopper = 0xFFFFFFFFLL;
if (amount > kMaxCopper)
amount = kMaxCopper;
int height = kDefaultHeight;
if (Game::Lua::IsNumber(L, 2)) {
const int h = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (h > 0)
height = h;
}
const int gold = static_cast<int>(amount / 10000);
const int silver = static_cast<int>((amount % 10000) / 100);
const int copper = static_cast<int>(amount % 100);
std::string out;
if (amount == 0) {
AppendDenom(L, out, "COPPER_AMOUNT_TEXTURE", kCopperFallback, 0, height);
} else {
if (gold > 0)
AppendDenom(L, out, "GOLD_AMOUNT_TEXTURE", kGoldFallback, gold, height);
if (silver > 0)
AppendDenom(L, out, "SILVER_AMOUNT_TEXTURE", kSilverFallback, silver, height);
if (copper > 0)
AppendDenom(L, out, "COPPER_AMOUNT_TEXTURE", kCopperFallback, copper, height);
}
Game::Lua::PushString(L, out.c_str());
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetCoinTextureString", &Script_GetCoinTextureString);
Game::Lua::RegisterTableFunction("C_CurrencyInfo", "GetCoinTextureString",
&Script_GetCoinTextureString);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Currency::CoinText
+4 -3
View File
@@ -13,6 +13,7 @@
#include "Storage.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
@@ -25,7 +26,7 @@ namespace EquipmentSet::Storage {
namespace {
const char *ReadAccountName() {
return *reinterpret_cast<const char *const *>(Offsets::VAR_ACCOUNT_NAME_PTR);
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
}
const char *ReadCharacterName() {
@@ -34,10 +35,10 @@ const char *ReadCharacterName() {
}
const char *ReadRealmName() {
auto *info = *reinterpret_cast<uint8_t **>(Offsets::VAR_REALM_INFO_PTR);
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
if (info == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(info + Offsets::OFF_REALM_INFO_NAME);
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
}
// Removes trailing CR/LF/whitespace. Names from the engine are ASCII
+2 -2
View File
@@ -210,8 +210,8 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
unnamedMissing++;
continue;
}
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
const char *name = Game::Read<const char *>(
record, Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || name[0] == '\0') {
unnamedMissing++;
continue;
+29 -29
View File
@@ -112,13 +112,13 @@ void SMemFree(void *ptr) {
int TryClaim(const char *eventName) {
if (!g_writesEnabled)
return -1;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = 0; i < count; ++i) {
auto **namePtr = reinterpret_cast<const char **>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
auto **namePtr = Game::Ptr<const char *>(
base + i * Offsets::EVENT_ENTRY_STRIDE,
Offsets::OFF_EVENT_ENTRY_NAME);
if (*namePtr == nullptr) {
char *copy = SStrDup(eventName);
@@ -169,15 +169,15 @@ void Grow() {
return;
g_growLatched = true; // one attempt per build, whether or not it grows
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
return;
int nulls = 0;
for (int i = 0; i < count; ++i)
if (*reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
if (Game::Read<const char *>(
base + i * Offsets::EVENT_ENTRY_STRIDE,
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
++nulls;
@@ -193,8 +193,8 @@ void Grow() {
// moving the buffer would corrupt their subscription). Abort if so and
// fall back to claim-NULL.
for (int i = 0; i < count; ++i) {
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
const uint32_t head = Game::Read<uint32_t>(
base + i * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_HEAD);
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
Debug::Log::Printf(
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
@@ -231,9 +231,9 @@ void Grow() {
// Publish base BEFORE count so the globals never advertise more entries
// than the buffer holds. Then free the OLD buffer only — the name
// strings live on in newBuf and are freed by the reload teardown.
*reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
Game::Ref<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
SMemFree(base);
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
@@ -268,13 +268,13 @@ int Lookup(const char *name) {
int LookupByName(const char *name) {
if (name == nullptr)
return -1;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = 0; i < count; ++i) {
const char *entryName = *reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
const char *entryName = Game::Read<const char *>(
base + i * Offsets::EVENT_ENTRY_STRIDE,
Offsets::OFF_EVENT_ENTRY_NAME);
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
return i;
@@ -285,16 +285,16 @@ int LookupByName(const char *name) {
bool HasListeners(int slot) {
if (slot < 0)
return false;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || slot >= count)
return false;
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
// value is the empty self-sentinel — same test the grow-safety scan
// uses. So this is true iff at least one frame registered for the event.
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + slot * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
const uint32_t head = Game::Read<uint32_t>(
base + slot * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_HEAD);
return head != 0 && (head & 1) == 0;
}
@@ -330,12 +330,12 @@ int __fastcall Script_DumpSlot(void *L) {
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
const char *name = nullptr;
if (base != nullptr && slot >= 0 && slot < count) {
name = *reinterpret_cast<const char *const *>(
base + slot * Offsets::EVENT_ENTRY_STRIDE +
name = Game::Read<const char *>(
base + slot * Offsets::EVENT_ENTRY_STRIDE,
Offsets::OFF_EVENT_ENTRY_NAME);
}
Debug::Log::Printf("[dumpslot] base=0x%08X count=%d slot=%d name='%s'",
@@ -345,16 +345,16 @@ int __fastcall Script_DumpSlot(void *L) {
}
int __fastcall Script_DumpAllEvents(void *) {
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
Debug::Log::Printf("[dumpall] base=0x%08X count=%d",
static_cast<unsigned>(reinterpret_cast<uintptr_t>(base)),
count);
if (base == nullptr)
return 0;
for (int i = 0; i < count; ++i) {
const char *name = *reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
const char *name = Game::Read<const char *>(
base + i * Offsets::EVENT_ENTRY_STRIDE,
Offsets::OFF_EVENT_ENTRY_NAME);
if (name != nullptr && *name != '\0')
Debug::Log::Printf("[%d] %s", i, name);
+6 -10
View File
@@ -50,22 +50,20 @@ int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
if (slot < 0)
return 0;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr)
return 0;
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
const uintptr_t head = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
const uintptr_t head = Game::Read<uintptr_t>(entry, Offsets::OFF_EVENT_ENTRY_HEAD);
// First pass: count subscribers so we can reserve stack up front — an
// event with many registered frames would otherwise risk overflowing
// the default Lua stack slack as we push.
int count = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
if (*reinterpret_cast<void *const *>(node + Offsets::OFF_EVENT_NODE_FRAME))
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT)) {
if (Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME))
++count;
}
if (count == 0)
@@ -74,10 +72,8 @@ int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
int pushed = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
void *frame = *reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME);
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT)) {
void *frame = Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME);
if (frame != nullptr) {
UI::FrameObject::Push(L, frame);
++pushed;
+409
View File
@@ -0,0 +1,409 @@
// 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.
// FontString metric backports.
//
// 1.12's FontString method table (32 entries at 0x0087C1D8, registry context
// VAR_FONTSTRING_METHOD_REGISTRY) ships GetStringWidth but none of the later
// metric bindings. The engine already has every internal getter — these are
// the thin Lua bindings Blizzard never wrote for 1.12:
//
// GetStringHeight() — 2.3.0 binding; internal getter
// FUN_FONTSTRING_STRING_HEIGHT (wrap-aware,
// lines×fontH + (lines−1)×spacing, cache
// fs+0x100, 0 for empty text).
// GetUnboundedStringWidth() — the substring measure with the WHOLE string
// (FUN_FONTSTRING_MEASURE_SUBSTRING, len 0 =
// strlen): the string's width with no wrap cap.
// Icon-aware for free — the call routes through
// text/InlineTexture.cpp's co-hook.
// GetWrappedWidth() — the RENDERED width: the widest wrapped line
// (the tooltip auto-size's own recipe — break
// array + per-segment substring measures).
// GetNumLines() — the node's built line count (render truth,
// OFF_TEXT_NODE_BUILT_LINES) once painted; for
// an unbuilt node, the engine's own break-array
// computer (FUN_FONTSTRING_BREAK_ARRAY — also
// icon-aware via the wrap-stepper co-hook).
// GetLineHeight() — the fs font height via the same getter the
// rebuild feeds the text node (FUN_007727b0);
// excludes spacing, like retail (GetSpacing is
// its own method).
// IsTruncated() — did the engine cut the text off with an
// ellipsis? Reads the render's own output: the
// built text node's copy of the laid-out string
// (OFF_TEXT_NODE_TEXT) vs the source. Same
// "displayed vs full" comparison retail does; no
// reconstruction of the fit/wrap/maxLines logic.
// Reflects the last rendered layout (the display-
// text resolver's rect is zero until a render, so
// a Lua-time re-call cannot see the bound).
// SetMaxLines(n)/GetMaxLines() — cap the fontstring to n wrapped lines; text
// past the cap is ellipsized. Writes the engine's
// own per-fontstring maxLines field (fs+0x128,
// already honored by the truncation resolver) and
// re-invalidates the layout like SetText does.
// 1.12 has no SetWordWrap, so SetMaxLines(1) is
// how you get a single-line, ellipsized label.
// SetFormattedText(fmt,...) — string.format + SetText convenience. The format
// runs through the engine's OWN str_format C
// function (FUN_LUA_STR_FORMAT) under pcall — NOT
// _G.string.format, so an addon replacing
// string.format can't hijack it (matches retail).
// The set goes through FUN_FONTSTRING_SET_TEXT,
// keeping the sanitizer/pipe handling identical to
// SetText.
//
// Push conversion mirrors Script_GetStringWidth (0x0079E510): resolve self,
// call the internal getter, convert anchor units → UI pixels, push.
//
// No inline-icon adjustment lives HERE: the width/height internal getters are
// already icon-adjusted by their co-hooks (calling the hooked VA routes
// through the detour), and icons never change GetLineHeight.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace FontString::Metrics {
namespace {
// FUN_FONTSTRING_STRING_HEIGHT — ECX = fs, no stack args, float in x87 ST0.
using StringHeightInternal_t = float(__fastcall *)(void *fs);
// FUN_FONTSTRING_MEASURE_SUBSTRING — __thiscall(fs, text, len); dummy-EDX form.
using MeasureSubstring_t = float(__fastcall *)(void *fs, void *edx, const char *text, int len);
// FUN_FONTSTRING_FONT_HEIGHT — __thiscall(fs, mode-on-stack); dummy-EDX form.
using FsFontHeight_t = float(__fastcall *)(void *fs, void *edx, int mode);
// FUN_FONTSTRING_BREAK_ARRAY — __thiscall(fs, text, wrapWidth, outBreaks, cap).
using BreakArray_t = uint32_t(__fastcall *)(void *fs, void *edx, const char *text, float width,
int *outBreaks, int cap);
// FUN_FONTSTRING_SET_TEXT — __thiscall(fs, text, flag = 0); dummy-EDX form.
using FsSetText_t = void(__fastcall *)(void *fs, void *edx, const char *text, int flag);
// FUN_HANDLE_RELEASE — DecRef a ref-counted handle. __fastcall(handle).
using HandleRelease_t = void(__fastcall *)(void *handle);
// FUN_FONTSTRING_LAYOUT_INVALIDATE — region layout invalidate. __thiscall(region,
// int); dummy-EDX form.
using LayoutInvalidate_t = void(__fastcall *)(void *region, void *edx, int arg);
// Invalidate a fontstring's layout exactly as SetText (FUN_00771D80) does after
// a content change, so a layout-affecting property change (max lines) takes
// effect on the next draw: drop the cached measures, release the built node,
// clear the layout-dirty bit, and notify the region to re-lay-out. Re-calling
// SetText would NOT work — it early-outs when the text string is unchanged.
void InvalidateLayout(void *fs) {
Game::Ref<float>(fs, Offsets::OFF_FONTSTRING_WIDTH_CACHE) = 0.0f;
Game::Ref<float>(fs, Offsets::OFF_FONTSTRING_HEIGHT_CACHE) = 0.0f;
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (block != nullptr) {
reinterpret_cast<HandleRelease_t>(Offsets::FUN_HANDLE_RELEASE)(block);
Game::Ref<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK) = nullptr;
}
Game::Ref<uint32_t>(fs, Offsets::OFF_FONTSTRING_DIRTY_FLAGS) &= ~1u;
void *region = reinterpret_cast<void *>(reinterpret_cast<uintptr_t>(fs) + 0x24);
reinterpret_cast<LayoutInvalidate_t>(Offsets::FUN_FONTSTRING_LAYOUT_INVALIDATE)(
region, nullptr, 0);
}
// Anchor units → UI pixels: `FUN_0041AE40(FUN_0041AD70() × DAT_007FFD68 × v)`
// = v × [VAR_UI_COORD_SCALE_DIV] × 1024 / [VAR_UI_COORD_SCALE_MUL] — the exact
// push chain Script_GetStringWidth uses (the inverse of
// Tooltip::LinePool::PixelToInternal).
double InternalToPixel(float v) {
const float mul = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_MUL);
const float div = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_DIV);
if (mul == 0.0f)
return 0.0;
return static_cast<double>(v) * div * Offsets::UI_COORD_SCALE_UNIT / mul;
}
// The fs's wrap budget in the units FUN_FONTSTRING_BREAK_ARRAY expects
// (width-getter units — it multiplies by fs+0x7C itself before gxu, verified
// decompile), or 0 when the rect is unresolved. rectWidth ÷ scale, calibrated
// EMPIRICALLY against the render with a three-point probe (a 100px box at
// scale ~0.91, text whose candidate lines measure 78.1 / 94.6 / 101.6px):
// raw rectW → effective budget ~91px → broke one word EARLY,
// rectW/scale² → effective budget ~110px → broke one word LATE,
// rectW/scale → effective budget 100px → breaks exactly where the
// render does (94.6 ≤ 100 < 101.6).
// All three observations fit budget_px = pxOf(param) × scale, pinning the
// single ÷scale. (A pure paper derivation from the rebuild's node budget
// predicted /scale² — one hop of that chain models the live behaviour wrong;
// the probe is the arbiter.)
float WrapBudget(void *fs) {
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
float width = (rc[3] > rc[1]) ? (rc[3] - rc[1]) : (rc[1] - rc[3]);
const float s = Game::Read<float>(fs, Offsets::OFF_LAYOUT_SCALE);
if (width > 0.0f && s > 0.0f)
width /= s;
return width;
}
// Shared self-resolve for every method here.
void *ResolveSelf(void *L, const char *usage) {
void *fs = nullptr;
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
fs = Game::Lua::ResolveObject(L, 1);
if (fs == nullptr)
Game::Lua::Error(L, "%s", usage);
return fs;
}
int __fastcall Script_GetStringHeight(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:GetStringHeight()");
if (fs == nullptr)
return 0;
const float h = reinterpret_cast<StringHeightInternal_t>(
Offsets::FUN_FONTSTRING_STRING_HEIGHT)(fs);
Game::Lua::PushNumber(L, InternalToPixel(h));
return 1;
}
int __fastcall Script_GetUnboundedStringWidth(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:GetUnboundedStringWidth()");
if (fs == nullptr)
return 0;
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (text == nullptr || *text == '\0') {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
const float w = reinterpret_cast<MeasureSubstring_t>(
Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING)(fs, nullptr, text, 0);
Game::Lua::PushNumber(L, InternalToPixel(w));
return 1;
}
int __fastcall Script_GetNumLines(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:GetNumLines()");
if (fs == nullptr)
return 0;
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (text == nullptr || *text == '\0') {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
// Render truth first: the built node's line count. Zero until the node
// paints once (hidden fs, mid-rebuild) — fall back below.
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
if (block != nullptr) {
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
if (node != nullptr) {
const int built = Game::Read<int>(node, Offsets::OFF_TEXT_NODE_BUILT_LINES);
if (built > 0) {
Game::Lua::PushNumber(L, static_cast<double>(built));
return 1;
}
}
}
// Unbuilt: the engine's own break-array computer, fed the fs's wrap
// budget (see WrapBudget's scale note). An unresolved rect (0 width)
// means no wrap constraint exists yet — one line, like a fresh
// single-anchor fs.
const float width = WrapBudget(fs);
if (width <= 0.0f) {
Game::Lua::PushNumber(L, 1.0);
return 1;
}
int breaks[64];
uint32_t lines = reinterpret_cast<BreakArray_t>(Offsets::FUN_FONTSTRING_BREAK_ARRAY)(
fs, nullptr, text, width, breaks, 64);
if (lines > 64u)
lines = 64u;
if (lines == 0u)
lines = 1u;
Game::Lua::PushNumber(L, static_cast<double>(lines));
return 1;
}
int __fastcall Script_GetWrappedWidth(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:GetWrappedWidth()");
if (fs == nullptr)
return 0;
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (text == nullptr || *text == '\0') {
Game::Lua::PushNumber(L, 0.0);
return 1;
}
auto measure =
reinterpret_cast<MeasureSubstring_t>(Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING);
// The fs's wrap budget (see WrapBudget's scale note). Without one
// (unresolved rect) nothing wraps, and the rendered width IS the
// one-line width.
const float budget = WrapBudget(fs);
if (budget <= 0.0f) {
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
return 1;
}
// The tooltip auto-size's own recipe (FUN_00530640): break positions from
// the break-array computer, then the widest per-segment measure. Both
// callees are icon-aware through their co-hooks, and a segment-final icon
// reports its ink edge — so this is the rendered width, wrap and icons
// included.
int breaks[64];
uint32_t segs = reinterpret_cast<BreakArray_t>(Offsets::FUN_FONTSTRING_BREAK_ARRAY)(
fs, nullptr, text, budget, breaks, 64);
if (segs > 64u)
segs = 64u;
if (segs <= 1u) {
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
return 1;
}
int len = 0;
while (len < 0x4000 && text[len] != '\0')
++len;
float widest = 0.0f;
for (uint32_t i = 0; i < segs; ++i) {
const int start = breaks[i];
int end = (i + 1 < segs) ? breaks[i + 1] : len;
if (start < 0 || start >= end || end > len)
continue;
// A segment runs to the NEXT segment's start, which includes the
// whitespace the wrap consumed — the render never draws it, and a
// trailing space also flips the last real letter from ink to
// full-advance treatment (+~1.5px, measured). Trim to what renders.
while (end > start && (text[end - 1] == ' ' || text[end - 1] == '\n' ||
text[end - 1] == '\r' || text[end - 1] == '\t'))
--end;
if (start >= end)
continue;
const float w = measure(fs, nullptr, text + start, end - start);
if (w > widest)
widest = w;
}
Game::Lua::PushNumber(L, InternalToPixel(widest));
return 1;
}
int __fastcall Script_GetLineHeight(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:GetLineHeight()");
if (fs == nullptr)
return 0;
const float h = reinterpret_cast<FsFontHeight_t>(Offsets::FUN_FONTSTRING_FONT_HEIGHT)(
fs, nullptr, 1);
Game::Lua::PushNumber(L, InternalToPixel(h));
return 1;
}
int __fastcall Script_IsTruncated(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:IsTruncated()");
if (fs == nullptr)
return 0;
// Retail computes this by running the display-text resolver and strcmp'ing
// its result against the source (5.4.8 inner fn 0x0045E001). 1.12's resolver
// needs the LAID-OUT rect (fs+0x64), which is zero until a render pass, so a
// Lua-time re-call always sees an unbounded box and never truncates. Read the
// render's OWN output instead: the built text node keeps a copy of exactly
// the string it laid out (OFF_TEXT_NODE_TEXT) — the ellipsized "<prefix>..."
// form on truncation, else the source verbatim. So this reflects the last
// rendered layout (the fontstring must have drawn once), matching retail's
// "displayed vs full" semantics.
bool truncated = false;
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
const char *src = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
if (block != nullptr && src != nullptr) {
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
const char *shown =
(node != nullptr) ? Game::Read<const char *>(node, Offsets::OFF_TEXT_NODE_TEXT)
: nullptr;
if (shown != nullptr) {
// A truncated display is exactly <source-prefix> + "...". Require the
// "..." suffix, a length strictly shorter than the source, and a
// matching byte-prefix. This rejects a STALE node — one left from a
// previous, different text after a fresh SetText, not yet re-laid-out
// — which would otherwise read as a truncation of the new text.
const size_t nlen = std::strlen(shown);
const size_t slen = std::strlen(src);
if (nlen >= 3 && nlen < slen && shown[nlen - 3] == '.' &&
shown[nlen - 2] == '.' && shown[nlen - 1] == '.' &&
std::memcmp(shown, src, nlen - 3) == 0)
truncated = true;
}
}
Game::Lua::PushBool(L, truncated);
return 1;
}
int __fastcall Script_SetMaxLines(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:SetMaxLines(maxLines)");
if (fs == nullptr)
return 0;
// <= 0 or a non-number means "no cap" — the engine treats 0 as unlimited.
int maxLines = 0;
if (Game::Lua::IsNumber(L, 2)) {
const double n = Game::Lua::ToNumber(L, 2);
if (n > 0.0)
maxLines = static_cast<int>(n);
}
if (maxLines != Game::Read<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES)) {
Game::Ref<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES) = maxLines;
InvalidateLayout(fs);
}
return 0;
}
int __fastcall Script_GetMaxLines(void *L) {
void *fs = ResolveSelf(L, "Usage: fontstring:GetMaxLines()");
if (fs == nullptr)
return 0;
Game::Lua::PushNumber(
L, static_cast<double>(Game::Read<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES)));
return 1;
}
int __fastcall Script_SetFormattedText(void *L) {
void *fs = nullptr;
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
fs = Game::Lua::ResolveObject(L, 1);
if (fs == nullptr || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: fontstring:SetFormattedText(\"format\"[, ...])");
return 0;
}
const int top = Game::Lua::GetTop(L);
Game::Lua::CheckStack(L, top + 4);
Game::Lua::PushCClosure(
L, reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_FORMAT), 0);
for (int i = 2; i <= top; ++i)
Game::Lua::PushValue(L, i);
if (Game::Lua::PCall(L, top - 1, 1, 0) != 0) {
const char *msg = Game::Lua::ToString(L, -1);
Game::Lua::Error(L, "%s", (msg != nullptr) ? msg : "SetFormattedText: format failed");
return 0;
}
const char *result = Game::Lua::ToString(L, -1);
reinterpret_cast<FsSetText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(
fs, nullptr, (result != nullptr) ? result : "", 0);
Game::Lua::SetTop(L, top);
return 0;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"GetStringHeight", &Script_GetStringHeight},
{"GetUnboundedStringWidth", &Script_GetUnboundedStringWidth},
{"GetWrappedWidth", &Script_GetWrappedWidth},
{"GetNumLines", &Script_GetNumLines},
{"GetLineHeight", &Script_GetLineHeight},
{"IsTruncated", &Script_IsTruncated},
{"SetMaxLines", &Script_SetMaxLines},
{"GetMaxLines", &Script_GetMaxLines},
{"SetFormattedText", &Script_SetFormattedText},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FONTSTRING_METHOD_REGISTRY), g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace FontString::Metrics
+70 -93
View File
@@ -58,9 +58,11 @@
// resolve via ReadModAttr's precedence. Verbs: `target` (with the engine's
// default-interaction precedence — pending spell / cursor item cast/drop on the
// unit instead of switching target), `assist`, `focus`, `spell` (reads the
// `spell` attribute and casts it on the unit via our native C_Spell.CastAtUnit —
// the unit's GUID goes straight to the cast dispatcher, no target juggling, and
// ground-target spells land at the unit's feet), `item` (uses the `item`
// `spell` attribute and casts it: with a `unit` attribute, on that unit via our
// native C_Spell.CastAtUnit — the unit's GUID goes straight to the cast
// dispatcher, no target juggling, and ground-target spells land at its feet;
// with no `unit`, on the current target via the plain CastSpellByName), `item`
// (uses the `item`
// attribute — a name/itemID/link via C_Item.UseItemByName with the unit as the
// use target, or a "bag slot" string like "0 1" via UseContainerItem; the
// deprecated `bag`/`slot` attributes also work), `macro` (from the
@@ -98,6 +100,7 @@
#include "Offsets.h"
#include "baselib/Ascii.h"
#include "cursor/Info.h"
#include "macro/Execute.h"
#include "object/Resolve.h"
#include "spell/AtCursor.h"
#include "spell/AtUnit.h"
@@ -299,78 +302,6 @@ bool CallTableFunc2Str(void *L, const char *table, const char *method,
return called;
}
// ---- macrotext execution (stock ChatEdit_ParseText, no addon dependency) ----
//
// 1.12 has no `RunMacroText`/`RunMacro` global — those are addon shims (pfUI
// wraps `ChatEdit_ParseText`; SuperCleveRoidMacros ships its own `RunMacro`).
// To run macro text without depending on an addon, we replicate pfUI's shim in
// C: build a throwaway "edit box" whose `GetText` returns the line and whose
// every other method is a harmless no-op (via an `__index` metamethod), then
// hand it to the stock FrameXML `ChatEdit_ParseText(editBox, 1)` — the same
// path the real chat box uses to dispatch a slash command / send a line.
// GetText: returns upvalue(1), the captured line.
int __fastcall MacroGetText_c(void *L) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
return 1;
}
// A no-op standing in for any edit-box method the parser happens to call.
int __fastcall MacroNoop_c(void *) { return 0; }
// __index(tab, key): hand back the no-op so `editBox:AnyMethod()` is safe.
int __fastcall MacroIndex_c(void *L) {
Game::Lua::PushCClosure(L, &MacroNoop_c, 0);
return 1;
}
// Runs a single macro line through the stock chat parser.
void RunMacroLineC(void *L, const char *line, size_t len) {
if (len == 0) return;
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // fake editBox
const int obj = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "GetText"); // obj.GetText = closure over line
Game::Lua::PushLString(L, line, static_cast<unsigned int>(len));
Game::Lua::PushCClosure(L, &MacroGetText_c, 1);
Game::Lua::SetTable(L, obj);
Game::Lua::NewTable(L); // metatable { __index = noop }
const int mt = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "__index");
Game::Lua::PushCClosure(L, &MacroIndex_c, 0);
Game::Lua::SetTable(L, mt);
// No lua_setmetatable binding — use the Lua global.
if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
Game::Lua::PushValue(L, obj);
Game::Lua::PushValue(L, mt);
Game::Lua::Call(L, 2, 0);
}
if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
Game::Lua::PushValue(L, obj);
Game::Lua::PushNumber(L, 1); // send = 1
Game::Lua::Call(L, 2, 0);
}
Game::Lua::SetTop(L, top);
}
// Runs macro text one line at a time — a real macro is line-delimited, and a
// single ChatEdit_ParseText call only dispatches one command.
void RunMacroTextC(void *L, const char *text) {
if (!text) return;
for (const char *p = text; *p;) {
const char *nl = p;
while (*nl && *nl != '\n') ++nl;
RunMacroLineC(L, p, static_cast<size_t>(nl - p));
p = (*nl == '\n') ? nl + 1 : nl;
}
}
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
@@ -384,12 +315,11 @@ std::unordered_map<const void *, std::string> g_unitByFrame;
uint64_t g_lastSet = 0;
const void *CurrentMouseFocus() {
const void *ctx = *reinterpret_cast<const void *const *>(
const void *ctx = Game::Read<const void *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
if (ctx == nullptr)
return nullptr;
return *reinterpret_cast<const void *const *>(
reinterpret_cast<const uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
return Game::Read<const void *>(ctx, Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
}
// The engine's real mouseover setter — `__stdcall(guidLo, guidHi, prevLo,
@@ -422,7 +352,7 @@ bool GuidHasLiveUnit(uint64_t guid) {
// works for an offline member). The engine's setter (FUN_00492890) gates this
// call on a live object, so offline members get nothing — we do it here.
void BuildRosterUnitTooltip(uint64_t guid) {
void *tooltip = *reinterpret_cast<void *const *>(
void *tooltip = Game::Read<void *>(
static_cast<uintptr_t>(Offsets::VAR_GAMETOOLTIP_OBJECT_PTR));
if (tooltip == nullptr)
return;
@@ -434,9 +364,8 @@ void BuildRosterUnitTooltip(uint64_t guid) {
// no owner and never re-appears (only a subsequent live-unit hover, which
// takes the engine's own anchor path, would). Use the self-contained
// invoker FUN_FRAME_INVOKE_SCRIPT, safe to call from the WorldTick.
const int anchorHandler = *reinterpret_cast<const int *>(
reinterpret_cast<uint8_t *>(tooltip) +
Offsets::OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER);
const int anchorHandler = Game::Read<int>(
tooltip, Offsets::OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER);
if (anchorHandler != 0)
reinterpret_cast<void(__fastcall *)(int, void *)>(
static_cast<uintptr_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT))(anchorHandler,
@@ -620,11 +549,18 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
return true;
}
if (Ascii::EqualCI(verb, "spell")) {
if (!unit) return false;
char spell[128];
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
return false;
Spell::AtUnit::CastByName(spell, unit);
// With a `unit`, cast straight at it (GUID → dispatcher, no target
// juggling; a ground-target spell lands at its feet). With no `unit`,
// fall back to the plain global cast on the current target — a
// `type="spell"` button that sets no `unit` attribute (issue #18) now
// works like `/cast <spell>` instead of doing nothing.
if (unit)
Spell::AtUnit::CastByName(spell, unit);
else
Game::Lua::CallGlobalString(L, "CastSpellByName", spell);
return true;
}
if (Ascii::EqualCI(verb, "item")) {
@@ -646,16 +582,57 @@ bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
return false;
}
if (Ascii::EqualCI(verb, "macro")) {
// Give the macro a unit to act on. Vanilla macros have no `@unit`
// conditional, and unlike the `spell` verb (which feeds a GUID straight
// to the cast dispatcher) we can't inject a target into arbitrary macro
// text — so a plain `/cast Flash Heal` would hit the current target, not
// the clicked unit. Emulate the classic Clique click-heal: snapshot the
// current target, retarget the clicked unit, run the macro, then restore
// the previous target so the click doesn't leave the player retargeted.
// The macro's actions (/cast etc.) dispatch against the clicked unit
// synchronously while it's selected, so restoring the target afterward
// doesn't affect them.
uint64_t prevTarget = 0;
const bool swapped = unit != nullptr;
if (swapped) {
prevTarget =
(static_cast<uint64_t>(Game::Read<volatile uint32_t>(
Offsets::VAR_CURRENT_SELECTION_GUID_HI))
<< 32) |
Game::Read<volatile uint32_t>(
Offsets::VAR_CURRENT_SELECTION_GUID_LO);
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
}
char macro[512];
if (!ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro) &&
!ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro))
return false;
// Prefer an addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) — it
// handles named macros and extended macro text; fall back to the stock
// ChatEdit_ParseText path when no RunMacro global is present.
if (!Game::Lua::CallGlobalString(L, "RunMacro", macro))
RunMacroTextC(L, macro);
return true;
bool handled = false;
// `macrotext` is raw macro text (the modern default). Run it as text
// through the stock chat parser so each line dispatches via
// SlashCmdList — that routes /cast etc. through any addon slash hooks
// (e.g. SuperCleveRoidMacros' conditional /cast). A name-based RunMacro
// (which is what SuperCleveRoidMacros ships) can't interpret text and
// would silently resolve nothing.
if (ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro)) {
Macro::Execute::Text(L, macro);
handled = true;
}
// Deprecated `macro` form: a saved-macro name/index. Prefer an
// addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) to run the
// named macro's body; fall back to the stock parser when none exists.
else if (ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro)) {
handled = Macro::Execute::Saved(L, macro);
}
// Restore the target the click swapped away from. `FUN_TARGET_BY_GUID`
// validates the GUID resolves to a live unit and bails otherwise, so a
// target that despawned mid-macro is dropped cleanly rather than
// committed; a zero prior target means "no target" → ClearTarget.
if (swapped) {
if (prevTarget)
reinterpret_cast<void(__fastcall *)(const uint64_t *)>(
Offsets::FUN_TARGET_BY_GUID)(&prevTarget);
else
Game::Lua::CallGlobal(L, "ClearTarget");
}
return handled;
}
if (Ascii::EqualCI(verb, "stop") || Ascii::EqualCI(verb, "stopcasting")) {
Game::Lua::CallGlobal(L, "SpellStopCasting");
+11 -17
View File
@@ -131,8 +131,7 @@ int __fastcall Script_IsMouseOver(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
const float effScale = Game::Read<float>(obj, Offsets::OFF_REGION_EFFECTIVE_SCALE);
double left, right, top, bottom;
if (effScale == 0.0f ||
@@ -193,13 +192,10 @@ int __fastcall Script_GetRect(void *L) {
// there, so it reports false, as it should.
int __fastcall Script_IsDragging(void *L) {
void *self = Game::Lua::ResolveObject(L, 1);
void *ctx = *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
void *ctx = Game::Read<void *>(static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
void *dragTarget =
ctx != nullptr
? *reinterpret_cast<void *const *>(
reinterpret_cast<const uint8_t *>(ctx) +
Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
? Game::Read<void *>(ctx, Offsets::OFF_UI_CONTEXT_DRAG_TARGET)
: nullptr;
Game::Lua::PushBool(L, self != nullptr && self == dragTarget);
return 1;
@@ -348,9 +344,9 @@ int __fastcall Script_HookScript(void *L) {
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 *>(
const uint32_t count = Game::Read<uint32_t>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
auto *base = *reinterpret_cast<const uint8_t *const *>(
auto *base = Game::Read<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
if (count == 0 || base == nullptr)
return false;
@@ -360,21 +356,19 @@ bool FrameHasEvent(void *frame, const char *eventName) {
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);
const char *name = Game::Read<const char *>(
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);
uintptr_t node = Game::Read<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)
if (Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
return true;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT);
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT);
}
return false;
}
+6 -12
View File
@@ -54,8 +54,7 @@ int CallScript(uintptr_t fn, void *L) {
// The UI context is a pointer stored at VAR_UI_CONTEXT_PTR (null pre-world).
void *UIContext() {
return *reinterpret_cast<void *const *>(
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
return Game::Read<void *>(static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
}
// Strata name -> draw rank, low (behind) to high (on top). Unknown -> 0.
@@ -110,8 +109,7 @@ Cursor ReadCursor(void *L) {
// With `self` at Lua idx 1, is the cursor within the frame's rect?
bool CursorOverSelf(void *L, void *obj, const Cursor &cur) {
const float effScale = *reinterpret_cast<const float *>(
reinterpret_cast<const uint8_t *>(obj) + Offsets::OFF_REGION_EFFECTIVE_SCALE);
const float effScale = Game::Read<float>(obj, Offsets::OFF_REGION_EFFECTIVE_SCALE);
if (effScale == 0.0f)
return false; // unpositioned region — no rect
double left, right, top, bottom;
@@ -141,19 +139,16 @@ int __fastcall Script_GetMouseFoci(void *L) {
// The engine's authoritative topmost focus — the frame GetMouseFocus
// returns. Forced to index 1 below.
void *focus = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
void *focus = Game::Read<void *>(ctx, Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
const Cursor cur = ReadCursor(L);
std::vector<Hit> hits;
void *node = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(ctx) + Offsets::OFF_UI_CONTEXT_FRAME_LIST_HEAD);
void *node = Game::Read<void *>(ctx, Offsets::OFF_UI_CONTEXT_FRAME_LIST_HEAD);
while (node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0) {
// Read the next link before any Lua work (the walk is engine-list
// based, independent of the Lua stack, but this keeps it obvious).
void *next = *reinterpret_cast<void *const *>(
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_ENUM_NEXT);
void *next = Game::Read<void *>(node, Offsets::OFF_FRAME_ENUM_NEXT);
Game::Lua::SetTop(L, 0);
UI::FrameObject::Push(L, node); // (self)
@@ -164,8 +159,7 @@ int __fastcall Script_GetMouseFoci(void *L) {
Game::Lua::SetTop(L, 1);
CallScript(Offsets::FUN_SCRIPT_FRAME_GET_STRATA, L); // (self, strata)
const int rank = StrataRank(Game::Lua::ToString(L, 2));
const int level = *reinterpret_cast<const int *>(
reinterpret_cast<uint8_t *>(node) + Offsets::OFF_FRAME_LEVEL);
const int level = Game::Read<int>(node, Offsets::OFF_FRAME_LEVEL);
hits.push_back(Hit{node, rank, level});
}
+4 -7
View File
@@ -77,11 +77,10 @@ const auto RawGetI = reinterpret_cast<RawGetI_t>(Offsets::LUA_RAWGETI);
// Returns the frame's Lua object ref (frame+0x08), lazily creating it exactly
// as the engine runner does (`if (refcount == 0) ScriptRegister(frame, 0)`).
int EnsureLuaRef(void *frame) {
auto *f = reinterpret_cast<uint8_t *>(frame);
if (*reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
if (Game::Read<int>(frame, Offsets::OFF_COBJECT_LUA_REFCOUNT) == 0)
reinterpret_cast<ScriptRegister_t>(
Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER)(frame, nullptr);
return *reinterpret_cast<int *>(f + Offsets::OFF_COBJECT_LUA_REF);
return Game::Read<int>(frame, Offsets::OFF_COBJECT_LUA_REF);
}
// Writes "arg" + k (k in 1..19) into buf; buf must hold at least 6 bytes.
@@ -193,9 +192,7 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
// as the first positional to mirror retail's (self, event, arg1..argN).
const bool isOnEvent =
haveThis &&
*reinterpret_cast<const int *>(reinterpret_cast<uint8_t *>(frame) +
Offsets::OFF_FRAME_ONEVENT_SLOT) ==
handlerRef;
Game::Read<int>(frame, Offsets::OFF_FRAME_ONEVENT_SLOT) == handlerRef;
const int selfRef = haveThis ? EnsureLuaRef(frame) : 0;
Arg args[kMaxArgs];
@@ -230,7 +227,7 @@ bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr)
// --- call handler(self, [event,] arg1..argN) ---------------------------
const int callBase = GetTop(L); // == base + saved-block size
const int errRef = *reinterpret_cast<const int *>(
const int errRef = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
int errIdx = 0;
if (errRef > 0) {
+4 -3
View File
@@ -18,6 +18,7 @@
#include "Notes.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
@@ -41,14 +42,14 @@ std::string g_path;
bool g_loaded = false;
const char *ReadAccountName() {
return *reinterpret_cast<const char *const *>(Offsets::VAR_ACCOUNT_NAME_PTR);
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
}
const char *ReadRealmName() {
auto *info = *reinterpret_cast<uint8_t **>(Offsets::VAR_REALM_INFO_PTR);
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
if (info == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(info + Offsets::OFF_REALM_INFO_NAME);
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
}
const char *ReadCharacterName() {
+8 -9
View File
@@ -34,8 +34,7 @@ namespace FriendList::WhoInfo {
namespace {
int WhoResultCount() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULT_COUNT));
return Game::Read<int>(Offsets::VAR_WHO_RESULT_COUNT);
}
// `C_FriendList.GetNumWhoResults()` → `numWhos, totalCount`. The first is the
@@ -44,7 +43,7 @@ int WhoResultCount() {
// global's two returns.
int __fastcall Script_GetNumWhoResults(void *L) {
const int displayed = WhoResultCount();
const int total = *reinterpret_cast<const int *>(
const int total = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULT_COUNT) + 4);
Game::Lua::PushNumber(L, static_cast<double>(displayed));
Game::Lua::PushNumber(L, static_cast<double>(total));
@@ -67,15 +66,15 @@ int __fastcall Script_GetWhoInfo(void *L) {
static_cast<uintptr_t>(Offsets::VAR_WHO_RESULTS) +
static_cast<size_t>(index - 1) * Offsets::WHO_RESULT_STRIDE);
const char *name = reinterpret_cast<const char *>(entry + Offsets::OFF_WHO_NAME);
const char *guild = reinterpret_cast<const char *>(entry + Offsets::OFF_WHO_GUILD);
const int level = *reinterpret_cast<const int *>(entry + Offsets::OFF_WHO_LEVEL);
const char *name = Game::Ptr<const char>(entry, Offsets::OFF_WHO_NAME);
const char *guild = Game::Ptr<const char>(entry, Offsets::OFF_WHO_GUILD);
const int level = Game::Read<int>(entry, Offsets::OFF_WHO_LEVEL);
const uint32_t raceID =
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_RACE);
Game::Read<uint32_t>(entry, Offsets::OFF_WHO_RACE);
const uint32_t classID =
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_CLASS);
Game::Read<uint32_t>(entry, Offsets::OFF_WHO_CLASS);
const uint32_t zoneID =
*reinterpret_cast<const uint32_t *>(entry + Offsets::OFF_WHO_ZONE);
Game::Read<uint32_t>(entry, Offsets::OFF_WHO_ZONE);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "fullName", name); // inline; "" if empty
+3 -3
View File
@@ -72,10 +72,10 @@ int __fastcall Script_GetGameObjectInfoByID(void *L) {
if (rec == nullptr)
return 0; // not cached -> nil
const uint32_t type = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_GAMEOBJECT_TYPE);
const uint32_t type = Game::Read<uint32_t>(rec, Offsets::OFF_GAMEOBJECT_TYPE);
const uint32_t displayID =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_GAMEOBJECT_DISPLAYID);
const char *name = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_GAMEOBJECT_NAME);
Game::Read<uint32_t>(rec, Offsets::OFF_GAMEOBJECT_DISPLAYID);
const char *name = Game::Read<const char *>(rec, Offsets::OFF_GAMEOBJECT_NAME);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "gameObjectID", goID);
+8 -8
View File
@@ -63,10 +63,10 @@ static int __fastcall Script_GameTooltipGetGameObject(void *L) {
return 0;
const auto *base = static_cast<const uint8_t *>(tooltipObj);
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO);
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_HI);
const uint32_t guidLo =
Game::Read<uint32_t>(base, Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO);
const uint32_t guidHi =
Game::Read<uint32_t>(base, Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_HI);
if (guidLo == 0 && guidHi == 0)
return 0;
@@ -111,10 +111,10 @@ static int __fastcall Script_GameTooltipHasGameObject(void *L) {
return 1;
}
const auto *base = static_cast<const uint8_t *>(tooltipObj);
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO);
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_HI);
const uint32_t guidLo =
Game::Read<uint32_t>(base, Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO);
const uint32_t guidHi =
Game::Read<uint32_t>(base, Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_HI);
Game::Lua::PushBool(L, guidLo != 0 || guidHi != 0);
return 1;
}
+10 -9
View File
@@ -13,6 +13,7 @@
#include "group/MemberStats.h"
#include "Game.h"
#include "Offsets.h"
namespace Group::MemberStats {
@@ -23,7 +24,7 @@ namespace {
using Lookup_t = const uint8_t *(__fastcall *)(uint64_t *guid);
uint16_t U16(const uint8_t *p, int off) {
return *reinterpret_cast<const uint16_t *>(p + off);
return Game::Read<uint16_t>(p, off);
}
} // namespace
@@ -67,13 +68,13 @@ Stats Lookup(uint64_t guid) {
s.level = U16(e, Offsets::OFF_RAID_SLOT_LEVEL);
s.areaId = U16(e, Offsets::OFF_RAID_SLOT_AREA_ID);
const uint32_t flags =
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_FLAGS);
Game::Read<uint32_t>(e, Offsets::OFF_RAID_SLOT_FLAGS);
s.online = (flags & Offsets::RAID_SLOT_FLAG_ONLINE) != 0;
s.dead = (flags & Offsets::RAID_SLOT_FLAG_DEAD) != 0;
s.subgroup = static_cast<int>(*reinterpret_cast<const uint32_t *>(
e + Offsets::OFF_RAID_SLOT_SUBGROUP)) + 1;
s.subgroup = static_cast<int>(Game::Read<uint32_t>(
e, Offsets::OFF_RAID_SLOT_SUBGROUP)) + 1;
s.rank = static_cast<int>(
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_RANK));
Game::Read<uint32_t>(e, Offsets::OFF_RAID_SLOT_RANK));
return s;
}
@@ -93,8 +94,8 @@ const uint16_t *AuraArray(uint64_t guid) {
if ((e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
Offsets::GROUP_MEMBER_STATUS_ONLINE) == 0)
return nullptr;
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_GROUP_MEMBER_STATS_AURAS);
return Game::Ptr<const uint16_t>(
e, Offsets::OFF_GROUP_MEMBER_STATS_AURAS);
}
// Raid slot — auras at +0x64, no online gate (mirrors the engine's raid
@@ -102,8 +103,8 @@ const uint16_t *AuraArray(uint64_t guid) {
auto slotFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
if (const uint8_t *e = slotFn(&guid)) {
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_RAID_SLOT_AURAS);
return Game::Ptr<const uint16_t>(
e, Offsets::OFF_RAID_SLOT_AURAS);
}
return nullptr;
+3 -6
View File
@@ -46,12 +46,9 @@ int __fastcall Script_GetCreatureID(void *L) {
Game::Lua::PushNil(L);
return 1;
}
const Type type = Classify(guid);
if (type != Type::Creature && type != Type::Pet) {
Game::Lua::PushNil(L);
return 1;
}
const uint32_t entryID = static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu);
// `Guid::CreatureEntry` handles the creature/pet type gate + the bits-24-47
// extraction, and returns 0 for players, non-creature types, and entry 0.
const uint32_t entryID = CreatureEntry(guid);
if (entryID == 0) {
Game::Lua::PushNil(L);
return 1;
+7
View File
@@ -51,6 +51,13 @@ const char *FormatAsString(uint64_t guid, char *buf, std::size_t cap) {
return buf;
}
uint32_t CreatureEntry(uint64_t guid) {
const Type type = Classify(guid);
if (type != Type::Creature && type != Type::Pet)
return 0;
return static_cast<uint32_t>((guid >> 24) & 0xFFFFFFu);
}
Type Classify(uint64_t guid) {
if (guid == 0)
return Type::Unknown;
+8
View File
@@ -76,6 +76,14 @@ inline bool IsGameObject(uint64_t guid) { return Classify(guid) == Type::Game
inline bool IsDynamicObject(uint64_t guid) { return Classify(guid) == Type::DynamicObject; }
inline bool IsCorpse(uint64_t guid) { return Classify(guid) == Type::Corpse; }
// The creature-template / NPC entry ID packed into a creature or pet GUID
// (bits 24-47). Vanilla puts the per-spawn counter in the low 24 bits and the
// template entry above it. Returns 0 for any other GUID type (players,
// gameobjects, items, the 0 sentinel) and when the field itself is 0 — the
// engine never assigns entry 0, so 0 uniformly means "no creature template".
// Shared by `C_CreatureInfo.GetCreatureID` and `UnitCreatureID`.
uint32_t CreatureEntry(uint64_t guid);
// Minimum buffer size for `FormatAsString`'s output — 16 hex digits +
// the `"0x"` prefix + a NUL terminator.
constexpr std::size_t STRING_SIZE = 19;
+6 -6
View File
@@ -138,8 +138,8 @@ ItemInfo ReadItemInfo(const uint8_t *cgItem) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return {0, 0};
}
return {*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_ITEM_LEVEL),
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE)};
return {Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_ITEM_LEVEL),
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE)};
}
// Bank-walk helpers — bypass the bank-window gate on `GetItemBySlot`
@@ -184,8 +184,8 @@ void WalkGuidArrayRange(const uint8_t *invMgr, int firstSlot, int lastSlot,
std::vector<Candidate> &candidates) {
if (invMgr == nullptr)
return;
auto *guidArray = *reinterpret_cast<const uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
auto *guidArray = Game::Read<const uint64_t *>(
invMgr, Offsets::OFF_INVMGR_GUID_ARRAY);
if (guidArray == nullptr)
return;
for (int slot = firstSlot; slot <= lastSlot; ++slot) {
@@ -203,8 +203,8 @@ void WalkBankBags(std::vector<Candidate> &candidates) {
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return;
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
playerInvMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
auto *playerGuidArray = Game::Read<const uint64_t *>(
playerInvMgr, Offsets::OFF_INVMGR_GUID_ARRAY);
if (playerGuidArray == nullptr)
return;
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
+10 -4
View File
@@ -42,16 +42,22 @@ namespace {
// `uint *` (the ItemStats cache lookup uses the integer both as a
// hash key and as the field-zero match target, which is how it
// inherits the pointer typing in the decomp).
//
// `outDuration`/`outStart` are *unsigned* millisecond ticks (same OS counter
// and epoch as `GetTime()`). Reading `outStart` into a signed `int` makes
// every timestamp negative once the machine has been up past 2^31 ms
// (~24.9 days), so `startTime + duration` lands in the past and every item
// reads as off cooldown. Keep them unsigned; see `Time::Clock`.
using QueryItemCooldown_t = bool(__fastcall *)(uint32_t itemID,
int *outDuration,
int *outStart,
uint32_t *outDuration,
uint32_t *outStart,
uint32_t *outEnable);
} // namespace
void PushCooldown(void *L, int itemID) {
int durationMs = 0;
int startMs = 0;
uint32_t durationMs = 0;
uint32_t startMs = 0;
uint32_t enable = 0;
if (itemID > 0) {
auto fn = reinterpret_cast<QueryItemCooldown_t>(
+3 -3
View File
@@ -39,7 +39,7 @@ constexpr int OFF_CONTAINER_GUID_LO = 0x08;
constexpr int OFF_CONTAINER_GUID_HI = 0x0C;
bool ItemLocked(const uint8_t *item) {
return (*reinterpret_cast<const uint32_t *>(item + Offsets::OFF_ITEM_CLIENT_LOCK) & 1) != 0;
return (Game::Read<uint32_t>(item, Offsets::OFF_ITEM_CLIENT_LOCK) & 1) != 0;
}
// Reads a CGItem's 64-bit instance GUID into lo/hi. False if the instance
@@ -48,8 +48,8 @@ bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
*lo = Game::Read<uint32_t>(instance, Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = Game::Read<uint32_t>(instance, Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
return true;
}
+185 -60
View File
@@ -17,8 +17,11 @@
#include "item/Arg.h"
#include "item/CGItem.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -64,21 +67,10 @@ static void FireItemDataLoadResult(int itemID, bool success) {
itemID, success);
}
// Engine callback for **implicit** cache fills (transparent warmup
// triggered by our `Script_GetItemInfo` hook or `SetItemByID`). Fires
// only the broad `GET_ITEM_INFO_RECEIVED` event — `ITEM_DATA_LOAD_RESULT`
// is reserved for explicit `RequestLoadItemData(ByID)` paths to match
// modern API semantics.
//
// Calling convention is __stdcall, 2 args / 8 bytes — matching the
// Item-load callbacks are __stdcall, 2 args / 8 bytes — matching the
// callback at 0x004FDC30 (used by 4 sites in the binary) which ends in
// `ret 8`. If the engine called us with a different convention, that
// callback would crash the engine, so stdcall is verified by induction.
static void __stdcall ItemLoadCallback_Implicit(void *userData, int success) {
const auto itemID = static_cast<int>(reinterpret_cast<uintptr_t>(userData));
FireGetItemInfoReceived(itemID, success != 0);
}
// `ret 8`. If the engine called us with a different convention it would
// crash, so stdcall is verified by induction.
using ItemLoadCallback_t = void(__stdcall *)(void *userData, int success);
// Calls DBCache_ItemStats_C_GetRecord. With `callback=nullptr`,
@@ -123,8 +115,8 @@ static int ResolveLocationToItemID(void *L, int idx) {
auto *instance = Item::InstanceBlock(item);
if (instance == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_ITEM_ID));
return static_cast<int>(Game::Read<uint32_t>(
instance, Offsets::OFF_INSTANCE_BLOCK_ITEM_ID));
}
static int __fastcall Script_IsItemDataCachedByID(void *L) {
@@ -191,10 +183,42 @@ constexpr int kMaxWaitTicks = 1200;
// One-way latch; see the relog note in `WarmCache`.
constexpr int kSettleTicks = 60;
// Minimum spacing between outgoing *background* SMSG_ITEM_QUERY_SINGLE
// requests. A single /reload can make addons (pfQuest / Questie / Bagnon)
// look up hundreds of items in one frame. Unthrottled, every cache miss
// fired its own packet at once: a reported burst of 1,325 requests in
// ~14 ms exhausted a consumer router's NAT table and dropped the
// machine's internet connectivity (issue #25). 50 ms caps the sustained
// background rate at 20 requests / second. OnWorldTick is the single
// point that issues queries, so this one floor governs the bulk path.
constexpr uint32_t kMinRequestSpacingMs = 50;
// Priority tier. The player's carried items (equipment + backpack +
// equipped bags) are what the bag / paperdoll UI shows immediately on
// login; behind the background floor they showed as "unknown" for a
// second or two while a bulk addon scan drained ahead of them. These
// are issued on a separate, more generous budget so they resolve fast.
// The set is bounded (~120 items) and the engine prefetches inventory in
// parallel, so a small per-tick cap keeps them near-instant without
// re-introducing a burst: worst case is kOwnedPerTick + 1 packets per
// frame (~7), far below the flood that broke connectivity.
constexpr int kOwnedPerTick = 6;
constexpr int kMaxOwned = 256;
constexpr uint32_t kOwnedRefreshMs = 1000; // carried set changes slowly
static Pending g_pending[kMaxPending];
static int g_pendingCount = 0;
static bool g_settled = false;
static int g_worldTicks = 0;
static uint32_t g_lastRequestMs = 0;
// Snapshot of the itemIDs the player currently carries. Rebuilt by a
// pure-C inventory walk (no Lua stack — safe at world-tick time) at most
// once per kOwnedRefreshMs, and only while there is queued work.
static uint32_t g_owned[kMaxOwned];
static int g_ownedCount = 0;
static uint32_t g_ownedBuiltMs = 0;
static bool g_ownedEverBuilt = false;
static int FindPending(uint32_t itemID) {
for (int i = 0; i < g_pendingCount; ++i) {
@@ -335,69 +359,170 @@ static const Game::HookAutoRegister _hookCacheResponse{
reinterpret_cast<void *>(&ItemStatsCacheResponse_h),
reinterpret_cast<void **>(&ItemStatsCacheResponse_o)};
// Escalation + timeout. The hook covers the happy path (engine fills
// cache → we fire). This tick is only responsible for:
// --- Owned-item priority set --------------------------------------------
//
// Determines which pending items are the player's carried inventory so
// OnWorldTick can issue them ahead of background bulk lookups. The walk
// reads the engine's GUID arrays directly (no Lua stack, no GetItemBySlot
// bank gate) — the same pure-C pattern `item/Count` uses — so it is safe
// to run at world-tick time.
static void AddOwned(uint32_t itemID) {
if (itemID == 0)
return;
for (int i = 0; i < g_ownedCount; ++i)
if (g_owned[i] == itemID)
return; // dedup — stacks / duplicates collapse to one entry
if (g_ownedCount < kMaxOwned)
g_owned[g_ownedCount++] = itemID;
}
// Collects itemIDs from linear slots [first, last] of an inventory
// manager's GUID array (invMgr+OFF_INVMGR_GUID_ARRAY). Empty slots hold a
// zero GUID and are skipped.
static void CollectOwnedFrom(const uint8_t *invMgr, int first, int last) {
if (invMgr == nullptr)
return;
auto *guids = Game::Read<const uint64_t *>(
invMgr, Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return;
for (int slot = first; slot <= last; ++slot) {
const uint8_t *item = Item::Location::ResolveByGUID(guids[slot]);
if (item != nullptr)
AddOwned(static_cast<uint32_t>(Item::ID::FromCGItem(item)));
}
}
static void RebuildOwned() {
g_ownedCount = 0;
auto *invMgr = Unit::Identity::PlayerInventoryManager();
if (invMgr != nullptr) {
// Player invMgr linear slots 0..38: equipment + bag-slot items +
// backpack (bank starts at INVMGR_BANK_MAIN_FIRST_SLOT = 39).
CollectOwnedFrom(invMgr, 0,
Offsets::BACKPACK_LINEAR_BASE + Offsets::BACKPACK_NUM_SLOTS - 1);
// Contents of each equipped bag (its own invMgr, slot count at +0).
for (int bagID = 1; bagID <= 4; ++bagID) {
auto *bagInv =
static_cast<const uint8_t *>(Item::Location::EquippedBagInventory(bagID));
if (bagInv == nullptr)
continue;
const int count = static_cast<int>(
Game::Read<uint32_t>(bagInv, Offsets::OFF_INVMGR_SLOT_COUNT));
if (count > 0)
CollectOwnedFrom(bagInv, 0, count - 1);
}
}
g_ownedEverBuilt = true;
}
static bool IsOwned(uint32_t itemID) {
for (int i = 0; i < g_ownedCount; ++i)
if (g_owned[i] == itemID)
return true;
return false;
}
// Escalation + timeout — and the ONE place any item query is issued, so
// the rate limiter here governs every path into the cache. This tick is
// responsible for:
// 1. Latching the startup gate via a tick-count fallback, in case no
// engine response arrives early enough to latch it first.
// 2. Issuing a query for items the engine doesn't naturally prefetch
// (e.g. quest rewards) — but only once `g_settled`, so we don't
// race the engine's startup-time state machine. The engine sends
// the SMSG, the response handler hook fires the event when the
// response lands.
// 2. Issuing queries — for items the engine doesn't naturally prefetch
// (quest rewards) AND for every `WarmCache` miss — but only once
// `g_settled` (don't race the engine's startup state machine). The
// player's carried items go out first, up to `kOwnedPerTick` per
// tick; everything else is background, one per `kMinRequestSpacingMs`
// (don't flood the network; issue #25). The engine sends the SMSG;
// the response handler hook fires the event when the response lands.
// 3. Firing failure immediately for items the server rejected (the
// escalation callback marked them `failed`), and as a last resort
// giving up after `kMaxWaitTicks` for items that never resolve at
// all (lost query, server silent) — fire failure.
// giving up after `kMaxWaitTicks` for items whose query was already
// sent but never resolved (lost query, server silent).
static void OnWorldTick() {
if (!g_settled && ++g_worldTicks >= kSettleTicks)
g_settled = true;
const uint32_t now = Time::Clock::NowMs();
// Refresh the carried-item set while there is queued work, at most
// once per kOwnedRefreshMs — the set changes slowly and the walk is
// pointer-chasing, not free.
if (g_settled && g_pendingCount > 0 &&
(!g_ownedEverBuilt || Time::Clock::Elapsed(g_ownedBuiltMs, now) >= kOwnedRefreshMs)) {
RebuildOwned();
g_ownedBuiltMs = now;
}
// Two independent issue budgets this tick:
// - owned (carried) items: up to kOwnedPerTick, no spacing floor —
// bounded and player-visible, so they resolve fast.
// - background items: one, and only once the spacing floor elapsed.
// World ticks fire once per frame (~10-16 ms), so the background floor
// is wall-clock, not a tick count, to bound its rate regardless of FPS.
int ownedBudget = g_settled ? kOwnedPerTick : 0;
bool bgAvailable =
g_settled && Time::Clock::Elapsed(g_lastRequestMs, now) >= kMinRequestSpacingMs;
for (int i = 0; i < g_pendingCount;) {
if (!g_pending[i].requestIssued && g_settled) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_Escalated);
g_pending[i].requestIssued = true;
if (g_settled && !g_pending[i].requestIssued) {
const bool owned = IsOwned(g_pending[i].itemID);
bool issue = false;
if (owned && ownedBudget > 0) {
issue = true;
--ownedBudget;
} else if (!owned && bgAvailable) {
issue = true;
bgAvailable = false;
g_lastRequestMs = now;
}
if (issue) {
CacheFetch(g_pending[i].itemID, &ItemLoadCallback_Escalated);
g_pending[i].requestIssued = true;
}
}
// A server rejection (marked by the escalation callback) resolves
// the item as failure without waiting out the timeout.
// A fill (engine response) or a server rejection (escalation
// callback set `failed`) resolves the item either way.
if (TryCompletePending(i))
continue;
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
const uint32_t itemID = g_pending[i].itemID;
const bool implicit = g_pending[i].implicit;
RemovePendingAt(i);
FireByTag(itemID, false, implicit);
continue;
// Only time out items whose query is actually in flight. Counting
// from track-time would fire spurious failures for items still
// waiting their turn behind the rate limiter.
if (g_pending[i].requestIssued) {
if (g_pending[i].ticksWaiting >= kMaxWaitTicks) {
const uint32_t itemID = g_pending[i].itemID;
const bool implicit = g_pending[i].implicit;
RemovePendingAt(i);
FireByTag(itemID, false, implicit);
continue;
}
++g_pending[i].ticksWaiting;
}
++g_pending[i].ticksWaiting;
++i;
}
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
// Public C++ API — see Item/Data.h. Implicit (transparent) warmup
// path used by the `Script_GetItemInfo` hook and `SetItemByID`. Fires
// the cache request only if the item isn't already cached; uses the
// implicit callback which fires only `GET_ITEM_INFO_RECEIVED` (not
// `ITEM_DATA_LOAD_RESULT`), matching modern semantics for ambient
// cache fills.
// Public C++ API — see Item/Data.h. Implicit (transparent) warmup path
// used by the `Script_GetItemInfo` hook and `SetItemByID`. Routes every
// call through the pending queue so OnWorldTick's single rate-limited
// issue point governs all outgoing item queries (issue #25). `Track`
// resolves an already-cached item for free (a pure lookup, no SMSG) and
// queues only a genuine miss; the response-handler sweep then fires
// `GET_ITEM_INFO_RECEIVED` (implicit), never `ITEM_DATA_LOAD_RESULT`.
void WarmCache(uint32_t itemID) {
// Post-settle (steady state): query immediately for lowest latency.
// The engine invokes our implicit callback when the data lands.
if (g_settled) {
CacheFetch(itemID, &ItemLoadCallback_Implicit);
return;
}
// Startup window: do NOT eagerly create the cache entry — that
// orphans it (the engine's prefetch skips already-existing entries,
// and our SMSG goes into a hole while dispatch isn't ready), leaving
// the item permanently nil for the session. This is the exact race
// that broke pfQuest/Questie scanning at PLAYER_ENTERING_WORLD. Defer
// through the pending set instead; OnWorldTick issues the query once
// settled and the response-handler sweep fires GET_ITEM_INFO_RECEIVED.
//
// NOTE: g_settled is a one-way latch per process. The race this
// guards is the initial-login cold-WDB window. On relog the WDB is
// already warm so cold-fetch pressure is minimal; we don't re-arm it.
// Do NOT issue the query here. Two reasons converge on the queue:
// - Network: a post-settle immediate CacheFetch let a reload-time
// lookup burst fire one SMSG per item in a single frame and flood
// the network (issue #25). The queue's rate limiter prevents it.
// - Startup race: eagerly creating the cache entry before the engine
// settles orphans it — the engine's prefetch skips already-existing
// entries and our SMSG goes into a hole while dispatch isn't ready,
// leaving the item permanently nil. This broke pfQuest/Questie
// scanning at PLAYER_ENTERING_WORLD. OnWorldTick issues only once
// `g_settled`, so deferring here fixes that too.
Track(itemID, /*implicit=*/true);
}
+5 -5
View File
@@ -83,8 +83,8 @@ int __fastcall Script_C_Item_GetEnchantInfo(void *L) {
// Localized display name (8-slot locale array at +0x34). Clamp the
// locale index to the array bounds — index 8 would read the locale
// mask column that follows. enUS (0) fallback for empty slots.
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLITEMENCHANT_NAME);
int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
auto names = Game::Ptr<const char *const>(rec, Offsets::OFF_SPELLITEMENCHANT_NAME);
int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
if (locale < 0 || locale >= kNumNameLocales)
locale = 0;
const char *name = names[locale];
@@ -93,9 +93,9 @@ int __fastcall Script_C_Item_GetEnchantInfo(void *L) {
if (name == nullptr || name[0] == '\0')
return 0; // unnamed record -> nil
auto type = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(rec + Offsets::OFF_SPELLITEMENCHANT_ARG);
auto type = Game::Ptr<const int32_t>(rec, Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = Game::Ptr<const int32_t>(rec, Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = Game::Ptr<const int32_t>(rec, Offsets::OFF_SPELLITEMENCHANT_ARG);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "enchantID", enchantID);
+41 -41
View File
@@ -38,7 +38,7 @@ namespace Item::GetData {
namespace {
int CurrentLocaleIndex() {
return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
return Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
}
// Mirrors the lookup in `Item::Info`. ItemClass.dbc record `+0x0C` is
@@ -53,10 +53,10 @@ const char *LookupClassName(uint32_t classID) {
// ItemSubClass.dbc walker — same fallback chain as Script_GetItemInfo
// (verbose name first, short name on miss).
const char *LookupSubClassName(uint32_t classID, uint32_t subClassID) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_ITEMSUBCLASS_COUNT);
const int count = Game::Read<int>(Offsets::VAR_ITEMSUBCLASS_COUNT);
if (count <= 0)
return "";
auto *records = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
auto *records = Game::Read<const uint8_t *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
if (records == nullptr)
return "";
const int locale = CurrentLocaleIndex();
@@ -66,12 +66,12 @@ const char *LookupSubClassName(uint32_t classID, uint32_t subClassID) {
continue;
if (*reinterpret_cast<const uint32_t *>(record + 0x04) != subClassID)
continue;
const char *verbose = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
const char *verbose = Game::Read<const char *>(
record, Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
if (verbose != nullptr && verbose[0] != '\0')
return verbose;
const char *shortName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
const char *shortName = Game::Read<const char *>(
record, Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
return (shortName != nullptr && shortName[0] != '\0') ? shortName : "";
}
return "";
@@ -172,10 +172,10 @@ void SetF32Array(void *L, const uint8_t *record, const char *key,
void SetStatsMap(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "stats");
Game::Lua::NewTable(L);
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
auto *types = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = Game::Ptr<const int32_t>(
record, Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i) {
if (types[i] == 0 && values[i] == 0)
continue;
@@ -193,18 +193,18 @@ void SetStatsMap(void *L, const uint8_t *record) {
void SetSpellsArray(void *L, const uint8_t *record) {
Game::Lua::PushString(L, "spells");
Game::Lua::NewTable(L);
auto *ids = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
auto *charges = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CHARGES);
auto *cooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_COOLDOWN);
auto *categories = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY);
auto *catCooldowns = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_CATEGORY_CD);
auto *ids = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
auto *charges = Game::Ptr<const int32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_CHARGES);
auto *cooldowns = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_COOLDOWN);
auto *categories = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_CATEGORY);
auto *catCooldowns = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_CATEGORY_CD);
int written = 0;
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i) {
if (ids[i] == 0)
@@ -234,38 +234,38 @@ void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
// Identity strings
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
const char *name = Game::Read<const char *>(
record, Offsets::OFF_ITEMSTATS_NAME);
Game::Lua::SetFieldString(L, "name", name);
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
const char *description = Game::Read<const char *>(
record, Offsets::OFF_ITEMSTATS_DESCRIPTION);
if (description != nullptr && description[0] != '\0')
Game::Lua::SetFieldString(L, "description", description);
// Display
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
const uint32_t displayInfoID = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
Game::Lua::SetFieldNumber(L, "displayInfoID", static_cast<double>(displayInfoID));
char iconPath[260];
if (BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
Game::Lua::SetFieldString(L, "icon", iconPath);
// Quality / classification
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const uint32_t quality = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_QUALITY);
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
const uint32_t classID = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_SUBCLASS);
Game::Lua::SetFieldNumber(L, "classID", static_cast<double>(classID));
Game::Lua::SetFieldNumber(L, "subclassID", static_cast<double>(subClassID));
Game::Lua::SetFieldString(L, "className", LookupClassName(classID));
Game::Lua::SetFieldString(L, "subclassName", LookupSubClassName(classID, subClassID));
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const uint32_t invType = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
Game::Lua::SetFieldNumber(L, "inventoryType", static_cast<double>(invType));
Game::Lua::SetFieldString(L, "equipLoc", LookupInvType(invType));
@@ -273,8 +273,8 @@ void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
SetU32(L, record, "bindType", Offsets::OFF_ITEMSTATS_BONDING);
// Flags: raw + decoded bits.
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
const uint32_t flags = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_FLAGS);
Game::Lua::SetFieldNumber(L, "flags", static_cast<double>(flags));
Game::Lua::SetFieldBool(L, "isConjured",
(flags & Offsets::ITEM_FLAG_CONJURED) != 0);
@@ -289,8 +289,8 @@ void PushItemDataTable(void *L, uint32_t itemID, const uint8_t *record) {
SetI32(L, record, "maxStackSize", Offsets::OFF_ITEMSTATS_STACKABLE);
SetI32(L, record, "maxCount", Offsets::OFF_ITEMSTATS_MAX_COUNT);
SetU32(L, record, "containerSlots", Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS);
const uint32_t rawBagFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
const uint32_t rawBagFamily = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_BAG_FAMILY);
Game::Lua::SetFieldNumber(L, "bagFamily",
static_cast<double>(BagFamilyIdToBitmask(rawBagFamily)));
+17 -17
View File
@@ -30,7 +30,7 @@ namespace Item::Info {
static const char *PushedOrEmpty(const char *s) { return (s != nullptr && s[0] != 0) ? s : ""; }
static int CurrentLocaleIndex() { return *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX); }
static int CurrentLocaleIndex() { return Game::Read<int>(Offsets::VAR_LOCALE_INDEX); }
static const char *LookupItemClassName(uint32_t classID) {
return PushedOrEmpty(DBC::LocalizedField(
@@ -39,10 +39,10 @@ static const char *LookupItemClassName(uint32_t classID) {
}
static const char *LookupItemSubClassName(uint32_t classID, uint32_t subClassID) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_ITEMSUBCLASS_COUNT);
const int count = Game::Read<int>(Offsets::VAR_ITEMSUBCLASS_COUNT);
if (count <= 0)
return "";
auto *records = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
auto *records = Game::Read<const uint8_t *>(Offsets::VAR_ITEMSUBCLASS_RECORDS);
if (records == nullptr)
return "";
const int locale = CurrentLocaleIndex();
@@ -54,12 +54,12 @@ static const char *LookupItemSubClassName(uint32_t classID, uint32_t subClassID)
continue;
// Mirror Script_GetItemInfo's fallback chain at 0x48E311..0x48E32C:
// try the verbose name first, fall back to the short name.
const char *verbose = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
const char *verbose = Game::Read<const char *>(
record, Offsets::OFF_ITEMSUBCLASS_DISPLAY_NAME + locale * 4);
if (verbose != nullptr && verbose[0] != 0)
return verbose;
const char *shortName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
const char *shortName = Game::Read<const char *>(
record, Offsets::OFF_ITEMSUBCLASS_NAME + locale * 4);
return PushedOrEmpty(shortName);
}
return "";
@@ -112,13 +112,13 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
}
const uint32_t classID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_CLASS);
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_SUBCLASS);
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_SUBCLASS);
const uint32_t displayInfoID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
const uint32_t invType =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
@@ -143,8 +143,8 @@ static int PushIconForItemID(void *L, int itemID) {
const uint8_t *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return 0;
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
const uint32_t displayInfoID = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
return 0;
@@ -274,10 +274,10 @@ static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const uint32_t sellPrice = u32(Offsets::OFF_ITEMSTATS_SELL_PRICE);
const uint32_t bindType = u32(Offsets::OFF_ITEMSTATS_BONDING);
const uint32_t setID = u32(Offsets::OFF_ITEMSTATS_ITEM_SET);
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
const char *name = Game::Read<const char *>(
record, Offsets::OFF_ITEMSTATS_NAME);
const char *description = Game::Read<const char *>(
record, Offsets::OFF_ITEMSTATS_DESCRIPTION);
// Apply the link's random suffix ("... of the Owl") to both the name and
// the reconstructed link when the input carried one (arg.suffix); with no
+4 -4
View File
@@ -58,8 +58,8 @@ bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
auto *inst = Item::InstanceBlock(item);
if (inst == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = *reinterpret_cast<const uint32_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
*lo = Game::Read<uint32_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = Game::Read<uint32_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
return true;
}
@@ -84,8 +84,8 @@ int __fastcall Script_C_Item_IsLocked(void *L) {
Game::Lua::PushBool(L, false);
return 1;
}
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
item + Offsets::OFF_ITEM_CLIENT_LOCK);
const uint32_t flags = Game::Read<uint32_t>(
item, Offsets::OFF_ITEM_CLIENT_LOCK);
Game::Lua::PushBool(L, (flags & Offsets::ITEM_CLIENT_LOCK_BIT) != 0);
return 1;
}
+2 -4
View File
@@ -79,6 +79,7 @@
#include "item/Location.h"
#include "object/Resolve.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -128,10 +129,7 @@ bool g_seeded = false; // true once the login baseline has been taken
uint64_t g_playerGuid = 0; // re-arm the baseline when this changes (relog)
uint32_t g_loginMs = 0; // engine ms at which the current player resolved
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
}
using Time::Clock::NowMs;
bool Contains(const uint64_t *arr, int count, uint64_t guid) {
for (int i = 0; i < count; ++i)
+27 -27
View File
@@ -155,14 +155,14 @@ void AddSpellStatAuras(Accum *acc, int spellID, long sign) {
static_cast<uint32_t>(spellID));
if (sp == nullptr)
return;
auto aura = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto misc = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
auto base = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
auto dice = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
auto aura = Game::Ptr<const int32_t>(
sp, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto misc = Game::Ptr<const int32_t>(
sp, Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
auto base = Game::Ptr<const int32_t>(
sp, Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
auto dice = Game::Ptr<const int32_t>(
sp, Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
// Vanilla "+N Attack Power" spells carry a melee (99) AND a ranged
// (124) aura with the same value; the generic melee key subsumes the
@@ -248,10 +248,10 @@ void AddSpellStatAuras(Accum *acc, int spellID, long sign) {
// mp5 / defense on base items (vanilla stores them as equip spells, not
// stat slots).
void AddEquipSpellStats(Accum *acc, const uint8_t *record, long sign) {
auto ids = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
auto ids = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_ID);
auto triggers = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; ++i)
if (ids[i] != 0 && triggers[i] == 1) // ON_EQUIP
AddSpellStatAuras(acc, static_cast<int>(ids[i]), sign);
@@ -288,14 +288,14 @@ long Value(const Accum *acc, const char *key) {
double ComputeDPS(const uint8_t *record) {
if (record == nullptr)
return 0.0;
const uint32_t delayMs = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DELAY);
const uint32_t delayMs = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_DELAY);
if (delayMs == 0)
return 0.0;
auto *dmgMin = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MIN);
auto *dmgMax = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MAX);
auto *dmgMin = Game::Ptr<const float>(
record, Offsets::OFF_ITEMSTATS_DAMAGE_MIN);
auto *dmgMax = Game::Ptr<const float>(
record, Offsets::OFF_ITEMSTATS_DAMAGE_MAX);
double avgTotal = 0.0;
for (int i = 0; i < Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT; ++i)
avgTotal += (static_cast<double>(dmgMin[i]) +
@@ -312,9 +312,9 @@ void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign) {
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
return;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
auto type = Game::Ptr<const int32_t>(en, Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = Game::Ptr<const int32_t>(en, Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = Game::Ptr<const int32_t>(en, Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
@@ -341,8 +341,8 @@ void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
static_cast<uint32_t>(suffixID));
if (rp == nullptr)
return;
auto enchants = reinterpret_cast<const uint32_t *>(
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
auto enchants = Game::Ptr<const uint32_t>(
rp, Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s)
ApplyEnchant(acc, enchants[s], sign);
}
@@ -350,10 +350,10 @@ void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign) {
if (record == nullptr)
return;
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
auto *types = Game::Ptr<const uint32_t>(
record, Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = Game::Ptr<const int32_t>(
record, Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i)
AddByKey(acc, StatKeyForItemModType(types[i]),
sign * static_cast<long>(values[i]));
+4 -4
View File
@@ -44,8 +44,8 @@ bool ReadGuid(const void *cgObject, uint32_t *lo, uint32_t *hi) {
auto *inst = Item::InstanceBlock(base);
if (inst == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = *reinterpret_cast<const uint32_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
*lo = Game::Read<uint32_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = Game::Read<uint32_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
return true;
}
@@ -259,8 +259,8 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
// have to special-case "move everything".
auto *srcDescriptor = Item::ObjectFields(srcItem);
const int srcStack = srcDescriptor == nullptr ? 0 :
static_cast<int>(*reinterpret_cast<const uint32_t *>(
srcDescriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT));
static_cast<int>(Game::Read<uint32_t>(
srcDescriptor, Offsets::OFF_DESCRIPTOR_STACK_COUNT));
if (count > srcStack)
return false; // server would reject anyway; fail fast and locally
if (count == srcStack)
+6 -6
View File
@@ -241,16 +241,16 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
}
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
const char *name = Game::Read<const char *>(
record, Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || *name == '\0')
return 0;
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
const uint32_t quality = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_QUALITY);
int suffix = 0;
if (*reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_COMPARE_FLAG) != 0)
suffix = *reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_COMPARE_SUFFIX);
if (Game::Read<int>(base, Offsets::OFF_TOOLTIP_COMPARE_FLAG) != 0)
suffix = Game::Read<int>(base, Offsets::OFF_TOOLTIP_COMPARE_SUFFIX);
char link[256];
std::snprintf(link, sizeof(link),
+6 -5
View File
@@ -13,6 +13,7 @@
#include "item/TooltipItem.h"
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
#include "item/Location.h"
@@ -27,10 +28,10 @@ int CurrentID(const void *tooltipObj, uint64_t *outGuid) {
auto *c = static_cast<const uint8_t *>(tooltipObj);
const uint64_t itemGuid =
*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_ITEM_GUID_LO);
Game::Read<uint64_t>(c, Offsets::OFF_TOOLTIP_ITEM_GUID_LO);
// Link item: the itemID field is authoritative (and reliably cleared).
const int linkItemID = *reinterpret_cast<const int *>(c + Offsets::OFF_TOOLTIP_ITEM_ID);
const int linkItemID = Game::Read<int>(c, Offsets::OFF_TOOLTIP_ITEM_ID);
if (linkItemID > 0) {
if (outGuid != nullptr)
*outGuid = itemGuid;
@@ -44,11 +45,11 @@ int CurrentID(const void *tooltipObj, uint64_t *outGuid) {
// it to the itemID (this also yields the dressed link for callers).
if (itemGuid == 0)
return 0;
if (*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_UNIT_GUID_LO) != 0)
if (Game::Read<uint64_t>(c, Offsets::OFF_TOOLTIP_UNIT_GUID_LO) != 0)
return 0;
if (*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO) != 0)
if (Game::Read<uint64_t>(c, Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO) != 0)
return 0;
if (*reinterpret_cast<const int *>(c + Offsets::OFF_TOOLTIP_SPELL_ID) > 0)
if (Game::Read<int>(c, Offsets::OFF_TOOLTIP_SPELL_ID) > 0)
return 0;
const uint8_t *cg = Item::Location::ResolveByGUID(itemGuid);
+4 -4
View File
@@ -31,7 +31,7 @@ static int __fastcall Script_GetInboxItemID(void *L) {
if (mailID < 0)
return 0;
const int count = static_cast<int>(*reinterpret_cast<const uint32_t *>(
const int count = static_cast<int>(Game::Read<uint32_t>(
Offsets::VAR_INBOX_COUNT));
if (mailID >= count)
return 0;
@@ -39,15 +39,15 @@ static int __fastcall Script_GetInboxItemID(void *L) {
// `VAR_INBOX_ENTRIES` is a pointer slot, not the array — the
// engine reads it via `MOV ECX, [imm32]; MOV EDI, [ECX + idx*4]`
// (two derefs total before the entry pointer is in hand).
auto **entries = *reinterpret_cast<const uint8_t ***>(Offsets::VAR_INBOX_ENTRIES);
auto **entries = Game::Read<const uint8_t **>(Offsets::VAR_INBOX_ENTRIES);
if (entries == nullptr)
return 0;
auto *entry = entries[mailID];
if (entry == nullptr)
return 0;
const int itemID = static_cast<int>(*reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_INBOX_ENTRY_ITEM_ID));
const int itemID = static_cast<int>(Game::Read<uint32_t>(
entry, Offsets::OFF_INBOX_ENTRY_ITEM_ID));
if (itemID == 0)
return 0;
+3 -3
View File
@@ -38,13 +38,13 @@ static int __fastcall Script_GetLootSlotItemID(void *L) {
}
int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_LO);
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_HI);
const uint32_t guidLo = Game::Read<uint32_t>(Offsets::VAR_LOOT_GUID_LO);
const uint32_t guidHi = Game::Read<uint32_t>(Offsets::VAR_LOOT_GUID_HI);
if ((guidLo | guidHi) == 0)
return 0;
slot -= 1;
if (*reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_LOOTABLE) != 0) {
if (Game::Read<uint32_t>(Offsets::VAR_LOOT_LOOTABLE) != 0) {
if (slot <= 0)
return 0;
slot -= 1;
+5 -5
View File
@@ -30,20 +30,20 @@ static int __fastcall Script_GetMerchantItemID(void *L) {
if (index < 0)
return 0;
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(Offsets::VAR_MERCHANT_NPC_GUID_LO);
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(Offsets::VAR_MERCHANT_NPC_GUID_HI);
const uint32_t guidLo = Game::Read<uint32_t>(Offsets::VAR_MERCHANT_NPC_GUID_LO);
const uint32_t guidHi = Game::Read<uint32_t>(Offsets::VAR_MERCHANT_NPC_GUID_HI);
if ((guidLo | guidHi) == 0)
return 0;
const int count = static_cast<int>(*reinterpret_cast<const uint32_t *>(
const int count = static_cast<int>(Game::Read<uint32_t>(
Offsets::VAR_MERCHANT_COUNT));
if (index >= count)
return 0;
auto *entry = reinterpret_cast<const uint8_t *>(
Offsets::VAR_MERCHANT_ITEMS + index * Offsets::MERCHANT_STRIDE);
const int itemID = static_cast<int>(*reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_MERCHANT_ITEM_ID));
const int itemID = static_cast<int>(Game::Read<uint32_t>(
entry, Offsets::OFF_MERCHANT_ITEM_ID));
if (itemID == 0)
return 0;
+5 -11
View File
@@ -49,6 +49,7 @@
#include "Offsets.h"
#include "dbc/Names.h"
#include "event/Custom.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -63,14 +64,7 @@ T Read(const void *base, int off) {
return *reinterpret_cast<const T *>(static_cast<const uint8_t *>(base) + off);
}
// Engine millisecond clock — same source (and epoch) as Lua's `GetTime()`
// (which reads this scaled by 0.001), so a value stored here is directly
// comparable to `GetTime()` once divided by 1000.
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
using Time::Clock::NowMs;
// Modern rollType constants (what C_LootHistory.GetPlayerInfo returns).
constexpr int ROLL_PASS = 0;
@@ -98,13 +92,13 @@ const uint8_t *LookupPlayerRecord(uint64_t guid) {
const char *NameFromRecord(const uint8_t *rec) {
return rec != nullptr
? reinterpret_cast<const char *>(rec + Offsets::OFF_PLAYER_INFO_NAME)
? Game::Ptr<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)
? Game::Read<uint32_t>(rec, Offsets::OFF_PLAYER_INFO_CLASS)
: 0;
}
@@ -318,7 +312,7 @@ int RecordAllPassed(const void *msg) {
// 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 *>(
const uint8_t *node = Game::Read<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_LOOTROLL_STORE_HEAD));
if (node == nullptr)
return;
+5 -8
View File
@@ -76,10 +76,9 @@ constexpr int VTBL_GET_POSITION_OFFSET = 0x14;
// bit of *race* — yields false positives for any Human / Dwarf /
// Undead / Gnome player and false negatives for dead mobs.
bool IsLootable(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags =
Game::Read<uint32_t>(fields, Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
@@ -87,10 +86,8 @@ bool IsLootable(const void *unit) {
// engine uses this as the contribution from both sides (player + target)
// in its interact-range computation.
float BoundingRadius(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return Game::Read<float>(fields, Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
// Invokes the unit's virtual `GetPosition` (vtable slot at offset
+7 -12
View File
@@ -143,18 +143,15 @@ constexpr float MIN_INTERACT_RANGE = 5.0f;
// === Range / lootability helpers (identical to Loot::Nearby) ===
bool IsLootableUnit(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
fields + Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
const uint32_t flags =
Game::Read<uint32_t>(fields, Offsets::OFF_UNIT_FIELD_DYNAMIC_FLAGS);
return (flags & Offsets::UNIT_DYNFLAG_LOOTABLE) != 0;
}
float BoundingRadius(const void *unit) {
auto *fields = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_DESCRIPTOR);
return *reinterpret_cast<const float *>(
fields + Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
auto *fields = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
return Game::Read<float>(fields, Offsets::OFF_UNIT_FIELD_BOUNDING_RADIUS);
}
const C3Vector *GetPosition(const void *unit, C3Vector *outBuf) {
@@ -190,8 +187,7 @@ bool InInteractRange(const void *player, const void *target) {
// `FUN_LOOT_CONTROLLER` run, so the slot table is fully filled in
// and the link builder works against it.
void ScrapeCurrentLoot(LootEntry *out) {
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_LOOT_LOOTABLE);
const uint32_t coinRaw = Game::Read<uint32_t>(Offsets::VAR_LOOT_LOOTABLE);
out->coin = (coinRaw == 0xFFFFFFFFu) ? 0u : coinRaw;
const bool hasCoin = coinRaw != 0;
@@ -239,8 +235,7 @@ void ScrapeCurrentLoot(LootEntry *out) {
void LootCurrentWindow() {
// Coin present when `VAR_LOOT_LOOTABLE` holds a real amount (0 = none;
// 0xFFFFFFFF = already-looted sentinel, never seen on a fresh open).
const uint32_t coinRaw = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_LOOT_LOOTABLE);
const uint32_t coinRaw = Game::Read<uint32_t>(Offsets::VAR_LOOT_LOOTABLE);
if (coinRaw != 0 && coinRaw != 0xFFFFFFFFu) {
auto LootMoney = reinterpret_cast<LootMoney_t>(Offsets::FUN_CMSG_LOOT_MONEY);
LootMoney(s_scan.player);
+2 -2
View File
@@ -57,8 +57,8 @@ uint32_t s_desiredItemID = 0;
int s_ticksSinceTransition = 0;
uint64_t ReadLootTargetGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(Offsets::VAR_LOOT_GUID_HI);
const uint32_t lo = Game::Read<uint32_t>(Offsets::VAR_LOOT_GUID_LO);
const uint32_t hi = Game::Read<uint32_t>(Offsets::VAR_LOOT_GUID_HI);
return static_cast<uint64_t>(hi) << 32 | lo;
}
+31 -28
View File
@@ -48,6 +48,7 @@
#include "spell/CrowdControl.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -56,21 +57,18 @@ namespace LossOfControl {
namespace {
using TickMs_t = uint32_t(__fastcall *)();
int NowMs() {
return static_cast<int>(
reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)());
}
using Time::Clock::NowMs;
using Time::Clock::Reached;
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
const char *n =
*reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELL_NAMES + locale * 4);
Game::Read<const char *>(rec, Offsets::OFF_SPELL_NAMES + locale * 4);
return (n != nullptr) ? n : "";
}
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_ICON_ID);
const int iconID = Game::Read<int>(rec, Offsets::OFF_SPELL_RECORD_ICON_ID);
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID));
@@ -81,7 +79,7 @@ const char *SpellIconPath(const uint8_t *rec) {
}
int SpellSchoolIndex(const uint8_t *rec) {
return *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_SCHOOL);
return Game::Read<int>(rec, Offsets::OFF_SPELL_SCHOOL);
}
// The control-loss classifier lives in `Spell::CrowdControl::Classify` so the
@@ -90,12 +88,14 @@ int SpellSchoolIndex(const uint8_t *rec) {
// ---- School-interrupt lockout state (fed by SMSG_SPELL_COOLDOWN) -----------
// One active lockout per school index (0..6); active while now < endMs.
// One active lockout per school index (0..6); active until `Reached(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.
// in the past on the shared uptime clock, so no reset is needed. Unsigned,
// like every engine-tick store: the OS counter wraps at 2^32 ms and a signed
// `int` goes negative past 2^31 ms (~24.9 days uptime). See `Time::Clock`.
struct SchoolLock {
int startMs;
int endMs;
uint32_t startMs;
uint32_t endMs;
};
SchoolLock g_schoolLock[Offsets::SPELL_SCHOOL_COUNT] = {};
@@ -137,9 +137,9 @@ void CooldownSub(uint32_t opcode, Net::CDataStore *packet) {
// (ProhibitSpellSchool); single/varied packets are normal cooldowns and
// ignored.
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
const int now = NowMs();
const uint32_t now = NowMs();
g_schoolLock[schoolIdx].startMs = now;
g_schoolLock[schoolIdx].endMs = now + firstDur;
g_schoolLock[schoolIdx].endMs = now + static_cast<uint32_t>(firstDur);
}
}
@@ -152,17 +152,17 @@ struct LocEntry {
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
uint32_t startMs; // 0 = unknown
uint32_t endMs; // 0 = unknown / no timing
};
int BuildList(LocEntry *out, int maxOut) {
const int now = NowMs();
const uint32_t 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) {
if (g_schoolLock[s].endMs != 0 && !Reached(now, g_schoolLock[s].endMs)) {
LocEntry &e = out[n++];
e.locType = "SCHOOL_INTERRUPT";
e.spellID = 0;
@@ -202,11 +202,12 @@ int BuildList(LocEntry *out, int maxOut) {
e.endMs = 0;
uint64_t caster = 0;
uint32_t expMs = 0, durMs = 0;
if (Aura::Source::Get(playerGuid, spellID, &caster, &expMs, &durMs) &&
if (Aura::Source::Get(playerGuid, spellID, slot, &caster, &expMs,
&durMs) &&
expMs != 0) {
e.endMs = static_cast<int>(expMs);
e.endMs = expMs;
if (durMs != 0)
e.startMs = static_cast<int>(expMs - durMs);
e.startMs = expMs - durMs;
}
}
}
@@ -302,7 +303,7 @@ int __fastcall Script_GetActiveLossOfControlData(void *L) {
return 0; // nil
const LocEntry &e = entries[index - 1];
const int now = NowMs();
const uint32_t now = NowMs();
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "locType", e.locType);
@@ -314,12 +315,14 @@ int __fastcall Script_GetActiveLossOfControlData(void *L) {
// 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.endMs != 0 && !Reached(now, e.endMs)) {
Game::Lua::SetFieldNumber(L, "timeRemaining",
static_cast<double>(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, "startTime",
static_cast<double>(e.startMs) * 0.001);
Game::Lua::SetFieldNumber(
L, "duration", static_cast<double>(e.endMs - e.startMs) * 0.001);
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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/>.
// Vanilla 1.12 has no RunMacro or RunMacroText global. Raw macro text is
// dispatched one line at a time through FrameXML's ChatEdit_ParseText using a
// small throwaway edit-box table. Saved macros are resolved with the stock
// GetMacroIndexByName/GetMacroInfo globals before entering that same path.
#include "macro/Execute.h"
#include "Game.h"
#include <cstddef>
#include <string>
namespace Macro::Execute {
namespace {
int __fastcall Script_GetText(void *L) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
return 1;
}
int __fastcall Script_Noop(void *) { return 0; }
int __fastcall Script_Index(void *L) {
Game::Lua::PushCClosure(L, &Script_Noop, 0);
return 1;
}
void Line(void *L, const char *line, size_t length) {
if (length == 0)
return;
if (line[length - 1] == '\r')
--length;
if (length == 0)
return;
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L);
const int objectIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "GetText");
Game::Lua::PushLString(L, line, static_cast<unsigned int>(length));
Game::Lua::PushCClosure(L, &Script_GetText, 1);
Game::Lua::SetTable(L, objectIndex);
Game::Lua::NewTable(L);
const int metatableIndex = Game::Lua::GetTop(L);
Game::Lua::PushString(L, "__index");
Game::Lua::PushCClosure(L, &Script_Index, 0);
Game::Lua::SetTable(L, metatableIndex);
if (Game::Lua::PushGlobalFunction(L, "setmetatable")) {
Game::Lua::PushValue(L, objectIndex);
Game::Lua::PushValue(L, metatableIndex);
Game::Lua::PCall(L, 2, 0, 0);
}
if (Game::Lua::PushGlobalFunction(L, "ChatEdit_ParseText")) {
Game::Lua::PushValue(L, objectIndex);
Game::Lua::PushNumber(L, 1);
Game::Lua::PCall(L, 2, 0, 0);
}
Game::Lua::SetTop(L, top);
}
bool ParseIndex(const char *value, int *index) {
if (value == nullptr || *value == '\0')
return false;
int result = 0;
for (const char *p = value; *p != '\0'; ++p) {
if (*p < '0' || *p > '9')
return false;
const int digit = *p - '0';
if (result > (0x7FFFFFFF - digit) / 10)
return false;
result = result * 10 + digit;
}
if (result <= 0)
return false;
*index = result;
return true;
}
bool CallRunMacro(void *L, const char *macro) {
const int top = Game::Lua::GetTop(L);
if (!Game::Lua::PushGlobalFunction(L, "RunMacro")) {
Game::Lua::SetTop(L, top);
return false;
}
int index = 0;
if (ParseIndex(macro, &index))
Game::Lua::PushNumber(L, index);
else
Game::Lua::PushString(L, macro);
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
Game::Lua::SetTop(L, top);
return ok;
}
int ResolveIndex(void *L, const char *macro) {
int index = 0;
if (ParseIndex(macro, &index))
return index;
const int top = Game::Lua::GetTop(L);
if (!Game::Lua::PushGlobalFunction(L, "GetMacroIndexByName")) {
Game::Lua::SetTop(L, top);
return 0;
}
Game::Lua::PushString(L, macro);
if (Game::Lua::PCall(L, 1, 1, 0) == 0 && Game::Lua::IsNumber(L, -1))
index = static_cast<int>(Game::Lua::ToNumber(L, -1));
Game::Lua::SetTop(L, top);
return index;
}
bool GetBody(void *L, int index, std::string *body) {
const int top = Game::Lua::GetTop(L);
if (!Game::Lua::PushGlobalFunction(L, "GetMacroInfo")) {
Game::Lua::SetTop(L, top);
return false;
}
Game::Lua::PushNumber(L, index);
const bool called = Game::Lua::PCall(L, 1, 3, 0) == 0;
const bool found = called && Game::Lua::IsString(L, -1);
if (found)
*body = Game::Lua::ToString(L, -1);
Game::Lua::SetTop(L, top);
return found;
}
} // namespace
void Text(void *L, const char *text) {
if (L == nullptr || text == nullptr)
return;
for (const char *line = text; *line != '\0';) {
const char *end = line;
while (*end != '\0' && *end != '\n')
++end;
Line(L, line, static_cast<size_t>(end - line));
line = *end == '\n' ? end + 1 : end;
}
}
bool Saved(void *L, const char *macro) {
if (L == nullptr || macro == nullptr || *macro == '\0')
return false;
if (CallRunMacro(L, macro))
return true;
const int index = ResolveIndex(L, macro);
if (index <= 0)
return false;
std::string body;
if (!GetBody(L, index, &body))
return false;
Text(L, body.c_str());
return true;
}
} // namespace Macro::Execute
+27
View File
@@ -0,0 +1,27 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
// A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Macro::Execute {
// Runs raw macro text through vanilla FrameXML's ChatEdit_ParseText dispatcher.
void Text(void *L, const char *text);
// Runs a saved macro by name or decimal index. Prefers an addon-provided
// RunMacro implementation when present, then falls back to GetMacroInfo plus
// the stock text dispatcher.
bool Saved(void *L, const char *macro);
} // namespace Macro::Execute
+22 -30
View File
@@ -13,21 +13,20 @@
#include "map/Area.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
namespace Map::Area {
int RowForAreaID(uint32_t areaID) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
const int count = Game::Read<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)
Game::Read<uint32_t>(rec, Offsets::OFF_WMA_AREA_ID) == areaID)
return id;
}
return -1;
@@ -35,17 +34,15 @@ int RowForAreaID(uint32_t areaID) {
int CurrentViewRow() {
const int continent =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
Game::Read<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);
return Game::Read<int>(Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
const uint8_t *blob =
Game::Read<const uint8_t *>(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);
const int zone = Game::Read<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);
@@ -80,20 +77,19 @@ double OverlayMargin(int wmaRow, double mapXpct, double mapYpct) {
const double cx = mapXpct / 100.0 * kZoneCanvasW;
const double cy = mapYpct / 100.0 * kZoneCanvasH;
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
Game::Read<int>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
double best = -1.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)
Game::Read<int>(rec, Offsets::OFF_WMO_WORLDMAP_AREA) != wmaRow)
continue;
const int hl = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_LEFT);
const int hr = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_RIGHT);
const int ht = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_TOP);
const int hb = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_BOTTOM);
const int hl = Game::Read<int>(rec, Offsets::OFF_WMO_HITRECT_LEFT);
const int hr = Game::Read<int>(rec, Offsets::OFF_WMO_HITRECT_RIGHT);
const int ht = Game::Read<int>(rec, Offsets::OFF_WMO_HITRECT_TOP);
const int hb = Game::Read<int>(rec, Offsets::OFF_WMO_HITRECT_BOTTOM);
const double sw = hr - hl, sh = hb - ht;
if (sw <= 0.0 || sh <= 0.0)
continue; // no / degenerate hit rect
@@ -111,8 +107,7 @@ double OverlayMargin(int wmaRow, double mapXpct, double mapYpct) {
bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
double *outMapY) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
// Two candidate tracks over the zones whose rect contains the point:
// land* — the point is on the zone's drawn landmass (an overlay hit rect
// encloses it): authoritative, wins whenever any zone qualifies.
@@ -132,10 +127,9 @@ bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
if (Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID) != mapID)
continue;
const int areaID =
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
const int areaID = Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID);
if (areaID == 0)
continue; // continent-spanning row, not a zone
@@ -191,15 +185,14 @@ bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
}
bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMapY) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
const int count = Game::Read<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)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != areaID)
if (Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID) != areaID)
continue;
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
@@ -219,17 +212,16 @@ bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMap
}
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
const int count = Game::Read<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)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
if (Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID) != mapID)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != 0)
if (Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID) != 0)
continue; // want the continent-spanning row
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
+3 -4
View File
@@ -80,7 +80,7 @@ bool PushTriggerInfo(void *L, int id) {
if (rec == nullptr)
return false;
const int mapID = *reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID);
const int mapID = Game::Read<int>(rec, Offsets::OFF_AT_MAP_ID);
const float x = FloatField(rec, Offsets::OFF_AT_X);
const float y = FloatField(rec, Offsets::OFF_AT_Y);
const float z = FloatField(rec, Offsets::OFF_AT_Z);
@@ -134,8 +134,7 @@ int __fastcall Script_GetAreaTriggers(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATRIGGER_COUNT);
const int count = Game::Read<int>(Offsets::VAR_AREATRIGGER_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_AREATRIGGER_RECORDS,
@@ -144,7 +143,7 @@ int __fastcall Script_GetAreaTriggers(void *L) {
if (rec == nullptr)
continue;
if (filter &&
*reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID) != wantMap)
Game::Read<int>(rec, Offsets::OFF_AT_MAP_ID) != wantMap)
continue;
outIdx += 1;
+4 -6
View File
@@ -67,8 +67,7 @@ int __fastcall Script_GetAreas(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATABLE_COUNT);
const int count = Game::Read<int>(Offsets::VAR_AREATABLE_COUNT);
for (int id = 1; id <= count; ++id) {
const char *name =
DBC::AreaName(static_cast<uint32_t>(id), /*resolveToParent=*/false);
@@ -98,18 +97,17 @@ int __fastcall Script_GetMapAreaIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
const int count = Game::Read<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)
continue;
const int areaID = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
const int areaID = Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID);
if (areaID == 0)
continue; // continent-spanning row, no zone id
const char *dir = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_WMA_NAME);
const char *dir = Game::Read<const char *>(rec, Offsets::OFF_WMA_NAME);
if (dir == nullptr || dir[0] == '\0')
continue;
Game::Lua::SetFieldNumber(L, dir, static_cast<double>(areaID));
+12 -15
View File
@@ -274,7 +274,7 @@ void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
int dbcW, int dbcH) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
Game::Read<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)
@@ -388,8 +388,7 @@ const uint8_t *ExplorationBits() {
auto *player = static_cast<const uint8_t *>(fn("player"));
if (player == nullptr)
return nullptr;
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
auto *sub = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr)
return nullptr;
return sub + Offsets::OFF_PLAYER_EXPLORED_BITS;
@@ -404,8 +403,8 @@ bool IsOverlayExplored(const uint8_t *overlayRec, const uint8_t *bits) {
if (bits == nullptr)
return false;
for (int k = 0; k < 4; ++k) {
const int areaID = *reinterpret_cast<const int *>(
overlayRec + Offsets::OFF_WMO_AREA_ID + k * 4);
const int areaID =
Game::Read<int>(overlayRec, Offsets::OFF_WMO_AREA_ID + k * 4);
if (areaID <= 0)
continue;
const uint8_t *areaRec =
@@ -413,10 +412,10 @@ bool IsOverlayExplored(const uint8_t *overlayRec, const uint8_t *bits) {
Offsets::VAR_AREATABLE_COUNT, static_cast<uint32_t>(areaID));
if (areaRec == nullptr)
continue;
if (*reinterpret_cast<const int *>(areaRec + Offsets::OFF_AREATABLE_EXPLORE_GATE) < 0)
if (Game::Read<int>(areaRec, Offsets::OFF_AREATABLE_EXPLORE_GATE) < 0)
return true; // area needs no exploration → always counts
const int areaBit = *reinterpret_cast<const int *>(
areaRec + Offsets::OFF_AREATABLE_AREA_BIT);
const int areaBit =
Game::Read<int>(areaRec, Offsets::OFF_AREATABLE_AREA_BIT);
if (areaBit < 0)
continue;
const int byteIdx = areaBit >> 3;
@@ -455,8 +454,7 @@ void PushZoneOverlays(void *L, int wmaRow, Filter filter) {
static_cast<uint32_t>(wmaRow));
const char *mapDir =
(wmaRec != nullptr)
? *reinterpret_cast<const char *const *>(wmaRec +
Offsets::OFF_WMA_NAME)
? Game::Read<const char *>(wmaRec, Offsets::OFF_WMA_NAME)
: nullptr;
// Exploration bitfield only needed for the filtered variants.
@@ -464,15 +462,14 @@ void PushZoneOverlays(void *L, int wmaRow, Filter filter) {
(filter == Filter::All) ? nullptr : ExplorationBits();
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
Game::Read<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)
Game::Read<int>(rec, Offsets::OFF_WMO_WORLDMAP_AREA) != wmaRow)
continue;
if (filter != Filter::All) {
@@ -483,8 +480,8 @@ void PushZoneOverlays(void *L, int wmaRow, Filter filter) {
continue;
}
const char *texName = *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_WMO_TEXTURE_NAME);
const char *texName =
Game::Read<const char *>(rec, Offsets::OFF_WMO_TEXTURE_NAME);
const bool hasTexture = (texName != nullptr && texName[0] != '\0');
const auto fieldInt = [rec](uintptr_t off) {
+4 -4
View File
@@ -60,10 +60,10 @@ int __fastcall Script_GetMapWorldSize(void *L) {
if (rec == nullptr)
return 0;
const float left = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_LEFT);
const float right = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_RIGHT);
const float top = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_TOP);
const float bottom = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_BOTTOM);
const float left = Game::Read<float>(rec, Offsets::OFF_WMA_LOC_LEFT);
const float right = Game::Read<float>(rec, Offsets::OFF_WMA_LOC_RIGHT);
const float top = Game::Read<float>(rec, Offsets::OFF_WMA_LOC_TOP);
const float bottom = Game::Read<float>(rec, Offsets::OFF_WMA_LOC_BOTTOM);
Game::Lua::PushNumber(L, static_cast<double>(left) - right); // width (world Y span)
Game::Lua::PushNumber(L, static_cast<double>(top) - bottom); // height (world X span)
+436
View File
@@ -0,0 +1,436 @@
// 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/>.
// `Model:SetDisplayInfo(creatureDisplayID)` — backport of the modern Model
// method that points a Model frame at a creature by its display ID, instead of
// requiring a `.mdx` file path (`Model:SetModel`). Vanilla already loads models
// by path; this just prepends the DBC resolution modern clients do internally:
//
// creatureDisplayID → CreatureDisplayInfo[id] +0x04 ModelID
// → CreatureModelData[ModelID] +0x08 ModelPath
// → Model:SetModel's worker (vtable +0x94)
// → apply the display's TextureVariation skins
// → character displays: dress via the engine compositor
// (baked skin, hair, geosets, equipment — see the
// "Character-display dressing" section below)
//
// The load path is the engine's own (`Script_SetModel` FUN_0076d950 calls the
// same vtable +0x94 worker); we add the display-ID front end and the same
// dressing a spawned unit gets. It is the foundational "display ID → loaded
// model" step — a rendered portrait-by-display-ID
// (SetPortraitTextureFromCreatureDisplayID) would build render-to-texture on
// top of this, but a live Model frame needs neither.
//
// SetCreature(creatureID) is the entry-ID cousin: it resolves through the client
// creature cache to a display ID, then runs the same load — see its note below.
#include "Game.h"
#include "Offsets.h"
#include "creature/Info.h"
#include "dbc/Lookup.h"
#include "model/DisplayInfo.h"
#include "tick/WorldTick.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
namespace Model::DisplayInfo {
namespace {
// The CFrameScriptObject "is-a" vmethod, at vtable +0x10 for every frame type,
// and the Model model-load worker at vtable +0x94 (the one Script_SetModel
// invokes after resolving self). Both single-use here, so kept local.
constexpr int kVmtIsA = 0x10;
constexpr int kVmtLoadModel = 0x94;
using IsA_t = char(__thiscall *)(void *self, int typeId);
using LoadModel_t = void(__thiscall *)(void *self, const char *path);
using SetReplaceableTexture_t = void(__thiscall *)(void *model, int type, const char *path);
template <typename Fn>
Fn Vmethod(void *obj, int byteOffset) {
auto *vtbl = *reinterpret_cast<uint8_t **>(obj);
return *reinterpret_cast<Fn *>(vtbl + byteOffset);
}
// The Model frame-script type id. The engine assigns it lazily on the first
// Model method call; mirror that assignment (FUN_0076d950's prologue) so a
// type-check is valid even if no stock Model method has run yet.
int ModelTypeId() {
auto &typeId = Game::Ref<int>(Offsets::VAR_MODEL_LUA_TYPE_ID);
if (typeId == 0) {
auto &counter = Game::Ref<int>(Offsets::VAR_FRAMESCRIPT_TYPE_ID_COUNTER);
counter += 1;
typeId = counter;
}
return typeId;
}
// Resolve arg1 to a Model object, or nullptr if it is not a Model. Mirrors the
// resolve-then-typecheck prologue every Model method uses: generic object
// resolve, then the IsA vmethod. The type-check matters here because we call a
// Model vtable slot directly — a non-Model region would dispatch a wrong method.
void *ResolveModel(void *L) {
void *obj = Game::Lua::ResolveObject(L, 1);
if (obj == nullptr)
return nullptr;
if (!Vmethod<IsA_t>(obj, kVmtIsA)(obj, ModelTypeId()))
return nullptr;
return obj;
}
// Apply a creature display's TextureVariation skins to the just-loaded model.
// The variation columns are bare filenames that live in the model's own
// directory; they fill the model's MONSTER_1/2/3 replaceable-texture slots
// (M2/MDX texture types 11/12/13). FUN_0076cfe0 no-ops when no model is loaded,
// so this is safe to call unconditionally after the load.
void ApplySkins(void *model, const char *modelPath, const uint8_t *displayRec) {
std::string dir(modelPath);
const auto slash = dir.find_last_of("\\/");
dir = (slash == std::string::npos) ? std::string() : dir.substr(0, slash + 1);
auto setTexture = reinterpret_cast<SetReplaceableTexture_t>(
Offsets::FUN_MODEL_SET_REPLACEABLE_TEXTURE);
constexpr int kMonsterSlot[3] = {11, 12, 13};
for (int i = 0; i < 3; ++i) {
const char *variation = Game::Read<const char *>(
displayRec, Offsets::OFF_CREATUREDISPLAYINFO_TEXTURE_VARIATION + i * 4);
if (variation == nullptr || variation[0] == '\0')
continue;
const std::string skin = dir + variation;
setTexture(model, kMonsterSlot[i], skin.c_str());
}
}
// --- Character-display dressing via the engine compositor -------------------
//
// Character-based displays (CreatureDisplayInfo.extendedDisplayInfoID != 0) use
// a shared Character\Race\Sex base model that the engine "dresses" per display:
// baked body texture, hair texture, hair / facial-hair geosets (beards,
// earrings, teeth), and equipment geosets (sleeves, robe→trousers, boots,
// tabard, cape). Units run this through a compositor object
// (CGUnit_C::UpdateCharacterCustomization, FUN_005fb200) once their model
// loads; SetPortraitTexture waits on the same compositor, which is why
// portraits always look right. We drive the identical compositor for the
// Model frame, mirroring the unit flow field for field — see the
// FUN_CHARCOMP_* notes in Offsets.h.
//
// Still not shown: weapons. NPC weapons come from the server-pushed
// UNIT_VIRTUAL_ITEM_SLOT_DISPLAY fields on live units, not from any client
// DBC, so a display ID alone can't produce them.
// The compositor info struct FUN_CHARCOMP_SET_INFO copies (0x5B dwords).
// Layout read straight out of FUN_005fb200's NPC branch.
struct CharCompInfo {
uint32_t race; // +0x00 Extra +0x04
uint32_t sex; // +0x04 Extra +0x08
uint32_t hairColor; // +0x08 Extra +0x18
uint32_t skin; // +0x0C Extra +0x0C
uint32_t zero; // +0x10 engine passes 0
uint32_t face; // +0x14 Extra +0x10
uint32_t facialHair; // +0x18 Extra +0x1C
uint32_t hairStyle; // +0x1C Extra +0x14
void *model; // +0x20 AddRef'd by caller; builder owns the ref
uint8_t useBake; // +0x24 1 = load bakePath instead of compositing
char bakePath[263]; // +0x25 "Textures\BakedNpcTextures\<bake>"
uint32_t geosetDefaults[16]; // +0x12C per-group defaults (see kGeosetGroupDefaults)
};
static_assert(sizeof(CharCompInfo) == 0x16C, "must match the engine's 0x5B-dword copy");
// The engine's geoset-group defaults from FUN_005fb200 — group N shows
// N*100+1, except ears (group 7) whose "shown" variant is 702. The hair and
// facial-hair groups get overwritten by the compositor's CharHairGeosets /
// CharacterFacialHairStyles resolution.
constexpr uint32_t kGeosetGroupDefaults[16] = {1, 101, 201, 301, 401, 501,
601, 702, 801, 901, 1001, 1101,
1201, 1301, 1401, 1501};
using CharCompCreate_t = void *(__cdecl *)();
using CharCompDestroy_t = void(__fastcall *)(void *builder);
using CharCompSetInfo_t = char(__thiscall *)(void *builder, const CharCompInfo *info);
using CharCompSetItem_t = void(__thiscall *)(void *builder, int slot, int itemDisplayID);
using CharCompPump_t = char(__thiscall *)(void *builder, int *unused);
using ModelAddRef_t = void(__fastcall *)(void *instance);
using ModelLoaded_t = int(__thiscall *)(void *instance, int tryLoad, int recurseChildren);
// A pending / finished dress for one Model frame. The model loads async, and
// the engine only dresses a LOADED model (its poll gate FUN_00607da0 checks
// FUN_MODEL_INSTANCE_LOADED first), so each job waits on WorldTick for the
// load, configures the compositor once, then pumps it to completion. The
// builder stays alive after that — units keep theirs for the model's whole
// lifetime, and it holds the ref on the model instance — until the frame
// loads another display or the reload teardown.
struct DressJob {
void *frame = nullptr; // CSimpleModelFFX that requested the dress
void *instance = nullptr; // model instance the job is bound to
const uint8_t *extraRec = nullptr; // CreatureDisplayInfoExtra (DBC memory, stable)
void *builder = nullptr; // engine compositor (owned)
bool configured = false;
bool done = false;
int ticks = 0;
};
// Model frames are few and permanent; SetDisplayInfo users fewer still.
DressJob g_jobs[8];
// Give up on a model that never finishes loading (bad path, missing file).
constexpr int kMaxDressTicks = 600;
void CancelJob(DressJob &job) {
if (job.builder != nullptr)
reinterpret_cast<CharCompDestroy_t>(Offsets::FUN_CHARCOMP_DESTROY)(job.builder);
job = DressJob{};
}
DressJob *FindJob(void *frame) {
for (auto &job : g_jobs)
if (job.frame == frame)
return &job;
return nullptr;
}
// Claim a slot for `frame`: its own existing slot, a free one, or — all busy —
// a finished one (the dressed model keeps its geosets and texture refs; only
// the kept-alive builder is dropped early).
DressJob *ClaimJob(void *frame) {
DressJob *slot = FindJob(frame);
if (slot == nullptr)
for (auto &job : g_jobs)
if (job.frame == nullptr) {
slot = &job;
break;
}
if (slot == nullptr)
for (auto &job : g_jobs)
if (job.done) {
slot = &job;
break;
}
if (slot == nullptr)
slot = &g_jobs[0];
CancelJob(*slot);
return slot;
}
// Queue a dress for a character display just loaded into `frame`. Mirrors the
// engine's own early-out: an Extra record with no bake name gets no
// customization at all (FUN_005fb200 returns before creating the builder).
void ScheduleDress(void *frame, const uint8_t *extraRec) {
const char *bake = Game::Read<const char *>(
extraRec, Offsets::OFF_CREATUREDISPLAYINFOEXTRA_BAKE_NAME);
if (bake == nullptr || bake[0] == '\0')
return;
DressJob *job = ClaimJob(frame);
job->frame = frame;
job->instance = Game::Read<void *>(frame,
Offsets::OFF_SIMPLEMODELFFX_MODEL_INSTANCE);
job->extraRec = extraRec;
}
// One WorldTick step for one job: wait for the load, then configure + feed
// equipment (the engine's order: SetInfo, then the 10 Extra equipment slots),
// then pump until done. Field-for-field mirror of FUN_005fb200's NPC branch.
void StepJob(DressJob &job) {
void *instance = Game::Read<void *>(job.frame,
Offsets::OFF_SIMPLEMODELFFX_MODEL_INSTANCE);
if (job.instance == nullptr)
job.instance = instance;
if (instance == nullptr || instance != job.instance || ++job.ticks > kMaxDressTicks) {
CancelJob(job); // model swapped out from under us / never loaded
return;
}
if (!job.configured) {
if (!reinterpret_cast<ModelLoaded_t>(Offsets::FUN_MODEL_INSTANCE_LOADED)(instance, 0, 0))
return;
void *builder = reinterpret_cast<CharCompCreate_t>(Offsets::FUN_CHARCOMP_CREATE)();
if (builder == nullptr) {
CancelJob(job);
return;
}
job.builder = builder;
const uint8_t *extra = job.extraRec;
auto field = [extra](int off) {
return Game::Read<uint32_t>(extra, off);
};
CharCompInfo info = {};
info.race = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_RACE);
info.sex = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_SEX);
info.hairColor = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_HAIR_COLOR);
info.skin = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_SKIN);
info.face = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_FACE);
info.facialHair = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_FACIAL_HAIR);
info.hairStyle = field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_HAIR_STYLE);
info.model = instance;
info.useBake = 1;
const char *bake = Game::Read<const char *>(
extra, Offsets::OFF_CREATUREDISPLAYINFOEXTRA_BAKE_NAME);
std::snprintf(info.bakePath, sizeof(info.bakePath), "%s%s",
"Textures\\BakedNpcTextures\\", bake);
std::memcpy(info.geosetDefaults, kGeosetGroupDefaults, sizeof(kGeosetGroupDefaults));
// The builder owns this ref (releases it on destroy / re-configure).
reinterpret_cast<ModelAddRef_t>(Offsets::FUN_MODEL_INSTANCE_ADDREF)(instance);
if (!reinterpret_cast<CharCompSetInfo_t>(Offsets::FUN_CHARCOMP_SET_INFO)(builder, &info)) {
CancelJob(job); // destroy releases the model ref already copied in
return;
}
auto setItem = reinterpret_cast<CharCompSetItem_t>(Offsets::FUN_CHARCOMP_SET_ITEM);
for (int slot = 0; slot < 10; ++slot) {
const auto displayID = static_cast<int>(
field(Offsets::OFF_CREATUREDISPLAYINFOEXTRA_EQUIP + slot * 4));
if (displayID != 0)
setItem(builder, slot, displayID);
}
// The helm/shoulder feeds above attach child model instances. The frame
// only lights its OWN instance (pre-render callback registered by
// SetModelInstance), so the children would render unlit — pure black.
// Give each child the frame's own light/fog callback; it reads the
// frame's live light struct, so later SetLight calls reach them too.
using SetRenderCb_t = void(__thiscall *)(void *instance, const void *cb, void *userData);
auto setRenderCb =
reinterpret_cast<SetRenderCb_t>(Offsets::FUN_MODEL_INSTANCE_SET_RENDER_CB);
const void *lightFogCb =
reinterpret_cast<const void *>(Offsets::FUN_SIMPLEMODELFFX_LIGHT_FOG_CB);
for (auto *child = Game::Read<uint8_t *>(
instance, Offsets::OFF_MODEL_INSTANCE_CHILD_HEAD);
child != nullptr; child = Game::Read<uint8_t *>(
child, Offsets::OFF_MODEL_INSTANCE_CHILD_NEXT))
setRenderCb(child, lightFogCb, job.frame);
job.configured = true;
}
if (reinterpret_cast<CharCompPump_t>(Offsets::FUN_CHARCOMP_PUMP)(job.builder, nullptr))
job.done = true; // builder kept — it owns the model ref for the dressed model
}
void OnWorldTick() {
for (auto &job : g_jobs)
if (job.frame != nullptr && !job.done)
StepJob(job);
}
const Tick::WorldTick::AutoSubscribe g_tick{&OnWorldTick};
// Resolve a creature display ID to its model file and skins, and load it into
// the Model frame. Shared by SetDisplayInfo (display ID direct) and SetCreature
// (creature entry ID -> cache -> display ID). No-op for an unknown display ID.
void LoadDisplay(void *model, int displayID) {
if (displayID <= 0)
return;
const uint8_t *displayRec = DBC::Record(Offsets::VAR_CREATUREDISPLAYINFO_RECORDS,
Offsets::VAR_CREATUREDISPLAYINFO_COUNT,
static_cast<uint32_t>(displayID));
if (displayRec == nullptr)
return;
const uint32_t modelID = Game::Read<uint32_t>(
displayRec, Offsets::OFF_CREATUREDISPLAYINFO_MODEL_ID);
const char *modelPath = DBC::StringField(Offsets::VAR_CREATUREMODELDATA_RECORDS,
Offsets::VAR_CREATUREMODELDATA_COUNT,
modelID,
Offsets::OFF_CREATUREMODELDATA_MODEL_PATH);
if (modelPath == nullptr)
return;
// Any pending or kept dress for this frame is about to point at the
// wrong model — drop it before the load swaps the instance.
if (DressJob *job = FindJob(model))
CancelJob(*job);
Vmethod<LoadModel_t>(model, kVmtLoadModel)(model, modelPath);
ApplySkins(model, modelPath, displayRec);
// Character-based display (Character\Race\Sex base): dress it through the
// engine compositor, else body and hair render white with every geoset on.
const uint32_t extID = Game::Read<uint32_t>(
displayRec, Offsets::OFF_CREATUREDISPLAYINFO_EXTENDED_ID);
if (extID != 0) {
const uint8_t *extraRec = DBC::Record(Offsets::VAR_CREATUREDISPLAYINFOEXTRA_RECORDS,
Offsets::VAR_CREATUREDISPLAYINFOEXTRA_COUNT, extID);
if (extraRec != nullptr)
ScheduleDress(model, extraRec);
}
}
int __fastcall Script_SetDisplayInfo(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: Model:SetDisplayInfo(creatureDisplayID)");
return 0;
}
void *model = ResolveModel(L);
if (model == nullptr) {
Game::Lua::Error(L, "Model:SetDisplayInfo(): self is not a Model");
return 0;
}
LoadDisplay(model, static_cast<int>(Game::Lua::ToNumber(L, 2)));
return 0;
}
// `Model:SetCreature(creatureID)` — the creature-entry cousin of SetDisplayInfo.
// Vanilla has no client-side creature-entry -> display table in a DBC; that
// mapping lives in the creature cache (creaturecache.wdb / SMSG_CREATURE_QUERY_
// RESPONSE), keyed by entry ID and carrying the display ID. So this resolves the
// entry through the cache, then loads exactly as SetDisplayInfo does.
//
// Limitation: only creatures already cached (queried this session or loaded from
// the WDB at login) resolve; an uncached entry is a no-op. A caller that needs an
// uncached creature can `C_CreatureInfo.RequestLoadCreatureByID` first, then call
// this after `CREATURE_DATA_LOAD_RESULT`.
int __fastcall Script_SetCreature(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: Model:SetCreature(creatureID)");
return 0;
}
void *model = ResolveModel(L);
if (model == nullptr) {
Game::Lua::Error(L, "Model:SetCreature(): self is not a Model");
return 0;
}
const int creatureID = static_cast<int>(Game::Lua::ToNumber(L, 2));
if (creatureID <= 0)
return 0;
const uint32_t displayID = Creature::Info::DisplayID(static_cast<uint32_t>(creatureID));
if (displayID == 0)
return 0; // not cached -> no-op (see limitation above)
LoadDisplay(model, static_cast<int>(displayID));
return 0;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetDisplayInfo", &Script_SetDisplayInfo},
{"SetCreature", &Script_SetCreature},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_MODEL_METHOD_REGISTRY),
g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
void PrepareForReload() {
for (auto &job : g_jobs)
CancelJob(job);
}
} // namespace Model::DisplayInfo
+20
View File
@@ -0,0 +1,20 @@
// 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.
#pragma once
namespace Model::DisplayInfo {
// The reload teardown destroys every Model frame (and with it the model
// instances our pending character-dress jobs point at). Drop all jobs and
// destroy their engine compositors before the engine starts tearing down.
void PrepareForReload();
} // namespace Model::DisplayInfo
+35 -39
View File
@@ -86,10 +86,10 @@ bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
// item's class *has* a proficiency concept (mask != 0) and the item's
// subclass bit is unset.
bool ProficiencyMet(const uint8_t *record) {
const uint32_t itemClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
const uint32_t itemClass = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClass = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_SUBCLASS);
if (itemClass > 16) // table is 17 entries (m_class 0..16)
return true;
auto *table = reinterpret_cast<const uint32_t *>(
@@ -105,27 +105,26 @@ bool ProficiencyMet(const uint8_t *record) {
// item's SkillLine.dbc row to the player's skill slot; the player must
// have the line at an effective rank (current + temp bonus) >= required.
bool RequiredSkillMet(const uint8_t *player, const uint8_t *record) {
const uint32_t reqSkill = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
const uint32_t reqSkill = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
if (reqSkill == 0)
return true; // no skill requirement
const uint32_t reqRank = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
const uint32_t reqRank = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
const int slot = toSlot(const_cast<uint8_t *>(player), reqSkill);
if (slot < 0)
return false; // player doesn't have the skill line at all
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
auto *sub = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr)
return false;
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
slot * Offsets::SKILL_INFO_STRIDE;
uint32_t rank = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
uint32_t rank = Game::Read<uint16_t>(rec, Offsets::OFF_SKILL_INFO_CUR);
if (rank != 0) // add the temp bonus only when there's a base value
rank += *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
rank += Game::Read<uint16_t>(rec, Offsets::OFF_SKILL_INFO_BONUS);
return rank >= reqRank;
}
@@ -138,11 +137,11 @@ bool RequiredSkillMet(const uint8_t *player, const uint8_t *record) {
bool PlayerKnowsSpell(int spellID) {
if (spellID <= 0)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
auto *bitmap = Game::Read<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const int spellCount = *reinterpret_cast<const int *>(
const int spellCount = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
@@ -163,8 +162,8 @@ bool PlayerKnowsSpell(int spellID) {
// specialization/proficiency in practice, so the chain walk is
// belt-and-suspenders, but it makes the check exact.
bool RequiredSpellMet(const uint8_t *player, const uint8_t *record) {
const int reqSpell = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
const int reqSpell = Game::Read<int32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
if (reqSpell <= 0)
return true; // no requirement
if (::Spell::Lookup::RecordForID(reqSpell) == nullptr)
@@ -180,8 +179,8 @@ bool RequiredSpellMet(const uint8_t *player, const uint8_t *record) {
// RequiredHonorRank — the player's PvP honor rank must be >= the item's.
// `sub` is the CGPlayer +0xE68 sub-struct (may be null pre-world).
bool RequiredHonorRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
const uint32_t req = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
if (req == 0)
return true;
if (sub == nullptr)
@@ -193,14 +192,14 @@ bool RequiredHonorRankMet(const uint8_t *sub, const uint8_t *record) {
// RequiredCityRank — a PvP-medal bitmask gate: the item's rank R passes
// when the player has earned bit (R-1) in the medal mask.
bool RequiredCityRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
const uint32_t req = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
if (req == 0)
return true;
if (sub == nullptr)
return false;
const uint32_t medals = *reinterpret_cast<const uint32_t *>(
sub + Offsets::OFF_CGPLAYER_PVP_MEDALS);
const uint32_t medals = Game::Read<uint32_t>(
sub, Offsets::OFF_CGPLAYER_PVP_MEDALS);
return (medals & (1u << ((req - 1) & 31))) != 0;
}
@@ -209,17 +208,17 @@ bool RequiredCityRankMet(const uint8_t *sub, const uint8_t *record) {
// indexes the engine's reaction-min threshold table; the player's total
// rep with the faction must be >= that threshold.
bool RequiredReputationMet(const uint8_t *record) {
const int faction = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
const int faction = Game::Read<int32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
if (faction == 0)
return true;
const uint32_t band = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
const uint32_t band = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
if (band > 7) // reaction-min table is 8 bands (0..7); guard OOB
return true;
auto getStanding = reinterpret_cast<RepStanding_t>(Offsets::FUN_REPUTATION_GET_STANDING);
const int current = getStanding(faction);
const int threshold = *reinterpret_cast<const int *>(
const int threshold = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_REACTION_MIN_TABLE) + band * 4);
return current >= threshold;
}
@@ -233,29 +232,27 @@ bool ComputeCanUse(int itemID) {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return false; // pre-login / no player
auto *desc = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
auto *desc = Game::Read<const uint8_t *>(player, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return false;
if (!ProficiencyMet(record))
return false;
const int playerLevel = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
const int playerLevel = Game::Read<int>(desc, Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
if (static_cast<int>(reqLevel) > playerLevel)
return false;
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t allowClass = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (RestrictedOut(allowClass, playerClass))
return false;
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t allowRace = Game::Read<uint32_t>(
record, Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (RestrictedOut(allowRace, playerRace))
return false;
@@ -267,8 +264,7 @@ bool ComputeCanUse(int itemID) {
return false;
// PvP rank + reputation gates read the CGPlayer +0xE68 sub-struct.
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
auto *sub = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
if (!RequiredHonorRankMet(sub, record))
return false;
if (!RequiredCityRankMet(sub, record))
+53 -45
View File
@@ -35,6 +35,7 @@
// them, or they appear in guild login spam), they will be.
#include "Game.h"
#include "Info.h"
#include "NameCache.h"
#include "Offsets.h"
#include "dbc/Names.h"
@@ -50,6 +51,17 @@ namespace Player::Info {
namespace {
// Bounded, always-terminated copy. Returns true iff a non-empty name landed.
bool CopyName(char *dst, const char *src, size_t n) {
if (dst == nullptr || n == 0)
return false;
size_t i = 0;
for (; src && src[i] && i + 1 < n; ++i)
dst[i] = src[i];
dst[i] = '\0';
return dst[0] != '\0';
}
// Thin shim: parses a GUID string and splits to hi/lo dwords. Shared parser
// lives in `Guid::Parse`.
bool ParseGUID(const char *str, uint32_t &outHi, uint32_t &outLo) {
@@ -194,54 +206,14 @@ int __fastcall Script_UnitNameFromGUID(void *L) {
Game::Lua::Error(L, "Usage: UnitNameFromGUID(\"0x...\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint32_t hi, lo;
if (!ParseGUID(guidStr, hi, lo))
if (!ParseGUID(Game::Lua::ToString(L, 1), hi, lo))
return 0;
if (hi == 0 && lo == 0)
char name[64];
if (!NameFromGuid((static_cast<uint64_t>(hi) << 32) | lo, name, sizeof name))
return 0;
// Object-manager path — TYPEMASK_OBJECT (0x01) is permissive; the
// name getter at `FUN_OBJECT_GET_NAME` does its own type check and
// safely returns the "UNKNOWNOBJECT" sentinel for non-unit
// objects (gameobjects etc.), so we just gate on the sentinel
// rather than pre-filtering by typemask.
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
void *obj = Object::ByGuid(Offsets::TYPEMASK_OBJECT,
(static_cast<uint64_t>(hi) << 32) | lo, nullptr, 0);
if (obj != nullptr) {
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
const char *name = getName(obj, nullptr);
if (name != nullptr && *name != '\0' &&
std::strcmp(name, "UNKNOWNOBJECT") != 0 &&
std::strcmp(name, "Unknown Being") != 0) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, "");
return 2;
}
}
// Friends-list fallback: the friend list keeps name + GUID for online
// AND offline friends, so a friend resolves even while unsynced and
// uncached. It needs no race/sex (this API returns only name + realm).
if (const char *fname = FriendList::NameForGuid(
(static_cast<uint64_t>(hi) << 32) | lo)) {
Game::Lua::PushString(L, fname);
Game::Lua::PushString(L, "");
return 2;
}
// Object-manager miss / sentinel — fall back to the persistent
// NameCache (when enabled). Covers ex-units no longer in the
// engine's sync window.
const std::string *cachedName = nullptr;
const NameCache::Entry *cached = NameCache::Lookup(
(static_cast<uint64_t>(hi) << 32) | lo, &cachedName);
if (cached == nullptr || cachedName == nullptr || cachedName->empty())
return 0;
Game::Lua::PushString(L, cachedName->c_str());
Game::Lua::PushString(L, "");
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, ""); // realm — always "" in vanilla
return 2;
}
@@ -272,6 +244,42 @@ int __fastcall Script_C_PlayerCache_GetPlayerInfoByName(void *L) {
} // namespace
// Resolution order matches vanilla's own `Script_UnitName` (0x00517020):
// 1. Object manager — TYPEMASK_OBJECT (0x01) is permissive; the name getter
// at `FUN_OBJECT_GET_NAME` does its own type check and routes through the
// player NameCache for players AND the creature cache for NPCs, so it
// resolves both with a single call. It returns the "UNKNOWNOBJECT"
// sentinel for non-unit objects (gameobjects etc.), so we gate on that
// rather than pre-filtering by typemask.
// 2. Friends list — keeps name + GUID for online AND offline friends, so a
// friend resolves even while unsynced and never seen in chat.
// 3. Persistent NameCache (when enabled) — ex-units no longer in the sync
// window (an ex-target who logged off, a chat name no longer visible).
bool NameFromGuid(uint64_t guid, char *buf, size_t bufSize) {
if (guid == 0 || buf == nullptr || bufSize == 0)
return false;
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
if (void *obj = Object::ByGuid(Offsets::TYPEMASK_OBJECT, guid, nullptr, 0)) {
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
const char *name = getName(obj, nullptr);
if (name != nullptr && *name != '\0' &&
std::strcmp(name, "UNKNOWNOBJECT") != 0 &&
std::strcmp(name, "Unknown Being") != 0)
return CopyName(buf, name, bufSize);
}
if (const char *fname = FriendList::NameForGuid(guid))
return CopyName(buf, fname, bufSize);
const std::string *cachedName = nullptr;
const NameCache::Entry *cached = NameCache::Lookup(guid, &cachedName);
if (cached != nullptr && cachedName != nullptr && !cachedName->empty())
return CopyName(buf, cachedName->c_str(), bufSize);
return false;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetPlayerInfoByGUID",
&Script_GetPlayerInfoByGUID);
+33
View File
@@ -0,0 +1,33 @@
// 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
// Shared GUID -> name resolution, factored out of `UnitNameFromGUID` so other
// modules (e.g. `Spell::Cast`'s `UnitSpellTargetName`) can turn a GUID into a
// display name without a Lua round-trip.
#include <cstddef>
#include <cstdint>
namespace Player::Info {
// Resolve any object GUID to a display name -- players AND creatures. Chain:
// object manager (the engine's polymorphic name getter) -> friends list ->
// persistent NameCache. Writes a NUL-terminated name into `buf` and returns
// true iff resolved; returns false (buf untouched) for GUID 0, a non-unit
// object, or an unknown / uncached GUID. Passive -- never triggers a name
// query.
bool NameFromGuid(uint64_t guid, char *buf, size_t bufSize);
} // namespace Player::Info
+9 -10
View File
@@ -31,6 +31,7 @@
// the free-look bit is held AND the camera yaw changes; STOPPED
// fires on LMB release.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
@@ -67,7 +68,7 @@ bool g_havePrevCameraYaw = false;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
const uint8_t *Controller() {
return *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_UI_INPUT_CONTROLLER);
return Game::Read<const uint8_t *>(Offsets::VAR_UI_INPUT_CONTROLLER);
}
const uint8_t *Player() {
@@ -76,12 +77,12 @@ const uint8_t *Player() {
}
const uint8_t *Camera() {
const uint8_t *gameState = *reinterpret_cast<const uint8_t *const *>(
const uint8_t *gameState = Game::Read<const uint8_t *>(
Offsets::VAR_GAME_STATE_PTR);
if (gameState == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
gameState + Offsets::OFF_GAME_STATE_CAMERA_PTR);
return Game::Read<const uint8_t *>(
gameState, Offsets::OFF_GAME_STATE_CAMERA_PTR);
}
void FireTransition(bool now, bool &prev, const char *startedName,
@@ -109,8 +110,7 @@ void OnWorldTick() {
return;
}
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
ctrl + Offsets::OFF_UI_INPUT_FLAGS);
const uint32_t flags = Game::Read<uint32_t>(ctrl, Offsets::OFF_UI_INPUT_FLAGS);
const bool freeLookHeld = (flags & Offsets::INPUT_FLAG_FREE_LOOK) != 0;
const bool mouselookHeld = (flags & Offsets::INPUT_FLAG_MOUSELOOK) != 0;
@@ -133,8 +133,7 @@ void OnWorldTick() {
// Once STARTED has fired during a hold, the latch stays on
// until the bit clears — so a drag-stop-drag motion within
// the same RMB hold doesn't flap STARTED/STOPPED.
const float bodyYaw = *reinterpret_cast<const float *>(
player + Offsets::OFF_PLAYER_BODY_YAW);
const float bodyYaw = Game::Read<float>(player, Offsets::OFF_PLAYER_BODY_YAW);
bool turning;
if (!mouselookHeld) {
turning = false;
@@ -159,8 +158,8 @@ void OnWorldTick() {
} else if (camera == nullptr) {
looking = false; // can't read camera yaw — bail
} else {
const float cameraYaw = *reinterpret_cast<const float *>(
camera + Offsets::OFF_CAMERA_RELATIVE_YAW);
const float cameraYaw = Game::Read<float>(
camera, Offsets::OFF_CAMERA_RELATIVE_YAW);
if (g_wasLooking) {
looking = true;
} else if (g_havePrevCameraYaw &&
+1 -1
View File
@@ -52,7 +52,7 @@ const char *LookupQuestType(uint32_t row) {
if (!record)
return nullptr;
const int localeIdx =
*reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
if (localeIdx < 0 || localeIdx > 8)
return nullptr;
auto *names = reinterpret_cast<const char *const *>(record + 4);
+27 -27
View File
@@ -83,8 +83,8 @@ const uint8_t *PlayerFields() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
return Game::Read<const uint8_t *>(
player, Offsets::OFF_CGPLAYER_INFO);
}
// Net spell damage for a 0-based school (0=physical .. 6=arcane). POS is the
@@ -94,10 +94,10 @@ long SpellDamageForSchool(int school0) {
const uint8_t *pf = PlayerFields();
if (pf == nullptr)
return 0;
const int32_t pos = *reinterpret_cast<const int32_t *>(
pf + Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS + school0 * 4);
const int32_t neg = *reinterpret_cast<const int32_t *>(
pf + Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG + school0 * 4);
const int32_t pos = Game::Read<int32_t>(
pf, Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS + school0 * 4);
const int32_t neg = Game::Read<int32_t>(
pf, Offsets::OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG + school0 * 4);
return static_cast<long>(pos) - static_cast<long>(neg);
}
@@ -128,12 +128,12 @@ long ItemFlatHealing(const uint8_t *cgItem) {
// Random suffix ("… of Restoration") + applied permanent (slot 0) and
// temporary (slot 1) enchants (Healing Power, Mana Oil, …).
Item::StatAccum::ApplyRandomSuffix(
acc, *reinterpret_cast<const int32_t *>(
desc + Offsets::OFF_DESCRIPTOR_RANDOM_PROPERTY), +1);
acc, Game::Read<int32_t>(
desc, Offsets::OFF_DESCRIPTOR_RANDOM_PROPERTY), +1);
for (int slot = 0; slot <= 1; ++slot)
Item::StatAccum::ApplyEnchant(
acc, *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_DESCRIPTOR_ENCHANTMENT_ID +
acc, Game::Read<uint32_t>(
desc, Offsets::OFF_DESCRIPTOR_ENCHANTMENT_ID +
slot * Offsets::DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE), +1);
}
return Item::StatAccum::Value(acc, "ITEM_MOD_SPELL_HEALING_DONE_SHORT");
@@ -165,15 +165,15 @@ void AddSpellHealingAuras(long &total, int spellID, int32_t spirit,
if (sp == nullptr)
return;
if (requirePassive &&
!(*reinterpret_cast<const uint32_t *>(sp + Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
!(Game::Read<uint32_t>(sp, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
Offsets::SPELL_ATTR_PASSIVE))
return;
auto aura = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto base = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
auto dice = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
auto aura = Game::Ptr<const int32_t>(
sp, Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto base = Game::Ptr<const int32_t>(
sp, Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
auto dice = Game::Ptr<const int32_t>(
sp, Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const long amount = static_cast<long>(base[i]) + static_cast<long>(dice[i]);
if (aura[i] == kAuraModHealingDone)
@@ -194,21 +194,21 @@ long ComputeHealing() {
// Player Spirit / Armor for the aura-175 / aura-199 conversions.
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
const int32_t spirit = desc ? *reinterpret_cast<const int32_t *>(
desc + Offsets::OFF_UNIT_FIELD_STAT_SPIRIT) : 0;
const int32_t armor = desc ? *reinterpret_cast<const int32_t *>(
desc + Offsets::OFF_UNIT_FIELD_RESISTANCE_ARMOR) : 0;
const int32_t spirit = desc ? Game::Read<int32_t>(
desc, Offsets::OFF_UNIT_FIELD_STAT_SPIRIT) : 0;
const int32_t armor = desc ? Game::Read<int32_t>(
desc, Offsets::OFF_UNIT_FIELD_RESISTANCE_ARMOR) : 0;
// Active buffs — flat healing + Spirit/Armor conversions, one pass.
auto *table = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_BUFF_TABLE));
for (int i = 0; i < Offsets::PLAYER_BUFF_TABLE_COUNT; ++i) {
const uint8_t *entry = table + i * Offsets::PLAYER_BUFF_ENTRY_STRIDE;
if (*reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_PLAYER_BUFF_SLOT_CODE) < 0)
if (Game::Read<int32_t>(
entry, Offsets::OFF_PLAYER_BUFF_SLOT_CODE) < 0)
continue;
AddSpellHealingAuras(total, *reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_PLAYER_BUFF_SPELL_ID),
AddSpellHealingAuras(total, Game::Read<int32_t>(
entry, Offsets::OFF_PLAYER_BUFF_SPELL_ID),
spirit, armor, /*requirePassive=*/false);
}
@@ -216,9 +216,9 @@ long ComputeHealing() {
// and any passive flat healing, via the known-spell bitmap (bounded by
// spellCount). The passive gate assumes ranks supersede (only the learned
// rank is known), so no per-rank dedup is needed.
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
auto *bitmap = Game::Read<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
const int spellCount = *reinterpret_cast<const int *>(
const int spellCount = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (bitmap != nullptr && spellCount > 0) {
for (int id = 1; id <= spellCount; ++id)
+183 -35
View File
@@ -73,9 +73,11 @@
#include "dbc/Lookup.h"
#include "net/PacketDispatch.h"
#include "net/PacketReader.h"
#include "player/Info.h"
#include "spell/Lookup.h"
#include "spell/CastEvents.h"
#include "tick/WorldTick.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -85,7 +87,6 @@ namespace Spell::Cast {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using TickMs_t = uint32_t(__fastcall *)();
using GetCastTime_t = uint32_t(__fastcall *)(int spellID, int unit, int flag);
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord, int unit, int skipMod);
@@ -100,6 +101,13 @@ constexpr uint32_t SPELL_ATTR_TRADESPELL = 0x20;
// shots (Auto Shot / Shoot, AttributesEx2 bit 0x20) are the one exclusion.
constexpr uint32_t SPELL_ATTR_RANGED = 0x2;
// SMSG_SPELL_START target block: SpellCastTargets writes a u16 target mask,
// then a packed GUID for the unit target when this bit is set (1.12 server
// SpellCastTargets::write). Self / ground / item targets carry no unit GUID —
// a cast with none reports no target. Per project convention TARGET_* flags
// stay local (single-use, no drift).
constexpr uint16_t TARGET_FLAG_UNIT = 0x2;
struct TrackedSpell {
int spellID; // 0 = not casting / channeling
int startMs;
@@ -110,12 +118,27 @@ struct TrackedSpell {
TrackedSpell g_cast{0, 0, 0, 0};
TrackedSpell g_channel{0, 0, 0, 0};
// The unit GUID the player is currently casting / channeling AT (from the
// SMSG_SPELL_START target block), or 0 for a self / ground / untargeted cast.
// Reset on each fresh client-side stamp; filled when the confirming packet's
// target lands. Read by `UnitSpellTargetName` and gated there on g_cast /
// g_channel being active, so a stale value from a finished cast is never read.
uint64_t g_castTargetGuid = 0;
// True when g_cast was stamped from SMSG_SPELL_START (a chained same-spell
// recast the client cast path bailed on) rather than the client-side
// FUN_CAST_START_SET hook. Such casts never set VAR_CURRENT_CAST_SPELL, so
// the WorldTick VAR==0 clear must not touch them.
bool g_castFromServer = false;
// Set when a movement-immune cast's engine cast-state was dropped by the player
// MOVING (issue #23 — grenades). Such a cast isn't really interrupted; the
// server completes it. So we keep tracking it instead of ending it, and clear
// it on its SPELL_GO completion (→ STOP, no INTERRUPTED) or a grace past its end.
// A real cancel / interrupt happens with the player stationary, so it clears
// normally. Reset on every fresh cast stamp.
bool g_castMoveDropped = 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
@@ -132,7 +155,48 @@ void StampChannel(int spellID, int startMs, int endMs) {
g_cast.spellID = 0;
}
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
// Cast tracking keeps ticks in a signed int internally by design — see PushMs
// for why that's safe (small deltas cancel; the Lua boundary re-casts to
// uint32 so it matches GetTime()*1000 across the wrap). The reader itself
// still comes from the one canonical source.
int NowMs() { return static_cast<int>(Time::Clock::NowMs()); }
// A cast is interrupted by the caster moving only if its Spell.dbc InterruptFlags
// carry SPELL_INTERRUPT_FLAG_MOVEMENT (the server's HandleMovementOpcodes and the
// client's own CheckCast both gate the movement interrupt on this bit). Thrown
// items — grenades — lack it. Unknown spell → assume interruptible (the safe
// default: never keeps a phantom bar alive).
bool MovementInterruptible(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return true;
return (Game::Read<uint32_t>(
rec, Offsets::OFF_SPELL_RECORD_INTERRUPT_FLAGS) &
Offsets::SPELL_INTERRUPT_FLAG_MOVEMENT) != 0;
}
// Grace past a move-dropped cast's computed end before we give up on its
// SPELL_GO and end it anyway (covers the SPELL_GO landing ~1 RTT after the
// bar's computed end).
constexpr int kMoveDroppedGraceMs = 750;
// Is the local player moving in a way that drops an in-progress cast? Mirrors
// the engine's own cast-drop movement test (CheckCast masks the player movement
// flags with MOVEFLAG_MASK_CAST_DROP). Used to tell a spurious movement drop of
// a movement-immune cast (grenades) from a real cancel/interrupt (stationary).
// False (the safe answer — clear normally) when the player object or its
// movement block is missing.
bool PlayerMoving() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return false;
const uint8_t *move = Game::Read<const uint8_t *>(
player, Offsets::OFF_UNIT_MOVEMENT_INFO_PTR);
if (move == nullptr)
return false;
return (Game::Read<uint32_t>(move, Offsets::OFF_MOVEMENT_FLAGS) &
Offsets::MOVEFLAG_MASK_CAST_DROP) != 0;
}
// Pushes an engine-ms timestamp to Lua as an UNSIGNED 32-bit value.
//
@@ -180,9 +244,9 @@ int CastTimeMs(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec != nullptr) {
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
Game::Read<uint32_t>(rec, Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
const uint32_t attrEx2 =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
Game::Read<uint32_t>(rec, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
if ((attr & SPELL_ATTR_RANGED) && !(attrEx2 & Offsets::SPELL_ATTR_EX2_AUTOREPEAT_FLAG))
ms += 500;
}
@@ -203,12 +267,12 @@ int ChannelDurationMs(int spellID) {
}
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
return *reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELL_NAMES + locale * 4);
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
return Game::Read<const char *>(rec, Offsets::OFF_SPELL_NAMES + locale * 4);
}
bool IsTradeskill(const uint8_t *rec) {
return (*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
return (Game::Read<uint32_t>(rec, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
SPELL_ATTR_TRADESPELL) != 0;
}
@@ -216,13 +280,13 @@ bool IsTradeskill(const uint8_t *rec) {
// already stores the full "Interface\Icons\..." path (unlike item icons,
// which are bare filenames) — so it's used verbatim, no prefix.
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_ICON_ID);
const int iconID = Game::Read<int>(rec, Offsets::OFF_SPELL_RECORD_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 *path = *reinterpret_cast<const char *const *>(iconRec + Offsets::OFF_SPELLICON_PATH);
const char *path = Game::Read<const char *>(iconRec, Offsets::OFF_SPELLICON_PATH);
return (path != nullptr) ? path : "";
}
@@ -312,8 +376,10 @@ void __fastcall CastStartSet_h(int spellID, int targetState) {
if (dur > 0) {
const int now = NowMs();
g_cast = TrackedSpell{spellID, now, now + dur, 0};
g_castFromServer = false; // client-tracked; VAR==0 clears it
g_channel.spellID = 0; // a cast supersedes any channel
g_castFromServer = false; // client-tracked; VAR==0 clears it
g_castMoveDropped = false; // fresh cast — clear the move-drop latch
g_channel.spellID = 0; // a cast supersedes any channel
g_castTargetGuid = 0; // target arrives with the confirming packet
}
// dur == 0: instant (no bar) or channel (handled via +0x228 poll);
// leave g_cast — don't clobber an unrelated active cast.
@@ -334,7 +400,7 @@ const Game::HookAutoRegister _castStartHook{
// times to `UnitChannelInfo` (validated against the live +0x228 field).
bool IsChannelSpell(const uint8_t *rec) {
return (*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
return (Game::Read<uint32_t>(rec, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
Offsets::SPELL_ATTR_EX_CHANNELED) != 0;
}
@@ -348,6 +414,7 @@ int RemoteChannelDurationMs(const uint8_t *rec) {
struct RemoteCast {
uint64_t casterGuid;
uint64_t targetGuid; // unit the caster is casting at, 0 if none
int spellID;
int startMs;
int endMs;
@@ -359,9 +426,10 @@ constexpr int kRemoteCastSlots = 64;
RemoteCast g_remoteCasts[kRemoteCastSlots];
int g_remoteCursor = 0;
void StoreRemoteCast(uint64_t caster, int spellID, int startMs, int endMs,
bool isChannel) {
const RemoteCast entry{caster, spellID, startMs, endMs, isChannel, true};
void StoreRemoteCast(uint64_t caster, uint64_t targetGuid, int spellID,
int startMs, int endMs, bool isChannel) {
const RemoteCast entry{caster, targetGuid, spellID,
startMs, endMs, isChannel, true};
// One active cast per unit — replace any existing entry for this caster.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == caster) {
@@ -397,7 +465,8 @@ const RemoteCast *FindRemoteCast(uint64_t caster) {
// time so genuine chained casts aren't wrongly deduped.
constexpr int kCastStartDedupMs = 500;
void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime,
uint64_t targetGuid) {
if (caster == 0 || spellID == 0)
return;
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
@@ -419,8 +488,10 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// 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)
if (dur > 0) {
StampChannel(spellID, now, now + dur);
g_castTargetGuid = targetGuid;
}
return;
}
// channel && castTime > 0 → a CAST-THEN-CHANNEL spell (Mind Control:
@@ -453,11 +524,14 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// pushback already accumulated). A no-op when client and server agree.
g_cast.endMs =
g_cast.startMs + static_cast<int>(castTime) + g_cast.delayMs;
g_castTargetGuid = targetGuid; // confirming packet carries the target
return;
}
g_cast = TrackedSpell{spellID, now, now + static_cast<int>(castTime), 0};
g_castFromServer = true;
g_channel.spellID = 0; // a cast supersedes any channel
g_castMoveDropped = false; // fresh cast — clear the move-drop latch
g_channel.spellID = 0; // a cast supersedes any channel
g_castTargetGuid = targetGuid;
return;
}
// A channeled spell with a cast time (Mind Control) is in its CAST phase
@@ -467,7 +541,7 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
const bool instantChannel = channel && castTime == 0;
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
StoreRemoteCast(caster, targetGuid, spellID, now, endMs, instantChannel);
// Phase 2 events: START (cast) or CHANNEL_START (channel). Skip pure
// instants (non-channel, castTime 0 → no bar); their SUCCEEDED still fires
// from the SPELL_GO hook.
@@ -478,15 +552,23 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// SMSG_SPELL_START parse. Body (mirrored from nampower's SpellStartHandler):
// itemGuid(packed), casterGuid(packed), spellId(u32), castFlags(u16),
// castTime(u32). Runs from the Net::PacketDispatch funnel with the cursor
// already positioned at the body.
// castTime(u32), then the SpellCastTargets block: targetMask(u16) and — when
// TARGET_FLAG_UNIT is set — a packed target GUID (the unit the cast is aimed
// at; the field that backs UnitSpellTargetName). Runs from the
// Net::PacketDispatch funnel with the cursor already positioned at the body.
void ParseSpellStart(Net::CDataStore *packet) {
Net::ReadPackedGuid(packet); // itemGuid (unused)
const uint64_t caster = Net::ReadPackedGuid(packet);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
Net::Read<uint16_t>(packet); // castFlags (unused)
const uint32_t castTime = Net::Read<uint32_t>(packet);
HandleSpellStart(caster, spellID, castTime);
// TARGET_FLAG_UNIT is the first field the server writes after the mask, so
// the packed GUID sits immediately after it (SpellCastTargets::write). Other
// target kinds (ground coords, item) carry no unit GUID → target is 0.
const uint16_t targetMask = Net::Read<uint16_t>(packet);
const uint64_t targetGuid =
(targetMask & TARGET_FLAG_UNIT) ? Net::ReadPackedGuid(packet) : 0;
HandleSpellStart(caster, spellID, castTime, targetGuid);
}
// SMSG_SPELL_DELAYED — cast pushback. The server only sends it to the
@@ -566,11 +648,20 @@ void HandleCastAborted(uint64_t guid, int spellID) {
// caster receives their own broadcast is server-dependent; if it never
// fires, the endMs self-expiry backstop still applies.
if (guid == Unit::Identity::PlayerGuid()) {
// Cast interrupts are surfaced by PollPlayer (g_castSucceeded). Player
// Cast interrupts are surfaced by PollPlayer / SpellFailed_h. Player
// CHANNELS never fire INTERRUPTED (retail only fires CHANNEL_STOP for
// them, interrupted or not), so there's nothing to do for a channel.
if (g_cast.spellID == spellID)
g_cast.spellID = 0;
if (g_cast.spellID == spellID) {
// A movement-immune cast (grenade) whose cast-state the movement
// path just cleared isn't really interrupted — the server completes
// it (issue #23). Keep tracking it (mark it move-dropped); OnWorldTick
// ends it on its SPELL_GO or a grace past its end. A real cancel /
// interrupt happens with the player NOT moving, so it clears here.
if (!MovementInterruptible(spellID) && PlayerMoving())
g_castMoveDropped = true;
else
g_cast.spellID = 0;
}
return;
}
// Phase 2: remote unit — the poll fires INTERRUPTED + STOP for it.
@@ -611,8 +702,7 @@ 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);
const int current = Game::Read<int>(unit, Offsets::OFF_UNIT_CAST_SPELL);
if (current != 0 && current == spellID)
HandleCastAborted(Unit::Identity::GuidForObject(unit), spellID);
}
@@ -662,9 +752,21 @@ void OnWorldTick() {
// so they're exempt — they expire on their computed endMs (self-expiry in
// PushCastInfo).
if (g_cast.spellID != 0 && !g_castFromServer &&
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
Game::Read<int>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
g_cast.spellID = 0;
// End a move-dropped cast (issue #23): the engine cleared its cast-state on
// movement, but the server completes it. HandleCastAborted kept it tracked;
// end it once its SPELL_GO has landed (→ PollPlayer STOP, no INTERRUPTED) or,
// if none ever comes, a grace past its computed end (→ honest INTERRUPTED +
// STOP). Gated on the latch, so normal casts are untouched.
if (g_cast.spellID != 0 && g_castMoveDropped &&
(Spell::CastEvents::PlayerCastSucceeded() ||
Time::Clock::Reached(static_cast<uint32_t>(g_cast.endMs + kMoveDroppedGraceMs)))) {
g_cast.spellID = 0;
g_castMoveDropped = false;
}
// A target-selection spell (Disenchant, ground-target AoE, …) stamps
// g_cast at the green-cursor step, NOT when the cast actually begins:
// Spell_C_CastSpell (0x006E4B60) calls the cast-start writer
@@ -680,9 +782,9 @@ void OnWorldTick() {
// SENT castGUID); on cancel it simply stays cleared. Gated on
// !g_castFromServer so it can never touch that server re-stamp.
if (g_cast.spellID != 0 && !g_castFromServer &&
*reinterpret_cast<const int *>(Offsets::VAR_SPELL_TARGETING_FLAGS) != 0 &&
Game::Read<int>(Offsets::VAR_SPELL_TARGETING_FLAGS) != 0 &&
g_cast.spellID ==
*reinterpret_cast<const int *>(Offsets::VAR_PENDING_CAST_SPELL))
Game::Read<int>(Offsets::VAR_PENDING_CAST_SPELL))
g_cast.spellID = 0;
// Detect a player CHANNEL that ended before its computed endMs — the
@@ -703,8 +805,8 @@ void OnWorldTick() {
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);
const int chan = Game::Read<int>(
desc, Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
if (chan == g_channel.spellID)
g_channelConfirmed = true;
else if (chan == 0 && g_channelConfirmed)
@@ -791,14 +893,14 @@ static int __fastcall Script_UnitChannelInfo(void *L) {
if (u == Resolve("player"))
return PushChannelInfo(L, g_channel.spellID, g_channel.startMs, g_channel.endMs, true);
auto *desc = *reinterpret_cast<const uint8_t *const *>(
static_cast<const uint8_t *>(u) + Offsets::OFF_UNIT_DESCRIPTOR);
auto *desc = Game::Read<const uint8_t *>(
u, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
// The live +0x228 field is authoritative for "is this unit channeling
// right now"; the SMSG_SPELL_START cache adds real start/end times when
// we observed the channel begin (and still matches the current spell).
const int spellID = *reinterpret_cast<const int *>(desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
const int spellID = Game::Read<int>(desc, Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
if (spellID == 0)
return 0;
const RemoteCast *rc = FindRemoteCast(Unit::Identity::GuidForObject(u));
@@ -807,6 +909,49 @@ static int __fastcall Script_UnitChannelInfo(void *L) {
return PushChannelInfo(L, spellID, 0, 0, /*haveTimes*/ false);
}
// The unit GUID `caster` is currently casting / channeling AT, or 0 when it
// isn't casting or the spell has no unit target (self / ground / item). The
// player comes from g_cast / g_channel + g_castTargetGuid, gated on an active
// cast so a stale target from a finished cast is never returned; other units
// come from the SMSG_SPELL_START cache while inside their cast window.
static uint64_t TargetGuidForCaster(uint64_t caster) {
if (caster == 0)
return 0;
if (caster == Unit::Identity::PlayerGuid()) {
const int now = NowMs();
const bool casting = g_cast.spellID != 0 && now < g_cast.endMs;
const bool channeling =
g_channel.spellID != 0 && (g_channel.endMs == 0 || now < g_channel.endMs);
return (casting || channeling) ? g_castTargetGuid : 0;
}
const RemoteCast *rc = FindRemoteCast(caster);
return (rc != nullptr && NowMs() < rc->endMs) ? rc->targetGuid : 0;
}
// `UnitSpellTargetName(unit)` — name of the unit that `unit` is currently
// casting / channeling a spell at, or nil when `unit` isn't casting, the spell
// has no unit target (self / ground / item), or the target's name can't be
// resolved (an off-screen stranger). ClassicAPI extension; the cast's target
// comes from the SMSG_SPELL_START target block, captured for the player and
// any remote unit whose cast we've observed.
static int __fastcall Script_UnitSpellTargetName(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSpellTargetName(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
const uint64_t caster =
(token != nullptr) ? Unit::Identity::GuidForToken(token) : 0;
const uint64_t targetGuid = TargetGuidForCaster(caster);
char name[64];
if (targetGuid == 0 || !Player::Info::NameFromGuid(targetGuid, name, sizeof name)) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
static void RegisterLuaFunctions() {
// Registered under C_Spell rather than as globals to avoid clobbering
// the global `UnitCastingInfo` / `UnitChannelInfo` names. Addons that
@@ -820,6 +965,9 @@ static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Spell", "CastingInfo", &Script_CastingInfo);
Game::Lua::RegisterTableFunction("C_Spell", "UnitChannelInfo", &Script_UnitChannelInfo);
Game::Lua::RegisterTableFunction("C_Spell", "ChannelInfo", &Script_ChannelInfo);
// A novel ClassicAPI name (no addon ships its own), so it's safe as a
// global — and it matches the `UnitSpellTargetName(unit)` call shape.
Game::Lua::RegisterGlobalFunction("UnitSpellTargetName", &Script_UnitSpellTargetName);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+2 -2
View File
@@ -44,9 +44,9 @@ static const char *LocaleNameForID(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return nullptr;
const int locale = *reinterpret_cast<const int *>(
const int locale = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
return *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
return Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
}
// Hook on the single name → spellbook-slot resolver
+32 -11
View File
@@ -19,6 +19,7 @@
#include "net/PacketReader.h"
#include "net/SendObserver.h"
#include "spell/Lookup.h"
#include "time/Clock.h"
#include "unit/Identity.h"
#include <cstdio>
@@ -66,8 +67,8 @@ constexpr uint32_t kAutoShotSpellId = 75;
constexpr uint16_t kTargetFlagUnit = 0x0002;
const char *LocalizedField(const uint8_t *rec, int fieldOffset) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
return *reinterpret_cast<const char *const *>(rec + fieldOffset + locale * 4);
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
return Game::Read<const char *>(rec, fieldOffset + locale * 4);
}
// Cast UID for a REAL (Type-3) cast — player and remote alike. Per the retail
@@ -178,6 +179,17 @@ int g_castGuid = 0; // castGUID counter value for the tracked cast
int g_castDelay = 0; // last-seen accumulated pushback (ms)
bool g_castSucceeded = false; // SPELL_GO seen for the tracked cast this cast
// UNIT_SPELLCAST_INTERRUPTED de-dup. SpellFailed_h and PollPlayer can both
// surface the same interrupt: the client fires Spell_C_SpellFailed AND drops the
// tracked cast (e.g. an Esc cancel of a normal spell, or a movement interrupt).
// SpellFailed_h stamps here when it fires INTERRUPTED; PollPlayer skips its own
// for the same spell within the window, so one interrupt = one event. A
// SuperWoW-style kick clears the cast WITHOUT a Spell_C_SpellFailed, so no stamp
// is made and PollPlayer still owns those.
int g_lastInterruptSpell = 0;
int g_lastInterruptTMs = 0;
constexpr int kInterruptDedupMs = 1000;
int g_chanSpell = 0;
int g_chanGuid = 0;
@@ -257,15 +269,14 @@ constexpr int kChanSuccDeferMs = 500;
bool IsChanneledSpell(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
return rec != nullptr &&
(*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
(Game::Read<uint32_t>(rec, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX) &
Offsets::SPELL_ATTR_EX_CHANNELED) != 0;
}
int NowMs() {
using TickMs_t = uint32_t(__fastcall *)();
return static_cast<int>(reinterpret_cast<TickMs_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))());
}
// Only used for relative windows (`now - stampMs < window`), where the int
// deltas cancel and stay correct across the wrap. Reader from the one
// canonical source; see `Time::Clock`.
int NowMs() { return static_cast<int>(Time::Clock::NowMs()); }
// ---- UNIT_SPELLCAST_FAILED (client-side cast rejection) ----------------
// Co-hook `Spell_C_SpellFailed` — the local player's own cast failures
@@ -305,6 +316,8 @@ void __fastcall SpellFailed_h(uint32_t spellId, int result, int unk1, int unk2,
int guid = g_castSpell == sid ? g_castGuid
: recentCast ? g_endedGuid
: NextCastGuid(sid);
g_lastInterruptSpell = sid;
g_lastInterruptTMs = now;
Fire(kInterrupted, sid, guid);
return;
}
@@ -426,6 +439,8 @@ RemoteEvt *AllocRemoteEvt(uint64_t guid) {
} // namespace
bool PlayerCastSucceeded() { return g_castSucceeded; }
void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
int channelSpellID, int channelStartMs) {
// ---- Regular cast -------------------------------------------------
@@ -441,7 +456,13 @@ void PollPlayer(int castSpellID, int castStartMs, int castDelayMs,
// completion's SPELL_GO (and thus OnPlayerSucceeded) has already run
// a frame earlier, so the flag reliably tells the two apart even
// though the engine routes both through the same abort choke point.
if (!g_castSucceeded)
// Skip if SpellFailed_h already fired INTERRUPTED for this cast (de-dup
// — see g_lastInterruptSpell); still fire STOP.
const bool alreadyInterrupted =
g_lastInterruptSpell == g_castSpell &&
Time::Clock::Elapsed(static_cast<uint32_t>(g_lastInterruptTMs)) <
static_cast<uint32_t>(kInterruptDedupMs);
if (!g_castSucceeded && !alreadyInterrupted)
Fire(kInterrupted, g_castSpell, g_castGuid);
Fire(kStop, g_castSpell, g_castGuid);
g_endedSpell = g_castSpell; // for SpellFailed_h's interrupt-vs-fail gate
@@ -531,14 +552,14 @@ void OnPlayerSucceeded(int spellID) {
void PollReticle() {
const bool active =
*reinterpret_cast<const int *>(Offsets::VAR_SPELL_TARGETING_FLAGS) != 0;
Game::Read<int>(Offsets::VAR_SPELL_TARGETING_FLAGS) != 0;
if (active) {
if (s_reticleSpell == 0) {
// Reticle just came up — report the pending spell. Read the spellID
// now (it's set by Spell_C_CastSpell before targeting begins); if
// it's transiently 0 this frame, retry next tick.
const int spellID =
*reinterpret_cast<const int *>(Offsets::VAR_PENDING_CAST_SPELL);
Game::Read<int>(Offsets::VAR_PENDING_CAST_SPELL);
if (spellID != 0) {
s_reticleSpell = spellID;
s_reticlePlaced = false;
+7
View File
@@ -69,6 +69,13 @@ void PollReticle();
// order matches modern (CHANNEL_START → SUCCEEDED).
void OnPlayerSucceeded(int spellID);
// True once a SPELL_GO has been seen for the currently-tracked player cast
// (set by OnPlayerSucceeded, reset when a new cast starts). `Spell::Cast` reads
// it to tell a completed cast from an interrupted one when deciding whether to
// keep a movement-immune cast alive through a spurious cast-global drop (the
// grenade case, issue #23).
bool PlayerCastSucceeded();
// Fire `UNIT_SPELLCAST_CHANNEL_UPDATE` for the local player. Called from the
// MSG_CHANNEL_UPDATE hook (`Spell::Cast`) when a pushback re-anchors the
// player's active channel end. Reuses the tracked channel's castGUID (set by
+44 -13
View File
@@ -11,10 +11,14 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `CastSpellNoToggle(name | spellID)` — spam-safe variant of
// `CastSpellNoToggle(name | spellID [, unit])` — spam-safe variant of
// `CastSpellByName` that won't toggle off an already-active self-aura
// or auto-repeat. Covers everything the modern `/cast !Name` syntax
// covers in vanilla terms:
// or auto-repeat. The optional second argument is a unit token to cast
// at (issue #22), e.g. `CastSpellNoToggle("Auto Shot", "focus")` or
// `CastSpellNoToggle("Shoot", "targettarget")`: the spell fires at that
// unit without changing your current target. When it is omitted the call
// behaves exactly as before. Covers everything the modern `/cast !Name`
// syntax covers in vanilla terms:
//
// - Auto-repeat: Shoot, Auto-Shot, Wand. Engine tracks via the
// `VAR_ACTIVE_AUTO_REPEAT_SPELL` global.
@@ -41,6 +45,7 @@
#include "Game.h"
#include "Offsets.h"
#include "spell/Arg.h"
#include "spell/AtUnit.h"
#include "spell/Lookup.h"
#include "spell/MacroPrimarySpell.h"
@@ -61,7 +66,7 @@ const CastSpellByName_t Script_CastSpellByName_Engine =
reinterpret_cast<CastSpellByName_t>(Offsets::FUN_SCRIPT_CAST_SPELL_BY_NAME);
int ReadActiveSpellID() {
return *reinterpret_cast<const int *>(
return Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_ACTIVE_AUTO_REPEAT_SPELL));
}
@@ -69,9 +74,9 @@ const char *LocaleName(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return nullptr;
const int locale = *reinterpret_cast<const int *>(
const int locale = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
return *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
return Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
}
// Case-insensitive name match for the auto-repeat-name gate. Walks
@@ -150,10 +155,25 @@ int __fastcall Script_CastSpellNoToggle(void *L) {
// could shift it and invalidate `s`.
std::snprintf(nameBuf, sizeof(nameBuf), "%s", s);
} else {
Game::Lua::Error(L, "Usage: CastSpellNoToggle(\"name\" | spellID)");
Game::Lua::Error(L, "Usage: CastSpellNoToggle(\"name\" | spellID [, \"unit\"])");
return 0;
}
// Optional arg2 — a unit token to cast at (issue #22), e.g.
// CastSpellNoToggle("Auto Shot", "focus"). Copy it off Lua's string
// heap now, before the toggle gates and the SetTop below can shift the
// stack. A non-string arg2 (nil / absent) leaves unitToken null and the
// call keeps its original single-arg behavior.
char unitBuf[64];
const char *unitToken = nullptr;
if (Game::Lua::Type(L, 2) == Game::Lua::TYPE_STRING) {
const char *u = Game::Lua::ToString(L, 2);
if (u != nullptr && *u != '\0') {
std::snprintf(unitBuf, sizeof(unitBuf), "%s", u);
unitToken = unitBuf;
}
}
// Check 1 — auto-repeat (Shoot / Auto-Shot / Wand). Cheap global
// read; the spell-cast subsystem owns this state separately from
// the aura system.
@@ -181,12 +201,23 @@ int __fastcall Script_CastSpellNoToggle(void *L) {
return 1;
}
// Neither toggle would fire — safe to cast. Delegate to the engine
// with a fresh, one-arg stack so `lua_toboolean(L, 2)` (the
// `onSelf` flag inside Script_CastSpellByName) sees nil → false.
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, nameBuf);
Script_CastSpellByName_Engine(L);
// Neither toggle would fire — safe to cast.
if (unitToken != nullptr) {
// Cast at the given unit without disturbing the current target —
// reuses AtUnit's resolve→GUID→dispatch core. A unit-target /
// auto-repeat spell fires straight at the unit; a ground spell lands
// at its feet. Casts by name so numeric and string input match the
// no-unit path (highest known rank). A genuinely unknown token raises
// the engine's standard "Unknown unit" error, same as UnitHealth.
Spell::AtUnit::CastByName(nameBuf, unitToken);
} else {
// Delegate to the engine with a fresh, one-arg stack so
// `lua_toboolean(L, 2)` (the `onSelf` flag inside
// Script_CastSpellByName) sees nil → false.
Game::Lua::SetTop(L, 0);
Game::Lua::PushString(L, nameBuf);
Script_CastSpellByName_Engine(L);
}
// Reflect the post-cast state: true if either toggle is now active.
const bool autoRepeatNow = ReadActiveSpellID() != 0;
+10 -4
View File
@@ -31,10 +31,16 @@ namespace Spell::Cooldown {
namespace {
// The engine writes *unsigned* millisecond ticks into `outStart`/`outDuration`
// (same OS counter, same epoch as `GetTime()`). Reading `outStart` into a
// signed `int` makes every timestamp negative once the machine has been up
// past 2^31 ms (~24.9 days) — `startTime + duration` then lands a month in the
// past and callers see every cooldown as already elapsed. Keep them unsigned;
// see `Time::Clock` for the full rationale.
using QueryCooldown_t = void(__fastcall *)(int spellID, int bookType,
int *outDuration,
int *outStart,
int *outEnable);
uint32_t *outDuration,
uint32_t *outStart,
uint32_t *outEnable);
int __fastcall Script_C_Spell_GetSpellCooldown(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
@@ -43,7 +49,7 @@ int __fastcall Script_C_Spell_GetSpellCooldown(void *L) {
auto fn = reinterpret_cast<QueryCooldown_t>(
static_cast<uintptr_t>(Offsets::FUN_SPELL_QUERY_COOLDOWN));
int durationMs = 0, startMs = 0, enable = 0;
uint32_t durationMs = 0, startMs = 0, enable = 0;
fn(spellID, 0 /* bookType=player */, &durationMs, &startMs, &enable);
Game::Lua::NewTable(L);
+91 -22
View File
@@ -129,26 +129,26 @@ static bool ReadSpellInfo(int spellID, SpellInfoData &out) {
const int locale = ReadGlobal<int>(Offsets::VAR_LOCALE_INDEX);
out.spellID = spellID;
out.name = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
out.rank = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
out.name = Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
out.rank = Game::Read<const char *>(record, Offsets::OFF_SPELL_RECORD_RANK + locale * 4);
out.iconPath = nullptr;
const int iconID = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_ICON_ID);
const int iconID = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_ICON_ID);
if (auto *iconRec = LookupSubRecord(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT, iconID)) {
out.iconPath = *reinterpret_cast<const char *const *>(iconRec + Offsets::OFF_SPELLICON_PATH);
out.iconPath = Game::Read<const char *>(iconRec, Offsets::OFF_SPELLICON_PATH);
}
out.cost = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_MANA_COST);
out.cost = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_MANA_COST);
const uint32_t attrEx2 = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
const uint32_t attrEx2 = Game::Read<uint32_t>(
record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX2);
out.isFunnel = (attrEx2 & SPELL_ATTR_EX2_HEALTH_FUNNEL) != 0;
out.powerType = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_POWER_TYPE);
out.powerType = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_POWER_TYPE);
out.castTimeMs = 0;
const int castIndex = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_CASTING_TIME_INDEX);
const int castIndex = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_CASTING_TIME_INDEX);
if (auto *castRec = LookupSubRecord(Offsets::VAR_SPELL_CAST_TIMES_RECORDS,
Offsets::VAR_SPELL_CAST_TIMES_COUNT, castIndex)) {
out.castTimeMs = *reinterpret_cast<const int *>(castRec + 4);
@@ -156,7 +156,7 @@ static bool ReadSpellInfo(int spellID, SpellInfoData &out) {
out.minRange = 0.0f;
out.maxRange = 0.0f;
const int rangeIndex = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_RANGE_INDEX);
const int rangeIndex = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_RANGE_INDEX);
if (auto *rangeRec = LookupSubRecord(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT, rangeIndex)) {
out.minRange = *reinterpret_cast<const float *>(rangeRec + 4);
@@ -276,7 +276,7 @@ static int __fastcall Script_C_GetSpellName(void *L) {
return 0; // nil for unknown spellID
const int locale = ReadGlobal<int>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
const char *name = Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
if (name == nullptr || *name == '\0')
return 0; // empty / no name in current locale → nil
Game::Lua::PushString(L, name);
@@ -289,12 +289,12 @@ static int __fastcall Script_C_GetSpellTexture(void *L) {
if (record == nullptr)
return 0;
const int iconID = *reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_ICON_ID);
const int iconID = Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_ICON_ID);
auto *iconRec = LookupSubRecord(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT, iconID);
if (iconRec == nullptr)
return 0;
const char *path = *reinterpret_cast<const char *const *>(iconRec + Offsets::OFF_SPELLICON_PATH);
const char *path = Game::Read<const char *>(iconRec, Offsets::OFF_SPELLICON_PATH);
if (path == nullptr || *path == '\0')
return 0;
Game::Lua::PushString(L, path);
@@ -315,7 +315,7 @@ static bool BuildSpellLink(int spellID, char *out, size_t outLen) {
if (record == nullptr)
return false;
const int locale = ReadGlobal<int>(Offsets::VAR_LOCALE_INDEX);
const char *name = *reinterpret_cast<const char *const *>(record + Offsets::OFF_SPELL_NAMES + locale * 4);
const char *name = Game::Read<const char *>(record, Offsets::OFF_SPELL_NAMES + locale * 4);
if (name == nullptr || *name == '\0')
return false;
const int n = std::snprintf(out, outLen, "|cff71d5ff|Hspell:%d:0|h[%s]|h|r",
@@ -392,8 +392,8 @@ static int PushIsPassive(void *L, int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0;
const uint32_t attr = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
const uint32_t attr = Game::Read<uint32_t>(
record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
Game::Lua::PushBool(L, (attr & Offsets::SPELL_ATTR_PASSIVE) != 0);
return 1;
}
@@ -438,11 +438,11 @@ static int __fastcall Script_C_IsSpellPassive(void *L) {
static bool PlayerKnowsSpell(int spellID) {
if (spellID < 1)
return false;
const int spellCount = *reinterpret_cast<const int *>(
const int spellCount = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
auto *bitmap = Game::Read<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
@@ -522,10 +522,10 @@ template <typename Pred>
static bool AnyEffectTarget(const uint8_t *record, Pred pred) {
if (record == nullptr)
return false;
auto *targetsA = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
auto *targetsB = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_B);
auto *targetsA = Game::Ptr<const uint32_t>(
record, Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
auto *targetsB = Game::Ptr<const uint32_t>(
record, Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_B);
for (int i = 0; i < 3; ++i) {
if (pred(targetsA[i]) || pred(targetsB[i]))
return true;
@@ -617,6 +617,69 @@ static const Game::HookAutoRegister _hookUnlearnSpell{
Offsets::FUN_UNLEARN_SPELL, reinterpret_cast<void *>(&UnlearnSpell_h),
reinterpret_cast<void **>(&g_origUnlearnSpell)};
// Enum.SpellBookItemType values (retail). Vanilla's spellbook only ever
// produces real spells, so we emit Spell (player book) or PetAction (pet
// book); None/FutureSpell/Flyout don't occur in 1.12.
static constexpr int kSpellBookItemTypeSpell = 1;
static constexpr int kSpellBookItemTypePetAction = 3;
static const Game::Lua::EnumIntegerEntry kSpellBookSpellBankEntries[] = {
{"Player", 0}, {"Pet", 1},
};
static const Game::Lua::EnumIntegerEntry kSpellBookItemTypeEntries[] = {
{"None", 0}, {"Spell", 1}, {"FutureSpell", 2}, {"PetAction", 3},
{"Flyout", 4},
};
// `C_SpellBook.GetSpellBookItemInfo(slotIndex, spellBank)` -> table.
// `slotIndex` is 1-based across the whole book; `spellBank` is
// Enum.SpellBookSpellBank (0 = Player, 1 = Pet). Returns nil for an empty or
// out-of-range slot. Vanilla's spellbook holds only real spells, so `itemType`
// is always Spell (player) or PetAction (pet).
//
// Field deviations from retail, all forced by 1.12 lacking the data:
// - `iconID` is the icon PATH string (vanilla has no fileID), same as
// C_Spell.GetSpellInfo.iconID -- feed it straight to texture:SetTexture.
// - `isOffSpec` is always false (vanilla has no specializations).
// - `skillLineIndex` is omitted (nil) -- spellbook tabs aren't SkillLines.
static int __fastcall Script_C_SpellBook_GetSpellBookItemInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // modern returns nil (not an error) for a bad index
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int bank = Game::Lua::IsNumber(L, 2)
? static_cast<int>(Game::Lua::ToNumber(L, 2))
: 0;
const int bookType = (bank == 1) ? 1 : 0; // 1 = pet book, else player
const int spellID = Spell::Lookup::SpellbookSlotToID(slot, bookType);
if (spellID <= 0)
return 0; // empty slot / out of range -> nil
SpellInfoData info;
if (!ReadSpellInfo(spellID, info))
return 0;
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
const uint32_t attr =
record ? Game::Read<uint32_t>(
record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES)
: 0u;
Game::Lua::NewTable(L);
SetField(L, "itemType",
static_cast<double>(bookType == 1 ? kSpellBookItemTypePetAction
: kSpellBookItemTypeSpell));
SetField(L, "actionID", static_cast<double>(spellID));
SetField(L, "spellID", static_cast<double>(spellID));
SetField(L, "name", info.name);
// subName carries the rank text ("Rank N"); modern uses "" when absent.
SetField(L, "subName", info.rank ? info.rank : "");
SetField(L, "iconID", info.iconPath); // path string -- see note above
SetFieldBool(L, "isPassive", (attr & Offsets::SPELL_ATTR_PASSIVE) != 0);
SetFieldBool(L, "isOffSpec", false);
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetSpellInfo", &Script_GetSpellInfo);
Game::Lua::RegisterGlobalFunction("GetSpellLink", &Script_GetSpellLink);
@@ -637,6 +700,12 @@ static void RegisterLuaFunctions() {
&Script_C_Spell_IsSpellHarmful);
Game::Lua::RegisterTableFunction("C_Spell", "IsSpellHelpful",
&Script_C_Spell_IsSpellHelpful);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSpellBookItemInfo",
&Script_C_SpellBook_GetSpellBookItemInfo);
Game::Lua::RegisterIntegerEnum("Enum", "SpellBookSpellBank",
kSpellBookSpellBankEntries, 2);
Game::Lua::RegisterIntegerEnum("Enum", "SpellBookItemType",
kSpellBookItemTypeEntries, 5);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+34 -34
View File
@@ -33,8 +33,8 @@ const uint8_t *PlayerDescriptor() {
auto *player = static_cast<const uint8_t *>(fn("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
return Game::Read<const uint8_t *>(
player, Offsets::OFF_UNIT_DESCRIPTOR);
}
// Builds (or returns the cached) set of every spellID that appears
@@ -51,9 +51,9 @@ std::unordered_set<int> &TalentSpellSet() {
static std::unordered_set<int> set;
if (!set.empty())
return set;
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
const uint8_t *const *records = Game::Read<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_TALENT_DBC_RECORDS));
const int count = *reinterpret_cast<const int *>(
const int count = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_TALENT_DBC_COUNT));
if (records == nullptr || count <= 0)
return set;
@@ -63,8 +63,8 @@ std::unordered_set<int> &TalentSpellSet() {
const uint8_t *rec = records[i];
if (rec == nullptr)
continue;
auto *ranks = reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_TALENT_SPELL_RANK);
auto *ranks = Game::Ptr<const uint32_t>(
rec, Offsets::OFF_TALENT_SPELL_RANK);
for (int j = 0; j < 9; ++j) {
const uint32_t spellID = ranks[j];
if (spellID != 0)
@@ -80,8 +80,8 @@ int SpellBaseLevel(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return 0;
return *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL);
return Game::Read<int32_t>(
record, Offsets::OFF_SPELL_RECORD_BASE_LEVEL);
}
// Spell.dbc attribute filter for the learnable-spell walk. Only the
@@ -110,8 +110,8 @@ bool SpellHiddenFromSpellbook(int spellID) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return false;
const uint32_t attributes = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
const uint32_t attributes = Game::Read<uint32_t>(
record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
return (attributes & SPELL_ATTR_HIDDEN_CLIENTSIDE) != 0;
}
@@ -134,7 +134,7 @@ constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
int LocaleIndex() {
return *reinterpret_cast<const int *>(
return Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
@@ -169,8 +169,8 @@ bool IsPositiveRankTarget(uint32_t t) {
// single-rank buffs (Power Infusion / Innervate / Blessing of Kings, all
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
bool HasRankString(const uint8_t *record) {
const char *rank = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_SPELL_RECORD_RANK + LocaleIndex() * 4);
const char *rank = Game::Read<const char *>(
record, Offsets::OFF_SPELL_RECORD_RANK + LocaleIndex() * 4);
return rank != nullptr && *rank != '\0';
}
@@ -178,19 +178,19 @@ bool HasRankString(const uint8_t *record) {
// the spell isn't subject to the ranked-positive-aura target-level rule.
int RequiredTargetLevel(const uint8_t *record) {
const int spellLevel =
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_SPELL_LEVEL);
Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_SPELL_LEVEL);
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
return 0;
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
Game::Read<uint32_t>(record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES);
if (attr & Offsets::SPELL_ATTR_PASSIVE)
return 0;
if (!HasRankString(record)) // single-rank spell → not gated
return 0;
auto *effect = reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_EFFECT);
auto *effect = Game::Ptr<const uint32_t>(record, Offsets::OFF_SPELL_RECORD_EFFECT);
auto *targetA =
reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
Game::Ptr<const uint32_t>(record, Offsets::OFF_SPELL_RECORD_EFFECT_IMPLICIT_TARGET_A);
bool positiveAura = false;
for (int i = 0; i < 3; ++i) {
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
@@ -221,13 +221,13 @@ int __fastcall Script_GetSpellLevelInfo(void *L) {
return 0;
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_SPELL_LEVEL)));
Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_SPELL_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_MAX_LEVEL)));
Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_MAX_LEVEL)));
return 3;
}
@@ -308,8 +308,8 @@ int __fastcall Script_GetCurrentLevelSpells(void *L) {
const uint8_t classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
const uint8_t raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
const int32_t playerLevel = *reinterpret_cast<const int32_t *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const int32_t playerLevel = Game::Read<int32_t>(
desc, Offsets::OFF_UNIT_FIELD_LEVEL);
if (classByte == 0 || raceByte == 0 || playerLevel <= 0)
return 1;
@@ -337,9 +337,9 @@ int __fastcall Script_GetCurrentLevelSpells(void *L) {
(1u << 0) | (1u << 1) | (1u << 2) | (1u << 3) | (1u << 4) |
(1u << 6) | (1u << 7) | (1u << 8) | (1u << 10);
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
const uint8_t *const *records = Game::Read<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS));
const int count = *reinterpret_cast<const int *>(
const int count = Game::Read<int>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT));
if (records == nullptr || count <= 0)
return 1;
@@ -357,14 +357,14 @@ int __fastcall Script_GetCurrentLevelSpells(void *L) {
if (rec == nullptr)
continue;
const uint32_t recClassMask = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SLA_CLASS_MASK);
const uint32_t recRaceMask = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SLA_RACE_MASK);
const uint32_t recExcludeClass = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SLA_EXCLUDE_CLASS);
const uint32_t recExcludeRace = *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_SLA_EXCLUDE_RACE);
const uint32_t recClassMask = Game::Read<uint32_t>(
rec, Offsets::OFF_SLA_CLASS_MASK);
const uint32_t recRaceMask = Game::Read<uint32_t>(
rec, Offsets::OFF_SLA_RACE_MASK);
const uint32_t recExcludeClass = Game::Read<uint32_t>(
rec, Offsets::OFF_SLA_EXCLUDE_CLASS);
const uint32_t recExcludeRace = Game::Read<uint32_t>(
rec, Offsets::OFF_SLA_EXCLUDE_RACE);
// Skip if explicitly excluded.
if ((recExcludeClass & playerClassBit) != 0)
@@ -406,8 +406,8 @@ int __fastcall Script_GetCurrentLevelSpells(void *L) {
if (!isClassSpell && !isRacial)
continue;
const int spellID = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SLA_SPELL_ID);
const int spellID = Game::Read<int>(
rec, Offsets::OFF_SLA_SPELL_ID);
if (spellID <= 0)
continue;
if (SpellBaseLevel(spellID) != queryLevel)
+2 -2
View File
@@ -53,7 +53,7 @@ static const char *MechanicName(uint32_t mechanicID) {
Offsets::VAR_SPELLMECHANIC_COUNT, mechanicID);
if (rec == nullptr)
return nullptr;
auto names = reinterpret_cast<const char *const *>(rec + Offsets::OFF_SPELLMECHANIC_NAME);
auto names = Game::Ptr<const char *const>(rec, Offsets::OFF_SPELLMECHANIC_NAME);
const char *s = names[0]; // enUS
return (s != nullptr && s[0] != '\0') ? s : nullptr;
}
@@ -65,7 +65,7 @@ static int __fastcall Script_GetSpellMechanicByID(void *L) {
return 0; // nil for invalid / out-of-range spell IDs
const uint32_t mechanicID =
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_SPELL_RECORD_MECHANIC);
Game::Read<uint32_t>(record, Offsets::OFF_SPELL_RECORD_MECHANIC);
Game::Lua::PushNumber(L, static_cast<double>(mechanicID));
+8 -8
View File
@@ -32,7 +32,7 @@ uint32_t PlayerSpellFamily() {
auto *unit = static_cast<const uint8_t *>(resolve("player"));
if (unit == nullptr)
return 0;
auto *desc = *reinterpret_cast<const uint8_t *const *>(unit + Offsets::OFF_UNIT_DESCRIPTOR);
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
const uint8_t classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
@@ -40,7 +40,7 @@ uint32_t PlayerSpellFamily() {
Offsets::VAR_CHRCLASSES_COUNT, classByte);
if (cls == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(cls + Offsets::OFF_CHRCLASSES_SPELL_FAMILY);
return Game::Read<uint32_t>(cls, Offsets::OFF_CHRCLASSES_SPELL_FAMILY);
}
} // namespace
@@ -49,18 +49,18 @@ float Apply(const uint8_t *spellRecord, int op, float base) {
if (spellRecord == nullptr)
return base;
const uint32_t familyName = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_NAME);
const uint32_t attrEx3 = *reinterpret_cast<const uint32_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3);
const uint32_t familyName = Game::Read<uint32_t>(
spellRecord, Offsets::OFF_SPELL_RECORD_FAMILY_NAME);
const uint32_t attrEx3 = Game::Read<uint32_t>(
spellRecord, Offsets::OFF_SPELL_RECORD_ATTRIBUTES_EX3);
if (familyName == 0 ||
(attrEx3 & Offsets::SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS) != 0)
return base;
if (familyName != PlayerSpellFamily())
return base;
const uint64_t familyFlags = *reinterpret_cast<const uint64_t *>(
spellRecord + Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
const uint64_t familyFlags = Game::Read<uint64_t>(
spellRecord, Offsets::OFF_SPELL_RECORD_FAMILY_FLAGS);
if (familyFlags == 0)
return base;
+2 -2
View File
@@ -64,8 +64,8 @@ int __fastcall Script_GetSpellPowerCost(void *L) {
if (cost == 0xFFFFFFFF)
return 0; // engine error (no player context, etc.)
const int powerType = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_POWER_TYPE);
const int costPercent = *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_RECORD_MANA_COST_PERCENT);
const int powerType = Game::Read<int>(rec, Offsets::OFF_SPELL_RECORD_POWER_TYPE);
const int costPercent = Game::Read<int>(rec, Offsets::OFF_SPELL_RECORD_MANA_COST_PERCENT);
if (cost == 0 && costPercent == 0)
return 0; // nil — no resource cost
+3 -3
View File
@@ -61,14 +61,14 @@ namespace Spell::Radius {
static float MaxBaseRadius(const uint8_t *record) {
float best = -1.0f;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int radiusIndex = *reinterpret_cast<const int *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX + i * 4);
const int radiusIndex = Game::Read<int>(
record, Offsets::OFF_SPELL_RECORD_EFFECT_RADIUS_INDEX + i * 4);
const uint8_t *rad = DBC::Record(Offsets::VAR_SPELL_RADIUS_RECORDS,
Offsets::VAR_SPELL_RADIUS_COUNT,
static_cast<uint32_t>(radiusIndex));
if (rad == nullptr)
continue;
const float r = *reinterpret_cast<const float *>(rad + Offsets::OFF_SPELL_RADIUS_VALUE);
const float r = Game::Read<float>(rad, Offsets::OFF_SPELL_RADIUS_VALUE);
if (r > best)
best = r;
}
+2 -1
View File
@@ -13,6 +13,7 @@
#include "Range.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "unit/Position.h"
@@ -41,7 +42,7 @@ bool SpellIDHasRange(int spellID) {
if (rec == nullptr)
return false;
const uint32_t rangeIdx =
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_SPELL_RECORD_RANGE_INDEX);
Game::Read<uint32_t>(rec, Offsets::OFF_SPELL_RECORD_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,

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