The engine resolver FUN_OBJECT_RESOLVE_BY_GUID funnels unconditionally
into FUN_00464890, whose first action loads the registry root from
[VAR_LOCAL_PLAYER_PTR] and derefs its +0x24 hash mask with no null
check. That root is NULL out of world (login, loading screens, logout
teardown), so any resolve reaching it mid-transition dies with ERROR
#132 (MOV EAX,[EDX+0x24], EDX=0) - hit in the wild by Aura::Source's
SMSG_SPELL_GO path, whose Store/Claim eviction consults
DescriptorListsAura while packets still dispatch during transitions.
Promote the readiness predicate from Unit::TokenObserver's private
RegistryReady into Object::RegistryReady (object/Resolve.h) and gate
ByGuid on it, returning nullptr out of world. That is both the
contract ByGuid's comment already promised ("pre-world ... safe from
any context") and provably behavior-preserving: a zero result is the
engine's own handled "not found" case (verified in Ghidra - 0x468460
null-checks the node; 0x464870 itself returns 0 for guid==0), and out
of world the call had no non-crashing outcome. Every ByGuid call site
already null-checks. TokenObserver now delegates to the shared helper.
Fixes#34
Tooltip::SetEvents kept its handler cells in a fixed 32-entry table keyed
by tooltip pointer, assuming tooltips are permanent. They are not: every
/reload (and logout) destroys and recreates the tooltip objects, so the
table gained ~2 dead cells per reload with no eviction. After ~18 reloads
it filled, the resolver co-hook returned 0 for new tooltips, and
SetScript/HookScript failed with "doesn't have a 'OnTooltipSetItem'
script". Worse, a stale cell whose address the allocator recycled into a
new tooltip fired its old handler ref into the reset Lua registry - a
dead-epoch ref, garbage call, crash.
Add Tooltip::SetEvents::PrepareForReload() - a full cell-table clear -
and call it from FrameScript_Initialize_h alongside the other per-reload
resets. The clear is semantically right, not just leak plumbing: handler
refs never survive the Lua reset, so post-reload every cell held garbage
regardless; addons re-register in OnLoad and get fresh cells.
Fixes#33
* feat: port SecureActionButtonTemplate + CreateFrame comma-split
- Hook Script_CreateFrame (0x007060B0) to split comma-separated template
strings. Vanilla only accepts a single template name; modern addons pass
lists like 'UIPanelButtonTemplate, SecureActionButtonTemplate'.
- Add SecureTemplates.xml registering SecureFrameTemplate,
SecureActionButtonTemplate, SecureUnitButtonTemplate, and
InsecureActionButtonTemplate as real virtual XML templates.
- Add SecureTemplates.lua with modifier/button attribute resolution helpers
(SecureButton_GetModifiedAttribute, GetModifierPrefix, GetButtonSuffix,
GetModifiedUnit) and OnClick dispatchers.
- Remove duplicate SecureButton_GetAttribute/GetUnit from SecureStateDriver.lua
(now provided by SecureTemplates.lua which loads earlier).
- Include SecureTemplateTest debug addon for verification.
* Include AuraUtil.lua in ClassicAPI.toc
mistake from merge
* fix(frame): apply every template in a CreateFrame comma list
Vanilla 1.12 CreateFrame resolves the 4th arg as a single template name.
The initial comma-split hook only applied one template, and its tokenizer
dropped everything after the first entry for the common "A, B" format
(writing the NUL terminator clobbered the delimiter the advance step
checked). Rework it into real multi-inheritance:
- SplitTemplates: record whether a comma was hit before writing the
terminator, so "A, B" / "A,,B" / leading-comma inputs all split.
- CreateFrame_h: build the frame with the first resolved template through
the engine, then graft each remaining template onto the created object
with the engine's own inherit primitive - content (frameObj->vtable[+8],
FUN_00769820) and child <Frames> ((frameObj+0x24)->vtable[+8],
FUN_0076a060), the two __thiscall calls the builder FUN_006ee280 emits
per inherited node. Regions, backdrop, attributes, script handlers, and
child frames all apply, in left-to-right override order; OnLoad runs once.
- Offsets: add FUN_FRAMESCRIPT_STATUS_RESET (the XML-build error
accumulator reset, __thiscall) and document the status object layout.
Also fix the relocated SecureButton_GetUnit/GetModifiedUnit pet-token
pattern: "[^%%d]"/"[%%d]" matched literal % and d instead of the digit
class %d, so raid1pet never normalized to raidpet1. Restore single %d.
Verified in-game: a comma list applies both templates' regions/text in
either order; pet-suffix normalization works.
* refactor(secure): tidy SecureTemplates review follow-ups
- Guard RunMacroText (absent on this client) so a type=macro button with
only a macrotext attribute no-ops instead of erroring.
- Honor a SecureUnitButton_OnLoad menufunc: the menu/togglemenu action now
calls self.menu when set, else falls back to ClassicAPI_ToggleUnitMenu
(self.menu was previously stored but never read).
- InsecureActionButtonTemplate now inherits SecureActionButtonTemplate
instead of duplicating its OnClick block.
- Drop the committed SecureTemplateTest debug addon; test harnesses live in
the WoW install, not the repo.
* refactor(secure): make Frame::Attributes the single click dispatcher
The Lua SecureActionButton_OnClick from the secure-templates work
duplicated the native C++ dispatcher and only backfilled the verbs C++
lacked. Fold those into DispatchVerb so C++ is the sole dispatcher for
every secure button (template or plain SetAttribute), and drop the Lua
redundancy.
- DispatchVerb gains `action` (UseAction), `pet` (CastPetAction), `click`
(forward to the frame in the `clickbutton` attribute via
delegate:Click), and a custom fallback (a function stored as a raw
field on the frame named by the verb). `menu`/`togglemenu` now cancels
spell-targeting when a spell is on the cursor (the old
SecureUnitButton_OnClick nuance).
- Numeric spell IDs work as the `spell` attribute: SetAttribute("spell",
id) (a Lua number) or a numeric string cast the EXACT rank by ID via
the spellbook-slot resolver (Spell::AtUnit::CastByID at a unit,
Spell::AtCursor::DispatchSpellCast with none) - not name resolution,
which fired the highest known rank.
- CopyAttr coerces a number-valued attribute to its digits, so numeric
IDs also read for `item` and any other string attribute.
- Remove SecureActionButton_OnClick / SecureUnitButton_OnClick from
SecureTemplates.lua, simplify SecureUnitButton_OnLoad, and strip the
<OnClick> from the templates (plain Button templates now; the C++
closure installs on SetAttribute("type*")). The attribute-reader
helpers stay - public API and SecureStateDriver depend on them.
* fix(frame): restore the Lua stack after the multi-template graft
The comma-template graft can run a grafted template's child-frame OnLoad,
and in this hook-saturated environment a nested Lua op can shift the stack
slot the caller reads its return value from. Snapshot lua_gettop after the
engine build and restore it once the graft finishes, so the caller always
reads back the frame the engine left. Mirrors the Tooltip::SetEvents
save/restore discipline.
---------
Co-authored-by: Brues <5278969+brues-code@users.noreply.github.com>
C_UnitAuras.GetAuraDataByIndex builds a fresh Lua table per call, churning
Lua 5.0's mark-sweep GC when addons scan many units' auras in an OnUpdate
loop. Add Blizzard's own answer: positional (multi-return) accessors plus the
AuraUtil helper library, so hot loops allocate nothing.
DLL: C_UnitAuras.UnitAura / UnitBuff / UnitDebuff return the 15-value
Classic-Era UnitAura tuple with no table allocation. Built on a shared
Emit { Table, Positional } seam through PushEnriched -- selection, cache
fallback, out-of-range group, and timing resolution are the same code as the
table path; only the terminal leaf differs (BuildTable vs the new
PushPositional, both fed by an extracted ResolveDisplay). Namespaced under
C_UnitAuras so the native UnitBuff/UnitDebuff globals keep their vanilla
short return.
Addon: Util/AuraUtil.lua backports ForEachAura / FindAura / FindAuraByName /
UnpackAuraData over those accessors. ForEachAura (non-packed) and FindAura
both source from the positional accessor, so they allocate nothing per aura --
FindAura included, where retail itself allocates a table per aura scanned.
Verified in-game: positional values match GetAuraDataByIndex field-by-field
(#12 isBossDebuff / #13 castByPlayer differ by design, matching retail's
UnitAura-vs-AuraData divergence); ForEachAura counts match GetUnitAuras; and
the positional path allocates ~0 KB/scan where the table path allocates
~1.3 KB per aura.
Fixes#30
Add /Zi (compiler) + /DEBUG (linker) so the Release build produces a
line-level ClassicAPI.pdb alongside the existing linker map. /OPT:REF and
/OPT:ICF stay explicit so /DEBUG does not re-enable dead code and the
shipped DLL does not grow; the PDB is a separate file matched to the DLL
by signature.
CI release runs now bundle the pdb + map into one crash-symbols zip
attached to the release, replacing the loose renamed map. The zip name is
kept unappealing to normal players and carries the tag so a dev can match
symbols to the exact build.
ClassicAPI extension (not a 3.3.5 conditional). [known:spellID] uses
IsPlayerSpell (broad knowledge: spellbook, talents, recipes, racials);
[known:name] falls back to a spellbook name scan via GetSpellName, since
1.12 has no name-to-id resolver -- so a name matches castable spellbook
entries only.
Verified in-game: [known:133] false on a non-Mage, [known:45651] and
[known:Aspect of the Viper] both true by id and by name.
RegisterStateDriver / RegisterUnitWatch / attribute drivers, driven by a
Lua SecureCmdOptionParse whose macro-conditional grammar is reverse-
engineered from the 3.3.5 client. Builds entirely on the shipped frame
attribute system -- no DLL change.
- MacroOptions.lua: parse-once cached SecureCmdOptionParse. 33 conditions
mapped to 1.12 / ClassicAPI queries; states 1.12 lacks (flying, vehicle)
are constant-false, an unknown keyword warns once and never matches.
- SecureStateDriver.lua: 0.2s-poll manager with instant event rescans,
state-visibility Show/Hide, state-* attribute drivers, unit-existence
watch, SecureButton_GetAttribute/GetUnit. Registers via plain tables +
an immediate top-level resolve instead of the upstream attribute
bootstrap, so the synchronous OnAttributeChanged recursion guard never
swallows a target frame's handler.
Verified in-game: parser grammar, both drivers, and druid [stance:N] bar
numbering.
Backport frame:CreateMaskTexture plus texture:AddMaskTexture /
RemoveMaskTexture / GetNumMaskTextures / GetMaskTexture -- positioned,
rect-based masks, the object form later clients use (SetMask(path) stays
the simple single-shape form).
Positioned masks sample via texgen (object-space vertex position through
a per-unit texture matrix), the mechanism of the engine's own minimap
disc mask, so no vertex UV stream is consumed per mask. That lifts both
v1 limits at once:
- Up to 7 masks apply at once (device stage cap); a texture shows only
the intersection of all of them.
- mask:SetRotation rotates the clip shape (folded into the matrix), and a
mask can move/resize each frame, so the clip animates.
State is restored in a co-hook on the indexed submit (FUN_GX_PRIM_INDEXED)
the engine issues after the stream call, not inside the stream hook --
restoring earlier reverts the state the queued draw is flushed with.
Two incidental fixes: a SetTexCoord-cropped base now masks against the
same rect the engine's corner store uses, and a recycled mask region is
detached from every base still holding it.
Offsets.h: add the texgen / coord-transform / texture-matrix / state-journal
/ indexed-submit constants (decoded from the D3D9 backend state flush
FUN_005a2f00), and fix the FUN_GX_TEXUNIT_ENABLE/_DISABLE mislabel (they
are texture-matrix push/pop, not stage enable/disable).
Fold the thin "Modern text rendering" and "Modern addon loading" sections into
one "Modern client behaviors" table, and showcase two more event-driven
features:
- Event-driven nameplates: the C_NamePlate API + NAME_PLATE_UNIT_ADDED/REMOVED
events + nameplateN unit tokens.
- Focus target: FocusUnit/ClearFocus, PLAYER_FOCUS_CHANGED, the focus /
focustarget tokens, and the predefined FOCUSTARGET / TARGETFOCUS keybinds.
Highlights is now two groups: Lua 5.1 compatibility and Modern client behaviors.
Two README tweaks:
- Remove the "SavedVariables loaded first" row from the Modern addon loading
highlights -- compatibility plumbing, not a showcase feature. Still fully
documented in docs/API.md.
- Move Installation below Full API reference, so the page leads with the whole
showcase (features + the complete API surface) before install steps.
Drop the Table lengths and Environment protection rows from the Lua 5.1
compatibility table -- both are compatibility plumbing, not features anyone
installs for. They stay fully documented in docs/API.md. The showcase now
lists Syntax, String methods, and Script-handler arguments.
Mention the scale up front (550+ functions and 50+ events) and bump the Calls
catalog summary from the stale "400+" to match. Counts are approximate on
purpose so they don't go stale on every addition.
Rewrite the prose the restructure introduced so it reads naturally instead of
like a controlled-language manual: merge the intro's clipped two-sentence
opener, drop the "transparent / no API to call / addons use it automatically"
clunk from the Highlights blurb, and replace "The complete surface." in the API
reference intro. No structural or content changes.
Restructure so the flagship features surface first. The page used to open
with a ~60-row function catalog and buried the headline behaviors (Lua 5.1,
inline textures, modern addon loading) in a table at the very bottom.
- Promote the former "Behaviors" table to a top "Highlights" section,
reordered Lua-5.1-first (syntax, string methods, table lengths, environment
protection, script-handler arguments), then modern text rendering, then
modern addon loading. No rows dropped.
- Move Installation up, ahead of the reference.
- Group the full catalog under "Full API reference" with each table (Calls,
GlueXML, Macros, Console, Events, Globals, Unit tokens, Bindings) behind a
<details> block. Content is byte-for-byte unchanged -- every function,
event, global, and token stays in the file and all docs/API.md links are
intact.
Cartographer_Notes gates a WorldMap release-spirit hook behind the Ace2-era
`lua51` probe (loadstring("return function(...) return ... end")), treating a
5.1-capable Lua as a modern client:
if lua51 then
WorldMapDeathRelease:SetScript("OnMouseUp", func)
end
Our 5.1 syntax backport makes that probe pass, so Cartographer takes the modern
branch and hooks WorldMapDeathRelease -- a WorldMap frame 1.12 never had. The
nil index crashes Cartographer_Notes:OnEnable. On real vanilla the probe was
false and the line never ran.
Same 5.1-probe fallout as the Dewdrop table-length heal: making the client pass
5.1 probes makes addons assume modern-client frames exist. Provide an inert,
hidden WorldMapDeathRelease stub (gated on Cartographer being installed) so the
hook succeeds and does nothing -- the vanilla outcome. The embedded
!!!ClassicAPI addon loads first, so the frame exists before Cartographer's
OnEnable.
Fixes#29
Backports the modern Texture:SetMask, clipping a texture to a mask's alpha
shape - round portraits, round minimap buttons, and other shaped art. Vanilla
1.12 has no general masking (only the special-cased minimap disc).
Mirrors the engine's own masked-quad draw (the minimap blip mask FUN_004eae10):
a co-hook on the vertex-stream primitive FUN_0058a2a0 recovers the owning
CSimpleTexture from the batch positions pointer (region = positions -
OFF_SIMPLETEXTURE_CORNERS), and for a region with a mask set binds the mask on
texture unit 1 with a MODULATE combiner, then re-runs the engine's own submit
with uv1 = uv0 so unit 1 samples the mask at the texture's own coordinates. The
unit is enabled/disabled around the draw so the mask never leaks onto other
textures. A cold co-hook on the region ctor clears a region's mask on pooled
reuse; a kill switch and SEH latch guard the draw path.
The recipe was pinned by an in-game probe on the live GL backend and matched
against FUN_004eae10; the full RE is in the "Texture masking" block in Offsets.h
and the Mask.cpp header. Masks must be BLP or uncompressed 32-bit TGA with the
shape in the alpha channel; two round masks ship in the client, other shapes are
addon-supplied content.
A ScrollingMessageFrame re-SetTexts every visible line on each new message, and
the engine builds each line lazily at paint. The icon regions are separate
objects whose placement is only known once a line builds mid-paint - after the
frame's texture pass already drew - so on a content change the new glyphs
appeared one frame before their icons caught up.
Co-hook the SMF display refresh (FUN_00788750), which runs pre-render from the
frame's own methods. After it, force each icon-bearing line to resolve+build via
the engine's own Realize(0) -> RebuildString -> ensure-built path and apply its
icon placement synchronously, before the frame draws. The placement math
(ComputePlacementsForNode) and the per-fs apply (ApplyDirtyBranch) are shared
with the existing paint-flush / FrameTick pipeline, so the later flush dedups to
a no-op.
Additive and best-effort: any line whose rect is not resolvable in time falls
back to the unchanged paint + tick pipeline (correct, one-frame lagged). Gated by
a new _classicapi_InlineTexEarly toggle (default on) with an SEH latch; the whole
feature keeps its _classicapi_InlineTexEnable kill switch.
* README behaviors row + API.md section for the 5.1 table-length
healing now describe the behavior (cleared tables count as empty
without table.setn; arg tables and setn callers unaffected) instead
of the implementation and the Dewdrop/FuBar bug story -- that history
lives in the commit message and the Length.cpp comment.
* Release workflow ships the linker map as
USE_FOR_DEBUGGING_CAPI_CRASHES.map (copy before upload), so users
know to attach it to crash reports -- it resolves the logical
addresses in an ERROR #132 stack trace to function names.
Tokenize never cleared its output vector, and RunPasses re-lexes into
the SAME vector after each pass that rewrites the buffer -- so every
chunk that triggered two rewriting passes walked a DOUBLED token
stream: stale tokens at old-buffer positions, then fresh tokens
starting back at 0. Two field-real failure modes:
* CRASH (hex pass -> vararg pass): a fresh token's t.start lands
behind the splice cursor, `t.start - p` underflows as size_t, and
append() throws std::length_error("string too long") straight
through the loadbuffer hook into the engine -- fatal ERROR #132 at
RaiseException. Reported from the field on WeakAuras' bundled
Chomp\Internal.lua (hex BITS table + varargs); reproduced offline
byte-for-byte with a standalone harness compiled from this file
against the actual upstream Internal.lua.
* SILENT CORRUPTION (vararg pass -> ops pass): the same doubled stream
made the operator pass read stale in-string positions as code -- a
`%` INSIDE a string literal became __mod, emitting syntactically
invalid output (diff-proven: Chomp's `channel:gsub("^[^%:]+", ...)`
shipped as `channel:__mod(__mod(gsub("^[^,:]+",)) ...)`). Such files
failed to compile behind WoW's soft error() -- dead file, half-loaded
addon, no visible error. Any chunk combining `...` with `#`/`%` was
at risk.
Fixes:
1. Tokenize clears at entry (the root fix). Verified on the real
Chomp file: full pipeline transpiles cleanly -- hex -> exact
decimals, (...) -> (unpack(arg)), #q -> __len(q), string-literal
`%` untouched, the previously-mangled gsub line correct.
2. Catch-all guards at both hook boundaries (LoadBuffer_h and the
_classicapi_Transpile diagnostic): any future C++ exception from
the rewrite machinery falls back to compiling the ORIGINAL buffer
untouched -- worst case the chunk behaves as stock 1.12, never a
process kill.
The 5.1 syntax backport made the Ace2-era Lua-version probe pass:
local lua51 = loadstring("return function(...) return ... end")
and true or false
Every Ace2 library gates on it, and Dewdrop-2.0 (the menu library
behind FuBarPlugin minimap buttons -- SuperAPI, NampowerSettings) uses
it to no-op table.setn: correct on real 5.1, where lengths are computed
borders, but wrong on this VM, whose table functions still read 5.0's
out-of-band length. Cleared-and-reused menu tables kept stale lengths,
table.insert appended past the nil'd slots, and the menus silently
broke ("clicking the minimap button does nothing"). Bisect confirmed:
forcing the probe back to 5.0 (VarargExpansion off) restores them.
Since we made the client PASS 5.1 probes, 5.1 length semantics must
hold. The engine funnels every length read through luaL_getn
(LUAL_GETN = 0x6F5050 -- table.insert/getn/remove/concat/sort/foreachi,
plus our unpack), so one co-hook closes the gap for every consumer:
1. reported slot populated -> unchanged (one rawgeti of cost);
2. t[n] nil but t.n is a number -> unchanged: an explicit n field is
the 5.0 vararg-arg contract, where trailing nils are intentional
(unpack({10,nil,30,n=3}) keeps all three slots);
3. otherwise the out-of-band length is stale (keys cleared without
setn) -> return the true border, the 5.1 answer. Read-only, no
write-back: reads never mutate state, and the engine's own
table.insert re-syncs the stored length on the next append.
table.setn keeps working; the heal only changes the answer when the
stored length points past the last populated slot -- exactly the case
where 5.0 and 5.1 disagree.
Docs: API.md "Stale table lengths (5.1 healing)" section + README
behaviors row.
Fixes#29
Systematic sweep of the Lua 5.1 stdlib delta against what ClassicAPI
already backports found five gaps, all confirmed by in-game probes:
* unpack(list [, i [, j]]) -- 5.0's unpack silently ignored the range
args, so unpack(t, 2) returned the WHOLE table (wrong values, no
error; the only silent gap). Replaced with a port of 5.1's
luaB_unpack: ranges via luaL_getn + lua_rawgeti, so a vararg arg
table's n field keeps its embedded-nil count. Both states.
* string.gsub table replacement -- 5.0 accepts only string/function
and errors on a table. Shim on string.gsub AND the bare gsub global:
a table becomes an adapter closure over the engine's own gsub
(FUN_LUA_STR_GSUB = 0x007FD0E0, pinned by registration-table
position, name string, and decode). Because this engine's add_value
drops a non-string function result (match -> empty) where 5.1 keeps
the match on nil/false, the shim wraps the pattern in one outer
capture (^/$ anchors kept outside) so the closure receives the whole
match and can hand it back for missing keys -- exact 5.1 keep-match.
Patterns with a %1..%9 backref skip the wrap (renumbering hazard);
their missing-key behavior stays the engine's (documented residual).
* collectgarbage 5.1 options -- "count" -> gcinfo, "collect" ->
original collectgarbage(0), "step" -> loop-safe true, stop/restart/
setpause/setstepmul -> no-op 0, numbers forwarded untouched. The
originals are function objects stashed in the state's registry
before the overwrite, so no new engine offsets and each /reload
re-stashes the fresh engine original. Both states.
* table.maxn -- 5.1 luaB_maxn port (largest positive numeric key,
full next-walk so nil holes don't stop it). Both states.
* coroutine.running -- returns the running coroutine, nil on the main
thread. 5.0 has no lua_pushthread, so the thread TValue (tag 8 +
the running L, this build's {tt@0, value@+8} layout) is written to
the stack raw; main-thread detection via the registration-time L.
Verified in-game: ranged/holed/n-field unpack shapes, all four gsub
shapes (populated table, keep-match on missing key, whole-match key,
anchored pattern), maxn past a nil hole, collectgarbage("count"/
"collect"), and coroutine.running from main + inside a wrap.
Docs: five new API.md sections + TOC entries; README Lua list updated.
Follow-up hardening for the 5.1 string-method backport (9b9d80d):
* _classicapi_SetStringMethods(false) -- diagnostic kill switch on both
states, mirroring _classicapi_SetTranspileOption. While off the hook
forwards everything to the original resolver: exact stock behavior.
* Close both stale-capture windows by clearing each slot BEFORE its
state can die, instead of relying on "no Lua runs in the window":
- game slot: PrepareForReload (new StringMethods.h), called from
FrameScript_Initialize_h before the world Lua state is reset --
covers /reload AND /logout;
- glue slot: cleared at the top of the in-game registration -- once
world scripts are loading the glue state is gone, and the next glue
boot re-captures before any glue Lua runs, so a recreated glue
global_State at the same address can never match a stale capture.
A cleared slot fails closed (stock "attempt to index" error) until
its re-capture.
* StringLib.cpp: replace the obsolete "method sugar can't work" header
NOTE -- both call forms resolve through the same string table now.
* docs: kill-switch bullet in the API.md String methods section.
Lua 5.0 has no string methods at all -- the shared string metatable with
__index = string is a 5.1 addition, and both halves of the would-be
mechanism in this build match stock 5.0 exactly: lua_setmetatable
(0x006F4020) accepts only tables/userdata, and luaT_gettmbyobj
(0x006F7BD0) hardcodes &nilobject for every other type. So nothing can
be re-enabled; the 5.1 read path has to be added.
Transplant 5.1's mechanism at the one choke every string index reaches:
co-hook FUN_LUA_INDEX_NONTABLE (0x006F7EE0, luaV_gettable's non-table
continuation; 3 call sites -- the gettable else-branch plus
luaV_execute's OP_GETTABLE/OP_SELF fallbacks). When the indexed object
is a string, answer with luaV_gettable(L, <string library table>, key,
loop) -- byte-for-byte what the original's own TM-table branch does had
gettmbyobj returned that table, loop passed through unchanged. The
engine's unmodified lookup then yields exact 5.1 semantics, including
nil (not an error) for unknown keys. Non-strings fall through to the
original; settable on a string is a different continuation, untouched.
The site is cold: table __index dispatch (all frame/OO traffic) reads
the table's own metatable via fasttm (FUN_006F7D60) and never lands
here -- today's only callers are about-to-error non-table indexes.
One captured slot per Lua state (in-game + glue), keyed by global_State
so coroutine threads resolve too; each capture anchors the string table
in that state's registry against GC and re-runs per /reload and per
glue boot. Since baselib/StringLib already registers string.match /
gmatch / reverse into the same table, modern idioms like
msg:match("^!(%w+)") work with no further top-up.
Docs: new API.md "String methods" section; replaced the now-obsolete
"only string.foo(s), never s:foo()" caveat under string.match with a
pointer to it.
Verified in-game: literal and variable receivers, :format, 5.1 nil-miss,
and resolution inside a coroutine thread.
{rtN}/{skull}/etc. emitted a hardcoded :14:14 size, so a marker never
tracked the chat font-size setting. Emit :0:0 instead — the inline
emitter resolves height/width to the target line's font pixel height at
draw time, so markers grow and shrink with the chat font and scale up in
speech bubbles, matching retail's ICON_LIST `...Icon_%d:0`. The sheet
crop is size-independent, so cell selection is unchanged.
The startup gate that defers item queries until the engine's dispatch is
ready used a fixed 60-frame count, which is FPS-dependent: on a fast
machine it could fire before dispatch was ready (orphan risk) and on a
slow login it stretched well past its intended ~1s, leaving bag icons as
"?" longer than needed.
Anchor the delay to VAR_IN_WORLD (set by the enter-world initializer,
cleared on every transition) and measure a fixed 1000 ms past world-live,
so it is identical on any machine and only starts once the loading screen
is gone. The engine-response fast-latch is untouched: a real item
response still settles the gate instantly, so a warm WDB has no delay and
only a cold WDB waits the floor. Drops the now-unused frame counter.
A bag item's icon comes from its ItemStats cache entry (m_displayInfoID);
on a cold WDB an uncached bag item resolves displayID 0, and the icon
path draws INV_Misc_QuestionMark ("?"). Nothing in the bag-draw path
queries stats (GetContainerItemInfo and the icon getter both do
null-callback lookups), and the server pushes no item stats on login, so
a carried item stayed "?" until a hover, a loot, or an addon GetItemInfo
happened to query it. Equipped items are exempt (icon from the
server-pushed visible-items table).
Seed the request queue with every carried item once the startup gate
settles (one-shot per enter-world, re-armed on login and /reload) on the
existing owned-priority lane. This gives complete carried-item coverage
ahead of any addon's background flood, stays bounded by the same owned
budget so it adds no burst (issue #25), and is still deferred past
g_settled so it cannot orphan a cache entry before the engine's dispatch
is ready.
* feat(lua): backport 5.1 `#` and `%` operators via source transpile
Vanilla's Lua is 5.0, which lacks the `#` (length) and `%` (modulo)
operators, so modern addon ports that use them fail to COMPILE. Rather than
patch the 5.0 parser/VM (no OP_LEN/OP_MOD, no free opcode), rewrite the
SOURCE before it reaches the parser.
src/luasyntax/Transpile.cpp co-hooks luaL_loadbuffer (0x006F5690) — the one
function every compile funnels through (file scripts, loadstring, XML
handlers) — tokenizes each chunk with a small Lua-aware lexer, and runs a
precedence-climbing parser that rewrites, in one pass:
#operand -> __len(operand)
a % b -> __mod(a, b)
`#` is prefix but `%` is binary infix, so its operands are delimited by
PRECEDENCE: a*b%c -> __mod(a*b,c), a+b%c -> a+__mod(b,c), a%b%c chains
left-assoc as __mod(__mod(a,b),c), a%b^c -> __mod(a,b^c). The two operators
compose in the same pass (#t%2 -> __mod(__len(t),2)). No newlines are
inserted, so error line numbers are preserved.
Runtime helpers (registered on both the in-game and glue states):
- __len: string length, or a table border via bisection (5.1 `#`
semantics — ignores any table.setn count).
- __mod: a - floor(a/b)*b. NOT the engine's math.mod, which is C fmod
(truncated toward zero) and disagrees with `%` for negatives
(-1 % 3 == 2, but fmod(-1,3) == -1).
Safety: the rewrite runs only when a chunk contains `#`/`%`, and either as an
operator already fails to compile on 5.0 — so it cannot regress working code
except by misreading one inside a string/comment ("%d", "%a+", --[[ # ]]).
The lexer's string/comment skipping is the one safety-critical part and is
exact. Gated behind default-on toggles (_classicapi_SetLengthOperator /
SetModuloOperator); diagnostic _classicapi_TranspileLength returns the
rewrite. `...` is a separate, larger phase.
Adds FUN_LUAL_LOADBUFFER to Offsets.h with its derivation.
* feat(lua): honor the 5.1 `__environment` metatable field in getfenv/setfenv
Lua 5.1 lets a sandbox hide an environment: put `__environment` in the env
table's metatable and `getfenv` returns that value instead of the real env,
while `setfenv` refuses to change it. 1.12's engine already has this
protection but reads a RAW `__fenv` field on the env table itself — the older
5.0 form. Modern addons ported from 5.1 use the metatable `__environment`
spelling, which 1.12 silently ignored, so their sandboxes leaked.
getfenv (0x00702AC0) and setfenv (0x00702BF8) share one predicate
(0x00702BA0) that reads the marker and, when present, substitutes it / errors
"cannot change a protected environment". src/luaenv/Fenv.cpp co-hooks and
fully replaces that predicate to read `rawget(getmetatable(env),
"__environment")` first, falling back to the raw `env.__fenv` field. One hook
updates both consumers. The reimplementation preserves the predicate's exact
stack contract (push env, push marker, net +2, boolean return) that getfenv
(settop -2) and setfenv (settop -3) depend on; both metatable and env lookups
are RAW, so an `__index` metatable on the env is not disturbed.
The 5.1 mechanism was verified against 3.3.5's `FUN_0084F2F0` (raw
`__environment` off the metatable; no `"__fenv"` string exists there — Blizzard
renamed it and moved it to the metatable at the 5.0->5.1 jump). The change is
additive and inert: nothing on disk sets either marker, so every existing
getfenv/setfenv caller keeps getting the real env — it only activates when
code deliberately marks an environment.
Adds LUA_GET_METATABLE, LUA_GET_FENV, and FUN_LUA_ENV_PROTECT_PREDICATE to
Offsets.h.
* Use Lua length operator for class count
* feat(lua): backport the 5.1 `...` vararg expression via source transpile
This build is pure Lua 5.0 varargs: `function(...)` parses and populates the
`arg` table (with `arg.n`), but `...` used as an EXPRESSION is a parse error
("unexpected symbol near '...'") — verified in-game. So modern 5.1 addon code
using `...` (`{...}`, `local a,b = ...`, `f(...)`, `return ...`,
`select('#', ...)`) fails to compile.
Add a vararg pass to the transpiler (RewriteChunk runs it before the # / %
precedence parser) that rewrites the `...` EXPRESSION to `unpack(arg)`. This is
a faithful, position-independent substitution: `unpack(arg)` is itself a
multi-value expression, so it spreads in tail position and truncates to one
value elsewhere exactly as `...` does, and this build's `unpack(arg)` preserves
embedded nils via `arg.n` (verified: `f(1,nil,3)` -> `1 nil 3`). No precedence
analysis needed — unlike `%`, it's a token substitution once one case is
excluded: the `...` in a function's PARAMETER LIST (the vararg declaration that
creates `arg`) is left intact, found by scanning each `function` keyword and
skipping its param parens. Nested vararg functions work for free — each
`function(...)` makes its own `arg` and `unpack(arg)` binds to the nearest one
(verified: inner arg count 2, outer 3).
Safety mirrors # / %: only chunks containing `...` are touched, and `...` as an
expression already fails to compile on 5.0, so it can't regress working code;
`...` inside strings/comments is skipped by the lexer, and `.` (field) / `..`
(concat) are distinguished by run length. Gated behind the default-on
`_classicapi_SetVarargExpansion` toggle; `_classicapi_TranspileLength` now shows
the full rewrite (vararg + # + %).
File-scope `...` (the `local name, addon = ...` idiom) transpiles fine but only
yields values if the client passes varargs to addon chunks — a loader question,
separate from this syntax rewrite.
* feat(lua): provide addon-args so file-scope `local name, ns = ...` works
Modern addons open files with `local name, ns = ...` — the addon name plus a
per-addon shared namespace table, passed by the loader as the chunk's varargs.
Vanilla 1.12 never passed those (its main chunk has no `arg`), so with the
`...` backport in place the idiom resolved to `unpack(nil)` and errored. This
supplies the args ourselves.
In the luaL_loadbuffer hook, for an addon-file chunk (chunkname
`@Interface\AddOns\<Name>\...`, parsed case-insensitively) that uses a real
`...`, prepend a chunk-local `arg = {"<Name>", __addonns("<Name>"), n=2}`. The
existing `... -> unpack(arg)` pass then makes `local name, ns = ...` yield the
name and the namespace table. The preamble is newline-free (line numbers
preserved), placed after any UTF-8 BOM, and only injected for AddOns\ chunks
(FrameXML and loadstring are untouched); the addon name is rejected if it
contains characters that would break the string literal.
`__addonns(name)` returns the per-addon table — created once and returned
identically for every file of the addon (backed by a name->table map in the
Lua registry), matching modern WoW's shared second vararg. Registered in-game.
Verified in-game: an addon file's `local name, ns = ...` yields
`("<AddonName>", <table>)` with `ns == __addonns(name)` (shared across the
addon's files). File-scope `...` was the last piece; the in-function case was
already handled by the `...` pass.
* feat(lua): add C_AddOns.GetAddOnLocalTable(name)
Public alias of the addon-args namespace: returns the same per-addon table the
`local name, ns = ...` idiom yields — create-once and shared across every file
of the addon, backed by the same registry map as the internal `__addonns`
(both call Script_AddonNS). Lets code fetch an addon's namespace by name.
Registered via RegisterTableFunction on C_AddOns (non-destructive; coexists
with the existing C_AddOns.* functions regardless of module init order).
Currently ungated — it returns a table for any name, create-on-demand. TODO #99
records the follow-up to match retail: return nil unless the addon is loaded
AND its TOC declares `## AllowAddOnTableAccess: 1`, gating only this public
entry point while leaving the internal preamble path ungated.
* feat(lua): gate C_AddOns.GetAddOnLocalTable behind loaded + TOC opt-in
GetAddOnLocalTable was a raw alias of the addon-args namespace accessor:
it returned any addon's private table by name, loaded or not, with no
access control. Retail (11.0) gates this -- one addon reads another's
local table only when the target opts in via the `## AllowAddOnTableAccess`
TOC directive. Mirror that.
The public accessor now lives in addons/LocalTable.cpp and returns the
table only when the named addon is loaded (FUN_ADDON_IS_LOADED, by-name)
AND its .toc declares `## AllowAddOnTableAccess: <nonzero>` (scanned via
FUN_FILE_READ, read as a TOC boolean flag). Either check failing returns
nil. Directive spelling and function name both verified present in the
1.15.8 Classic Era binary.
The internal preamble path (an addon receiving its OWN table via the
injected `local _, ns = ...`) stays ungated -- an addon never needs
permission for its own namespace. Both paths now resolve to the same
per-addon table through LuaSyntax::PushAddonNamespace (AddonNamespace.h),
the single owner of the registry-backed name->table map.
Also close the bypass in the `__addonns` global itself. The map lives in
the Lua registry (unreachable from Lua -- this build ships no
debug.getregistry / debug introspection), so `__addonns` was the sole
Lua-visible door, and it was ungated. It is now context-gated: it answers
only while an addon's own chunk is mid-load, for its own name, one-shot.
At runtime it denies every name. Verified safe against the loader: the
addon file loader is strictly per-file load-then-run
(FUN_006edb90 -> FUN_006ede10 -> FUN_00704bc0 -> FUN_00704ae0, where
luaL_loadbuffer is immediately followed by lua_pcall), so nothing runs
between the grant being set and the preamble consuming it.
Net result: the only runtime path to another addon's local table is the
TOC-gated GetAddOnLocalTable.
* fix(lua): close the __addonns loadstring-forgery vector
The addon-args grant (`g_loadingAddon`) that lets a chunk's injected
preamble fetch its own namespace via the `__addonns` global was armed
whenever luaL_loadbuffer saw an `Interface\AddOns\...` chunkname with a
`...`. That chunkname is forgeable: `loadstring(s, chunkname)` takes a
caller-controlled chunkname (arg 2) and compiles WITHOUT running, so
loadstring("return ...", "@Interface\AddOns\Victim\x")
__addonns("Victim") -- returned Victim's private table
armed the grant and leaked another addon's local table, with no
`## AllowAddOnTableAccess` opt-in.
Fix: arm the grant only when the loadbuffer call comes from the engine's
immediate-run file funnel FUN_00704AE0 (it pcalls the chunk right after
compiling, so the preamble consumes the grant before anything else runs).
The hook checks `_ReturnAddress()` against RET_LUA_FILE_COMPILE
(0x00704B11, the instruction after that funnel's `call luaL_loadbuffer`).
`loadstring` (FUN_00703280) and `RunScript` (FUN_00704C70) call from
different return addresses, so neither can arm — even a loadstring nested
inside a RunScript body that itself runs through the file funnel. The two
callers that DO reach 0x00704B11 (FUN_00704bc0 DoFile, FUN_00704cd0
inline-XML-script) both supply engine-built chunknames, not caller data.
Verified in-game: forged loadstring compiles but yields nil.
Also fix two lexer/scan bugs found in review:
- SkipNumber treated e/E as an exponent marker in hex literals, so
`0xE-1 % 2` lexed `0xE-1` as one token and rewrote to __mod(0xE-1,2)
(=1) instead of 0xE-(1%2) (=13). Hex literals now use p/P only.
- The addon-path marker matched a backslash separator only while its
own terminator scan accepted both; it now matches `addons\` or
`addons/`, consistent with addons/Embedded.cpp.
* refactor(addons,lua): dedup CI helpers, share TOC scan, table-driven toggles
Review cleanups, behavior-preserving:
- Drop the hand-rolled LowerAscii/EqualsCI copies in luasyntax/Transpile.cpp
and addons/LocalTable.cpp for `_stricmp`/`_strnicmp` (the codebase norm).
- Add addons/Toc.h `FindValue(buf, size, directive, ...)` — the `## Key:`
line-start scan in one place. Embedded.cpp (Version) and LocalTable.cpp
(AllowAddOnTableAccess) both consume it; the file-read stays per-caller
since Embedded's is entangled with its disk-vs-embedded decision.
- Collapse the six per-switch transpile toggle functions to a {name, bool*}
table plus one `_classicapi_SetTranspileOption(name, bool)` /
`_classicapi_GetTranspileOption(name)` pair (name = Length / Modulo /
VarargExpansion). Nothing outside Transpile.cpp referenced the old names.
* feat(addons): load multi-flavor addons via _Vanilla/_Classic TOC selection
Modern addons target several game flavors from one folder by shipping
`<Name>_Vanilla.toc` / `_Mainline.toc` / `_Cata.toc` / … and frequently no
plain `<Name>.toc`. Vanilla 1.12 only ever opens `<Name>\<Name>.toc`, so such
an addon never registers -- it is invisible, not merely mis-loaded.
Backport the retail client's flavor-file selection at the file-read layer.
The scan's TOC parser FUN_0051C9B0 reads Interface\AddOns\<Name>\<Name>.toc
via FUN_FILE_READ (0x00648620) and registers the addon only if that read
succeeds; the load pass and GetAddOnMetadata read the same path. We already
co-hook FUN_FILE_READ for the embedded addon, so FileRead_h now calls
AddOns::FlavorToc::TryHandle in its non-!!!ClassicAPI branch: for a base-TOC
read it probes <Name>_Vanilla.toc then <Name>_Classic.toc via the original
reader and serves the first that exists in place of the base (the parser
passes outSize=NULL, so it just forwards args with a different path).
Precedence mirrors retail: flavor wins over an existing base, and _Vanilla
beats _Classic. Does NOT fall back to _Mainline/_Cata/etc -- an addon shipping
only those doesn't support vanilla and stays unlisted. Folder enumeration
already lists flavor-only folders, so only the TOC read needed redirecting.
Verified in-game: a folder with only FlavorTest_Vanilla.toc registers and
loads; GetAddOnInfo returns the _Vanilla title/notes.
* feat(addons): prefer _Turtle.toc flavor on Turtle-lineage clients
Extend multi-flavor TOC selection with a _Turtle.toc suffix, a ClassicAPI
extension (not a retail flavor) for addons targeting Turtle's server content.
Probe order becomes _Turtle -> _Vanilla -> _Classic, with _Turtle offered
first and only when Turtle::Detected() is true, so stock 1.12 never prefers
Turtle-only code.
The redirect fires at both the registration scan (glue/char-select) and the
in-world load pass, which must select the same flavor -- so detection has to
read consistently at glue-scan time and in-world. The in-world signal
(TURTLE_WOW_VERSION, engine-set) is absent on the glue state, so Turtle::Detect
now also recognizes Turtle's glue-time TURTLE_* GlueXML string globals
(AUTH_TURTLE_WEBSITE / TURTLE_ARMORY / TURTLE_KNOWLEDGE_DATABASE, set in
GlueStrings.lua at glue boot, before the scan). Clones keep those names and
only rebrand the URL values, so name presence is clone-robust where realmList
is not. Detected() now latches on either signal and works in both states;
Version() still fills from TURTLE_WOW_VERSION once in-world.
Verified in-game: a folder shipping only FlavorTest2_Turtle.toc registers and
loads on Turtle (GetAddOnInfo returns the _Turtle title), while the earlier
_Vanilla-only test still works.
* feat(addons): narrow flavor TOC selection to _Turtle + _ClassicAPI
Drop `_Vanilla`/`_Classic` from the flavor probe list. Those are the 1.15
Classic Era client's suffixes -- a modern engine with the full modern API and
modern FrameXML templates. Adopting them told a multi-flavor addon "I'm 1.15",
so its `_Vanilla` build would assume APIs/templates this 1.12 + partial-backport
client doesn't fully have, load, and then break. A clean non-load is better.
Select only the two flavors that describe THIS environment, most specific first:
_Turtle -- Turtle client/content, only when Turtle::Detected();
_ClassicAPI -- 1.12 + ClassicAPI, always (ClassicAPI is present by
definition -- it is the code doing the redirect).
Both are ClassicAPI conventions the addon author opts into, not an attempt to
impersonate a retail flavor. If neither exists, TryHandle returns false and the
caller does the normal base read, which chains through any other FUN_FILE_READ
hooker before reaching the engine.
Verified in-game: a folder with only `<Name>_ClassicAPI.toc` registers/loads; a
`_Turtle`-only folder registers on Turtle; a `_Vanilla`-only folder no longer
registers (correctly, now that `_Vanilla` is not a selected flavor).
* feat(addons): backport per-line TOC directives (conditions + variables)
Single-TOC multi-flavor addons gate individual file-reference lines and
expand path variables:
[Family]\Init.lua -> Classic\Init.lua
Vanilla.lua [AllowLoadGameType vanilla] kept
Mainline.lua [AllowLoadGameType mainline] dropped
Localization\[TextLocale].lua -> Localization\enUS.lua
Vanilla 1.12's TOC readers understand none of it: the scan parser
FUN_0051C9B0 only parses `##` metadata, and the load pass FUN_006edb90
hands each file line to FUN_006ede10 verbatim, where a trailing ` [...]`
breaks the extension check so the file silently never loads.
New module AddOns::TocRewrite rewrites the TOC buffer at the FUN_FILE_READ
chokepoint (alongside FlavorToc/Embedded), so the engine's own load loop
sees clean paths and both parsers are covered by one edit.
On this client the tokens resolve to: [AllowLoadGameType] keeps iff the
list has `vanilla`; [AllowLoadTextLocale] keeps iff it has the client
locale; [AllowLoad] keeps `game` and drops `glue`; [Family]->Classic,
[Game]->Vanilla, [TextLocale]->the client locale code. Locale comes from
VAR_LOCALE_NAME_TABLE (0x008558A4), the exact source Script_GetLocale
reads, so it matches GetLocale() byte-for-byte.
Gated to ...\AddOns\...\*.toc reads (a .lua `t[1]` would otherwise be
mangled); bracket-free TOCs stay zero-copy via a memchr bail; a rewritten
TOC goes into a fresh Storm buffer the engine frees like its own. Unknown
conditions fail safe (drop the line); gated-out lines become `#` comments.
Verified in-game on Turtle: 8 keep-cases loaded, 4 drop-cases (bare
mainline, glue, deDE, unknown keyword) did not.
* docs: document the branch's Lua 5.1 and TOC compatibility features
User-facing docs for the feat/lua-syntax-transpile branch.
docs/API.md:
- New "Lua 5.1 syntax: length, modulo, and vararg" section: the source
rewrite for `#`, `%`, and `...`-as-expression; what each form becomes
(`#` = border, ignores table.setn; `%` = 5.1 sign-of-b, contrasted with
math.mod); the `local name, tbl = ...` addon-args idiom; limits
(string/comment safety, first-close long-bracket matching, preserved
line numbers) and the diagnostic kill-switch.
- New "getfenv / setfenv environment protection" section: the 5.1
`__environment` metatable field, with the `__fenv` fallback noted.
- New "Conditional and multi-flavor TOC loading" section under AddOns:
`_ClassicAPI` / `_Turtle` flavor file selection and per-line
`[AllowLoadGameType]` / `[AllowLoadTextLocale]` / `[AllowLoad]`
conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables,
with the token resolution table and limits.
- Table-of-contents entries for all three.
README.md:
- Add C_AddOns.GetAddOnLocalTable to the AddOns calls row (was missing).
- Add three rows to the Behaviors table (Lua 5.1 syntax, Lua 5.1
environment protection, multi-flavor & conditional TOC loading), each
linking to its API.md section.
* feat(addons): accept multi-flavor `## Interface:` version lists
A retail TOC can list several interface versions on one line
(`## Interface: 120100, 50504, 38002, 20506, 11200`). Vanilla's parse is a
plain atoi (FUN_0064ade0) that reads only the first number into entry+0x1C,
and the loadability resolver FUN_0051e780 compares that to the client
version FUN_0051d7d0() (0x2BC0 = 11200): a mismatch is the OUT_OF_DATE
reason that blocks loading while checkAddonVersion is on. So a list whose
first entry isn't 11200 never loads even when 11200 appears later in it.
TocRewrite now special-cases the `## Interface:` line: parse the whole
list, and if the client version (read live via
FUN_ADDON_CLIENT_INTERFACE_VERSION, not hardcoded) is present but not
first, rewrite the value to that single number so the engine stores 11200
and marks the addon compatible. A list without 11200 (or already leading
with it) is left verbatim, so it stays out of date exactly as before. The
fast-path gate widened to `[` or `,`; the content-compare bail keeps
comma-bearing TOCs (e.g. `## Dependencies: A, B`) zero-copy.
Docs (API.md, README.md) describe the behavior and the 11200-only
compatibility rule.
Verified in-game on Turtle: GetAddOnInfo on an addon listing
120100,50504,38002,20506,11200 returned no reason (loaded); the ...,11509
control returned INTERFACE_VERSION (out of date).
* feat(addons): honor ## LoadSavedVariablesFirst
Modern addons use `## LoadSavedVariablesFirst: 1` so their SavedVariables
are restored before their Lua runs, letting file-scope code read config.
Vanilla always runs an addon's files first, then loads SavedVariables,
then fires ADDON_LOADED (verified in FUN_0051f240), and never parses the
directive — so file-scope SV is always nil.
New module AddOns::SavedVarsFirst co-hooks the per-addon file loader
FUN_ADDON_LOAD_FILES (FUN_006edb90). For an addon whose TOC declares the
directive, it loads the SavedVariables first, mirroring the engine's own
path construction two steps later:
account : WTF\Account\<account>\SavedVariables\<Name>.lua
per-char: WTF\Account\<account>\<realm>\<character>\SavedVariables\<Name>.lua
(falling back to the realm-less path)
account = *VAR_ACCOUNT_NAME_PTR, realm = FUN_GET_REALM_NAME, character =
FUN_GET_LOGIN_ACCOUNT_NAME (whose offset label is a misnomer — it is the
character name). Then it calls the original so the files run with SV
present. FUN_FILE_EXISTS picks the same per-char variant the engine would;
FUN_LUA_LOAD_FILE no-ops on a missing file.
v1 caveat (documented): the engine still runs its own SV load right after
the files, so a file-scope READ works but a file-scope WRITE is overwritten
before ADDON_LOADED. Writes belong in an ADDON_LOADED handler, as usual.
Docs: API.md "SavedVariables loaded first" + a README behaviors row.
Verified in-game on Turtle (login -> /reload): flagged SVFirst saw its DB
at file scope (sawAtFileScope=true), control SVNormal did not (false).
* feat(addons): suppress the redundant SavedVariables re-load (LoadSavedVariablesFirst v2)
v1 loaded a flagged addon's SavedVariables before its files, but the engine
still ran its OWN SV load right after the files — overwriting any value the
addon wrote to its SavedVariable at file scope before ADDON_LOADED.
v2 co-hooks FUN_LUA_LOAD_FILE and skips that one redundant re-load per
pre-loaded path: RunLuaFile loads each SV via the trampoline (bypassing the
hook) and records its full path; LuaLoad_h matches the engine's imminent
FUN_00704bc0(svPath, 0, 0) (case-insensitive, one-shot erase) and returns 0
without loading. The SV-load call sites ignore the return and the SV file
feeds nothing into the TOC checksum, so the skip is side-effect-free.
FUN_00704bc0 loads files only (RunScript runs strings through a different
path), so the extra hook is a cold addon-load-time path.
Now file-scope reads AND writes both survive — matching true
LoadSavedVariablesFirst. Docs (API.md) drop the write-clobber caveat.
Verified in-game on Turtle (fresh SavedVariables, repeated /reload): flagged
SVFirst's file-scope write persists (runs counter climbs 2, 3, 4), control
SVNormal stays 1.
* feat(addons): flavor-specific addon Bindings (Bindings_Turtle/_ClassicAPI.xml)
The Bindings.xml analog of FlavorToc. A modern addon may ship a
flavor-specific keybinding file instead of / alongside a plain
Bindings.xml. Vanilla only ever builds Interface\AddOns\<Name>\Bindings.xml,
gates it on an exists-check (FUN_00648a30), and — only then — loads it via
FUN_004b6f70, which reads through FUN_FILE_READ. So flavor selection needs
two pieces (a FileRead redirect alone can't help a flavor-only addon,
because the exists-check is on the base name):
- AddOns::FlavorBindings::TryHandle, called from the FUN_FILE_READ hook
after FlavorToc: for a …\AddOns\<Name>\Bindings.xml read, serve a flavor
variant if present (flavor wins over a plain Bindings.xml). Probe order
Bindings_Turtle.xml (Turtle only) -> Bindings_ClassicAPI.xml.
- A co-hook on FUN_FILE_EXISTS: report Bindings.xml as existing when the
base is absent but a flavor variant is present, so the loader runs for a
flavor-only addon. Non-Bindings paths pass straight through.
Suffixes are _Turtle / _ClassicAPI, never _Vanilla / _Classic (a _Vanilla
bindings file pairs with the 1.15 build we don't load, so its handlers
wouldn't exist). Docs: API.md flavor section + README behaviors row.
Verified in-game: on Turtle all three cases load (flavor-only ClassicAPI,
flavor-only Turtle, flavor-over-base with the flavor winning); on stock the
Turtle-only one correctly drops while the ClassicAPI ones still load.
* feat(lua): backport 5.1 hexadecimal number literals via source transpile
Lua 5.0's lexer reads only decimal digits + one '.' + 'e' exponent, so a
`0x...` literal is a compile error (hence addons resort to
`tonumber("0x...", 16)`). Lua 5.1 added hex literals. As with `#` / `%` /
`...`, we rewrite the SOURCE at the luaL_loadbuffer chokepoint: each hex
INTEGER token becomes the exact decimal it denotes (unsigned), which 5.0's
lexer accepts and reads as the same double 5.1 would.
RewriteHex runs first in the transpile pipeline, gated on a "0x"/"0X"
presence scan; it reuses the tokenizer (so strings/comments are never
touched) and inserts no newlines (line numbers preserved). Hex floats
(0x1.8p3) and > 64-bit literals are left as-is — vanishingly rare, and they
fail exactly as today (no regression). Also fixed SkipNumber to stop a
number at a `..` so `0xFF.."x"` (hex then concat) tokenizes cleanly.
Toggle: _classicapi_SetTranspileOption("HexLiterals", false).
Docs: API.md "Lua 5.1 syntax" section + README behaviors row.
Verified in-game (19/19 harness asserts) and cross-checked against retail:
0xFF->255, 0xFFFFFFFF->4294967295, bit.band(0xF0,0x0F)->0, "0xFF" untouched
all match modern WoW exactly.
* feat(frame): enable modern positional script args by default
Modern handler signatures — `function(self, delta)` / OnEvent
`(self, event, ...)` — are core Lua 5.1 behavior, so flip Frame::ScriptArgs
on by default instead of opt-in. Ports that use them now work with no
`SetModernScriptArgs(true)` call.
It's purely additive (the `this`/`arg1`/`event` globals are still set, so
vanilla-style handlers keep working). `SetModernScriptArgs(false)` remains
the escape valve — it reimplements the tail of the engine's hottest Lua
path (fires for every OnUpdate frame), and disabling it makes both runners
a straight passthrough to exact vanilla dispatch.
Docs + code comments updated from "default OFF (opt-in)" to "default ON".
* docs: surface modern script-handler arguments as a behavior
Add a README Behaviors-table row for the now-default modern positional
script arguments (function(self, delta), OnEvent (self, event, ...)),
alongside the Lua 5.1 syntax row. The SetModernScriptArgs / GetModernScriptArgs
functions stay in the Frame calls table as the off-switch.
* fix(lua): correct transpiler lexer edge cases and operator env-independence
Review findings against the Lua 5.1 syntax transpiler:
- Tokenize `.`/`..`/`...` as multi-char tokens like Lua's own lexer, so a
`...` vararg adjacent to a `..` concat (e.g. `x .. ...`) is recognized
instead of forming an unsplittable five-dot run that left `...`
un-rewritten and failed to compile. Guard the `#`/`%` field-access check
to a single-char `.` so `a..b` isn't misread as `a.b`.
- Nest level-0 `[[ ]]` long brackets in SkipLongBracket to match the engine's
own long-string reader (FUN_00700010 keeps a nesting counter). First-close
matching mis-identified nested-string boundaries and could rewrite `#`/`%`
bytes the parser treats as string content.
- Capture `__len`/`__mod`/`unpack` as chunk-locals in the load-time preamble
so the lowered `#`/`%`/`...` operators resolve under a setfenv sandbox with
no _G fall-through, mirroring real 5.1's environment-independent operators.
- Skip precompiled bytecode (0x1B chunk signature) instead of tokenizing it
as source and corrupting the chunk.
- Cap parse recursion at 200 (Lua's LUAI_MAXCCALLS) so a pathologically
nested chunk fails to compile cleanly instead of overflowing the C stack
inside the hook.
- Case-fold the addon-namespace map key in PushAddonNamespace so a
case-mismatched C_AddOns.GetAddOnLocalTable request lands on the addon's
real table instead of minting a fresh empty one.
The `.. ...` vararg and nested-long-bracket fixes verified in-game via
_classicapi_TranspileLength.
* fix(addons): gate LoadSavedVariablesFirst pre-load on the declared scope
LoadSavedVarsEarly chose which SavedVariables files to pre-load from file
existence alone, but the engine loads the account file only when the addon
declares `## SavedVariables` and the per-char file only when it declares
`## SavedVariablesPerCharacter`. So a stale file for a scope the addon no
longer declares got executed early (Lua the engine never runs) and queued in
g_pendingSuppress, which the engine then never drained — a leak of one entry
per stale file per /reload.
Mirror the engine: read the TOC once (ReadSvFlags, replacing
TocWantsSavedVarsFirst) for the reorder flag plus whether each SV scope is
declared, and load each file only when its directive is declared and the file
exists. Every suppress entry we queue is now a file the engine re-loads and
drains.
* fix(turtle): only trust glue markers on the glue Lua state
Probe() checked the `TURTLE_*` glue markers whenever detection wasn't yet
latched, on whatever Lua state was current — including in-world. Those names
are set by Turtle's GlueXML and are only trustworthy on the glue state; in
world any loaded addon can define a global by one of them (a Turtle-UI port
shipping the strings, say), which permanently mis-latched Turtle detection on
a stock client and skewed flavor TOC/Bindings selection for the session.
Gate the marker probe on a `g_onGlue` phase flag driven by the engine's own
per-state script-registration hooks: the in-world ModuleAutoRegister (fires
before the addon load pass) clears it, each glue boot's GlueModuleAutoRegister
sets it. Neither reads a forgeable Lua global. Turtle still detects via the
markers at the glue scan and via TURTLE_WOW_VERSION in-world; a stock client
can no longer be latched by an in-world global.
Also make Version() return nullptr (not "") in the glue-detected-but-no-version
window, matching the header contract.
* revert MAX_CLASSES
* fix(frame): only pass positional script args to handlers that declare them
Default-ON modern script args changed behavior for every handler on the
engine's hottest Lua path, and the docs' "purely additive" claim was wrong:
a handler that DECLARES a parameter received a real value where vanilla always
passed nil.
Gate the reimplemented positional path on the handler's arity: a Lua closure
that declares a parameter or is vararg (function(self, delta) / function(self,
...)) takes it; a param-less handler (the vast majority of vanilla handlers,
which read this/arg1 globals) and a C-closure handler tail-call the original
engine runner unchanged. So param-less handlers are provably identical to
vanilla AND off the hot path (also the efficiency win: OnUpdate handlers that
gain nothing no longer pay the modern tail every frame). Only param-declaring
handlers observe the (self, [event,] arg1..N) shape.
Arity is read from the handler's Proto; offsets verified against the engine's
own luaD_precall (numparams @ +0x45, is_vararg @ +0x46), lua_dump, and
lua_iscfunction/lua_tocfunction (closure @ TValue+8, isC @ +6, Proto @ +0xC).
The param-declaring-vanilla-handler case (a function reused as both a direct
call and a handler) is inherently ambiguous and still gets the value; documented
honestly in ScriptArgs.cpp, docs/API.md, and README with SetModernScriptArgs
(false) as the opt-out. Verified in-game across both runners, both paths, the
toggle, and the caveat.
* refactor(addons): centralize engine-IO typedefs and CI string helpers
The `__stdcall` FUN_FILE_READ / FUN_FILE_EXISTS / Storm-allocator function
types were copied into ~8 places across the addon files, and Lower/EqCI into
three. These are ABI-critical: a copy declared __cdecl by mistake drifts ESP
per call and kills the process with no crash log. Hoist them to one home:
- new addons/EngineIO.h — FileReadFn / FileExistsFn / SMemAllocFn / SMemFreeFn,
used by Embedded, FlavorToc.h, FlavorBindings.h/.cpp, SavedVarsFirst,
LocalTable, TocRewrite.
- addons/Toc.h gains Lower / EqCI, used by FlavorBindings, SavedVarsFirst,
TocRewrite.
Also drop EvalCondition's dead `known` out-parameter in TocRewrite (nothing
read it — an unknown keyword already returns false). Pure refactor, no behavior
change.
* perf(lua): tokenize each chunk once and gate the #/% parse on an operator token
The transpiler ran on every compiled chunk at login/reload, and:
- RewriteHex / RewriteVararg / RewriteAll each re-tokenized the whole chunk, so
a file that trips more than one pre-check was lexed two or three times;
- RewriteAll's fast-bail was a raw memchr for '%', which is present in nearly
every chunk via format strings ("%d"), so almost every chunk paid a full
recursive parse only to discard an empty edit list.
New RunPasses tokenizes once and shares the stream across the passes, re-lexing
only after a pass actually rewrites the buffer (rare). RewriteAll now gates the
parse on an actual '#'/'%' OPERATOR token — a '%' inside a string is a TK_STRING
token, not a punct, so the format-string case skips the parse entirely.
RunPasses also unifies the previously duplicated RewriteChunk / LoadBuffer_h
pipelines. Output is identical (verified in-game via _classicapi_TranspileLength:
vararg-next-to-concat, nested long brackets, and a format-string vs operator '%'
all rewrite exactly as before).
* feat(lua): backport 5.1 leveled long brackets; fix top-level vararg fallback
Adversarial testing of the transpiler surfaced two gaps beyond PR 28:
- Leveled long brackets ([=[ ]=], [==[ ]==]) are the one 5.1 syntax
construct still missing. The 5.0 engine lexer has no =-level handling
(verified in FUN_006ff610), so they fail to compile. A new source-walking
pre-pass rewrites a leveled long string to [[body]] when the body is safe
(byte-exact, keeps the leading-newline strip) or to a quoted literal with
\-newline continuations otherwise, and blanks leveled long comments to
spaces. Line numbers preserved. Gated by the new LongBrackets toggle.
- Top-level ... outside the addon file funnel (RunScript, XML <OnLoad>,
loadstring) expanded to unpack(arg) and threw on nil arg, where 5.1
yields nothing. Non-addon vararg chunks now get a local arg={n=0}
fallback.
Adds a /capitranspile self-test suite (19 cases, all passing in-game) and
updates docs/API.md (also fixing a stale limits note that described plain
long strings as non-nesting: they nest by depth, matching the engine) and
README.
Tightens the pfUI compatibility shim in AddOnCompat.lua. The temporary CreateFrame wrapper now leaves the maintained brues pfUI fork alone when either its X-Website tag or the pfUI.handlesHookScript capability flag is present, and only applies the HookScript workaround when neither signal exists.
VanillaFixes creates the process suspended and injects every dlls.txt
DLL sequentially via remote threads before resuming. The fallback
worker spawned from DllMain starts the moment our own LoadLibrary
releases the loader lock -- i.e. while VF is still injecting the NEXT
dlls.txt DLL -- so its ~90-prologue install (plus the MH_ApplyQueued
thread-freeze) raced that DLL's DllMain patching the same engine
functions. SuperWoWhook.dll failed to load whenever ClassicAPI
preceded it in dlls.txt.
DllMain now skips the worker when VfPatcher.dll is present in the
process: under VF the `Load` export is the only install trigger, and
it is inherently race-free -- VfPatcher calls it on the game's main
thread after the process resumes, when every DllMain in the injection
chain has already completed, serialized in dlls.txt order. VfPatcher
is always injected before any dlls.txt DLL, and the Load mechanism
shipped in the same VF release (v1.4) as dlls.txt support itself, so
VF present implies Load will fire. Non-VF injectors keep the worker
unchanged.
The wrap-stepper co-hook special-cased auto-width fontstrings (single-anchor,
no explicit width — pfUI addon-list labels, TwitchEmotes labels): when the whole
line fit within 1.5 glyphs of exactly filling its budget it applied zero shrink,
to avoid a spurious wrap from ~1-glyph drift between the width and wrap hooks'
icon-sum computations. The cost was that real-width frames landing within 1.5
glyphs of their edge also gave up their shrink and let icons overflow instead of
wrapping.
The trailing-icon trim, outline-ink threading, and exact-K measure work have
since tightened that drift under half a glyph, so the existing 0.5-glyph
feasibility tolerance now carries the auto-width case on its own. Verified
in-game: pfUI/TwitchEmotes labels no longer spuriously wrap, and near-full
real-frame lines wrap correctly instead of overflowing.
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
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).
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".
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
* 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>
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.
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).
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.
* 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.
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.
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
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.
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.
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.
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.
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.
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.
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.
* 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>
* 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.
* 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>
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.
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.
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.
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.
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.
ClassicAPI_ToggleUnitMenu's initialize closure ran the same level-1
customization at every level. When the mouse crossed a nested entry
(Loot Method / Loot Threshold / Raid Target Icon) the menu builds its
level-2 submenu and re-invokes initialize; our AddFocusButton /
AddCancelButton call UIDropDownMenu_AddButton with no level, which
defaults to level 1 (not the current level), appending onto the still-
open level-1 list. Level 1 is not reset while a submenu rebuilds, so the
list grew every hover until "Too many buttons in UIDropDownMenu:
ClassicAPIUnitMenuDropDown", pushing the real entries out of reach.
Make initialize level-aware: at level >= 2 hand off to UnitPopup_ShowMenu
(it resolves the submenu from UIDROPDOWNMENU_MENU_VALUE and adds its own
Cancel); do the scratch-copy + Focus/Cancel append only at level 1 -
mirroring how Blizzard's own unit dropdowns init.
Spell_C_CastSpell (0x006E4B60) calls the cast-start writer
FUN_CAST_START_SET before Spell_C_TargetSpell raises the reticle, so a
target-selection spell (Disenchant, ground-target AoE) stamps g_cast at
the green-cursor step - seconds before the item/ground is clicked and the
cast actually begins. The later click re-enters Spell_C_CastSpell only to
hit its "already this spell" short-circuit, so the writer never re-fires
for the real start. Result: UNIT_SPELLCAST_START (and a UnitCastingInfo
cast bar) fired far too early, and a cancel fired a phantom
START/INTERRUPTED/STOP for a cast that never happened.
Drop the client stamp in OnWorldTick while the reticle is up for that
spell. On selection the server's SMSG_SPELL_START re-stamps g_cast via
the fresh-stamp path (it is 0), so START fires with the correct time and
the SENT castGUID; on cancel it stays cleared. Gated on !g_castFromServer
(never touches the server re-stamp) and on g_cast.spellID ==
VAR_PENDING_CAST_SPELL (only the spell whose reticle is actually up).
Normal casts have no targeting phase and are unaffected.
Add build/Release/ClassicAPI.map to the release step's asset list so
every vX.Y.Z release ships the linker map beside the DLL. It resolves
the `0001:offset` logical addresses in a shipped build's ERROR #132
crash dump back to function names for that exact build. Dev-build
artifacts stay DLL-only.
RunModern reimplements the frame-script runner tail, saving the
this/arg1..argN globals it clobbers so it can restore them after the
handler pcall. The old save used luaL_ref into the registry, mirroring
the engine's own FUN_00704f10 - but any push before the protected call
can longjmp (out-of-memory being the real case), which unwinds past the
C frame and skips the restore: the refs leak and the globals stay
clobbered for later handlers.
Save the old values on the Lua stack instead. An unwinding error resets
L->top past them for free, so there is nothing to leak and no clobbered
state to strand. This also drops the per-call luaL_ref/luaL_unref churn
and the per-arg re-read from globals on the OnUpdate hot path; the call
args are pushed directly from a pure-C parse of the vararg buffer.
The stack save is deeper than the registry save (~2N slots live at
once), so CheckStack up front; if it cannot grow (or there is no Lua
state) RunModern returns false before any side effect and the thunk
falls back to the original engine runner, never double-running.
Behavior unchanged: default-off passthrough, and identical
(self, [event,] arg1..argN) semantics when enabled.
Add /MAP to the shared-linker flags so the Release build produces
ClassicAPI.map, which resolves the `0001:offset` logical addresses in
WoW's ERROR #132 crash dumps back to function names. Linker-only output;
no effect on code layout.
Carnage's Ferocious Bite gate was a hardcoded list of five rank IDs. Replace
it with the server's own structural test (IsDruidFerociousBite): druid family
+ the Rip/Bite family flag + a direct-damage effect (the damage effect is what
separates Bite from Rip, its DoT sibling that shares the flag). Rank-proof and
covers any Turtle-added rank with no ID list. Verified against the client
Spell.dbc: all five FB ranks are {family 7, flag 0x800000, Effect[0]
SCHOOL_DAMAGE}; Rip shares the flag but is APPLY_AURA, and Rake carries a
different flag.
The family + flag-bit test is the client-side form of the server's
SpellEntry::IsFitToFamily, and Aura::Source::AffectedMatchesRaw read the same
two fields inline, so extract Spell::Lookup::IsFitToFamily and route both
through it. Adds SPELL_EFFECT_SCHOOL_DAMAGE (= 2, verified vs the server's
SpellDefines.h).
Turtle's Carnage refreshes the caster's Rip and Rake off Ferocious Bite,
behind a per-combo-point roll whose outcome the client only sees as the
combo point the proc returns. A src/turtle/Carnage watcher arms on a
Ferocious Bite send and confirms on the first combo-point rise, then
refreshes the cached auras. Verified against tortoise-wow
spell_druid_carnage: icons 108 (Rip) / 494 (Rake), family bits 0x800000 /
0x1000, caster scoping, and RefreshHolder (restore the aura's own applied
duration).
A cache duration edit has no engine packet behind it, so nothing re-fired
UNIT_AURA and event-driven aura bars never saw the change. The trigger mods
(ApplyMod) and the Carnage refresh now enqueue the affected unit, and the
WorldTick flush re-broadcasts UNIT_AURA via the engine's own per-token
signal (FUN_UNIT_EVENT_BROADCAST), deferred off the SMSG_SPELL_GO subscriber.
Also move the two static Turtle duration mods (Conflagrate -> Immolate,
Molten Blast -> Flame Shock) from Lua into src/turtle/DurationMods via a new
C++ entry Aura::Source::AddDurationMod, and drop the now-unused exact-spellID
RegisterAuraDurationModifier Lua export (RegisterAuraDurationModifierByTrigger
stays for Shadow Weaving).
Based on the approach from @fuffc in #15.
Closes#15
The TOC entry pointed at #unit_spellcast_-events-player, but the heading
generates #unit_spellcast_-events (no -player suffix) — broken link. Move
both the TOC entry and the content block out of the Spell section and into
the Events section where the other event docs live, and correct the anchor.
The capture was a single slot, and ConsumeCapturedCP cleared it before
testing whether the spellID even matched. Every player-cast aura spell
reaches TryComboScaledMs, so any aura landing between a finisher's
CMSG_CAST_SPELL and its own SMSG_SPELL_GO consumed the snapshot and left the
finisher with no combo points — falling back to the 0-CP base duration.
Not a rare race. Turtle's rogue Taste for Blood casts spell 52529 on the
player off the same Rupture, and its SPELL_GO arrives first, so Rupture
reported a static 12s (its 6s 0-CP base plus the talent) at every combo
count. Untalented, with no proc to clobber the slot, the same cast scaled
correctly — which is what made this look like a modifier bug rather than a
capture one.
Key the captures by spellID in a small ring and consume only the matching
entry. The comment claiming one cast is in flight at a time was reasoning
about finishers alone; the funnel sees every cast.
Add item/Record.{h,cpp} with Item::PeekRecord(itemID) -- the passive
ItemStats_C cache peek (null callback, no network query). Route the three
duplicate implementations through it:
- the per-module GetItemRecord_t typedef + fn(cache,id,&zero,nullptr,
nullptr,0) call in ~25 read-only item accessors
- Item::StatAccum::FetchRecord (deleted; 7 callers repointed)
- item/Tooltip.cpp's local PeekItemRecord (deleted)
Behavior-identical: every converted call was already a passive peek with
identical args (dedup flag is a no-op when the callback is null).
item/Data.cpp is deliberately untouched -- it owns the ACTIVE fetch
(CacheFetch passes a real callback to fire SMSG_ITEM_QUERY_SINGLE, the
load-bearing cache-race path in CLAUDE.md) and keeps its own typedef.
OBJ_TYPE_* was a partial second naming of the same object-type bits as
the complete TYPEMASK_* set (OBJ_TYPE_ITEM=2=TYPEMASK_ITEM, ... GAMEOBJECT
=0x20 both) -- a single-source-of-truth violation. Repoint all ~18
references to TYPEMASK_* (identical values, behavior-preserving) and
delete the OBJ_TYPE_* definitions, folding their derivation notes
(bitmask semantics, FUN_004F8DB0 for PLAYER, FUN_0052AA20 for GAMEOBJECT,
FUN_00529FE0 SetUnit) into the surviving TYPEMASK_* comment.
Add item/CGItem.{h,cpp} with two null-safe accessors for a CGItem's two
descriptor-like pointers:
InstanceBlock(cgItem) -> CGItem + 0x08 (item GUID @ +0, itemID @ +0x0C)
ObjectFields(cgItem) -> CGItem + 0x114 (stack/flags/charges/durability/...)
Replace the 30 hand-written `*reinterpret_cast<const uint8_t *const *>(x +
OFF_ITEM_INSTANCE_BLOCK/OFF_ITEM_DESCRIPTOR)` derefs across 24 files with
these calls. Behavior-preserving: same deref, all reads were already const,
and each site keeps its own null-check and field reads. Helpers add a
cgItem null guard (strictly safer).
Every caller of the engine object resolver (0x00468460) re-declared the
same __fastcall typedef and split the GUID into lo/hi by hand -- 30 call
sites across 28 files. Add object/Resolve.{h,cpp} with
void *Object::ByGuid(int typeMask, uint64_t guid, const char *debugName, int priority)
and route every site through it (behavior-identical: same typeMask,
debugName, priority per site; single-uint64 callers pass guid, split-lo/hi
callers reconstruct it).
Also collapse the duplicate offset constant: FUN_CLNT_OBJ_MGR_OBJECT_PTR
was a second name for the same 0x00468460 address as
FUN_OBJECT_RESOLVE_BY_GUID (a single-source-of-truth violation). Remove
it; the ABI-equivalent (u32,void*,u64,int) view it documented is folded
into the surviving constant's comment.
Add baselib/Ascii.h (header-only Ascii::ToLower / Ascii::EqualCI) and
replace the two hand-rolled case-insensitive compares (frame/Attributes'
EqI, taxi/Map's IEqual + AsciiLower).
Add DBC::ClassIdForToken / RaceIdForToken (token -> ChrClasses/ChrRaces
id, the reverse of ClassToken/RaceToken) backed by a shared
IdForStringField walk using Ascii::EqualCI, and replace player/NameCache's
two near-identical ResolveClassToken / ResolveRaceToken loops.
Add Unit::Identity::NameForGuid(guid) -- a passive NameCache peek that
returns the cached character name -- and replace the two identical local
copies in friendlist/IsFriend and friendlist/IsIgnored. Both files no
longer touch Offsets directly, so drop the now-dead include.
Add DBC::RaceToken (race client-filename token) and make AreaName skip
the parent Record lookup when resolveToParent is false, then replace the
inline ChrClasses/ChrRaces/AreaTable name+token reads across the codebase
with the shared helpers:
player/Info, creature/Info, player/LocationInfo, unit/ClassBase,
unit/RaceBase, map/Areas, taxi/Map, loot/History (drops its verbatim
ClassTokenForId), classes/Info (drops local Records/LocaleIndex).
All behavior-preserving -- the helpers wrap the same DBC::Record /
LocalizedField / StringField calls with identical id==0 / out-of-range /
empty-string -> nullptr semantics (verified against Lookup.cpp).
Left alone: NameCache's reverse token->id resolvers (no forward helper
fits) and the Record() reads of non-name fields in spell/Mod,
map/Overlays, creature/Info.
The second value's meaning varies by server core. Mangos/cmangos cores
send the query match count; Turtle (tortoise-wow) sends the whole
realm's online population whenever more than 49 players are online,
ignoring the filter (verified against tortoise-wow MiscHandler.cpp:
data.put(4, count > 49 ? count : clientcount), count = online players).
Earlier wording claimed it was always the match total, which is wrong
off Turtle -- confirmed by a Twinstar /who priest returning 15/15.
Add the modern table form of /who results. Vanilla's global
GetWhoInfo returns positional values and no class token; GetWhoInfo
here returns a table with filename (the "MAGE"/"WARRIOR" token, keyed
for RAID_CLASS_COLORS) plus fullName, fullGuildName, level, raceStr,
classStr, and area. GetNumWhoResults returns the shown + total counts.
Reads the engine's /who buffer directly (WHO_RESULT_* globals added to
Offsets.h, verified by decompiling Script_GetWhoInfo / GetNumWhoResults).
No gender field -- vanilla's /who entry has no sex.
Share the class/race/zone display-name resolvers: extract ClassName,
ClassToken, AreaName out of GetFriendInfo into dbc/Names.{h,cpp} (adding
RaceName and an AreaName resolveToParent flag -- friends resolve a
sub-area to its zone, /who uses the area's own name). GetFriendInfo and
GetWhoInfo both use them now.
Document both functions in docs/API.md and README.md.
Return the locale-independent class token ("WARRIOR", "MAGE", ...)
alongside the localized className, read from the same ChrClasses.dbc
filename column GetPlayerInfoByGUID's englishClass uses. It is the key
for RAID_CLASS_COLORS and other class tables, so addons can color a
friend's name without matching the localized class name. nil when
unknown, so className and classFilename appear together. Document in
docs/API.md.
Add a friends-list fallback to UnitNameFromGUID, between the object
manager and the persistent name cache. The friend list keeps name +
GUID for online AND offline friends (SMSG_FRIEND_LIST), so a friend's
GUID now resolves to a name even when the unit is not synced and was
never seen in chat -- the case that previously returned nil.
Only UnitNameFromGUID uses this: it returns name + realm, and the
friend entry has the name. GetPlayerInfoByGUID is left unchanged because
it also returns race and sex, which the friend entry does not carry.
Add FriendList::NameForGuid helper. Document the lookup order in
docs/API.md.
Add the online-friend count. Vanilla stores no online total, so count
friend entries whose connected byte is set -- the same flag
GetFriendInfo reports. Document in docs/API.md and README.md.
Add the two friend-note GlobalStrings vanilla 1.12 lacks, per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW), from Blizzard's Classic
GlobalStrings. Lets note-aware UI (e.g. a pfUI "Set Note" entry driven
by C_FriendList.SetFriendNotes) label itself in the client language.
Add ignore-list membership checks. Vanilla's ignore list is a flat u64
GUID array in the social singleton (social + 0x650, stride 8, max 25,
terminated by the first zero) -- verified by decompiling GetIgnoreName
(FUN_005AE570 reads social + 0x650 + index*8) and GetNumIgnores
(FUN_005AE550 stops at the first zero, caps at 25).
IsIgnoredByGuid parses the GUID string and walks the array.
IsIgnored accepts a name or GUID string (mirroring IsFriend); for a name
it resolves each ignored GUID to its cached name through the NameCache
and compares -- the same GUID->name path GetIgnoreName uses -- so it
matches any ignored player the client has synced this session.
Share the ignore layout + IgnoreGuidByIndex / IsGuidIgnored helpers in
FriendList.h alongside the friend layout. Document both functions in
docs/API.md and README.md.
UIDropDownMenu_CreateInfo does not exist in stock 1.12.1 FrameXML, so
building the Focus and Cancel buttons through it errored with "attempt
to call global UIDropDownMenu_CreateInfo (a nil value)". Build the info
tables directly ({}) -- the vanilla idiom UIDropDownMenu_AddButton
expects.
Unify ClassicAPI_ToggleUnitMenu so every menu (players and NPCs) shows
the base menu minus its trailing Cancel, then appends Focus + Cancel.
An NPC resolves to the RAID_TARGET_ICON menu, whose entries
UnitPopup_HideButtons hides entirely unless you are grouped and can
place markers -- so solo the menu was empty and never opened. The
appended Focus + Cancel now always open it, and the NPC's own name is
used as the title.
Give the Cancel button a no-op func: UIDropDownMenuButton_OnClick
early-returns when a button has no func and never hides the list, so the
funcless Cancel did nothing on click.
Add a contextual Focus / Clear Focus entry to the ClassicAPI unit
dropdown (ClassicAPI_ToggleUnitMenu), shown for real non-self units
(party / raid / target). pfUI unit frames route their right-click
through this dropdown, so it reaches every frame in use.
Built entirely inside our own dropdown: no hooksecurefunc and no
mutation of Blizzard's shared UnitPopupMenus tables, so default frames
and other addons are unaffected. Cancel stays last by showing the base
menu minus its trailing Cancel, then appending Focus and Cancel with our
own click funcs. "Clear Focus" shows when the unit is the current focus,
"Set Focus" otherwise.
Labels use localized SET_FOCUS / CLEAR_FOCUS globals, added per locale
(enUS/deDE/esES/frFR/ruRU/zhCN/zhTW) from Blizzard's Classic
GlobalStrings.
Update the GetFriendInfo entry so notes is a live field (with the
per-character persistence note), add SetFriendNotes and
SetFriendNotesByIndex sections plus TOC and README-matrix rows. Example
values are from a verified in-game /dump.
Vanilla has no friend notes, so persist them client-side like the equipment manager. New friendlist/Notes.{h,cpp}
stores guid -> note in
WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt --
per-character (matching the friends list's own scope), GUID-keyed (stable
and realm-unique), atomic tmp+rename write.
Expose C_FriendList.SetFriendNotes(name, notes) and
SetFriendNotesByIndex(index, notes) -- empty/nil clears, fires
FRIENDLIST_UPDATE -- and fill the notes field in GetFriendInfo /
GetFriendInfoByIndex from the store.
Character isolation (the equipment-manager trap): EnsureLoaded re-resolves
the path every call and reloads on change; Load clears the map before the
file-open check, so a note-less character still wipes the previous one's
map; and an empty-globals transition drops the cache so a stale path is
never read or written.
FriendInfo's optional strings (notes, className, area) are left nil when
absent instead of "" -- SetFieldString coerces null to "", which is
truthy in Lua and would fool an `if info.notes then` check.
Verified in-game: set / get / clear by name and by index.
Add API.md sections and the README matrix row for GetFriendInfo,
GetFriendInfoByIndex, and GetNumFriends, with the FriendInfo field list
and example values captured from a live /dump (verified, not guessed).
Return the modern FriendInfo table (name, connected, level, className,
area, guid, afk, dnd, plus vanilla-truthful mobile / referAFriend /
rafLinkType defaults). Fields are read straight from the friend entry,
mirroring the engine's GetFriendInfo (FUN_005AD060): class name via
ChrClasses.dbc, zone via AreaTable.dbc resolved to the parent area, both
locale-applied. Verified in-game against a live friends list -- online,
offline, level-60 and level-1, with AFK and DND toggled.
Factor the friend-entry layout and list accessors into a shared
friendlist/FriendList.h and move IsFriend onto it, so the one struct has
one definition. Add OFF_AREATABLE_PARENT_ID (0x08) for the zone resolve.
Add API.md sections and README matrix rows for the four functions, with
example returns verified against Spell.dbc. Also correct two stale docs:
- IsRangedAutoAttackSpell now describes the AttributesEx2 autorepeat flag
(bit 0x20), matching the fixed behavior -- not the old Attributes
RANGED-bit test that wrongly matched Aimed Shot / Multi-Shot.
- ResetsMeleeSwing mirrors the server's IsMeleeAttackResetSpell: true for
cast-time spells whose casting resets the swing (Fireball, Frostbolt),
false for instant specials (Sinister Strike) and for Slam (NOT_RESET
flag). The first draft had the examples and framing backwards.
Backport the friend-list membership check. Friends live inline in the
social singleton (0x00C28168): up to 50 entries, stride 0x20, each with
a GUID at +0x08 and a name pointer at +0x04 (verified from GetNumFriends
/ GetFriendInfo and the count walker FUN_005AE490). IsFriend matches the
input GUID against +0x08 -- the GUID the server sends for online and
offline friends -- with a name-compare fallback (GUID -> NameCache name),
and also accepts a bare character name.
Renamed VAR_WHO_SYSTEM -> VAR_SOCIAL_SYSTEM: 0x00C28168 is the one social
singleton shared by the friend list, ignore list, and /who system, so it
gets one accurate name instead of a who-specific alias plus a duplicate.
The remaining flag-bit duplicates -- the same drift risk that caused the
AttributesEx2/Ex3 mix-up -- now live once in Offsets.h:
- SPELL_ATTR_PASSIVE (0x40): was already in Offsets.h yet copied in
Info.cpp and Level.cpp; drop both locals.
- SPELL_ATTR_EX_CHANNELED (0x4|0x40): identical local in Cast.cpp and
CastEvents.cpp; add to Offsets.h and repoint.
- SPELL_ATTR_EX2_AUTOREPEAT_FLAG (0x20): the same bit under two names
(Cast's _AUTOREPEAT, AutoAttack's _FLAG); unify in Offsets.h.
Single-use flag bits (TRADESPELL, RANGED, HEALTH_FUNNEL,
HIDDEN_CLIENTSIDE, ON_NEXT_SWING, NOT_RESET_AUTO_ACTIONS) and per-module
effect/target enum codes stay local -- they are not duplicated and read
clearer at the use site.
Fold the last duplicated DBC offsets into Offsets.h:
- SpellIcon.dbc path (+0x04): 4 copies across 3 files -> OFF_SPELLICON_PATH.
aura/Data and spell/Cast had local consts; spell/Info used two raw
`iconRec + 4` literals.
- totem/Tracker's OFF_MISC_VALUE (0x1A8) duplicated the existing
OFF_SPELL_RECORD_EFFECT_MISC_VALUE -- repointed. OFF_TOTEM_TOOL (0xA0) is
a Spell.dbc field, added as OFF_SPELL_RECORD_TOTEM.
Audit result: unit-descriptor offsets were already fully centralized in
Offsets.h, and item ItemStats_C fields are read via a named struct rather
than offset consts, so neither needed work. Spell.dbc and its sub-DBC
record offsets are now a single source of truth.
Extend the single-source-of-truth cleanup to the whole Spell.dbc record
layout. 40 local constexpr offset copies across 16 files (spell/, totem/,
lossofcontrol/, macro/, aura/) redefined field offsets that belong in
Offsets.h: name (0x1E0), icon (0x1D4), effect (0xF4), rank (0x204),
effect implicit targets (0x148/0x154), powerType (0x7C), manaCost (0x80),
rangeIndex (0x90), castingTimeIndex (0x48), maxLevel (0x6C), spellLevel
(0x74), manaCostPercent (0x270).
Add the ones Offsets.h lacked as OFF_SPELL_RECORD_* and repoint every
local use to the Offsets:: constant, deleting the duplicates. Purely
mechanical and behavior-preserving -- every local value already matched
the canonical (verified before repointing). SpellIcon.dbc / SpellRange.dbc
sub-record offsets (0x04/0x08) stay local; they are different structs.
SpellHiddenFromAutoLearn tested AttributesEx3 bit 0x02000000, a gate
ported from a MoP function (FUN_00911c60) whose +0x24 field is not
1.12's AttributesEx3. Verified against Spell.dbc: in 1.12 the bit is set
on only 44 of 27925 records -- a grab-bag of passive procs, odd DoT
ranks, the Night Elf priest Starshards line, and Turtle customs -- so it
is not an auto-learn flag and could only wrongly hide legitimate
learnable spells (Starshards carries it).
Drop the AttributesEx3 test and keep the verified HIDDEN_CLIENTSIDE
(Attributes bit 0x80) filter, renamed SpellHiddenFromSpellbook. The
authoritative auto-learn signal is SkillLineAbility.learnOnGetSkill
(col 9), not a Spell.dbc bit; this feature reports the broader
"trainable at level" set and applies no learnOnGetSkill filter.
The check tested Attributes bit 1 (RANGED), which every ranged ability
sets -- so Aimed Shot and Multi-Shot wrongly counted as ranged
auto-attacks. The two auto-repeat spells (Auto Shot, Shoot) are the ones
with SPELL_ATTR_EX2_AUTOREPEAT_FLAG (AttributesEx2 bit 5, 0x20). Verified
from Spell.dbc: Auto Shot (75) and Shoot (5019) carry Ex2 0x20; Aimed
Shot (19434) and Multi-Shot (2643) set only RANGED, not the flag.
isFunnel read AttributesEx bit 6, which is CHANNELED_2 -- false for
Health Funnel itself and true for unrelated channels, so it never
actually detected funnel spells. The real flag is
SPELL_ATTR_EX2_HEALTH_FUNNEL (AttributesEx2 bit 11, 0x800). Verified
from Spell.dbc: Health Funnel (755, Ex2=0x808) and Hellfire (1949,
Ex2=0x800) carry it; the channeled non-funnels Drain Life, Mind Flay,
Arcane Missiles, and Rain of Fire do not.
The prior consolidation moved the spell-mod disable read from record
+0x24 to +0x20 on the handoff's claim that all three "0x24 readers"
intend AttributesEx2. A tortoise-wow sweep plus client disassembly
disproved that for the mod gate: the client's spell-mod core
(FUN_006e6b30) reads [record + 0x24] & 0x20000000 -- AttributesEx3 bit
29, SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS, which is the real "ignore
caster modifiers" flag. AttributesEx2 bit 29 is CANT_CRIT and has
nothing to do with spell mods. So +0x20 was a regression.
Add OFF_SPELL_RECORD_ATTRIBUTES_EX3 (0x24) and the correctly named
SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS to Offsets.h, and point Mod.cpp
at them. Cast.cpp's autorepeat read stays at AttributesEx2 (bit 5,
AUTOREPEAT_FLAG) -- that one was a genuine EX2 flag and the move to
+0x20 was correct.
Level.cpp's auto-learn gate was ported from a Cata function
(FUN_00911c60, outside 1.12's .text) and its 1.12 meaning is
unverified, so restore it to the original +0x24 read (no behavior
change from before the consolidation) and mark the constant unverified
pending a real 1.12-client check.
Add C_Spell.GetSpellDispelType, IsNextMeleeSpell, and ResetsMeleeSwing.
Each reads a Spell.dbc field straight from the record: dispel type
(0x10), the on-next-swing attribute bits (0x18 & 0x404), and the melee
swing-reset rule (InterruptFlags 0x54 has AUTOATTACK and AttributesEx2
0x20 lacks NOT_RESET_AUTO_ACTIONS). All offsets come from the shared
Offsets.h constants. This lets pfUI drop nampower's GetSpellRecField.
The Attributes (0x18), AttributesEx (0x1C), and AttributesEx2 (0x20)
record offsets were each copied as local constants across several spell
modules. AttributesEx2 was 0x24 in three of those copies, which is
actually AttributesEx3 -- an off-by-one-field bug. Spell.dbc stores each
record as a flat uint32 array by column, so AttributesEx2 (column 8) is
at 0x20. Verified against the 1.12.1 client, the server column order,
and nampower.
Move the record offsets to Offsets.h as the single source of truth (add
AttributesEx at 0x1C and InterruptFlags at 0x54; correct AttributesEx2
to 0x20) and remove the local copies in AutoAttack, Cast, CastEvents,
Info, and Level. This corrects the autorepeat aim-time skip, the
auto-learn hide check, and the spell-mod family gate -- all of which read
AttributesEx2 -- and removes the duplication that let one wrong offset
become three bugs.
The Aura::Source cache holds one entry per (target, spellId) for every
aura-applying SMSG_SPELL_GO observed — every buff on every unit in view, not
just the player's. At 256 entries the table was permanently full in a group or
raid, so nearly every new cast fell through Store's overflow branch, which
overwrote a blind round-robin slot with no presence or expiry check. That slot
was almost always a live buff, dropping its cached caster — the "buff kept its
sourceGUID for a few minutes then lost it" report (e.g. Blessing of Wisdom).
Size kCacheSize to the realistic worst case (2048 — a fully raid-buffed 40-man
plus debuffs, ~80 KB static) so the table stops living at capacity, and replace
the round-robin overflow with a presence-aware victim: prefer the
least-recently-updated ORPHAN (no live descriptor backing — out of range /
despawned), and only touch a still-present entry under true saturation (every
one of 2048 slots a live aura, practically unreachable). This extends the
descriptor-presence invariant the tick sweep and slot-reuse already honor to
the last unguarded eviction path, so an aura still on a resolvable unit keeps
its source for its whole life. Verified in-game: 30+ minutes in a full
Stormwind with no source loss.
The self-buff sourceGUID inference from 7caee62 ran only at display, so a
self-buff we never saw cast (e.g. login-persisted Inner Fire) showed
sourceUnit="player" yet was excluded from HELPFUL|PLAYER — IsPlayerCast
still keyed off the Aura::Source cache alone, which has no entry for it.
Centralize the resolution in EffectiveCaster (observed caster, else the
self-buff inference: a self-only-target aura is cast by the unit carrying
it) and route display plus both filter predicates (IsPlayerCast,
GroupIsPlayerCast) through it, so the two paths can't disagree. The
predicate stays unit-scoped: a self-buff on the player matches PLAYER; the
same self-buff on another unit resolves to that unit and correctly does not.
Two changes so an aura's source stops vanishing:
Pin observed casters. The WorldTick expiry sweep (and Store's slot
reuse) dropped a cache entry once its computed expirationMs elapsed —
but that time is only a base-duration estimate for non-player casters
(we lack their mod tables), so a talent-extended debuff expired in the
cache while still live on the target. The entry took the caster with it,
and the descriptor path then rendered the aura with no sourceGUID. Now
an entry whose aura the owning unit's live descriptor still lists is
never timer-reclaimed (DescriptorListsAura resolves the GUID through the
object manager); only genuinely orphaned entries — unit out of range /
stealthed / despawned — expire. The fallback path already skips expired
entries, so no phantom can resurface. PushEnriched also reports an
already-elapsed cached expiration as unknown rather than a negative
remaining time.
Infer self-buff casters. On a caster miss (no cache entry, or one with
no caster), a self-only-target aura can only have been self-cast, so the
unit carrying it is its own source. Factor the record scan out of the
C_Spell.IsSelfBuff Lua wrapper into Spell::IsSelfBuff::IsSelfBuff and
call it from PushEnriched to recover sourceUnit/sourceGUID for
pre-existing self-buffs we never saw applied. Caster is exact; timing
stays unknown.
Reuses the C_LossOfControl classifier so the two never drift. Extract
ClassifyControlLoss out of the LoC module into a shared
Spell::CrowdControl: Classify(rec) returns the hard control-loss type
string (stun/fear/silence/root/charm/confuse/pacify/possess/disarm) and
still backs C_LossOfControl; IsCrowdControl(spellID) backs the aura
filter.
Crowd control is broader than loss of control: IsCrowdControl also
counts movement slows / snares (SPELL_AURA_MOD_DECREASE_SPEED = 33,
added to Offsets.h), which are crowd control but not a loss of control —
so a Frostbolt/Hamstring slow is CROWD_CONTROL while C_LossOfControl
still ignores it. Classify (LoC) is unchanged.
Wire it as a third CcMode dimension on the Aura::Data::Match struct,
checked in MatchesAura; tokenizer honors CROWD_CONTROL / !CROWD_CONTROL.
Docs updated. Verified in-game: Psychic Scream (fear) and a Frostbolt
slow both classify as CROWD_CONTROL; DoTs and buffs do not.
Modern DISPELLABLE means "can be dispelled/purged/stolen by SOME
mechanism, regardless of whether the player can". Confirmed against the
Turtle/Tortoise server: an aura is dispellable iff its Spell.dbc Dispel
type is Magic(1), Curse(2), Disease(3), or Poison(4) — the server's
DISPEL_ALL_MASK — and vanilla applies no per-aura "undispellable"
attribute, so the dispel type is the whole story.
Data::IsDispellable(spellID) reads the Dispel field (OFF_SPELL_DISPEL_TYPE)
via the existing SpellRecord and checks 1..4 (the type ID directly, not
dispelName != nil, in case SpellDispelType.dbc names Stealth/Invisibility).
Fold the caster and dispel predicates into one Aura::Data::Match struct
that replaces the standalone CasterMode parameter across the Find*/Push*/
Append* surface, applied by a shared MatchesAura(match, isPlayerCast,
spellID). Orthogonal filter dimensions now add a struct field instead of
another parameter. Existing caster behavior is unchanged.
Tokenizer honors DISPELLABLE / !DISPELLABLE; docs updated. Verified
in-game: HARMFUL|DISPELLABLE returns Magic debuffs, HARMFUL|!DISPELLABLE
returns physical debuffs (Rend).
shell: bash on the self-hosted Windows runner resolved to the WSL
launcher at C:\Windows\System32\bash.exe (Git Bash isn't on the
NETWORK SERVICE account's PATH), so Resolve version died with 'no
installed distributions'. Default the build job's run steps to Windows
PowerShell 5.1 (always present) and rewrite Resolve version accordingly,
dropping the bash/WSL dependency from the native-Windows build.
Probe repo runners in a pick-runner job and hand the resolved label
(self-hosted if one is online, else windows-latest) down to build via
needs, so an offline self-hosted runner no longer queues the build
indefinitely.
The aura filter string was matched with raw strstr, which the modern
AuraFilters token set breaks: strstr(f, "PLAYER") also matches
RAID_PLAYER_DISPELLABLE (false PLAYER-only), and "!PLAYER" contains
"PLAYER" so it was read as PLAYER-only — the opposite of intent.
Replace it with a real tokenizer (Aura::Api::ParseFilters): tokens
separated by "|" or whitespace, each optionally negated with a leading
"!", whole-token matching. Honored tokens: HELPFUL / HARMFUL
(descriptor slot ranges) and PLAYER / !PLAYER (caster == / != the local
player). Every other AuraFilters token (RAID, CANCELABLE, DISPELLABLE,
INCLUDE_NAME_PLATE_ONLY, MAW, …) is accepted and ignored — vanilla has
no data for them.
!PLAYER makes the caster restriction ternary, so the Aura::Data layer's
`bool playerOnly` becomes `CasterMode { Any, PlayerOnly, NotPlayer }`
threaded through every Find*/Push*/Append* function (plus the
lossofcontrol call site). Existing Any/PlayerOnly behavior is preserved
exactly; NotPlayer is additive. A cache miss counts as not-player, so
!PLAYER includes auras whose cast we did not observe (matching
IsPlayerCast's miss semantics).
Also fixes: "PLAYER" alone on the by-id / by-name getters now searches
both ranges instead of wrongly restricting to helpful.
Docs: API.md gains an STE "Filter tokens" note under GetAuraDataByIndex.
pfUI-style unit frames have no OnEnter; their tooltip is produced by
Frame::Attributes driving the engine mouseover setter (FUN_00492890).
That setter builds the unit tooltip only when the GUID resolves to a
live object, so an offline / far out-of-range party or raid member
(roster GUID, no object) got no tooltip.
The unit-tooltip builder FUN_00529FE0 itself works offline — with no
object it fills name/level/class + the "Offline" line from the party/
raid roster (same path GameTooltip:SetUnit("party1") uses). So when the
hovered unit-frame's GUID has no live object, build the tooltip
ourselves from the roster.
Fire OnTooltipSetDefaultAnchor before building (via the self-contained
FUN_FRAME_INVOKE_SCRIPT), exactly as the engine setter does — that
FrameXML handler sets the tooltip owner/anchor and is what shows it.
Without it the first hover worked by luck but a re-hover after the
leave-clear had no owner and stayed invisible (only a live-unit hover,
which takes the engine's own anchor path, would recover it).
New offsets: VAR_GAMETOOLTIP_OBJECT_PTR (0x00B4B3C4, the mouseover
tooltip object) and OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER (0x444).
Modern WoW exposes GetMouseFoci() (the stack of frames under the
cursor) alongside GetMouseFocus(), with GetMouseFocus() ==
GetMouseFoci()[1]. Vanilla stores only the single topmost focus
(Script_GetMouseFocus reads *(ctx + 0x7C)); there is no stored stack.
Reimplement natively (no Lua polyfill, no hooks): walk the engine frame
list that Script_EnumerateFrames iterates (head *(ctx + 0xCC4), next
+0x308, low-bit/null sentinel), keep frames that are visible +
mouse-enabled and whose rect contains the cursor (Region getters +
cursor/effective-scale, mirroring Frame::Modern's IsMouseOver), order
top-first by strata rank then frame level (level read directly from
+0xC4), and force index 1 to the engine's stored focus so the invariant
holds by Lua table identity. Frames are pushed via the shared
UI::FrameObject::Push so == compares the same wrapper GetMouseFocus
returns.
Filters to the mouse-enabled (interactive) stack, matching 1.15.x
(verified: GetMouseFocus skips a higher non-mouse-enabled cooldown to
land on the button, and 1.15.x GetMouseFoci returns the same set).
/framestack lists more because it is a pure-geometry debug view, not the
mouse-focus set.
Docs: API.md entry (Simplified Technical English).
Push-triggered runs can be silently dropped when GitHub throttles
webhooks during an Actions incident, leaving no way to kick a build.
Add a workflow_dispatch trigger as a manual escape hatch, runnable
from the Actions UI.
A single Resolve step computes is_release/tag from the pushed ref, the
dispatched ref, or an optional `tag` input, and every downstream step
keys off it — so push behavior is unchanged. Dispatch modes: run from a
vX.Y.Z tag (release build of that commit), run from a branch with a
`tag` input (release build attached to that tag), or run from a branch
with a blank input (dev build artifact). The Release step now passes
tag_name explicitly so it also works when the ref is a branch.
Two related fixes to the embedded-addon precedence so users stop
running a stale/mismatched on-disk !!!ClassicAPI without realizing it,
while keeping the developer's disk-wins workflow intact.
Version is now a pure build-flag artifact. The embedded copy's
`## Version:` is stamped from CLASSICAPI_TAG at embed time
(cmake/embed_addon.cmake rewrites the line in-memory before hex
encoding, portable via a temp-file round-trip). The committed source
toc stays `## Version: DEV` and is never edited, so a release build is
`-DCLASSICAPI_TAG=vX.Y.Z` and nothing else. Dropped the CI sed step
that previously mutated the source toc. Moved the version-derivation
block above the embed step so CLASSICAPI_TOC_VERSION is available to it.
Dev override moved from the toc version to an explicit marker. A
`.classicapi-dev` file in the on-disk addon folder now forces the disk
copy to win regardless of version (checked via the original
FUN_FILE_READ, so it reflects a real on-disk file). Consequently `DEV`
no longer sorts as "newer than any release" in CompareVersions — it
sorts oldest — so a stray `DEV` source/clone copy on a user's disk no
longer silently shadows a newer embedded release. The normal local
loop still works without the marker (local DEV DLL + DEV disk = tie =
disk wins); the marker is only needed to prefer disk against a released
DLL. The marker is gitignored and excluded from the embed (both globs),
and the release ships only the DLL, so it can exist only in a
developer working tree.
ClassicAPI 1.8.4 added a UPDATE_MOUSEOVER_UNIT loss-fire polyfill
(matching retail): the event now also fires when the mouseover goes
unit -> nothing, with `mouseover` already cleared. Puppeteer's
EnemyTracker OnEvent reads `unit = UnitGUID("mouseover")` on that
event, which is now nil on the loss fire; it then calls
`UnitCanAttack("player", unit)`, and Puppeteer routes UnitCanAttack
through its DoubleUnitProxy (no nil guard) straight to native
UnitCanAttack -> engine raises `Usage: UnitCanAttack("unit",
"otherUnit")` on the nil arg.
Wrap PTEnemyUpdater's OnEvent from our AddOnCompat shim and skip the
handler when UPDATE_MOUSEOVER_UNIT fires with no live mouseover, so
the nil never reaches the proxy. Gains still pass through, so enemy
tracking is unaffected. Deferred via ContinueOnAddOnLoaded (Puppeteer
loads after us) and guarded on PTEnemyUpdater existing (it is only
created when SuperWoW is present).
The CBOR encoder walked string-keyed tables with lua_next (pairs()
order), which Lua does not guarantee to be stable. The pfUI export
path rebuilds its config tables on every press, so each rebuild
re-hashed the same keys into a different internal layout and the map
serialized its key/value pairs in a different byte order -> different
compressed bytes -> different Base64. Harmless (every variant decodes
to the same config) but no stable fingerprint.
EncodeTable's map branch now stashes each key, records its encoded
bytes, sorts by encoded-key bytes (shorter first, then bytewise per
RFC 7049 3.9), then re-fetches key/value in sorted order. Nested
maps sort recursively, so the whole byte stream is canonical. Decode
is order-agnostic, so the round trip stays lossless.
Verified in-game: encode/decode/re-encode of a pfUI profile now
produces a byte-identical Base64 string.
C_UnitAuras dropped sourceGUID/sourceUnit for buffs cast by other units
(e.g. a druid's Mark of the Wild / Gift of the Wild on the player).
SMSG_SPELL_GO arrives before the SMSG_UPDATE_OBJECT that adds the aura to
the target's descriptor. In that window an addon aura poll runs
ReconcileCache, which calls EvictAbsent. The just-captured cache entry
named an aura the descriptor did not list yet, so EvictAbsent dropped it
and lost the caster. OnAuraAdded then recreated the entry with no caster,
so sourceGUID stayed nil. Verified by trace: every "Store NEW caster=0x8"
was followed at once by "EvictAbsent (had caster=0x8)".
Give each cache entry a write timestamp (stampMs) and exempt entries
younger than kEvictGraceMs (2s) from EvictAbsent. A real removal is far
older and still gets dropped, and OnAuraRemoved evicts genuine removals
directly, so stale-aura cleanup is unaffected.
Verified in-game: a remote druid buff now reports sourceGUID; a self-cast
buff still reports sourceGUID + sourceUnit="player".
`GameTooltip:SetSpellByID` now shows the spell rank in the top-right
corner of the title line. The cross-class `SetTalentByID` fallback shows
it too. Both go through `Spell::Tooltip::ShowByID`.
The engine builder `BuildSpellTooltip` (`FUN_0052E610`) sets the title
line right-text to `Rank[locale]` when `param_5` or `param_2` is not
zero. `param_5` has no other effect in the builder (confirmed by
disassembly). `SetAction` and `SetPetAction` pass `param_5=1` for this.
Native `SetSpell` and the old `SetSpellByID` passed `0`, so they did not
show the rank. `ShowByID` now passes `param_5=1`.
The change is additive. A spell with no rank (passive or racial) shows
nothing there, the same as the engine.
With this change, the macro tooltips of SuperCleveRoidMacros match the
native action-button tooltips.
The CGPlayer sub-struct at [unit+0xE68] (guild key, PLAYER_FLAGS, quest
list, visible items) exists only on real player objects. Reads of it were
gated on UNIT_FLAG_PLAYER_CONTROLLED, but the server sets that flag on
pets, totems, guardians, companions, and MC'd creatures too (Turtle
UnitDefines.h; Pet.cpp/SpellEffects.cpp/SpellAuras.cpp setters) -- all
TYPEID_UNIT creatures with no such sub-struct. On one of them the +0xE68
pointer is garbage-but-non-null, so the null check passes and the deref
AVs (ERROR #132, pfUI issue #34: UnitIsInMyGuild on a nameplate pet/totem,
frequent in crowded dungeons/raids).
Add Unit::Flags::IsPlayerObject (m_objectType == OBJECT_TYPE_PLAYER via
[obj+0x14]) and use it as the gate at every arbitrary-unit +0xE68 reader:
- UnitIsInMyGuild (ReadGuildKey) -- the reported crash
- UnitIsAFK/UnitIsDND (TestPlayerFlag) -- same latent crash on a pet
- IsUnitOnQuest (Quest::Log::UnitHasQuest)
- GetInventoryItemID (non-local unit visible-items path)
Local-player-only readers are unaffected. IsPlayerControlled stays for
minion/ownership classification (Unit::Pet), which wants the broad flag.
The vanilla CreateMacro / EditMacro globals take an icon INDEX into the
GetMacroIconInfo list, and that list omits INV_* item icons entirely --
so item icons are unreachable through the stock API even though the
engine stores and renders them fine. C_Macro.CreateMacro / EditMacro
accept the icon as a texture string (a full Interface\Icons\<name> path
or a bare <name> basename) instead, writing the basename directly to the
macro struct; GetMacroInfo re-prefixes it as Interface\Icons\%s on read,
so any Interface\Icons\ texture round-trips.
Both go straight to the engine's own workers (FUN_004f1010 create /
FUN_004f1280 edit), which do the bounded copy, re-run the primary-spell
parser, set the persist-on-logout dirty flag, and refresh the action
bars. EditMacro resolves its index arg as a slot number or a macro name
(via the GetMacroIndexByName resolver), leaves any nil field unchanged,
and reads back the entry's local flag so the worker's unconditional
rewrite of it is a no-op.
The legacy globals stay untouched (index-only); string-icon callers opt
in through the C_Macro namespace. Verified in-game incl. an INV_* item
icon surviving a relog. Unblocks a pfUI macro-UI remodel.
On this Lua 5.0 build, unpack() ignores its explicit i/j arguments entirely
and takes its element count from getn() -- which returns the table's `.n`
field, or border-searches to the first nil when `.n` is absent. Verified in
the binary (luaB_unpack 0x007030a0 -> luaL_getn 0x006f5050) and in-game
(unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
- GenerateClosure built its forwarding table `rest` with no `.n`, so
unpack(rest, 1, restN) border-searched and silently dropped every value
past the first nil hole in the payload -- despite the comment claiming
embedded nils survive. It now stamps `all.n` and unpack(all) forwards
holes intact.
- Every other unpack(x, 1, x.n) / unpack(x, 1, count==x.n) call was a no-op:
the table already carried `.n`, so unpack(x) is identical. Simplified
across FunctionUtil, Pools, CallbackRegistry, and TableUtil's SafeUnpack,
and corrected the header comments that recommended the redundant form.
A callback firing during the engine's logout teardown can touch state
that has already been freed, faulting with ERROR #132 (ACCESS_VIOLATION).
A dedicated frame owns PLAYER_LOGOUT and tears down frameEventFrame's
event delivery (UnregisterAllEvents + clear OnEvent) so nothing dispatches
during teardown. A separate frame keeps this decoupled from the
ref-counted consumer event set, which may no longer hold PLAYER_LOGOUT.
ShaguTweaks' libpredict sender frame (ShaguTweaksPredictionSender) registers
TBC-era events alongside its vanilla ones — COMBAT_LOG_EVENT_UNFILTERED and
UNIT_SPELLCAST_START/STOP/FAILED/INTERRUPTED/SENT (libpredict.lua:342-347). In
stock 1.12.1 those never fire, so registering them was a harmless no-op and only
the vanilla SPELLCAST_* path ran. ClassicAPI now backports those events, so
libpredict's TBC branches fire too and double-process cast/heal predictions.
Unregister the six TBC events on libpredict.sender once ShaguTweaks loads,
restoring vanilla-only behavior. Guarded on the addon existing and the
libpredict table being present.
EventRegistry callbacks (RegisterOnceFrameEventAndCallback, etc.) dropped any
payload whose first arg was nil — e.g. this client's reload PLAYER_ENTERING_WORLD
(arg1=nil, arg2=1) delivered nil to every callback parameter. Root cause is a
WoW Lua 5.0 quirk: unpacking a table whose hole lands right after a present
element into a `func(first, ...)` collapses the receiving vararg count to 0, so
the payload after `owner` was silently lost.
Fix both build-then-unpack sites in CallbackRegistry to pass the leading arg
explicitly and the payload as the unpacked tail:
- TriggerEvent Function branch: func(owner, unpack(arg, 1, arg.n))
- GenerateClosure: func(bound[1], unpack(rest, 1, restN))
Verified in-game: RegisterOnceFrameEventAndCallback("PLAYER_ENTERING_WORLD", cb)
now delivers arg2=1 to the callback's 2nd parameter on reload.
The attribute methods and the modern-script-args toggle shipped on this
branch but weren't in the README index, and the toggle wasn't documented
at all.
- README: add frame:SetAttribute / SetAttributeNoHandler / ClearAttribute /
GetAttribute, the OnAttributeChanged script, and SetModernScriptArgs /
GetModernScriptArgs to the Frame row.
- docs/API.md: document SetModernScriptArgs(enable) / GetModernScriptArgs()
(opt-in modern positional handler args) with a Frame TOC entry. The
attribute methods and OnAttributeChanged were already covered.
* frame: backport Frame:SetAttribute/GetAttribute + unit-frame mouseover/click
Adds native SetAttribute / SetAttributeNoHandler / GetAttribute on the base
Frame registry: a per-frame, case-insensitive value store with the 3.3.5
prefix/name/suffix getter precedence, stored on the frame's own Lua table.
A `unit` attribute makes the frame a mouseover source. A WorldTick poll of the
engine's mouse-focus frame (GetMouseFocus's global) calls the real mouseover
setter (FUN_00492890) for its unit -- 1:1 with hovering the 3D model: model
highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the native GUID slot.
Stomp-proof (the engine's 3D-hover setter is event-driven) and independent of
the frame's scripts, so an addon setting its own OnEnter can't break it.
A `type1`/`type2`/`type` attribute installs a chained OnClick on that frame only
(PCall-guarded, Button-only) that dispatches target / assist / focus on the
unit at click time, then runs any prior handler.
No global hooks: mouseover reads the mouse-focus global; clicks use the frame's
own OnClick. Deliberately does NOT MinHook the button-click dispatcher
(FUN_00779540) -- SuperWoW's click-casting owns that prologue and a second
inline hook corrupts the trampoline (ERROR #132).
* frame: extend attribute backport (one-verb click dispatcher + OnAttributeChanged)
The `type*` click path now mirrors retail's SecureActionButton_OnClick:
resolve ONE verb per click from the modifier/button-qualified `type`
attribute and perform it, instead of a fixed `type1=target`.
- Modifier/button resolution: `[alt-/ctrl-/shift-]type[1..5]` with
prefix..name..suffix -> name..suffix -> name precedence, read from the
live key state and the click's button.
- Verbs: target (with the engine's default-interaction precedence -
cast a pending spell / drop a cursor item on the unit instead of
switching target), assist, focus, spell, stopcasting, macro, and menu.
- Own-the-click: when a verb resolves we perform it and do NOT chain the
frame's previous OnClick, so a configured `type1` no longer double-
dispatches alongside an addon's own conditional click handler.
- Self-healing wire: addons that re-SetScript OnClick (pfUI on every raid
relayout) clobber our closure; WireOnClick re-wraps a clobbered Lua
handler but skips its own C closure, so re-setting `type*` recovers
without double-chaining.
Dispatch goes straight to C++ where we own the code - no Lua round-trip
through the global table: spell -> Spell::AtUnit::CastByName (a native
unit-targeted cast; the unit's GUID is fed to the engine's dispatcher so
there's no target juggling, and ground-target spells land at the unit's
feet), focus -> Unit::Focus::Set, and the target verb's predicates ->
Spell::AtCursor::IsPlacementActive / Cursor::Info::HasItem. Only genuine
engine entries (TargetUnit/AssistUnit/SpellTargetUnit/DropItemOnUnit/
SpellStopCasting) and addon-provided functions still go through Lua
globals. Spell::AtUnit gains a C++ header sharing its CastCore with the
C_Spell.CastAtUnit Lua entry.
macro verb: takes the content from the `macrotext`/`macro` attribute and
prefers an addon-provided RunMacro (SuperCleveRoidMacros etc. - handles
named macros and extended macro text), falling back to running the text
natively when no RunMacro global is present. The native path runs each
line through the stock FrameXML ChatEdit_ParseText via a throwaway
edit-box (GetText closure + no-op __index), so it needs no addon.
menu/togglemenu verb: pops the standard unit dropdown at the cursor via
the new ClassicAPI_ToggleUnitMenu (Util/SecureUnitMenu.lua), resolving
the unit -> menu type with Blizzard's own generic resolver
(self->SELF, pet->PET, grouped player->PARTY, other player->PLAYER,
NPC->RAID_TARGET_ICON). Keeps the FrameXML dropdown logic in Lua with
the C side as a thin call.
OnAttributeChanged is now a real SetScript/GetScript/HookScript-able
frame script on every frame: SetAttribute fires it, SetAttributeNoHandler
suppresses it. Made settable by co-hooking the base-frame script-name
resolver FUN_0076a0d0 (the one every frame type delegates through) and
handing back an external per-frame {handler, context} cell for that name -
frames are immortal in 1.12, so the cell never goes stale. One hook does
it (vs the tooltip OnTooltipSet* work's five) because we already own the
firing side. 1.12 convention: the handler reads this = frame, arg1 = name,
arg2 = value. Recursion-guarded so a handler may itself SetAttribute.
* frame: add Frame:ClearAttribute (retail 11.2.0)
`cleared = frame:ClearAttribute(name)` removes an attribute and returns
whether it was set. Registered on the base Frame method registry next to
SetAttribute/GetAttribute, so every frame has it.
Routes through DoSet's nil-value path (removes the stored key, drops a
`unit` mouseover binding) but does NOT fire OnAttributeChanged — verified
against a live retail 11.2 client: SetAttribute fires the handler,
ClearAttribute fires nothing, and the return is true for a set attribute /
false when there was nothing to clear.
* frame: store attributes in the Lua registry, hidden from Lua
Attributes were kept as a subtable on the frame's own Lua table under a
control-char key ("\1ClassicAPIAttributes"). That key, while collision-
safe, is still enumerable via pairs(frame) and readable/writable from Lua.
Move the store to registry[frame] in the Lua registry (LUA_REGISTRYINDEX),
which has no _G path — so the attribute map is invisible to pairs(frame)
and can't be read or tampered with from script, matching how retail keeps
attributes C-side. Only PushSub changed (create/fetch now key the registry
by the frame table); GetAttribute/DoSet/CopyAttr sit on top unchanged.
1.12 frames are immortal, so the strong registry key never leaks. The dead
kAttrKey constant is removed.
Verified in-game: SetAttribute/GetAttribute still round-trip, and a
pairs(frame) scan no longer finds any ClassicAPI key.
* frame: add the `item` click verb (SecureActionButton item action)
type*="item" uses the modifier-qualified `item` attribute:
- a "bag slot" string (e.g. "0 1") -> UseContainerItem(bag, slot);
- otherwise an item name / itemID / link -> C_Item.UseItemByName(item, unit),
passing the unit as the use target (self-use items ignore it, targeted
ones like scrolls/bandages apply to it);
- the deprecated `bag` + `slot` attributes are honored when `item` is unset.
Both bag paths go through lua_call (new CallTableFunc2Str / CallGlobalNum2)
rather than a direct C++ call: the item-use bag walk does SetTop(L, 0), so
it needs its own Lua stack frame — a direct call would wipe the dispatcher's
stack. (spell stays direct because Spell::AtUnit is stack-free; the helper
comment records the distinction.) Retail's "bag slot" string is the
preferred form; the deprecated per-attribute form is supported too.
* docs: format the attribute click verbs as a table
The verbs were one long paragraph of nested parentheticals - hard to scan.
Replace it with a Verb | Extra attributes | Action table so each verb's
inputs and behavior are legible at a glance. Content-equivalent; no API change.
* frame: clear target on the `target` verb when unit is "none"
Retail's SecureActionButton target action treats a "none" unit as "clear
target". Match it: the `target` verb calls ClearTarget() when the resolved
`unit` is "none" (case-insensitive), before the normal cursor/spell
precedence. Mouseover already handles "none" implicitly (it resolves to
GUID 0 = no unit). Docs' target-verb row updated.
* frame: pass modern positional args to script handlers
Vanilla always invokes frame-script handlers with zero Lua args; the
handler reads this/arg1/event as globals. Modern addon ports write
SetScript("OnMouseWheel", function(self, delta) ...) and get nothing.
Co-hook both script runners -- FUN_FRAME_INVOKE_SCRIPT (no-arg scripts)
and FUN_FRAME_RUN_SCRIPT_ARGS (scripts with values) -- and reimplement
their pcall tail: keep the engine's exact this/argN global save-set-
restore and message-handler errfunc, but additionally push self +
arg1..argN as real positional arguments and pcall with nargs = 1+N.
Purely additive: a zero-param vanilla handler ignores the extras and
reads globals; a modern (self, delta) handler binds them.
OnEvent gets (self, event, arg1..argN), detected structurally by
matching the invoked handler ref against the frame OnEvent slot
(frame+0x0C) -- exact, since each SetScript makes a distinct ref.
Runtime switch SetModernScriptArgs()/GetModernScriptArgs(), default on;
when off both detours are a straight passthrough to the original runner,
which matters because FUN_FRAME_RUN_SCRIPT_ARGS is the hottest Lua path
in the engine (every OnUpdate frame). Verified in-game across
OnMouseWheel/OnClick/OnValueChanged/OnUpdate/OnEnter/OnLeave/OnEvent.
* net: consolidate spell SMSG hooks onto one dispatch funnel
The spell subsystem's per-opcode SMSG handlers (SpellGo, SpellStart,
SpellDelayed, ChannelStart/Update, SpellFailure, SpellFailedOther,
SpellCooldown) were each co-hooked directly -- and nampower (hadesmem)
plus SuperWoW detour the same prologues. Two inline hookers on one
prologue is the documented MinHook collision risk, and a whole-protocol
bisect fingered exactly this cluster as the crash-suspect surface.
Add Net::PacketDispatch: a receive-side mirror of Net::SendObserver that
co-hooks the single incoming-SMSG dispatch funnel FUN_NET_MESSAGE_DISPATCH
(0x00537AA0) -- a chokepoint nampower does NOT hook (it detours the leaf
handlers) -- and fans each packet out to opcode subscribers with the
cursor at the body and centralized save/restore.
Migrate all eight handlers to subscribers: aura/Source (SpellGo, which
re-decodes the packed guids + spellId the leaf handler used to hand it
pre-decoded), spell/Cast (Start/Delayed/Channel*/Failure*), and
lossofcontrol (Cooldown). The parse bodies are unchanged; only the hook
site moved. Subscribers run before the engine's leaf handler instead of
after a co-hook's original -- verified order-independent for all eight
(self-contained cache/state updates; SpellGo's aura-application handoff
rides a separate SMSG_UPDATE_OBJECT).
Cuts the ClassicAPI-nampower shared spell prologues from 10 to 3 (the
OnAura* method hooks, which have no packet chokepoint to relocate to).
One hook on an uncontended function replaces eight on contended ones.
Verified in-game: cast bars, channels (incl. Mind Control cast-then-
channel), pushback, remote-cast interrupt eviction, and C_LossOfControl
school lockouts all intact.
* ScriptArgs is back to opt-in
* frame: fix ScriptArgs comments to match opt-in default
* frame/net: dedupe packet observers, reuse Lua/token helpers, fix attr edge cases
Post-review cleanup of the attribute + SMSG-consolidation work.
- net: extract the shared packet-observer plumbing (PacketObserver.h) — the
intrusive subscriber list, Subscribe(), and the read-opcode / reset-cursor /
fan-out / restore loop — so Net::PacketDispatch (SMSG, u16) and
Net::SendObserver (CMSG, u32) each reduce to a one-line hook thunk over
FanOutByOpcode<OpcodeT>. Drops the dead per-namespace Callback aliases
rather than keeping them as compat shims.
- Game::Lua: promote PushGlobalFunction / CallGlobal / CallGlobalString into
the shared Lua helpers (a pattern several modules open-code); Frame
attributes use them instead of private copies.
- frame/Attributes: resolve unit tokens through Unit::Identity::GuidForToken
(engine resolver + player fast-path) instead of a duplicate raw-cast wrapper.
- frame/Attributes: wire the chained OnClick for every "type" action attribute
the click resolver reads (IsTypeAttr: modifier prefixes + type1..type5), not
just type/type1/type2 — a middle-click or modifier-only binding now installs.
- frame/Attributes: FireAttributeChanged snapshots and restores the caller's
arg1/arg2 around the fire, so calling SetAttribute from inside a running
script handler no longer clobbers its globals.
Remove Mixin/CreateFromMixins from the AddOns helper list and streamline the surrounding text. Drop outdated paragraphs describing engine-dependent addon features and the note about the triple-`!!!` folder prefix/load-order. This is a documentation cleanup only; no code or behavior changes.
"mark1".."mark8" (star..skull) now work as unit tokens against the whole
Script_Unit* surface, and fire per-token unit events (UNIT_HEALTH, ...)
with arg1 == "markN" -- the same TokenObserver path focus/nameplateN use.
Resolution: a markN branch in the FUN_TOKEN_TO_GUID hook
(unit/TokenExtensions.cpp) reads the engine's 8-slot marker table
(VAR_RAID_TARGET_GUIDS = 0x00B71368, mark{1..8} = table[N-1]), so every
stock Unit* gains the token for free and "mark1target" composes via the
existing suffix walker.
Events: unit/RaidTarget.cpp runs a WorldTick watcher that diffs the marker
table and attaches/detaches one descriptor observer per marked unit,
deferring registration until the unit is in the object table (a unit can
be marked while out of range and enter later) -- the same range handling
Focus does, x8. One distinct callback per slot fires its fixed "markN"
token. Idle (no markers set) costs 8 compares and no object lookups; a
marker on a non-unit is never observed.
Verified the array shape against the binary (reverse lookup FUN_004bb190
walks 8 uint64 slots) and against nampower's CGRaidInfo_m_raidtargets.
Docs added under Unit tokens (markN); README token table updated.
The bundled library provides FrameXML-compat fixes (e.g. the tooltip
mouseover guard) that the DLL and other addons rely on; letting a user
uncheck it at character-select would silently break them.
FinalizeEmbeddedEntry now sets entry+0x29 (filter-out) to hide it from
the AddOns list -- the flat-display builder FUN_0051da70 copies only
+0x29==0 entries into GetNumAddOns/GetAddOnInfo -- and entry+0x2b
(DefaultState) enabled. A co-hook on the per-character enable resolver
FUN_ADDON_ENABLE_RESOLVE (0x0051e470) force-returns "enabled" for
!!!ClassicAPI, defeating any stale WTF disable-override that hiding
alone would otherwise leave stuck off.
Hiding only affects the index-based display array; the addon hash table
keeps the entry, so ## Dependencies: !!!ClassicAPI, IsAddOnLoaded, and
GetAddOnInfo-by-name all still resolve. README updated to match.
The DLL now fires UPDATE_MOUSEOVER_UNIT on mouseover loss (retail
parity). The stock FrameXML GameTooltip OnEvent recolors the name
unconditionally via GameTooltip_UnitColor("mouseover"), which returns
white when no mouseover exists -- so the loss fire (and the transient
clear when clicking a unit) flashed NPC and PvP-flagged-player names
white during the tooltip fade.
Wrap the stock OnEvent in Util/GameTooltip.lua: swallow the event when
UnitExists("mouseover") is false, otherwise delegate to the original
untouched. The name keeps its color through the loss window and
recolors normally when a mouseover resolves again.
* spell: backport UNIT_SPELLCAST_* events for the local player
Adds the TBC+ cast/channel events (START, STOP, DELAYED, SUCCEEDED,
INTERRUPTED, FAILED, SENT, CHANNEL_START, CHANNEL_UPDATE, CHANNEL_STOP)
for the player, so cast-bar / rotation addons written against the modern
signature work on 1.12. Each carries (unit, castGUID, spellID, spellName,
rank) -- SENT inserts `target` at arg2 -- with a synthesized modern-shape
castGUID (Cast-3-0-0-0-<spellID>-<castUID>) shared across all of a cast's
events so START pairs with STOP. arg1 is always "player" (player-only phase).
The events are derived, not independently tracked: Spell::Cast already
hooks every cast/channel path for UnitCastingInfo/UnitChannelInfo, so
Spell::CastEvents reuses that state.
- START/STOP/DELAYED/CHANNEL_START/STOP: polled from Cast's OnWorldTick
against the previous snapshot.
- SUCCEEDED: fired from the SMSG_SPELL_GO hook (covers instants too).
Deferred for channels so it lands after CHANNEL_START (modern order).
- INTERRUPTED/FAILED: split by result code in a Spell_C_SpellFailed
co-hook -- a started cast that aborts (kick/move/LoS) fires INTERRUPTED
(repeated per retry, reusing the cast guid); a pre-cast rejection fires
FAILED.
- SENT: minted from the CMSG_CAST_SPELL send, threading its castGUID
forward so all of a cast's events share one guid.
- CHANNEL_UPDATE: from a new MSG_CHANNEL_UPDATE co-hook, which also
re-anchors g_channel.endMs to the server's remaining time so channel
pushback (which shortens vanilla channels) shows in UnitChannelInfo.
Every fire is gated on Event::Custom::HasListeners (new), so the system
costs one pointer-compare per transition when no addon registers.
Supporting changes:
- Net::SendObserver: shared co-hook on the NetClient send funnel with an
AutoSubscribe list, so CastEvents (SENT) and ComboDuration (combo-point
capture) both watch outgoing packets without owning the hook. Extracted
from ComboDuration's private hook.
- Cast.cpp: player channel now self-expires at its computed endMs in the
poll (was cleared only by the ~1s-lagged +0x228 field, so CHANNEL_STOP
trailed the real end by ~1s); wrap-safe delta compare so a long-uptime
ms tick past 2^31 can't misfire it.
Player-only; remote-unit fan-out, FAILED_QUIET, and empowered casts are
not implemented.
* spell: extend UNIT_SPELLCAST_* to non-player units (phase 2)
Fires the six observable cast/channel events -- START, STOP,
CHANNEL_START, CHANNEL_STOP, SUCCEEDED, INTERRUPTED -- for other units,
not just the player. Each is fanned out once per unit token currently
mapping to the caster (target / focus / nameplateN / party / raid / pet /
mouseover), so a target frame, a party frame, and a nameplate cast bar
all get their own event with a shared castGUID -- matching retail.
Token fan-out reuses the engine's own GUID->token reverse map
(FUN_00515c50, nampower's unfilled GetNamesFromGUID) for native tokens,
plus focus/nameplate which the engine can't see, via
Unit::Identity::TokensForGUID. SuperWoW's raw-GUID token ("0x...") is
filtered out so only standard tokens fan out.
Remote events are derived from Spell::Cast's existing packet hooks:
- START / CHANNEL_START from HandleSpellStart (SMSG_SPELL_START)
- SUCCEEDED from the SPELL_GO hook (Aura::Source)
- STOP / CHANNEL_STOP at the computed end, or on abort
- INTERRUPTED (casts only) from HandleCastAborted (ClearCastingSpell /
failure packets)
A per-caster state machine (PollRemote, from OnWorldTick) fires them in
modern order; a channel's SPELL_GO cast->channel ClearCastingSpell is
filtered so it doesn't self-interrupt at its own start.
Not fired for remotes (data is caster-only / client-local): SENT,
DELAYED, FAILED, CHANNEL_UPDATE.
Also in this change:
- castGUID follows type rule from https://warcraft.wiki.gg/wiki/GUID#Cast : real casts are Type 3 with a
time-based castUID (low 23 bits = UNIX second, high bits = per-second
counter, matching retail's structure), threaded across a cast's events
(player and remote converge on the same value); FAILED is Type 2 with
a flat incrementing integer.
- Channels never fire INTERRUPTED (player or remote) -- retail emits
only CHANNEL_STOP whether a channel completes or is cut short.
arg1 is the caster's unit token ("player" or another token); the
spellName / rank tail extensions are unchanged.
* spell: return the castGUID as UnitCastingInfo's castID
UnitCastingInfo / CastingInfo now return the cast's castGUID as castID
(return 7) instead of nil -- the same string the UNIT_SPELLCAST_* events
carry, so an addon can correlate the polled info with the events. It's
pulled from Spell::CastEvents (the single source of the castUID) via a new
CurrentCastGuid accessor, and works for the player and remote units. The
timing stays sourced from Spell::Cast -- only the castGUID crosses over,
so there's no duplication of cast state.
UnitChannelInfo is left retail-exact (no castID; channels don't carry
one), even though our channel events do include the shared castGUID.
Also corrects the spell-cast-GUID spec attribution in comments/docs to
warcraft.wiki.gg/wiki/GUID#Cast (it was wrongly credited to Wowhead).
* spell: add UNIT_SPELLCAST_RETICLE_TARGET / _CLEAR events
Backports the ground-target reticle events for the player:
RETICLE_TARGET when an AoE placement reticle comes up (Blizzard, Flare,
Rain of Fire, ...), RETICLE_CLEAR when it's cancelled. Args are
(unit, "", spellID, spellName, rank); arg1 is always "player".
Driven by polling the engine's targeting-flag global (0x00CECAC0 -- the
predicate behind SpellIsTargeting) from OnWorldTick: 0->nonzero fires
RETICLE_TARGET with the pending spellID (0x00CEAC58, written by
Spell_C_CastSpell); nonzero->0 fires RETICLE_CLEAR. CLEAR is suppressed
on placement (a CMSG_CAST_SPELL for the reticle spell went out, seen via
the shared send observer), so it fires only on a genuine cancel --
matching retail, which emits no CLEAR when the spell is placed/cast.
The castGUID slot (arg2) is "" rather than retail's nil: there's no cast
to identify yet, and the engine's event dispatcher can't emit a nil
mid-argument-list (a NULL %s corrupts the args after it -- the nil trick
only survives as the last arg). unit (arg1) and spellID (arg3) are exact.
* spell: fire UNIT_SPELLCAST_FAILED_QUIET for the quiet result whitelist
Vanilla's Spell_C_SpellFailed dropped SPELL_FAILED_DONT_REPORT silently.
Modern surfaces suppressed (no-error-text) failures as
UNIT_SPELLCAST_FAILED_QUIET rather than FAILED.
Verified against the 3.3.5 client's unit-spellcast dispatch (FUN_007fecc0),
which selects the QUIET event for a fixed 3-code whitelist, mapped to 1.12's
SpellCastResult enum (FUN_006e23e0):
CHARMED (0x14) . DONT_REPORT (0x17) . SPELL_IN_PROGRESS (0x61)
SPELL_IN_PROGRESS (casting while already casting / a nampower spell-queue
rejection) is the common case that previously mis-fired as a loud FAILED.
* unit: accept raw GUID literals as unit tokens
Fold a "0x<hex>" branch into the existing FUN_TOKEN_TO_GUID hook so every
Unit* function (UnitName, UnitHealth, UnitClass, UnitExists, ...) accepts a
64-bit GUID literal in place of a token. The parsed GUID goes straight to
the engine's own object-manager lookup (the path "player" takes), so an
unloaded/out-of-range GUID resolves to nil like any absent unit, no error.
Reuses the existing suffix walker, so "0x...target" composes.
Gated on SuperWoW's absence (GetModuleHandleA("SuperWoWhook.dll"), cached):
SuperWoW already provides GUID input via its own resolver hook, so when it
is loaded we defer to avoid two layers parsing the same string. Behaves
identically either way. Input direction only.
C_Loot.LootAllCorpses([max]) loots every nearby lootable corpse in
sequence -- coin (CMSG_LOOT_MONEY) plus every item slot (CMSG_LOOT_ITEM)
from each window before releasing and advancing. Optional max caps the
corpse count.
Shares Loot::Scan's existing corpse-walk state machine and its single
FUN_LOOT_CONTROLLER hook -- MinHook allows one hook per target, so this
can't be a separate module. ScanNearbyLoot scrapes each window and
releases; LootAllCorpses does the same walk but actually loots it first.
The walk stays silent (LOOT_OPENED/LOOT_CLOSED suppressed, so LootFrame
never flickers) while the item-received / money chat + inventory events
fire normally.
Packet-based and target-clean, matching the rest of the C_Loot suite:
no right-click, no target change, no auto-walk toward out-of-range
corpses. Money goes through FUN_005ea8c0 (CMSG_LOOT_MONEY) with the SAME
player pointer passed to FUN_CMSG_LOOT_UNIT -- whose send wrote this
window's target GUID to the player's +0x1D28 guard slot, so the money
send's internal guard passes. Item sends bypass the client BoP/unique
confirm dialog (the server still enforces real loot permissions).
Reimplemented in spirit from WeirdUtils's LootAllCorpses (public domain)
on our own packet loot primitives + object-scan helpers, rather than its
right-click-autoloot approach. Wires the existing AOELOOT keybinding to
it (was ScanNearbyLoot).
f91ffd3 gated the drain on a one-way PLAYER_ENTERING_WORLD latch
(Event::EnteringWorld::HasEnteredWorld), added on the belief that no
pollable in-world flag existed. cd674aa found the real one:
VAR_IN_WORLD (0x00B4B424), 1 while in world and 0 during any loading
screen.
The one-way latch stays true forever after the first login, so if the
object burst re-populates bag descriptors during a LATER zone/instance
loading screen it would fire BAG_UPDATE_DELAYED mid-load again -- the
very thing the gate was meant to prevent, recurring on every zone.
VAR_IN_WORLD clears on every transition, so polling it re-suppresses
during later loads and emits one DELAYED once actually in-world
(matching retail's one-per-load).
Switch the gate to VAR_IN_WORLD and remove the now-dead HasEnteredWorld
latch/accessor and its header; EnteringWorld keeps only its PEW-payload
role.
Turtle's server re-sends SMSG_BINDPOINTUPDATE on every map/zone
transition with the unchanged bind location, behind a loading screen.
The valid-flag gate only suppresses the initial post-login sync, so
every map change fired HEARTHSTONE_BOUND.
A player can't bind mid-load, so gate on the engine's "world is live"
flag instead: VAR_IN_WORLD (0x00B4B424), set to 1 by the enter-world
init FUN_004908C0 and back to 0 by the world-teardown FUN_00490A80 that
runs at the start of every transition (verified via the enter/leave
pair in Ghidra). The resync arrives with the flag 0 (suppressed); a
real innkeeper bind always arrives with it 1 (fires) -- including a
rebind at the same inn, unlike a location diff would.
Add VAR_IN_WORLD to Offsets.h (the real pollable in-world boolean;
0x00B4E37C, once wrongly labeled this, is only instance grace-period
state). Update the bind-handler offset comment and docs/API.md.
The in-world gate added in 5d785ea polled 0x00B4E37C, believed to be an
"in world" flag. It isn't: verified in the binary it's instance
grace-period state, written from a packet handler (FUN_0049e6c0) and
read only by the "recently left an instance" 90-second window check
(FUN_00495c90). It never reliably goes non-zero from being in the world,
so OnWorldTick's early-return held g_pending forever and
BAG_UPDATE_DELAYED stopped firing entirely. There is no clean pollable
in-world boolean at these offsets (0x00B4E378 is the map ID; map 0 is
valid).
Gate on the correct signal instead: PLAYER_ENTERING_WORLD, already owned
by Event::EnteringWorld. Expose a one-way HasEnteredWorld() latch flipped
the first time PEW fires (loading screen done, world live) and stays true
across /reload; UpdateDelayed holds g_pending until then, then emits one
DELAYED covering the settled login inventory. Correct the mislabeled
0x00B4E37C note in Offsets.h so the trap isn't repeated.
PLAYER_EQUIPMENT_CHANGED fired an extra time per /reload. The event's
descriptor-field observers are registered in ObserverSetup_h, a co-hook
of the engine's per-enter-world inventory-observer setup. /reload
re-runs that setup but does NOT destroy the CGPlayer object, so the
prior session's observer nodes are still attached; the engine registrar
always appends (no dedup by callback), stacking a second observer per
equipment field each reload. Logout/login recreates the player object,
old nodes die with it, so the count resets to one -- matching the
observed "doubles only on /reload" symptom.
Unregister each equipment-field observer before re-registering it. On
/reload this drops the stale node (same object/guid/callback key) so we
keep exactly one per slot; on logout/login the object is already gone so
the unregister no-ops. Mirrors the objects-survive-/reload guard
Unit::TokenObserver uses for nameplates.
The per-frame world tick (FUN_0066FD50) runs behind the initial-login
loading screen, and the engine populates every bag slot during the
initial object burst -- setting g_pending while still loading. That made
OnWorldTick fire BAG_UPDATE_DELAYED repeatedly mid-load, so addons
rescanned bags for inventory that wasn't a real change yet.
Gate the drain on the engine's in-world flag (new VAR_IN_WORLD =
0x00B4E37C): hold g_pending -- without clearing -- until the flag is set.
The first in-world frame then emits a single DELAYED covering the settled
login inventory (matching retail's one BAG_UPDATE_DELAYED at login), and
a pending update suppressed during a later zone transition fires on
return. The three bag hooks are unchanged (still just set the flag).
AppendNonBagOwned read each bank-bag container's slot count straight
from the invMgr's first dword with no upper bound. A container resolved
while only partially initialized (plausible right at login) can yield a
garbage value there, driving AppendGuidArray into a multi-billion-
iteration loop -- a multi-second freeze. Clamp to kMaxContainerSlots
(64), far above any real vanilla/Turtle bag, so it's invisible to real
inventories and only trips on a bogus count.
Backports retail 4.x's frame-cached GetTime() behavior under a distinct
name, leaving vanilla GetTime() live/per-call. 1.12's Script_GetTime
recomputes the OS tick on every call, so same-frame calls can differ;
GetTimeCached() samples once per frame (via the shared Tick::WorldTick
hook) and returns that until the next frame, on the same epoch as
GetTime() so the two are directly comparable. Falls back to a live
sample before the first world tick so it never returns 0.
Deliberately NOT a perf feature and documented as such: the 1.12 tick
source is cheap (GetTickCount shared-page read or rdtsc) and both
functions pay the same dominant Lua->C dispatch, so caching saves
nothing measurable. The only benefit is frame-stable semantics; the
real hot-loop fix is caching GetTime() in a Lua local, which no C
function can do for the caller. New global chosen over modifying
GetTime() to avoid changing a core vanilla function's behavior.
Vanilla fires UPDATE_MOUSEOVER_UNIT only when a mouseover unit is gained;
moving the cursor off a unit clears the mouseover and signals nothing.
Retail fires for both gain and loss, so addons watching
UnitExists("mouseover") can react when it goes false. This restores the
loss fire.
Co-hooks the engine's mouseover set/clear chokepoint (FUN_00492890) --
the ONLY writer of the mouseover GUID globals, so every gain/loss/change
flows through it. Resolves whether the mouseover is a unit before and
after the original and fires the event on the unit->non-unit transition.
Gating on unit TYPE (not just "GUID present -> absent") is load-bearing:
the same GUID slot also holds gameobjects and items, which never fire a
gain, so a naive present->absent test spuriously fired when moving off a
herb node or chest (reported in testing). The unit|player object-mask
resolve fixes that; the !hasUnit half prevents a double-fire on any
->unit transition the engine already covered inline.
Change-gated target (per-frame would spam the gain event while
hovering), so it's a cool hook. Documented in API.md (loss-fire fix
subsection + TOC) and Events.md notes.
Re-reads the current level's live table in place. The nav stack holds
the real table reference (never a copy), so refresh reflects mutations
made since the view was built -- keys added/removed, values changed --
without leaving the current view. Scroll position is preserved (Back and
drill reset to top; refresh deliberately doesn't) and Update re-clamps
the offset if the entry count shrank. No-op if the held reference is no
longer a table.
Adds TableInspector_Refresh() and a Refresh button in the header between
Back and the breadcrumb (breadcrumb shifted right and narrowed to fit).
Add the two globals to the State section of docs/API.md (with TOC
entries) and the README State row. Notes the 1/nil return contract
(retail + SuperWoW 2.2 parity) and that the state is a live WMO
geometry query, not zone/area based.
Backport the modern `IsIndoors()` / `IsOutdoors()` no-arg globals to 1.12.
Both return 1 when the condition holds, nil otherwise (the historical
retail contract, also what SuperWoW 2.2 uses), so `if IsOutdoors()` and
`IsOutdoors() == 1` both work. Recomputed live from the engine's WMO
geometry query, so they flip as the player crosses an interior threshold
rather than tracking zone/area; nil pre-world.
The signal is a live WMO query, not a cached flag. Vanilla ships no Lua
binding for it, and the static anchors all dead-ended (the WMO runtime
flag bits differ from 3.3.5; ZONE_CHANGED_INDOORS is never fired with a
literal event ID; WMOAreaTable is only the area-naming path). SuperWoW
2.2 exposes `IsIndoors`, so decoding its handler gave the exact recipe:
resolve the local player object, then call FUN_00612cc0(unit) -- a thunk
that reads the unit world/collision context at +0xE0 and tail-jumps to
the WMO query FUN_006706f0 (nonzero = outdoors, fully null-safe). Same
shape as the 3.3.5 client's IsIndoors (unit+0xB8), so cross-confirmed.
Added Offsets::FUN_UNIT_IS_OUTDOORS with the full trail; the two
functions live in Unit::State next to IsMounted / IsSwimming and share
Unit::Identity::PlayerObject().
C_Item.GetItemNameByID and C_Item.GetItemInfo resolved an item link down
to just the itemID and dropped the random suffix (3rd `item:` field), so
"item:12022:0:769:0" returned "Iridium Chain" instead of "Iridium Chain of
the Owl" (and GetItemInfo's rebuilt link lost the 769). The global
GetItemInfo applies it; now these match.
Item::Arg::Resolve parses the suffix alongside the itemID. New
Item::Link::NameFromIDSuffix builds the suffix-decorated name from
(itemID, suffixID) without a live CGItem, via the engine's inner name
builder (FUN_005d8b00 = the formatter behind FUN_ITEM_BUILD_INSTANCE_NAME,
which reads the localized "of the X" suffix from ItemRandomProperties).
Item::Link::BasicFromIDSuffix preserves the suffix in the rebuilt link
(payload + bracketed name); BasicFromItemID is now a suffix=0 wrapper.
GetItemNameByID and GetItemInfo route through these; numeric/suffixless
input still yields the base name. Numeric item stats are unaffected (the
suffix only decorates the name/link). Enchant/unique fields are normalized
to 0 in the rebuilt link (suffix is what affects the name).
Verified in-game: item:12022:0:769:0 -> "Iridium Chain of the Owl" (name +
link); bare 12022 / item:12022:0:0:0 -> "Iridium Chain".
C_UnitAuras expirationTime for a debuff on another unit is derived from
SMSG_SPELL_GO at cast time; the vanilla server never retransmits a LATER
server-side duration change on a non-self unit (verified in the server
source: SetAuraDuration/RefreshHolder, and UpdateAuraDuration is self-
scoped -- on a mob no packet is built at all). So a refresh/consume goes
stale in our cache. We can't hear the change, but we DO see the triggering
cast, so mirror the server's edit off that.
Aura::Source gains a rule engine applied in SpellGo_h (before the aura
gate -- triggers apply no aura themselves): a trigger matched by exact
spellID OR by SpellFamilyName + school index, an affected aura matched by
family + family-flag overlap (+ optional icon, rank-proof like the server
scripts), and an op (refresh/reduce/set/remove). Proc-applied affected
auras enter the cache unattributed (casterGuid 0); the rule accepts those
and attributes them to the trigger caster (also gives them a sourceUnit).
Lua: C_UnitAuras.RegisterAuraDurationModifier (exact-ID trigger) and
RegisterAuraDurationModifierByTrigger (family+school trigger), so the
server-specific ruleset lives in the addon, not the DLL.
Default Turtle ruleset (AuraDurationModifiers.lua, gated on
TURTLE_WOW_VERSION; IDs/flags DBC-verified):
Conflagrate -> reduce Immolate 3s
Molten Blast -> refresh Flame Shock
Shadow Weaving (5/5 talent 15334) -> priest shadow-school casts refresh
Shadow Vulnerability 15258 (covers the max-stack refresh blind spot;
Turtle reworked it, so stock DBC proc masks don't apply -- match by
school). Best-effort: DoT-tick procs emit no cast packet.
Carnage's roll-gated Rip/Rake refresh is deliberately excluded (the client
can't know the server roll).
Verified in-game (debug trace): Conflagrate/Molten Blast, and Mind Blast /
SW:P / Mind Flay all refresh 15258 with advancing expiration + sourceUnit.
Previously CastAtUnit only handled ground-target spells (placement at the
unit's feet); a non-ground spell fired with no implicit target (fell to
current selection). Now it resolves the unit once to its object, reads
position AND GUID, and dispatches the cast WITH that GUID -- the engine's
FUN_SPELL_CAST_DISPATCH substitutes it exactly where it would the current
selection (verified: passing 0 substitutes VAR_CURRENT_SELECTION_GUID), so
a unit-target/normal spell fires straight at that unit regardless of
current target.
Branch on placement state after dispatch: no placement -> cast went out
(unit-target); ground placement -> commit at the unit's feet (existing);
other placement -> unit not accepted as target, cancel + false.
AtCursor gains an optional targetGuid on DispatchSpellCast[ByName] (0 =
prior behavior, so CastAtCursor is unchanged) and IsPlacementActive().
Verified in-game: ground spells still land at the unit's feet; Frostbolt/
Renew/PW:Fortitude cast on the named unit while another unit stays targeted.
IsFirstLoadThisSession is glue-only -> GlueXML-calls section only.
IsOnGlueScreen is registered on both states -> main in-game table AND the
GlueXML-calls section.
IsFirstLoadThisSession() - glue-only; true only during the first glue
screen since process launch, false after a world->glue logout (login <->
character-select stays within the first session). Backed by a
process-static glue-boot counter incremented in the GlueModuleAutoRegister
callback (fires once per glue boot).
IsOnGlueScreen() - true on a GlueXML screen, false in the world.
Registered on both Lua states; since glue and in-game states are mutually
exclusive the answer is a constant per state (true from glue, false
in-game), so it's callable from either environment with no runtime flag.
Verified in-game: first login true/true, post-world-logout false; in-game
C_Glue.IsOnGlueScreen() false.
Own alpha times every ancestor's alpha -- the product WoW composites at
render time. Vanilla has only GetAlpha (a frame's own alpha); this walks
self -> parent -> ... via the engine's own GetAlpha/GetParent, so it
reflects the engine exactly, including 8-bit alpha quantization
(SetAlpha(0.5) -> 127/255). Frame-scoped, matching GetAlpha's registry.
Verified in-game: matches an equivalent Lua GetAlpha/GetParent walk, and
a two-level 0.5/0.5 chain returns (127/255)^2 = 0.248043 exactly.
Backport the modern UnitPosition -> posY, posX, posZ, instanceID. Reads
any visible unit's world position through the shared Unit::Position helper
(CGObject::GetPosition vtable slot 5 -- same path UnitDistanceSquared
uses), so no group/taint restriction like retail: works for target,
mouseover, nameplates, in-range party/raid. nil when the unit has no
known position.
Retail return order (posY=world Y/west, posX=world X/north, posZ=height);
instanceID is the current Map.dbc id. Verified in-game: squared delta of
two UnitPosition reads matches UnitDistanceSquared exactly.
Note: SuperWoW also defines a global UnitPosition with a different shape;
last registrant wins if both load. Documented in docs/API.md.
Two more Region-base methods backported from 3.3.5, both on the Region
registry so they resolve on every region type.
GetRect() -> left, bottom, width, height, composed from the engine's own
GetLeft/GetBottom/GetWidth/GetHeight (all UI-scale conversion is engine
code). Returns nothing for an unpositioned region, matching retail.
IsDragging() -> true while the region is the frame currently being
moved/sized. Reads the engine's single global drag target
(*(VAR_UI_CONTEXT_PTR) + 0xCFC); StopMovingOrSizing (0x00776990) gates its
own clear on the identical `context+0xCFC == self` predicate. Non-frame
regions never match the stored frame, so they report false.
Verified in-game: GetRect on UIParent/Minimap; IsDragging toggles with a
live frame drag.
Add a MouseIsOver(region, top, bottom, left, right) global that forwards
to the native Region:IsMouseOver method (mirrors modern FrameXML's
forwarding; nil offsets default to 0 in the C method).
Drop DebugTools' hand-rolled DebugTools_IsMouseOver helper in favor of
the native method. This also fixes _CollectFramesUnderMouse's showHidden
path: the old helper baked a visibility check into the geometric test, so
a hidden-but-hovered frame could never surface; the native method is
purely geometric and visibility is already gated separately at the call
site.
Native Region method (all region types) for cursor-in-rect testing with
optional per-edge offsets. Mirrors the engine's own math: delegates to
the Region-registry rect getters (GetLeft/Right/Top/Bottom) and
GetCursorPosition, dividing the cursor by the region's effective scale
read from obj+0x7C -- the same field GetLeft/GetEffectiveScale read
internally, so it works on textures/fontstrings where GetEffectiveScale
(Frame-only) would typecheck-fail. Returns false for an unpositioned
region. Offset semantics match modern FrameXML's MouseIsOver forwarding.
A bag update in progress transiently drops items from the pure-C++ bag
read -- sometimes the entire backpack (all 16 slots) for a frame or two.
The old logic rebuilt the seen-set from each scan, so a momentarily-
missing item fell out of the set and was re-flagged "new" the instant it
reappeared (e.g. dragging/swapping an item into the backpack lit it up).
Replace the rebuilt-each-scan set with a persistent seen-set whose entries
carry a consecutive-absent-scan counter (SeenReconcile): present items
reset it, absent ones age by one, and only items absent kMaxMissStreak
scans running are evicted. Counting scans rather than wall-clock means a
pause with no bag activity never ages an owned item, and any reappearance
resets the streak -- so transient drops are absorbed while the set stays
bounded to ~currently-owned items (genuinely-removed items age out).
Verified in-game: backpack repeatedly vanishing from the scan, seen-set
holds steady, zero false flags.
The server resends out-of-range party/raid stats as deltas only, so a
member seen in range and then moved away again leaves an empty party-
stats aura array (it's cleared when they become visible and never re-
sent), and C_UnitAuras showed nothing until a full roster resync (relog).
Their auras still sit in the Aura::Source cache from when we observed them
in range, so the four group enumerate paths now fall back to the cache
when the group array is empty -- gated by GroupArrayHasVisibleAura, the
same "container non-empty => authoritative" rule the descriptor path uses
(DescriptorHasVisibleAura). A populated array is trusted as-is (no
phantoms); only the empty-array gap consults the cache, deduped and
visibility/playerOnly-filtered.
Returns the spell's three per-effect SpellMechanic ids (Spell.dbc
EffectMechanic[3], +0x13C) as a 1-based array, or nil for an invalid spell.
Complements GetSpellMechanicByID, which reads only the spell-level Mechanic
field (+0x14): vanilla stores some mechanics on an effect instead -- e.g.
Rake's bleed is effect-level (spell-level 0, EffectMechanic[2]=15), so
GetSpellMechanicByID misses it while this returns {0,15,0}.
Add OFF_SPELL_RECORD_EFFECT_MECHANIC offset, API.md section + TOC entry, and
README index entry.
Permanent auras (paladin auras, passives, racials) carry an infinite
SpellDuration (base -1); the server stores their fixed cast-time in the
player buff-expiration table, so it reads as already-elapsed and drifts
into the past. We were reporting that stale timestamp, giving a permanent
aura like Devotion Aura a negative time-left. Mirror the engine's own
GetPlayerBuffTimeLeft (Script at 0x004e4930 -> FUN_004e4450), which reads
the same table and returns 0 once now >= expiration. Signed compare
absorbs the GetTickCount wrap, matching the engine.
An out-of-range party/raid member has no CGUnit and never yields
SMSG_SPELL_GO, so the caster/duration cache never learned their auras'
timing and expirationTime stayed 0. But SMSG_PARTY_MEMBER_STATS carries
their aura spell IDs and the server sends it on change
(SendUpdateToOutOfRangeGroupMembers flushes the moment an aura add/remove
dirties the group mask), so a spell ID that newly appears in the group
array did so ~one tick + latency after the real application.
Aura::Source::ObserveGroupAuras (called from the group enumerate paths in
Aura::Data) keeps a per-member snapshot of the last-seen spell-ID set and,
on a genuine absent->present transition, stamps now + base duration into
the same cache PushEnriched already reads. Covers party and raid alike
(AuraArray reads the raid slot too; snapshot table sized for a 40-man).
Base duration only (no remote caster mods) and no caster recorded
(sourceUnit/GUID stay nil). Auras already present at first sight have
unknown age and are not stamped; snapshots reset on map change and after
30s unpolled. The Store guard keeps any real SpellGo timing, so this
never downgrades better data.
Verbatim backport of retail's Blizzard_SharedXML vector mixins
(CreateVector2D/3D/4D + Vector*DMixin + the free Vector*_ helpers).
Pure math on CreateFromMixins and Lua 5.0 math.*, so no adaptation was
needed. Registered the three files in the TOC (we list .lua directly
rather than via retail's VectorLib.xml include).
Backport of retail's Rectangle helper (sides, stretch/move/adjust,
contains/intersects, center, width/height accessors), built on
CreateFromMixins. Registered in the TOC.
Backport of retail's table.count (added 11.2.5): returns
(numTableNodes, numArrayNodes, maxArrayIndex) -- total entries, entries
with an integer key in [1..numTableNodes], and the largest positive
integral key (0 if none). Pure lua_next walk, no Lua-internal layout
dependency; keys read via ToNumber only (ToString on a numeric key
would corrupt traversal). All five cases verified to match retail,
including negative-key handling ({[-3]=x} -> 1, 0, 0).
Modern WoW implements these FrameXML table primitives as engine C
functions (TableUtil); do the same so they exist whenever the DLL is
injected -- even if the !!!ClassicAPI addon is disabled. Behaviour
matches the Lua form exactly: shallow key/value copy from each table
arg, non-table args skipped, returns the object / new table. Registered
on both the in-game and glue states.
Slimmed the addon Mixin.lua to just CreateAndInitFromMixin (calls a Lua
:Init, so it stays Lua), mirroring retail's Mixin.lua. The taint-based
SecureMixin family is omitted -- vanilla 1.12 has no execution taint.
New C_ColorUtil native module (src/color/Util.cpp) with the modern color
helpers, all pure math/string: ConvertRGBToHSV, ConvertHSVToRGB,
ConvertHSVToHSL, ConvertHSLToHSV, ConvertHSLToRGB, GenerateTextColorCode,
WrapTextInColor, WrapTextInColorCode. Conventions verified in-game against
a retail-family client: hue in degrees [0,360), -1 achromatic sentinel,
bare AARRGGBB from GenerateTextColorCode, |c..|r wrapping.
Ported the addon Color.lua / ColorUtil.lua to match retail now that the
natives exist: ColorMixin:GetHSL/GenerateHexColor/WrapText* delegate to
C_ColorUtil (GetHSL now returns degrees), SetRGBA defaults alpha to 1,
byte getters use Round; added COLOR_FORMAT_* constants and
CreateColorFromBestRGBHexString. Kept 1.12-Lua-5.0-safe syntax and the
ClassicAPI-only CAPI_ApplyStandardColorGlobals / ITEM_QUALITY_COLORS bits.
New global returning whether the player can equip an off-hand weapon.
The server tracks this as Player::m_canDualWield, flipped on only by
SPELL_EFFECT_DUAL_WIELD (effect 40); exactly one client Spell.dbc row
carries that effect -- 674 "Dual Wield" -- so CanDualWield() is exactly
IsPlayerSpell(674). Factored the known-spell bitmap read into a shared
PlayerKnowsSpell() helper used by both.
New Win32-backed global that copies text to the Windows clipboard and
returns the byte length copied. Optional removeMarkup strips WoW UI
escape sequences (color codes, hyperlinks keeping visible text,
textures, |n, || -> |) so the clipboard gets plain text. UTF-8 in,
CF_UNICODETEXT out. Registered on both the in-game and glue states.
Docs: folded the lone Screen section and this into a single System
section rather than adding a one-function Clipboard section.
A player DoT with a duration talent (e.g. Improved Shadow Word: Pain,
2/2 -> 24s vs 18s base) intermittently showed the base duration. The
displayed value is the Aura::Source cache entry's durationMs, which is
computed two ways: SpellGo knows caster == player so it applies the
player's duration mods (skipMod 0 -> 24s), while the OnAuraAdded /
OnAuraStacksChanged application hooks have no caster and take the
unmodified base (skipMod 1 -> 18s).
Normally SpellGo owns the entry and the application hooks can't clobber
it, but the base value wins whenever SpellGo can't land its talented
value on THIS target's entry: an empty SMSG_SPELL_GO hit list files it
under the caster instead (numTargets == 0), or a slot reshuffle
evicts + refills the entry after SpellGo ran. Both are packet-order
dependent, hence the flicker.
Fix: SpellGo (which knows caster == player) records recent player aura
casts by spellId; the application hooks recompute with skipMod 0 and
attribute the caster when the spellId matches a recent player cast.
Robust against both the attribution miss and the refill race, and
leaves other units' auras on the base path (we lack their mod tables).
Generalize the item-only Tooltip::SetItemEvent into Tooltip::SetEvents,
backing all four object tooltip scripts through one shared fire path
(stack save/restore + recursion guard + Suppressor). One resolver
co-hook recognizes all four OnTooltipSet* names (the per-tooltip cell
now holds a slot per kind), plus one builder co-hook per kind firing its
script after the original build:
- Spell -> FUN_0052E610 (SetSpell/SetSpellByID/SetTalent/SetShapeshift;
7 stack args, RET 0x1c)
- Unit -> FUN_00529fe0 (SetUnit + mouseover; 1 arg, returns an int the
caller tests -> co-hook forwards it)
- GameObject -> FUN_0052aa20 (GO hover populator; 1 arg)
Each hook's declared stack-arg count matches its callee RET N (verified
from disassembly) so the caller's stack isn't corrupted.
Auras deliberately do NOT fire OnTooltipSetSpell: SetUnitBuff/Debuff/
PlayerBuff funnel through a separate aura-tooltip builder (FUN_0052f880)
that bypasses the spell builder -- matching retail, where unit auras are
a distinct tooltip data type.
Verified in-game: all three new scripts fire (spell hover, unit
mouseover, gameobject mouseover), item still fires, no crash.
Mirror the classID extension just added to GetTalentIDByIndex. classID
is the 4th arg (GetTalentSpellID(tab, idx[, rank[, classID]])); pass rank
as nil to keep its default. A numeric classID resolves the talent
against the Talent.dbc / TalentTab.dbc flat arrays via the shared
ResolveTalentRecordForClass, then reads SpellRank[rank-1] off the record.
One semantic difference from the same-class path: there's no player
currentRank for a class you aren't, so a cross-class query with no
explicit rank defaults to rank 1 (the canonical spellID) rather than
delegating to the engine's GetTalentInfo.
Verified in-game: own-class parity at rank 1 (14524 == 14524), Mage (8)
tab1 talent1 -> Arcane Subtlety (11210 r1, 12592 r2, r9 nil),
default-rank cross-class == rank 1, and bad tab/index/class all nil.
Previously it only read the local player's talent tree (the engine's
runtime per-tab arrays at VAR_TALENT_TAB_INFO_ARRAY, built only for the
player's class). Addons that want another class's talent IDs -- build
tooling, spec previews -- had no way to get them.
Add an optional 3rd arg: nil/omitted keeps the runtime path (player's
class, unchanged); a numeric classID resolves against the Talent.dbc /
TalentTab.dbc flat arrays instead, so any class works.
Ordering matches the player-class path exactly because it replicates the
engine's tree builder FUN_004f2c00: tabs taken from TalentTab.dbc in row
order filtered by classMask (+0x30; mask 0 = all, race not a factor --
FUN_004f2e50 is a plain bitmask test), then talents from Talent.dbc in
row order within each TabID. The runtime arrays are built from that same
walk, so GetTalentIDByIndex(t,i) == GetTalentIDByIndex(t,i,<player class>).
New Offsets: the flat (row-order) DBC array globals the builder uses
(VAR_TALENTTAB_DBC_FLAT_* / VAR_TALENT_DBC_FLAT_*), distinct from the
sparse by-ID index SetTalentByID uses.
Verified in-game: own-class parity (166 == 166), Mage (8) tabs 1/2/3
first talents = 37/1/18, and out-of-range tab/index/class all return nil
with no fault.
C_UnitAuras.* returned nothing for a party/raid member with no live
CGUnit (different map / far out of range): every path bailed on the
null unit, and the only fallback was the Aura::Source SMSG_SPELL_GO
cache, which needs the object to exist.
The server keeps transmitting each group member's current aura spell
IDs via SMSG_PARTY_MEMBER_STATS (GROUP_UPDATE_FLAG_AURAS / _NEGATIVE)
and the client stashes them in the group-member roster structs -- the
same source the built-in UnitBuff/UnitDebuff read out of range. Surface
them.
- Group::MemberStats::AuraArray(guid) mirrors the engine's own
out-of-range fallback in Script_UnitBuff/Script_UnitDebuff exactly
(party stats block +0x1a gated on the online flag, else raid slot
+0x64; both 48x u16).
- Factor aura-table enrichment (caster/expiration/duration) into a
single descriptor-independent PushEnriched core; Push and
PushFromCache now delegate to it.
- New group-array enumerate branch (PushNthGroupAura /
PushGroupAuraBySpellID / ...BySpellName / AppendGroupAuras), applying
the engine's per-slot visibility predicate and merging any
caster/timing the SpellGo cache observed.
- Api.cpp routes unit==null to the group array for every C_UnitAuras
entry point. Safe: the token resolver never raises (returns 0), and
the GUID lookup only runs after ResolveUnit already accepted the
token.
Spell IDs only (no stacks; IDs are u16-truncated) -- same limitations
as UnitBuff. Verified in-game: GetUnitAuras("raid1") cross-map returns
Power Word: Fortitude (10938), matching UnitBuff.
UnitClassBase / UnitRaceBase resolved via FUN_RESOLVE_UNIT_TOKEN and returned
(nil,nil) for a groupmate on another map -- but the engine's own UnitClass /
UnitRace fall back to the cached player-info record there (verified in-game:
UnitClass("raid1")="Priest", UnitRace("raid1")="Human" while our descriptor
path yielded nil). Give ours the same fallback: on a descriptor miss, read
class (+0x140) / race (+0x138) from the NameCache record by GUID. Verified
fixed: UnitClassBase("raid1")=("PRIEST",5), UnitRaceBase("raid1")=("Human",1).
That engine-NameCache peek existed in four places (the two above plus
loot/History and player/Info). Collapsed them into one primitive,
Unit::Identity::PlayerInfoRecord(guid) -> record* (null callback = peek only),
and pointed all four at it. FUN_PLAYER_INFO_LOOKUP is now called in exactly
one spot.
Extract the roster fallback (added for UnitPower) into a shared
Group::MemberStats reader and give it the full picture. Lookup(guid) tries
the party stats block (FUN_GROUP_MEMBER_STATS_LOOKUP) then the raid slot
(FUN_GROUP_MEMBER_SLOT_LOOKUP) -- the object-independent group roster fed by
SMSG_PARTY_MEMBER_STATS -- and returns health, maxHealth, power, maxPower,
powerType, level, areaId, online (both paths) plus dead, subgroup, rank (raid
path). Raid-struct layout mapped from Script_GetRaidRosterInfo (0x004bb560):
subgroup@+8, rank@+0xc, flags@+0x18 (bit0 online, bit2 dead), level@+0x22,
areaId@+0x24, plus the health/power fields already used.
UnitPower/Max/Missing now go through it (no behavior change -- verified target
still reads UnitPower == UnitMana == 2896 on a different map), and
UnitHealthMissing gains the same fallback so it no longer returns 0 for an
out-of-range groupmate. UnitPowerType already had the engine's own roster
fallback (0x00517940) and needs nothing.
The extra fields (level/area/online/dead/subgroup/rank) are populated but not
yet consumed -- groundwork for fixing UnitLevel / UnitIsConnected / UnitIsDead
and a full GetRaidRosterInfo for out-of-range members.
UnitPower / UnitPowerMax / UnitPowerMissing read the CGUnit descriptor
directly, so they returned 0 for a party/raid member on a different map
(no live object). The engine's UnitMana/UnitManaMax don't -- they fall back
to the cached member stats from SMSG_PARTY_MEMBER_STATS.
Mirror that fallback: when ResolveArgs finds no live object, resolve
token -> GUID and read the member's primary power from the party stats block
(FUN_GROUP_MEMBER_STATS_LOOKUP: type@+9, cur@+0xe, max@+0x10) then the raid
slot (FUN_GROUP_MEMBER_SLOT_LOOKUP: type@+0x58, cur@+0x60, max@+0x62), all
u16 -- exactly Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
Verified in-game: a different-map target now reports UnitPower == UnitMana
(2896) instead of 0.
Limitation (inherent): the roster caches only the member's primary power, so
an explicit non-primary powerType returns 0 out of range. Reached only after
ResolveArgs fails on an already-valid token, so TokenToGUID can't raise.
3.3.5 does not drop focus the instant a unit leaves the object table -- its
object-teardown purge (FUN_00524350) is gated by FUN_00512a30, which is
"party-member (FUN_0052d310) OR raid-member (FUN_00573200)". So a groupmate
whose CGUnit is destroyed by going out of range/LoS keeps focus and reattaches
when they return; only non-group units clear on despawn. (Verified in 3.3.5
via Ghidra: focus GUID at DAT_00bd07d0/d4, sole writer FUN_0051ff20, purge
gate decoded to the party/raid roster checks.)
OnWorldTick now mirrors that gate: on an ObjectByGUID miss it keeps focus when
IsGroupMemberGuid(g_focusGUID) -- reading the object-independent roster GUIDs
(VAR_PARTY_GUIDS 4-slot array + VAR_RAID_MEMBER_PTRS 40-slot member-ptr array,
GUID at *member+0) -- otherwise clears as before. A new g_focusObserved flag
tracks whether the "focus" unit-event observers are attached to a live object;
they die with the object on despawn, so OnWorldTick re-registers them when a
groupmate returns to range, and Set() registers only against a resolvable
object (deferring to OnWorldTick when focusing an out-of-range member).
Corrects the earlier note claiming vanilla/modern drop focus on mere range
loss -- that was wrong; the drop is object-teardown gated by group membership.
While a groupmate is out of range UnitExists("focus") is still false (no client
object to read), but the focus survives and snaps back live on their return.
Vanilla 1.12 has no spell-haste API. UnitSpellHaste reads UNIT_MOD_CAST_SPEED
(the cast-time multiplier at descriptor +0x22c that the server folds into
SpellEntry::GetCastTime) and converts it to the modern haste percentage:
haste% = (1 / modCastSpeed - 1) * 100
so 1.0 -> 0, 0.5 (half cast time) -> 100, and a slow like Curse of Tongues
(>1.0) -> negative. This is the Blizzard-shaped surface over the same field
nampower exposes raw as GetUnitField(unit, "modCastSpeed"), letting addons
drop that dependency (raw multiplier recoverable as 1/(1 + haste/100)).
Formula verified byte-exact against 4.3.4's Script_UnitSpellHaste
(FUN_004555b0): `(C1 / mult) * C2 - C2` with C1=1.0, C2=100.0 and a
div-by-zero guard -- the constants are forced by "unhasted must read 0" and
"return a percentage". The 4.3.4 field lives at desc+0x10c; +0x22c is the
verified 1.12 offset. Our div-guard uses `> 0` vs retail's `!= 0` (identical
for the always-positive cast-speed field, marginally more defensive).
New src/unit/SpellHaste.cpp + OFF_UNIT_MOD_CAST_SPEED; docs + README updated.
Completes the retail signature UnitPower(unit [, powerType [, unmodified]])
(and UnitPowerMax). A truthy third arg skips the per-type display divisor and
returns the raw internal value, via the same ToBoolean(L, 3) path already used
by UnitPowerMissing.
Verified in-game on a warrior (rage, divisor 10):
UnitPower(1)=96 UnitPower(1,true)=961 (floor(961/10)=96)
UnitPowerMax(1)=100 UnitPowerMax(1,true)=1000
UnitPowerMissing(1)=4 UnitPowerMissing(1,true)=39
The missing=4 (not 3) confirms the display-value subtraction: 100-96, not
(1000-961)/10 floored to 3.
Docs updated (signature, TOC anchor, unmodified note).
The power analogue of UnitHealthMissing: returns UnitPowerMax - UnitPower for
the given type in one native call. Reuses the Power module's ResolveArgs /
PowerDivisor, so it inherits the primary-power fallback (powerType omitted)
and per-type display divisor. The unmodified third arg (truthy) skips the
divisor for the raw internal deficit, matching retail's UnitPower.
Subtracts the two display values (each integer-divided) rather than dividing
the raw difference, so it equals UnitPowerMax(...) - UnitPower(...) exactly --
the rage case would otherwise be off by one (100/5 -> 95, not (1000-55)/10 =
94). Returns 0 for invalid units and for types the unit has no pool for
(e.g. energy on a mana user). Verified in-game: mana 6685-5929 = 756.
Docs + README updated.
Returns the health deficit -- the equivalent of
UnitHealthMax(unit) - UnitHealth(unit) -- in one native call, so healing
addons don't call both engine getters and subtract in Lua every frame.
One token resolve + two descriptor field reads (HEALTH +0x40, MAXHEALTH
+0x58); no display divisor (health is stored at face value), clamped at 0.
Tracks the engine's own UnitHealth/UnitHealthMax exactly in both forms:
real values (player 3600-1 = 3599) and vanilla's percentage form for
non-grouped units (target 100-85 = 15). Returns 0 for a valid-but-absent
unit, matching UnitHealth's convention.
FUN_006e3340 (the spell tooltip's cast-time helper, which our zero-latency
CastStartSet_h stamp reuses) omits the flat +500ms "aim time" the server adds
to every ranged, non-autorepeat spell -- SpellEntry::GetCastTime does
`castTime += 500` for SPELL_ATTR_RANGED. So the client prediction ran ~500ms
short of the server on Volley / Aimed Shot / Multi-Shot (Volley's real cast is
3000ms base + 500 = 3500ms), and the confirming SMSG_SPELL_START then snapped
the bar out ~1 RTT later. This was the dominant cause of the Volley overhang,
present with or without Quick Shots.
CastTimeMs now adds the +500 for SPELL_ATTR_RANGED spells (excluding Auto Shot
/ Shoot via the AttributesEx2 autorepeat bit, matching the server), so the
prediction is right from frame 1. Only applied when the spell already has a
cast time; instant ranged abilities the server gives a 500ms aim time still
surface through the packet path. The HandleSpellStart reconcile stays as the
backstop for any residual client/server divergence.
The client-side FUN_CAST_START_SET hook stamps g_cast with a zero-latency
prediction from FUN_006e3340, which folds in the caster's cast-speed /
ranged-haste multiplier (descriptor +0x22c). That is correct for spells the
server also hastes (Aimed Shot / Multi-Shot), but wrong for ones it does
not. Volley (1510 / 14294 / 14295) is the case: it is NOT channeled in the
client DBC (AttrEx has no 0x4/0x40) so it takes the regular-cast path, and
its Attributes & 0x30 == 0 so FUN_006e3340 applies the +0x22c multiplier --
yet Volley gains no benefit from Quick Shots server-side. The predicted bar
therefore ended ~before the real cast finished ("still casting after the bar
ends").
SMSG_SPELL_START carries the server's authoritative cast time, but for a
cast the client already stamped we were deduping the packet away entirely to
avoid restarting the bar. Instead, keep startMs (no visual restart) and snap
endMs to the server's castTime (preserving any accumulated pushback). Aimed
Shot / Multi-Shot are unaffected -- the server hastes them too, so client and
server agree and the reconcile is a no-op.
Closes https://github.com/brues-code/pfUI/issues/28
Both modules held per-character state in process globals that the DLL
never cleared on a logout -> character-select -> login (the DLL is not
reloaded across that transition), the same bug class as the equipment-set
leak.
Focus: g_focusGUID / g_focusTargetGUID carried a dead GUID from the prior
character into the next. The WorldTick despawn probe self-healed it within
a tick, but fired a spurious PLAYER_FOCUS_CHANGED and could not cover a
GUID collision. Clear it from a GlueModuleAutoRegister, which fires on the
world->glue return a logout triggers but NOT on /reload -- so focus still
survives /reload (retail behavior) and drops only on logout.
Who: a /who whose SMSG_WHO response never crossed a logout left g_inFlight
stuck (IsWhoQueryPending wrongly true for the next character) and the
VAR_WHO_TO_UI_FLAG override forced on (routing the next character's /who to
the WhoList). Reset in-flight state and restore the flag on every in-game
Lua (re)build via ModuleAutoRegister, which fires per login and per /reload
before any addon runs -- previously only ResetIfStuck's lazy 10s-on-next-
send backstop covered this.
EnsureLoaded latched on a process-global g_loaded flag that was never
reset. The injected DLL survives a logout -> character-select -> login
(no process restart), so opening the Equipment Manager on character A
cached A's file path and sets permanently; character B then read, and
saved into, A's data. Only a full game restart cleared the globals, and
never opening the manager on A left B correct -- matching the report.
Re-resolve the per-character path on every EnsureLoaded call and reload
when it changes, so the cache self-heals across any character/account
switch. Also clear the per-session ignored-slot state on switch. Every
mutation Persist()s immediately, so dropping the old in-memory sets
never loses unsaved work.
Fixes https://github.com/brues-code/pfUI/issues/29
Switch nameplateN indexing from a compacting vector (append on ADDED,
erase + shift-down on REMOVED) to retail-exact slot assignment: a plate
keeps its slot for its whole lifetime, a removal frees the slot, and the
next new plate reuses the lowest free one. Survivors are never renumbered
when another plate is removed - matching modern WoW, where a middle plate
vanishing leaves the others' tokens unchanged and its slot goes vacant
until reused.
g_orderedGUIDs -> g_slots (sparse; free slots hold GUID 0, trailing
empties trimmed). GetGUIDByIndex returns 0 for a free or out-of-range
slot; new GetSlotCount exposes the high-water mark. The reverse token walk
in Unit::Identity now scans 1..GetSlotCount() and skips gaps instead of
stopping at the first 0. The per-plate observer lifecycle is unchanged
(keyed on GUID: register on ADD, unregister on REMOVE).
Verified in-game: 3 plates in slots 1/2/3, removing the middle one leaves
slots 1 and 3 occupied and 2 vacant - no renumber.
Extend the focus observer to the focus unit's current target, so
UNIT_HEALTH / UNIT_AURA / ... fire with arg1 == "focustarget" like a
native token. Unlike focus (a sticky GUID), the focustarget GUID is
volatile - it changes whenever the focus re-targets, and vanilla fires no
event for a unit changing its target (UNIT_FIELD_TARGET, field 10, has no
event-table slot). So the WorldTick watcher re-reads the focus's target
each tick and re-points a second TokenObserver at it only when that
identity changes; the focustarget events themselves still fire
event-driven through the observer (only the re-pointing is tick-gated,
<=1 tick lag). Set() drops the stale focustarget observer immediately on
a focus change/clear.
Verified in-game: focustarget events fire and re-point when the focus
switches targets.
Register/Unregister funnel through FUN_00464890, which dereferences the
client/player-rooted observer registry at [VAR_LOCAL_PLAYER_PTR] (+0x24).
That root is null out of world (login, loading screens, logout teardown),
where the helper null-derefs -> ERROR #132 (ACCESS_VIOLATION reading 0x24).
The nameplate teardown on world-exit fires NAME_PLATE_UNIT_REMOVED for
every plate, each calling Unregister while the root was already null ->
crash. Player::Equipment never hit this because it only registers from the
enter-world hook; focus/nameplate register from Lua / WorldTick, which can
run mid-transition.
Gate both Register and Unregister on RegistryReady() ([VAR_LOCAL_PLAYER_PTR]
!= null). Out of world there are no live objects or observer nodes to touch
anyway - nodes die with their objects on teardown, and in-world callers
re-register / clean up on the next tick.
Make the synthetic focus / nameplateN unit tokens first-class sources of
the standard per-token unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA,
UNIT_LEVEL, ...): arg1 == "focus" / "nameplateN" now fires just like
"party1", so focus and nameplate frames can update event-driven instead
of polling.
Vanilla fires these events only for units it "watches"
(target/party/raid/pet/mouseover) - FUN_0060c520 recomputes a unit's
token-category mask and, on 0->nonzero, FUN_0051bbb0 registers bank-UNIT
descriptor-field observers whose callback broadcasts the field's event to
the guid's tokens (field index == event id; health +0x40 = event 16 =
UNIT_HEALTH). Focus and nameplates are not watch categories, so they
never fired.
New Unit::TokenObserver replicates that watch registration (FUN_0051bbb0's
field set + per-index sizes) with a caller-supplied callback, via the
engine's own observer registrar - the same API Player::Equipment uses. No
hooks: zero MinHook trampolines, hence no collision risk (the reason this
beats co-hooking the broadcast). The registrar appends a fresh node per
(guid, field), so our observers coexist with the engine's - a unit that is
target AND focus AND a nameplate fires all three tokens. Node lifetime is
engine-managed (nodes die with the object; unregister no-ops post-despawn).
- Focus: register/unregister in Unit::Focus::Set.
- Nameplates: register on NAME_PLATE_UNIT_ADDED, unregister on _REMOVED
and (all) in PrepareForReload, since objects survive /reload; the
callback resolves the changed guid to its current plate index at fire
time.
Offsets cross-confirmed against nampower's offsets.hpp (SendUnitSignal
0x515e50, StringParamFormat "%s" 0x82e280, SignalEventParam 0x703f50,
UNIT_HEALTH 16); the GUID->token reverse map nampower left as a TODO is
FUN_00515c50.
SetEquipmentSet built its lines by calling back through self:SetText /
self:AddLine / self:Show - a PushValue/GetTable/Insert/Call dance per
line. Replace that with the engine's own per-tooltip clear plus raw
add-line (with a small r,g,b to packed-color helper) - the same native
path SetTotem and SetHyperlinkCompareItem use. Localization is unchanged:
PushLocalizedFormatInt for the count lines, Lua string.format for the
ITEM_MISSING %s template.
Shows itself via Script_Show at the end, matching retail (the inner
builder tail-calls the show) and PaperDollFrame.lua, which sets the
anchor then calls SetEquipmentSet with no trailing :Show().
Backport of the TBC (2.4.0) tooltip method. Fills the tooltip with the
slot's active totem as two native lines - name (NORMAL_FONT_COLOR) and
time remaining (white) - mirroring retail's Script_GameTooltip_SetTotem
(verified against the 3.3.5 binary; the two line colors are read verbatim
from its color data). Built with the engine's own per-tooltip clear plus
raw add-line - no Lua method dispatch - and shows itself via Script_Show,
matching retail's TotemFrame.xml OnEnter which calls SetTotem with no
trailing :Show().
The time line mirrors SecondsToTimeAbbrev (raw seconds under a minute,
minutes rounded up above) and localizes through the FrameXML GlobalStrings
SPELL_TIME_REMAINING_SEC/_MIN with English fallbacks; slot data is the
existing GetTotemInfo tracker.
Re-introduces the event-table grow valve (removed in 538beda as
mis-timed) at the correct point. Event::Custom::Grow runs from
RetryClaims -- the frame:RegisterEvent hook -- on the first in-game
registration, which is AFTER RebuildEventTable (and SuperWoW/nampower's
clamp-to-700 that co-hooks it) but BEFORE any chain is populated. So
every chain is still an empty self-sentinel and the buffer can be moved
safely. A one-shot latch (reset per reload) runs it at most once per
build, and a hard "abort if any chain is live" scan backs the timing.
Same realloc logic the old EnsureCapacity used -- SMemAlloc a new
buffer, copy name pointers, re-init empty-sentinel anchors, swap the
{cap,count,base} globals, SMemFree the old -- bypassing SetEventCount's
destructive =700 clamp. Consumers read base fresh; the engine's reload
teardown frees our buffer + names as its own.
Dormant in practice (~200 low gaps vs ~46 reservations). Validated via a
new _classicapi_ForceEventGrow() diagnostic that forces a grow on the
next /reload: table grew 700->716, all events (ours, engine,
SuperWoW/nampower, VMT) survived with names intact, dispatch worked on
the grown table, and a following /reload cleanly freed the buffer.
Append counterpart to SetSpellByID -- adds a spell's tooltip (name, cast
time, cost, range, description) to the current tooltip WITHOUT clearing
existing lines, so callers can compose tooltips like
AddLine(header) + AddSpellByID(id).
BuildSpellTooltip's last arg gates the clear: SetSpellByID passes 0
(clears via FUN_00530050 first); AddSpellByID passes 1 (skips the clear
and appends). That append path is the engine's talent "next rank"
preview, so it emits a "Next rank:" header instead of the spell name and
doesn't stash the displayed spellID -- we overwrite that header line (at
the pre-build numLines index) with the real name from Spell.dbc and let
GetSpell keep reflecting the base tooltip. Verified in-game.
EnsureCapacity was meant to grow the event table when the engine's ~200
low NULL gaps ran short of our ~46 reservations, by reallocating the
entry buffer via Storm and swapping the base/count globals. But it was
called from LoadScriptFunctions_h, which runs BEFORE RebuildEventTable
inside FUN_0048FBF0 -- so it always operated on the previous cycle's
still-populated table, tripped its own "abort if any chain is live"
guard, and never actually grew anything. Mis-timed dead code doing risky
buffer surgery on the shared event table.
Claim-NULL into the engine's low gaps is the sole mechanism now; it
always suffices, and TryClaim logs once if it somehow doesn't. Drop the
function, its Storm alloc/free helpers, and the now-inaccurate offset
comments.
Reworks custom-event slot reservation to be coordination-free and
durable without depending on DLL load/hook order.
- Claim NULL-name slots walking LOW->high instead of from the tail.
SuperWoW/nampower own the high range (fixed IDs 549..655) and
destructively clamp the table to 700, and VanillaMinimapTracking also
walks the tail, so the top is contested; the engine's low gaps (~200,
incl. a ~64-slot hole at 42..106) are stable and uncontested. Verified
in-game: our events now land at 1..64, VMT alone at 699 -- clean
separation, independent of the clamp.
- Add Event::Custom::EnsureCapacity: on-demand grow run once from
LoadScriptFunctions_h (after module registration so all reservations
incl. lazy TTS are counted, before any frame registers). If NULL slots
< reserved, it reallocates the entry buffer via Storm and swaps the
{cap,count,base} globals directly, bypassing SetEventCount's clamp --
so it's load/hook-order independent. Defensive: reads fresh, verifies
all chains empty before moving the buffer (aborts otherwise), frees
only the old buffer (names are shared), publishes base before count.
Dormant in the common case (low gaps dwarf our count).
- Drops the SetEventCount co-hook (Capacity.cpp) entirely -- nothing new
on a SuperWoW/nampower-contended function. Keeps the one-shot
table-full log as the backstop.
Adds Storm SMemAlloc/SMemFree + VAR_EVENT_TABLE_CAP offsets.
Exposes the client's computed flight route to a taxi-map slot as an array
of TaxiNodes.dbc IDs in flight order { currentNodeID, ..., destNodeID } --
the exact chain TakeTaxiNode sends the server.
Mirrors the TakeTaxiNode worker (FUN_004dc8d0): a single direct hop when a
TaxiPath edge exists (via the FUN_004dbad0 adjacency matrix), otherwise the
client's precomputed multi-hop route array (slot +0x14, length +0x18). This
lets addons identify every hop of a per-character route without matching
flight-map coordinates back to nodes.
Offsets added: per-slot route array/count/flag, the current-node record
pointer, and the node adjacency matrix. Documented in docs/API.md + README.
Expose the taxi routing graph and per-path geometry that vanilla keeps in
TaxiPath.dbc / TaxiPathNode.dbc but never surfaces to Lua (and addons can't
read DBCs at all).
- GetTaxiPaths() -> array of { pathID, fromNodeID, toNodeID, cost } (every
directed flight-path edge; cost is the routing weight in copper).
- GetTaxiPathWaypoints(pathID) -> array of { x, y, z, mapID } world
coordinates ordered by node index; sum the 3D chords for path length
(/ 32 yd/s ~= single-hop flight seconds).
Lets addons build the flight graph, run travel-time / routing math, or drive
a map overlay without baking the geometry in. Pure DBC readers, no flight
master needed. Offsets for TaxiPathNode.dbc + TaxiPath cost added to
Offsets.h.
Returns the frames currently subscribed to an event as multiple return
values (retail's vararg signature). Reads the engine's own subscriber
chain off the event-table entry (entry+0xC head; node next @+0x04,
frame @+0x08, low-bit/null end sentinel) — the exact list
frame:RegisterEvent appends to, so it stays in sync with real
registrations, including AutoReserve custom events. Each frame is
pushed via the shared UI::FrameObject wrapper so frame methods work.
A counting pass drives lua_checkstack up front against heavily
subscribed events.
The detail ScrollFrame had mouse wheel enabled unconditionally, so even
when the content fit (bar hidden, 0 range) it still consumed wheel events
— clamping to a no-op but swallowing the scroll instead of letting it pass
through. Enable the wheel from SetDetailBarShown alongside the bar, so it's
on only when there's something to scroll.
The trade-skill-link viewer's reagent pane couldn't scroll to the last
row for some recipes (issue #10). Root cause, found by comparing against
Blizzard's Blizzard_TradeSkillUI: resizing a ScrollFrame's child does NOT
recompute its scroll range in 1.12 — you must call UpdateScrollChildRect()
(TradeSkillFrame_SetSelection does exactly this). We resized the child but
never refreshed the range, so the bar's travel was capped at a stale max.
- Call detailScroll:UpdateScrollChildRect() after (re)sizing the child in
RenderDetail and ClearDetail.
- Size the child from actual laid-out geometry (bottom of the last reagent
button relative to the child top + name-plate overhang) instead of a
hand-tallied constant that undercounted.
- Drive scrollbar visibility from the computed overflow (SetDetailBarShown)
rather than the engine's range-changed event: scrollBarHideable only
auto-hides on a range CHANGE, so content that fits from the start (e.g.
Turtle's roomy pane) never hid, and GetVerticalScrollRange isn't reliably
readable the same tick as UpdateScrollChildRect.
Net removes the old detailNeedScroll bool-tracking, per-need mouse-wheel
toggling, and the OnShow re-assert workaround — all of which were
compensating for the missing range recompute.
Fixes#10
Addons that re-include ..\..\FrameXML\Fonts.xml in their TOC (notably
SpecialTalentUI, LoadOnDemand — pulled in when the talent frame is first
opened) re-run its inline script, which resets NORMAL_FONT_COLOR /
HIGHLIGHT_FONT_COLOR / GRAY_FONT_COLOR to plain {r,g,b} tables and strips
the ColorMixin methods our UI relies on. That crashed the trade-skill-link
viewer's NORMAL_FONT_COLOR:GetRGB() (issue #10).
Factor the standard color-global setup into CAPI_ApplyStandardColorGlobals
(idempotent — only (re)creates a tag that's missing :GetRGB) and re-run it
after SpecialTalentUI loads via EventUtil.ContinueOnAddOnLoaded, next to the
existing pfUI compat in AddOnCompat.lua.
Also simplify the viewer frame anchor to SetPoint("CENTER").
Adds the post-vanilla TargetScript selection functions 1.12.1 lacks:
TargetDirectionEnemy(facing[,cone]) / TargetDirectionFriend
TargetNearest([reverse])
TargetNearestEnemyPlayer([reverse]) / TargetNearestFriendPlayer
GetPlayerFacing()
All five targeting functions reuse the engine's own tab-target core: the
visible-unit enumeration, the per-mode validity predicate FUN_00493e40
(reaction/attackability/alive, modes 1=enemy/2=friend), and the selection
commit. The only new logic is a player-GUID filter for the *Player variants
and the cone geometry for the direction variants. Cycle functions keep a
frozen per-filter snapshot advanced within a ~1s window (no ping-pong).
Target-setting routes through FUN_TARGET_BY_GUID (0x00489A40), which
rejects a GUID that no longer resolves to a live unit rather than
committing it — a stale snapshot GUID would otherwise leave "target"
pointing at freed unit data and crash the next handler that reads it.
GetPlayerFacing reads the CGUnit movement-block orientation at +0x9C4
(located by probing the player object across a known 180-degree turn); it's
the facing source the direction-target functions need.
Not backported (no vanilla equivalent): TargetPriorityHighlightStart/End,
IsTargetLoose, TargetToggle.
New offsets: FUN_TARGET_CANDIDATE_VALID (0x00493E40),
FUN_SET_SELECTION_BY_GUID (0x00493540), VAR_CURRENT_SELECTION_GUID_LO/HI,
OFF_UNIT_FACING (0x9C4).
Returns the active chat-bubble Frame objects, matching modern WoW's
7.2.5 signature. Vanilla's CGChatBubbleFrames are full CSimpleFrames
the engine creates in C++ without CreateFrame (same as default
nameplates), kept on an intrusive Storm TSList at 0x00B6F038 (next
link +0x318, low-bit sentinel). Mirror the engine's own walk
(FUN_004b1060) and push each as its canonical engine wrapper, so the
returned frames are real and method-capable and their text FontString
(+0x31c) surfaces via :GetRegions().
includeForbidden is accepted and ignored (vanilla has no forbidden
frames).
Factor the ScriptRegister->rawgeti wrapper push out of nameplate/Info
into a shared UI::FrameObject::Push; PushNamePlateFrame now forwards to
it (no behavior change).
Returns the slot's active totem total duration in seconds (0 when none) —
the totem's full lifetime from cast, same value as GetTotemInfo's 4th
return, companion to GetTotemTimeLeft. Reads the tracker's durationMs.
Verified in-game (constant 45 while time-left counts down).
Target the player's totem in a slot (1-4). Looks up the totem creature's
live GUID (object-manager scan for the player-owned creature of the
slot's tracked entry) and sets the target via FUN_00489a40 — the
set-target-by-GUID path Script_TargetUnit uses after its token→GUID
resolve. No-op when the slot is empty or the totem isn't visible. Added
Offsets::FUN_TARGET_BY_GUID.
Returns seconds remaining before the slot's totem auto-destroys (0 when
no totem is active), computed from the existing Totem::Tracker's
startMs + durationMs. Slots 1-4 = Fire/Earth/Water/Air. Verified in-game
counting down.
Backport the TBC PLAYER_TOTEM_UPDATE(totemSlot) event (absent from
vanilla's table, so reserved as a custom event) on top of the existing
Totem::Tracker. Every slot state change marks a dirty bit — the cast
path (SpellGo), duration expiry, and the WorldTick death-scan removal —
and OnTick flushes the dirty slots with Fire(id, "%d", slot). Flushing
from the tick keeps Lua handlers out of mid-packet context. Drops fire
next frame; removals within the ~250ms scan interval. Verified in-game.
Vanilla 1.12 tracks nothing about totems client-side (the totem bar,
PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
Totem::Tracker tracks the four element slots itself — data-driven:
- Slot from the summon spell's Spell.dbc effect (87-90 =
SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air; slot = effect - 86).
- Creature entry from the effect's EffectMiscValue (for death detection).
- Duration from SpellDuration.dbc via the engine duration helper.
- haveTotem tool item from the spell's Totem[0] field (0xA0) — the totem
the spell requires (a tool, not a consumed reagent) — resolved from the
player's spellbook on SPELLS_CHANGED, not hardcoded.
Casts are observed through Aura::Source's existing SMSG_SPELL_GO hook (a
one-line notify before its aura gate, since a summon applies no aura) —
no new hook, respects one-hook-per-target. Tool items resolve on
SPELLS_CHANGED (a no-arg event via the shared Event::SignalHook), when
the spellbook is populated. A slot clears when its duration elapses OR
when the totem creature disappears early (killed / Totemic Recall /
destroyed), detected by a throttled WorldTick object-manager scan for the
player-owned creature of that entry (seen-then-gone latch; CreatedBy ==
player so another shaman's same-entry totem can't mask yours).
GetTotemInfo returns haveTotem, totemName, startTime, duration, icon,
modRate=1, spellID. Turtle-custom totems are tracked automatically.
Also exposes Item::Count::InInventory (equipped + bags) as a shared
helper for the tool-presence check. Verified in-game: Earthbind lands in
slot 2, haveTotem reflects the Earth Totem tool, and Totemic Recall
clears the slot early.
Returns the GUID string of a unit's owner — the summoner of a
pet/guardian/totem or the charmer of a charmed unit — or nil for
players, world creatures, and unresolved units. Reuses Unit::Pet's
existing OwnerGuid helper (CharmedBy, else CreatedBy in m_objectFields),
the same field the pet predicates share. Verified in-game:
UnitOwnerGUID('target') == UnitGUID('targetowner').
Returns xPos, yPos, distance for the nearest visible game object of a
given GO-template ID, or nothing when none is in range — the game-object
counterpart of ClosestUnitPosition.
Extract the shared visible-object-manager scan into
Object::ClosestByEntry (object/ClosestByEntry.{h,cpp}): enumerate visible
objects, match GUID type + template entry (bits 24-47), return the
closest one's world position + center distance. Both ClosestUnitPosition
(TYPEMASK_UNIT / Creature) and ClosestGameObjectPosition
(TYPEMASK_GAMEOBJECT / GameObject) are now thin wrappers over it;
ClosestUnitPosition behavior is unchanged.
Same retail-vs-vanilla caveat as the unit version: returns the nearest
CURRENTLY-VISIBLE object rather than reading retail's static starting-
zone database (which vanilla lacks). Documented in docs/API.md.
Returns xPos, yPos, distance for the nearest creature of a given NPC
entry ID, or nothing when none is in range. Walks the visible-object
manager (Loot::Nearby pattern), matches creatures whose GUID encodes
the entry (bits 24-47, per guid/CreatureInfo), and returns the closest
one's world position + center distance in yards.
Semantic note: retail's ClosestUnitPosition reads a static client spawn
database (starting-zone mobs only); vanilla 1.12 has none, so this
returns the nearest CURRENTLY-VISIBLE creature of that entry (anywhere)
— more general than retail, but can't point at un-synced spawns.
Documented in docs/API.md.
Poor=0 through Artifact=6, matching the numeric quality GetItemInfo /
C_Item.GetItemQuality report. Heirloom (7) and WoWToken (8) are omitted
— no such items exist on 1.12. Registered in src/item/TypeInfo.cpp
alongside Enum.ItemClass / Enum.InventoryType. Docs + README updated.
- GetItemClassInfo(classID) -> localized ItemClass.dbc name, registered
as both a global and C_Item.GetItemClassInfo. Obsolete class slots
return the client's literal "...(OBSOLETE)" string.
- GetItemSubClassInfo(classID, subClassID) exposed as a global (the
C_Item form already existed); returns name + the armor-material bool.
- Enum.ItemClass: 0..15 with modern key names on the obsolete slots
(Gem/ItemEnhancement/CurrencyTokenObsolete/PermanentObsolete), plus
post-vanilla 16..19 for parity, matching the Enum.InventoryType
precedent in the same module.
All added to src/item/TypeInfo.cpp alongside the existing subclass /
inventory-type surface. Docs + README updated.
Two pieces of hardcoded Turtle-specific data were embedded in stock
modules; relocate them to src/turtle/ behind extension points so the
stock code carries only generic mechanism.
- aura/ComboDuration: add an AutoDanglingResolver extension point; the
stock module keeps the dangling-row detection + scaling formula, and
Turtle's reworked-Rip base/max values move to
src/turtle/ComboDuration.cpp. Not gated on Turtle::Detected() — the
dangling-row condition stays the gate (gate on the bug, not the
realm), preserving prior behavior exactly.
- spell/BonusDamage: add a Spell::HealingAura resolver extension point;
the stock GetSpellBonusHealing decoder keeps auras 135 (flat) and 175
(xSpirit), and Turtle's Ironclad aura 199 (xArmor) moves to
src/turtle/HealingAura.cpp, gated on Turtle::Detected() (an aura index
is a generic slot that could collide on another server).
Behavior is unchanged on Turtle; both extractions are pure relocation.
Turtle's item data leaves m_bagFamily at 0 on the bags/quivers
themselves — the four profession bags (Soul/Herb/Enchanting/
Engineering, Container class) and quivers/ammo pouches (Quiver
class) all read 0, so C_Item.GetItemFamily and
C_Container.GetContainerNumFreeSlots reported 0 for them even
though the tooltip clearly shows "Soul Bag" etc. Recover the family
from the item's class + subclass instead (keyed on class because
subclass numbers repeat: Container sub 2 = Herb Bag, Quiver sub 2 =
Quiver). Loot items keep carrying the field directly.
Factored the derivation into a shared Item::BagFamily helper
(BitmaskForRecord) used by both GetItemFamily and the free-slot
functions, so a bag and the items it holds always agree on the
family bit. Side effect: CalculateTotalNumberOfFreeBagSlots now
correctly excludes specialty bags from the general free-slot total.
Non-stock data lives in its own module: src/turtle/ with a
TURTLE_WOW_VERSION-based Turtle::Detected() gate (no realm sniffing)
and the custom Meat/Fish/Leather/Mining families (ItemBagFamily.dbc
rows 10-13) plugged into Item::BagFamily via an AutoResolver
extension point, so the stock table stays pure vanilla.
2026-07-20 16:38:42 -05:00
338 changed files with 35766 additions and 3968 deletions
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".",2);
A small DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW) that adds a
collection of Lua API calls Blizzard never exposed in 1.12 but which make
addon authoring noticeably less painful — primarily for backporting addons
written against later API versions (3.3.5+) where these calls do exist.
ClassicAPI is a DLL for World of Warcraft 1.12.1 (Vanilla / Turtle WoW). It
backports a large part of the modern WoW API — 550+ functions and 50+ events,
plus much of Lua 5.1 — into the 1.12 client, so addons written for later
versions (3.3.5+ or Classic Era) run with little or no change.
The DLL hooks the FrameScript engine after WoW boots and registers its
extensions through the same in-engine mechanisms WoW uses for its own Lua
functions. No companion addon is required.
It hooks the engine after WoW boots and registers everything the same way WoW
registers its own Lua functions. Nothing else to install — the DLL is
self-contained.
## What's added
## Highlights
Full per-function reference: **[docs/API.md](docs/API.md)**.
The headline features. Each one takes effect as soon as the DLL loads, so your
existing addons benefit without any code changes. The complete per-function
reference is in **[docs/API.md](docs/API.md)**.
### Calls
### Lua 5.1 compatibility
The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
| Feature | Effect |
|---------|--------|
| Syntax | Compiles the Lua 5.1 length (`#`), modulo (`%`), `...`-expression, `0x` hex-literal, and leveled long bracket (`[=[ ]=]`) syntax that vanilla's Lua 5.0 rejects, by rewriting addon source before it compiles. Each addon file also receives its `(name, table)` through `...` (`local name, tbl = ...`). See [Lua 5.1 syntax](docs/API.md#lua-51-syntax). |
| String methods | Every string value accepts method calls — `("asd"):upper()`, `("%d gold"):format(n)`, `msg:match("^!(%w+)")` — resolving through the `string` table, the way Lua 5.1 works. Vanilla's Lua 5.0 errors with `attempt to index a string value`. Works on literals and variables, in-world and on the login screen, and inside coroutines. See [String methods](docs/API.md#string-methods-supper-sformat). |
| Script-handler arguments | Frame-script handlers receive their values as positional arguments — `OnMouseWheel(self, delta)`, `OnClick(self, button)`, `OnEvent(self, event, ...)`, etc. — the way 5.1+ clients do, so modern addon ports work unmodified. The `this` / `arg1` globals stay set. A handler that declares no parameters is unaffected. A handler that declared a parameter and expected nil now receives its real value. On by default. `SetModernScriptArgs(false)` restores exact vanilla dispatch. See [SetModernScriptArgs](docs/API.md#setmodernscriptargsenable--getmodernscriptargs). |
### Modern client behaviors
| Feature | Effect |
|---------|--------|
| 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. |
| 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. |
| Event-driven nameplates | The modern `C_NamePlate` API, driven by real events instead of vanilla frame-scraping. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. See [NamePlate](docs/API.md#nameplate). |
| Focus target | A sticky focus unit, like later clients. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. See [Focus](docs/API.md#focus). |
| Multi-flavor & conditional TOC | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. See [Conditional and multi-flavor TOC loading](docs/API.md#conditional-and-multi-flavor-toc-loading). |
## Full API reference
Everything ClassicAPI adds, in full. Each section is collapsed — click a
heading to expand it. For signatures and return values, see the per-function
reference in **[docs/API.md](docs/API.md)**.
<details>
<summary><b>In-game Lua calls</b> — 550+ functions across ~60 namespaces</summary>
Registered on the **glue** Lua state (the engine that runs the login,
realm-select, and character-select screens). The persistence entries
@@ -86,10 +126,14 @@ the glue state because GlueXML had no way to reach them otherwise.
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
Engine-level extensions to macro parsing and dispatch — no new Lua
functions, just behavior the stock 1.12 engine didn't have. See the
@@ -101,7 +145,10 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `CastSpellByName("<spellID>")` | Same — numeric strings resolve through the engine's name resolver |
| `CastSpellNoToggle("<name>")` in a macro | Engine's macro parser now recognizes it as a primary-spell line, so the macro slot in an addon like pfUI highlights when its spell is auto-repeating or its self-aura is active |
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
### Bindings
</details>
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.
<details>
<summary><b>Bindings</b> — direct-action and override binding families</summary>
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.
| Binding | Action |
|---------|--------|
| `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
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. |
</details>
## Installation
@@ -216,36 +299,38 @@ The Lua-side companion library lives in
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
functions but that consumer code still expects to find as globals —
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.