Compare commits

...
28 Commits
Author SHA1 Message Date
Brues e47e881ae1 fix(event): FireValues applies RegisterUnitEvent filters
FireValues walks the subscriber chain itself and runs each OnEvent
through RunSlotWithValues, so it never reached the runner where
Frame::UnitEvent's gate sits. RegisterUnitEvent("UNIT_AURA_DELTA", unit)
fired for every unit's deltas.

Frame::UnitEvent::Accepts shares the gate's match. FireValues asks it
for each frame when the first value is a string, the same rule the
engine path uses for a %s arg1.
2026-10-31 00:21:18 -05:00
Brues d9da100110 feat(tradeskill): linked windows show the crafter's name
3.3.5's interface never shows whose profession a linked window holds.
Port 4.3.4's TradeSkillLinkNameButton and TradeSkillFrame_SetLinkName
to TradeSkillFrame and CraftFrame: "[name]" after the title, and a
click on it starts a whisper. The title moves left by half the name,
from its own anchor, so Turtle's -30 offset stays.
2026-10-30 22:48:40 -05:00
Brues 00cfe0c45b fix(smf): BackFill and RemoveMessagesByPredicate keep the engine's ring
A ring that is not full holds records 0..count-1: the engine's writer
fills them in order from an empty ring, and only a full ring wraps.
ScrollOffset and ScrollToOffset wrap by the count, and ScrollToTop
(0x00788690) lays out from record 0 unless the ring is full, so each one
is right only for that layout.

BackFillMessage wrote the new oldest message to the record before the
oldest, as 4.3.4's addToTop does. In a ring that is not full, that record
wraps to the end of the array. From then on, GetScrollOffset,
SetScrollOffset, ScrollByAmount and ScrollToTop read the wrong offset.
pfUI's chat wheel scrolled up into the backfilled lines and then could
not scroll back down.

RemoveMessagesByPredicate compacted from the oldest's record, so after a
removal from a full ring the kept messages began anywhere in the array.

BackFillMessage now rotates records 0..count by one, so the free record
comes round to 0, and writes the message there. RemoveMessagesByPredicate
first turns the ring so that the oldest is record 0, then compacts. Whole
records move, so every text keeps one owner. Clear leaves a free record's
old text for the writer to free. The extra-argument tables and the scroll
record (OFF_SCROLLINGMESSAGEFRAME_SCROLL_RECORD, +0x348) move with their
records. A scrolled-up frame is laid out again from its moved scroll
record, so the view stays.

A model of the engine's three functions agrees on 377,485 random
add, backfill, remove and scroll states. Not yet verified in game.
2026-10-30 22:36:53 -05:00
Brues d92464b86c feat(tradeskill): trade links follow 3.3.5
Profession links are now built and opened the way 3.3.5's
CGTradeSkillInfo does it. A received link opens in the engine's own
TradeSkillFrame. The ClassicAPI viewer is gone.

The link is 3.3.5's format:
trade:<spellID>:<rank>:<maxRank>:<GUID>:<bits>|h[<spell name>]. The
first field is the profession spell, not the skill line. The GUID
replaces the name. A reader whose name cache does not hold the linker
sends the name query and opens the link when the answer arrives. The
bits cover every SkillLineAbility row of the profession, as 3.3.5's link
run does. 1.12's DBC is sorted by ID and does not group a profession's
rows into one run, so the rows here are taken in ID order. A link opens
only when the reader's row count matches it.

The 1.12 list build (0x004FCA20) is 3.3.5's RefreshList without the
link branch. Three call-site patches add that branch:
- the spell list comes from the link,
- the ability rows use the linker's race and class,
- the rank is the linker's.
Two patches on TryCastSpell's toggle calls record the cast spell (3.3.5's
m_spellID). They also make a cast while a link shows open the player's
own list instead of closing the window. The game shutdown drops the link,
as 3.3.5's does.

Enchanting gets the same in the Craft window. Its rows are filtered by
castUI, because its recipes have no TRADESPELL bit. GetCraftListLink and
IsCraftLinked are the Craft window's twins of the 3.3.5 functions.
GetTradeSkillLine, GetCraftDisplaySkillLine and GetCraftName give the
linker's values while a link shows. Util/TradeSkillLink.lua now ports
3.3.5's linked mode, which hides Create, Create All and the count
controls, and keeps the link buttons.

Any spell whose Effect[0] is TRADE_SKILL can be linked. 1.12's
SkillLine.dbc has no canLink column.

Removed: C_TradeSkillUI.GetTradeSkillListLink, GetCraftListLink and
GetTradeSkillListRecipes. Old links no longer open.

Not yet verified in game.
2026-10-30 22:16:16 -05:00
Brues fec127c1af docs(item): DoesItemExistByID is true for any positive ID
The entry said a cache miss returns false and starts a load. The
function returns true for any positive item ID, cached or not, and
loads nothing. ContinueOnItemLoad depends on that.
2026-10-30 21:47:34 -05:00
Brues 37ee71c663 feat(item): C_PaperDollInfo.GetTemporaryEnchantmentInfo
Retail's per-slot temporary enchant, the function its deprecated
GetWeaponEnchantInfo is now built from. It reads the same temporary
enchant slot as C_Item.GetItemTempEnchantInfo, in retail's shape: an
INVSLOT_ number in, and a TemporaryItemEnchantInfo table out (enchantID,
remainingTimeMs, chargesRemaining, hasExpirationTime), or nothing when
the slot has no temporary enchant. A slot outside 1..19 also returns
nothing, so the engine never sees it.

hasExpirationTime is the descriptor duration gate that the shared reader
already tested before it asked for the time left.

C_Item.GetItemTempEnchantInfo stays as the ClassicAPI extension for a
weapon in a bag (#47). The docs link the two.
2026-10-30 21:20:05 -05:00
Brues 6259e22480 feat(lua): ItemUtil from retail
Util/ItemUtil.lua is now the ItemUtil table of retail
Blizzard_FrameXMLUtil/ItemUtil.lua, in retail's own text. As its header
lists, it leaves out ItemButtonUtil and DoesAnyItemSlotMatchItemContext
(retail drives them from frames this client does not have), the transmog
mixin, the currency path of GetItemDetails, and GetCraftingReagentCount
(reagent and account banks).

New for what it calls:
- Enum.BagIndex in the DLL, with Classic Era's names and this client's
  indices: Keyring -2, Bank -1, Backpack 0, Bag_1..4 = 1..4 and
  BankBag_1..6 = 5..10. Era's bank bags start at 6, after its reagent
  bag. The indices live in Offsets.h now, and container/Search.cpp drops
  its local copies.
- C_PaperDollInfo.GetInventorySlotInfoForInvSlot in the DLL. It takes the
  first PaperDollItemFrame.dbc row with that SlotNumber, and walks the
  rows the way the engine's GetInventorySlotInfo (0x004C81B0) does.
- C_Container.GetContainerNumSlots and C_Container.PickupContainerItem as
  Lua aliases of the stock globals in Compat.lua, next to
  C_ActionBar.ShouldShowKeyring, which is always true.
- ContainerFrame_GetContainerNumSlots and NUM_TOTAL_BAG_FRAMES from
  retail's ContainerFrame.lua, and GameTooltip_SuppressAutomaticCompareItem.
  Nothing reads that flag, because nothing compares items automatically.

docs/API.md covers all of it, plus Item objects and
C_Item.GetItemIDForItemInfo.

Verified in game: Enum.BagIndex.Keyring is -2, the backpack has 16 slots,
ShouldShowKeyring is true, slot 18 returns the ranged row with checkRelic
true, GetEmptyEquipSlotTooltip(1) is "Head", and FilterOwnedItems({6948})
finds a Hearthstone in the bags.
2026-10-30 21:12:37 -05:00
Brues 474da1a5b5 feat(lua): Item follows retail
Util/Item.lua is now retail Blizzard_ObjectAPI/Mainline/Item.lua in
retail's own text, and its listener is retail's AsyncCallbackSystem.lua
with the item and quest listeners. There is no spell listener: every
spell is client-side data here, so there is nothing to wait for. The one
other difference is that GetItemQualityColor reads ITEM_QUALITY_COLORS,
the same entry retail's ColorManager returns.

What follows retail now:
- Load callbacks get (id, success).
- An item callback also runs when the load fails, with success nil.
  TradeSkillLink's two callbacks now stop on a failure, because
  re-rendering would request the missing item again.
- C_Item.RequestLoadItemDataByID runs once per item, not once per
  callback.
- New methods: GetStackCount, ContinueWithCancelOnRecordLoad and
  IsRecordDataCached. ContinueOnItemLoad's usage errors name the bad
  itemID or link, and GetItemGUID returns the GUID an item was made from.

C_Item.GetItemIDForItemInfo is new in the DLL for ItemMixin:GetItemID's
fallback. It uses the resolver that GetItemInfoInstant uses.

Verified in game: Item:CreateFromItemID(19019) calls back with
(19019, 1), and C_Item.GetItemIDForItemInfo("item:6948") returns 6948.
2026-10-30 21:12:22 -05:00
Brues 6cee968a8a refactor(lua): ItemUtil.lua is Item.lua
The file holds retail's Item mixin (Blizzard_ObjectAPI/Mainline/Item.lua)
and its ItemEventListener, not retail's ItemUtil, so it takes Item.lua's
name. No code changes.
2026-10-30 21:12:07 -05:00
Brues 635c74d518 fix(smf): ScrollByAmount stops where ScrollUp stops
ScrollByAmount moved the offset straight to the clamped target, and the
clamp is retail's count - 1, where the oldest message sits alone on the
bottom line under empty space. 1.12's own ScrollUp (0x788610) stops
sooner, at +0x350 (4.3.4's m_atTop), which the layout sets once every
message back to the oldest fits the height (0x788A37). A chat wheel
built on ScrollByAmount (pfUI's) scrolled chat up until it was nearly
empty.

The engine has two scroll families, in 1.12 and 4.3.4 alike: ScrollUp
and PageUp stop at m_atTop, and ScrollToTop and SetScrollOffset go to
the oldest record. Retail defines ScrollUp as ScrollByAmount(1) and
ScrollToTop as SetScrollOffset(GetMaxScrollRange()), so ScrollByAmount
now steps the engine's ScrollUp / ScrollDown (0x788610 / 0x788650), the
loop PageUp (0x7885B0) runs, and SetScrollOffset / GetMaxScrollRange
keep count - 1. The clamped target still bounds the steps: a hidden
frame never lays out, so its ScrollUp never finds the top and wraps.

Verified in game: ScrollByAmount(1000) on ChatFrame1 stops with the
oldest message on the top line and an offset below GetMaxScrollRange().
2026-10-30 19:56:21 -05:00
Brues 260d40f49b fix(frame): let the engine runner parse script formats
Frame::ScriptArgs carried its own copy of the script runner's format
switch (0x704F10, %d/%u/%f/%s) so it could pass positional args.
SuperWoWhook extends the live switch at runtime: it points 'e' at a new
jump slot (0x7052CC) that pushes registry[ref] for a 4-byte ref, and
fires CREATE_CHATBUBBLE with "%e%s". Our copy skipped 'e' without
consuming its value, so %s read the ref as a string pointer and
crashed in lua_pushstring (0x6F38A5) for any handler that declares
parameters.

Both runners now run unchanged, and only their own lua_pcall is
redirected (0x705199, 0x704E79) through a thunk that reads the handler
ref, frame and arg count from the runner's frame. RunnerPCall reads
arg1..argN back from the globals the runner set and passes them as
(self, [event,] arg1..argN [, trailing]). The format parser and both
runner reimplementations are gone; RunCore remains for value fires.

Format fires now keep the runner's 19-argument limit and its
lua_gettable saves of this/argN, as vanilla handlers always had. No
engine or ClassicAPI format has more than 12 values; value fires
(FireValues, RunWithValues) are unchanged.

Verified in game: a RegisterAllEvents frame with a modern OnEvent
crashed on a chat bubble with the old build and prints
CREATE_CHATBUBBLE and the speaker GUID with this one.
2026-10-30 15:54:53 -05:00
Brues 8f6ff33a39 refactor(lua): CallbackRegistry in retail's own text
6cc9d0d already gave CallbackRegistry retail's behavior; the file is now
retail Blizzard_SharedXMLBase/CallbackRegistry.lua in retail's own syntax
(select("#", ...), `...` forwarding, retail's comments), with three
differences noted in the header:

- No AttributeDelegate: 1.12 has no taint or SetForbidden, so
  SecureInsertEvent and GetMutableCallbacksByEvent do the delegate's
  work directly.
- TriggerEvent calls securecallfunction from its own loops instead of
  handing secureexecuterange a vararg helper, which in Lua 5.0 would
  build an `arg` table per callback per event.
- UnregisterEvents uses table.wipe; 1.12 has no global wipe.

The one behavior change: TriggerEvent on a registry with no Event table
and undefined events disallowed now fails on self.Event as retail does,
instead of with the "doesn't exist" message. EventRegistry, and registries
derived from it, allow undefined events.

Verified in game: a closure gets its bound argument and a leading nil
payload, an erroring callback goes to the error handler while the others
run, a callback registered mid-dispatch runs from the next trigger, and
UnregisterEvents clears every callback.
2026-10-30 14:01:40 -05:00
Brues 5082f3c829 feat(lua): TableUtil follows retail
The file is now retail Blizzard_SharedXMLBase/TableUtil.lua in retail's
own syntax: the length operator, `...`, select, math.huge, table.wipe and
5.1's unpack(t, i, j) all work here now.

New from retail: TableUtil.SafeCountTable / SafeCountIndexTable,
tInvertToArray and CollectTableDifferences. Fixed by following retail:
TableUtil.Transform returned after the first value, ContainsAllKeys now
also fails on a key only the second table has, and SafeUnpack honors its
startIndex. TableUtil.ExecuteOnKeys stays as the one addition retail does
not have.

Verified in game: ContainsAllKeys, Transform, SafePack / SafeUnpack from
index 2, GetTableValueListFromEnumeration over ipairs,
CollectTableDifferences, SafeCountTable and ExecuteOnKeys all return the
expected values.
2026-10-30 13:57:09 -05:00
Brues 5d0568f759 feat(lua): Pools follows retail, with securecallmethod
Pools.lua is now retail Blizzard_SharedXMLBase/Pools.lua, on the
SecureTypes and ProxyUtil ports the Menu backport already uses.
CreateObjectPool, CreateFramePool and the rest alias the secure variants
as on retail, so they return proxies that expose only the pool methods.
CreateFramePool takes retail's (frameType, parent, template, resetFunc,
forbidden, postCreate, capacity); a table specialization goes through
FrameUtil.SpecializeFrameWithMixins; CreateObjectPool works without a
reset function; Pool_HideAndSetToDefaults is new. The ClassicAPI-only
CreateUnsecuredActorPool and CreateUnsecuredFontStringPoolCollection are
gone (nothing used them).

Two differences, noted in the header: no secret-value check on release
(1.12 has no secret values), and a forbidden pool makes normal frames
(no CreateForbiddenFrame).

The TOC loads SecureTypes and ProxyUtil before Pools, as retail's does;
Pools reads both at load.

SecurePoolCollectionMixin:Release calls securecallmethod, now in
src/baselib/SecureCall.cpp next to securecallfunction, on both Lua
states: object[method](object, ...) with a raw lookup, an error goes to
the error handler and the call returns nothing. That follows retail's
API documentation; it is not probed. docs/API.md covers it.

Verified in game: securecallmethod returns the method's result, a frame
pool hides its internals behind the proxy while acquire and release keep
the count, and a frame pool collection runs a table specialization's
OnLoad, acquires, and releases through securecallmethod.
2026-10-30 13:53:24 -05:00
Brues 9a78e159e7 feat(lua): FrameUtil follows retail
The file is now retail Blizzard_SharedXMLBase/FrameUtil.lua. New from
retail: FrameUtil.RegisterUpdateFunction / UnregisterUpdateFunction,
DialogStyleGlobalMouseDown, SpecializeFrameWithMixins /
ReflectStandardScriptHandlers, RegisterForVariablesLoaded,
FrameUtil.CreateFrame, UpdateScaleForFit / UpdateScaleForFitSpecific,
GetScaledCenter, FitToParent, RegisterForTopLevelParentChanged /
UpdateTopLevelParent and AnimTransitionMixin. RegisterFrameForUnitEvents
forwards `...` as retail does.

AnimTransitionMixin runs on retail's Blizzard_SharedXML/
ScriptAnimationUtil.lua, ported verbatim. Its ShakeFrame scales by
retail's ShakeStrengthUI cvar, which src/ui/ShakeStrength.cpp registers
with retail's help, category and default ("1.000000"); nothing in the
engine reads it.

Not ported: the fade and flash functions, which 1.12's own UIParent.lua
defines and UIParent's OnUpdate drives (Turtle has also changed them);
RegisterFrameForEventCallbacks and its two siblings (no
frame:RegisterEventCallback); ApplyDefaultScale (no GetDefaultScale).
SetParentMaintainRenderLayering does not carry the window: 1.12 has one.

README.md lists FrameUtil, ScriptAnimationUtil and the cvar.

Verified in game: SpecializeFrameWithMixins sets and calls OnLoad and
OnShow with the frame, a SlideLeft StartAnimIn ends at its anchor
(CENTER 0 0), and ShakeFrameRandom with ShakeStrengthUI at 1.000000
returns its cancel function.
2026-10-30 13:53:15 -05:00
Brues 503368623f feat(localization): BreakUpLargeNumbers, AbbreviateNumbers, AbbreviateLargeNumbers
Retail's number formatters (LocalizationDocumentation.lua) and
C_StringUtil.GetDefaultAbbreviationBreakpoints. Retail moved them from
FrameXML into C in 12.0.0, so the behavior is taken from probes of an enUS
retail client, recorded in src/localization/Numbers.cpp:

- BreakUpLargeNumbers drops the fraction of a whole number or one 1000 or
  more in size and groups it with LARGE_NUMBER_SEPERATOR while the
  breakUpLargeNumbers cvar (retail's, default on) is set; other numbers
  keep one decimal, rounded half away from zero. natural writes a million
  or more in words ("1.2 million", "123 million").
- AbbreviateNumbers walks retail's default breakpoints, or
  options.breakpointData, or the Asian table for options.locale koKR /
  zhCN / zhTW. Retail's defaults name THIRD_ and FOURTH_NUMBER_CAP_NO_SPACE,
  which its clients do not define, so billions and trillions carry no
  letter there and here.
- AbbreviateLargeNumbers walks FrameXML's old thresholds, rounds to a whole
  number outside the Asian locales, and falls back to BreakUpLargeNumbers.

The asian natural-word scales (1e4 / 1e8 / 1e12) are read off the strings,
not probed. options.config (CreateAbbreviateConfig) is not backported.

Every locale file, and a new koKR.lua, gets DECIMAL_SEPERATOR, FIRST/SECOND_NUMBER_CAP[_NO_SPACE]
and FIRST..FOURTH_NUMBER from the matching Classic GlobalStrings; the
German, Spanish and French words carry |7 plural tokens, which the grammar
pass does not expand yet.

FormattingUtil gains retail's GetCostString and GetItemCostString on top.

docs/API.md documents the four functions, the cvar and
Constants.MoneyFormattingConstants; README.md lists them.

Verified in game: every probed retail value reproduces exactly (BreakUp
with and without natural, AbbreviateNumbers with defaults, Asian locale
and custom breakpoints, AbbreviateLargeNumbers), the cvar toggles the
separators, and GetItemCostString shows the amount and item icon.
2026-10-30 13:33:40 -05:00
Brues 735d304a20 feat(currency): Constants.MoneyFormattingConstants and GetMoneyString's gold threshold
Retail exposes engine constant tables as Constants.<Name>, documented with
Type "Constants" and a Values list (MoneyConstantsDocumentation.lua).
Game::Lua::RegisterConstants builds Constants[sub] with the same integer
entries RegisterIntegerEnum takes, and the documentation recorder has a
constants kind next to the enum kind, so the API browser lists the table as
constants rather than as an enumeration.

Constants.MoneyFormattingConstants.GOLD_REWARD_THRESHOLD_TO_HIDE_COPPER is
10, retail's value. FormattingUtil's GetMoneyString now applies
checkGoldThreshold through retail's FormatDisplayCopper: above 10 gold the
copper is dropped.

Verified in game: the table is present, GetMoneyString(1234567, nil, true)
shows gold and silver only, and GetMoneyString(51234, nil, true) keeps its
copper.
2026-10-30 13:17:01 -05:00
Brues 29ddd78a80 feat(lua): FormattingUtil follows retail
The file is now retail Blizzard_SharedXML/FormattingUtil.lua (identical in
retail, Classic and Classic Era), in retail's own syntax: the length and
modulo operators and string methods all work here now.

New from retail: FormatValueWithSign (with SYMBOLIC_NEGATIVE_NUMBER "-%s" /
SYMBOLIC_POSITIVE_NUMBER "+%s", the same in every locale's GlobalStrings),
MoneyStringConstants, FormatPercentageRounded,
GetHighlightedNumberDifferenceString, FormatUnreadMailTooltip,
FormattingUtil.AddLeadingZeroes, ReplaceGenderTokens, and GetMoneyString's
color argument.

GetMoneyString gives the coin-icon form, retail's default; retail uses the
text-symbol form only under its colorblind mode, which 1.12 does not have.
checkGoldThreshold is still not applied (it needs
Constants.MoneyFormattingConstants), and GetCostString / GetItemCostString
wait on BreakUpLargeNumbers / AbbreviateNumbers. GetCurrencyString and
GetCurrenciesString are not ported: 1.12 has no currencies.

Verified in game: coin-icon money strings with and without thousands
separators, signed values, FormatLargeNumber, the highlighted number
difference, and gray leading zeroes.
2026-10-30 13:05:13 -05:00
Brues b6c21216e3 fix(lua): EventUtil follows retail; queued variables-loaded callbacks are error-isolated
TriggerOnVariablesLoaded ran the ContinueOnVariablesLoaded queue in a plain
loop, so one erroring callback skipped the rest, and the queue drains only
once, so they never ran that session. It now uses secureexecuterange as
retail does: each error goes to the error handler and the walk goes on.

AreVariablesLoaded reads UIParent.variablesLoaded, as on retail, instead of
a private flag. Retail's UIParent sets that field and calls
TriggerOnVariablesLoaded on VARIABLES_LOADED; 1.12's FrameXML does
neither, so EventUtil's own frame does both.

The header no longer says 1.12 lacks select, C_AddOns or
secureexecuterange; ClassicAPI provides all three. The unknown-event
message uses :format and IsEmpty uses #, as on retail.

Verified in game: AreVariablesLoaded and UIParent.variablesLoaded are
true, a late ContinueOnVariablesLoaded callback runs at once, IsEmpty
works, and ContinueAfterAllEvents rejects an unknown event.
2026-10-30 12:58:01 -05:00
Brues 6cc9d0db90 fix(lua): CallbackRegistry isolates callback errors and defers mid-dispatch registrations
TriggerEvent called each callback directly, so one error left the frame's
OnEvent script and skipped every callback after it in that dispatch. pfUI
now runs many listeners through one EventRegistry dispatch, so one failing
listener could silence the rest. Each callback now runs through
securecallfunction: the error goes to the engine's script error handler and
the loop goes on, which is retail's secureexecuterange contract. It is
called from TriggerEvent's own loop, not through a vararg helper, because a
vararg Lua function allocates an arg table per call on Lua 5.0.

A callback registered for an event while that event is dispatching now
waits in deferredCallbacks and joins after the outermost dispatch, as on
retail. Before, its key went into the table the pairs loop was walking.
UnregisterCallback clears only owners that are present, and drops pending
deferrals.

The rest now follows retail Blizzard_SharedXMLBase/CallbackRegistry.lua:
UnregisterEvents() clears every event and UnregisterEventsByEventTable
clears the listed ones; GenerateCallbackEvents checks its argument; the
file's own GenerateClosure copy gives way to FunctionUtil's global (one
table per call instead of two, with retail's owner + 4 argument limit).

EventRegistry:OnLoad keeps all instance state, since addons derive their
own registry from the instance (pfUI: CreateFromMixins(EventRegistry)).

Verified in game: an erroring callback reports to the handler and the next
one still runs; a callback registered mid-dispatch first runs on the next
TriggerEvent.
2026-10-30 12:49:21 -05:00
Brues c6c201e575 fix(text): auto-height font strings keep their last line past 4 KB
A font string with no height of its own is as tall as its text, and that
height (0x5C2070) is a product, n * fontHeight + (n - 1) * gap. FitText's
count of what fits (0x5C21C0) adds the lines up as a running float sum and
stops at the first line where blockHeight + 2^-20 < sum + fontHeight. Over
many lines the sum drifts from the product by more than 2^-20 at most UI
scales, the last line "does not fit", and FitText cuts the text to its
first 4092 bytes plus "...", with the rest of the box blank. A multi-line
edit box hit this once its text passed 4 KB (ScrollingEditBoxTemplate,
pfUI chat copy).

4.3.4 keeps the product and grows the margin per line: blockHeight +
numLines * 2^-20 (GxuFontGetMaxCharsWithinWidthAndHeight). Text::FitHeight
replaces the loop's fadd of the engine's 2^-20 with a thunk that adds
numLines times it. The first line keeps 1.12's margin; each further line
adds a millionth of the screen height, so real overflow is still cut. A
float32 model of the chain lost the last line in 1276 of 3000 multi-line
fits before and in none after; in game a 30783-byte copy box draws all of
its text.
2026-10-30 11:39:02 -05:00
Brues 77b304ae68 feat(smf): RemoveMessagesByPredicate
ScrollingMessageFrame:RemoveMessagesByPredicate(predicate), retail's: the
predicate is called with each message's GetMessageInfo values, oldest
first, and the messages it returns true for are removed; the rest keep
their order. It covers 4.3.4's RemoveMessagesByAccessID / ByExtraData,
whose int fields the retail-shaped records here do not have.

1.12's ring has no compaction of its own; the removal is 4.3.4's
RemoveLines loop over it: every kept record moves forward in ring order to
the first free slot (freeing the text it lands on, nulling the one it
left), the position moves back by the removed count, and the
extra-argument tables move with their records. Then the engine's own
layout (0x00788750) from the record at the kept scroll offset, clamped to
the oldest, and its re-arm (0x00788B80) - a layout at the bottom keeps the
fade state, so 4.3.4 re-arms after - or its Clear (0x007882B0) when
nothing is kept.

The predicate runs for every message before anything moves, through the
engine's error handler; a predicate that errors keeps that message, as
retail's safesecurecall does. GetMessageInfo shares its record lookup and
value push with it.
2026-10-30 02:14:08 -05:00
Brues c965e49117 feat(smf): BackFillMessage
ScrollingMessageFrame:BackFillMessage(text, r, g, b, ...), retail's: the
message becomes the oldest, for rebuilding a frame from history newest to
oldest (Blizzard_CombatLog's refilter adds each entry with it).

The ring's writer 0x00788150 has one slot rule: the one after the newest.
BackFillMessage writes the slot 4.3.4's AddMessage(..., addToTop) picks —
the free one before the oldest, (pos - count + 1) mod max once the count
has grown — and fills the record as the writer does: a Storm copy of the
text, AddMessage's colour (clamped, x255 + 0.5 truncated, alpha 0xFF; the
font's without r, g, b), shown, the frame's two times, id 0, and the extra
arguments kept for GetMessageInfo. Nothing already in the ring moves, so a
scrolled-up frame keeps its view; at the bottom the lines are laid out
again from the newest, as the writer does.

A frame at its line limit drops the message, as retail's CircularBuffer
PushBack does ("Won't overwrite front"); 4.3.4 overwrites the oldest slot
there, which Blizzard_CombatLog never reaches.
2026-10-30 01:45:47 -05:00
Brues ead52e79ce fix(editbox): caret, selection and click rows follow tall inline icons
A multi-line edit box places its caret, selection pieces and a click's
cursor by rows of one height (font height + spacing, pixel-aligned), while
the render moves a line holding an icon taller than the font, and every
line after it, down by the overflow, and the box grows to match. Below the
first tall-icon line the caret and highlight sat above their text, and
OnCursorChanged reported that y, so ScrollingEditBoxMixin's scroll-into-
view stopped short (pfUI chat copy with faction emblems).

4.3.4's CSimpleEditBox has the same fault: UpdateVisibleCursor,
UpdateHighlightArea and ConvertCoordinateToIndex are icon-blind too. So
this goes past 4.3.4, as editbox/CharClass does. EditBox::IconRows co-hooks
the three placers and gives each the render's move, from the new
Text::InlineTexture::LineIconShift over the box's own line table: the
click's y is mapped onto the uniform row before the engine reads it; the
selection piece and caret are re-anchored after, with the engine's own x;
the caret's OnCursorChanged is muted for the original call and fired once
with the corrected y through the engine's forwarder, with the same packing
PlaceCaret uses (verified in the disassembly and the binary's constants).

Also fixes the "caret positioner" misnomer for 0x77DE70 (the selection-
piece placer) in Offsets.h and the docs.
2026-10-30 01:15:01 -05:00
Brues 7cb6cda101 feat(smf): TOP insert mode
SetInsertMode(SCROLLING_MESSAGE_FRAME_INSERT_MODE_TOP) now shows the
newest message at the top, older ones below it, as retail's mixin does.

1.12's line layout anchors each line once, when it makes it, through one
SetPoint call (0x00788975). That call is redirected: lines of a TOP frame
take the TOP anchors, every other line keeps the engine's own arguments.
The layout remakes the lines when the frame resizes, so the redirect
covers that too. A mode change re-anchors the existing lines with the
layout's own steps and refreshes the display.

Insert mode moves out of Scroll.cpp into InsertMode.cpp.
2026-10-30 00:20:19 -05:00
Brues 5ab497f2c7 feat(smf): scroll offset, insert mode, display-refreshed callbacks
Retail's ScrollingMessageFrame SetScrollOffset, GetScrollOffset,
GetMaxScrollRange, ScrollByAmount, SetInsertMode / GetInsertMode with the
SCROLLING_MESSAGE_FRAME_INSERT_MODE_* globals, and
AddOnDisplayRefreshedCallback, which ScrollUtil's
InitScrollingMessageFrameWithScrollBar calls.

The offset is the engine's own: SetScrollFromBottom's body (0x00788700)
is 4.3.4's SetScrollOffset step for step, and GetCurrentScroll is
GetScrollOffset under retail's name. The setters clamp to
0..GetNumMessages()-1 first, as retail does; the engine wraps. 1.12's
layout stacks lines bottom-up only, so the insert mode is BOTTOM and
SetInsertMode raises an error for anything else.

The callbacks run after the frame's own update (0x00788460, OnUpdate then
the fader), where retail's OnPostUpdate runs them, for a frame the line
layout, AddMessage or ScrollByAmount marked. The layout hook moves out of
Text::InlineTexture into ScrollingMessageFrame::Layout, which owns it and
fans out to both.
2026-10-29 23:56:24 -05:00
Brues 205d1fd43b feat(smf): ScrollingMessageFrame GetMessageInfo
Retail's smf:GetMessageInfo(index): text, r, g, b, then every argument
AddMessage took after the colour. Index 1 is the oldest message, and an
index outside 1..GetNumMessages() returns nothing.

The text and colour come from the engine's record in the frame's message
ring, so UpdateColorByID's changes read back. The record keeps only an
integer id after the colour, so AddMessage is registered again over the
engine's: Storm's hash links a new method node at the head of its bucket
and the lookup takes the first match. It runs the engine's AddMessage,
then keeps the extra arguments for the record just written, in a
registry table reused per record.
2026-10-29 23:43:45 -05:00
Brues 950c9deb76 feat(death): C_DeathInfo self-resurrect options, SELF_RES_SPELL_CHANGED
C_DeathInfo.GetSelfResurrectOptions, C_DeathInfo.UseSelfResurrectOption
and Enum.SelfResurrectOptionType over what 1.12 already serves through
HasSoulstone and UseSoulstone. Both pick one option the same way:
PLAYER_SELF_RES_SPELL when the server set it, else the first carried
item whose on-use spell is a self-resurrect. The list holds that option
under HasSoulstone's alive gate, and using it calls the engine's
UseSoulstone. Nothing in 1.12 limits a self-resurrection, so canUse is
true, isLimited false and priority 0.

SELF_RES_SPELL_CHANGED is 4.3.4's watcher on PLAYER_SELF_RES_SPELL from
SetActiveMirrorHandlers. 1.12 watches no such field, so the watcher joins
CGPlayer_C::AddActiveWatchers and leaves before its removal, the lifetime
of the engine's own player watchers.
2026-10-29 20:37:40 -05:00
68 changed files with 7698 additions and 2847 deletions
+12 -5
View File
@@ -69,8 +69,9 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Cursor](docs/API.md#cursor) | `CursorHasMacro`, `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.AutoStoreItem`, `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemEquipmentSetInfo`, `C_Container.GetContainerItemQuestInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerFreeSlots`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetBackpackAutosortDisabled`, `C_Container.GetBankAutosortDisabled`, `C_Container.GetItemCooldown`, `C_Container.GetSortBagsRightToLeft`, `C_Container.HasContainerItem`, `C_Container.IsContainerFiltered`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SetBackpackAutosortDisabled`, `C_Container.SetBagPortraitTexture`, `C_Container.SetBankAutosortDisabled`, `C_Container.SetItemSearch`, `C_Container.SetSortBagsRightToLeft`, `C_Container.SortBags`, `C_Container.SortBankBags`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown`, `SetBagPortraitTexture` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| [Currency](docs/API.md#currency) | `GetCoinText`, `C_CurrencyInfo.GetCoinText`, `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
| [Currency](docs/API.md#currency) | `GetCoinText`, `C_CurrencyInfo.GetCoinText`, `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString`, `Constants.MoneyFormattingConstants` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [DeathInfo](docs/API.md#deathinfo) | `C_DeathInfo.GetSelfResurrectOptions`, `C_DeathInfo.UseSelfResurrectOption` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.PickupEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetAccountExpansionLevel`, `GetClassicExpansionLevel`, `GetExpansionLevel`, `GetMaxPlayerLevel` |
@@ -85,7 +86,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Frame: StatusBar](docs/API.md#statusbarsetreversefillisreversefill--statusbargetreversefill) | `:SetReverseFill` / `:GetReverseFill`, `:SetRotatesTexture` / `:GetRotatesTexture`, `:SetFillStyle` / `:GetFillStyle`, `:SetColorFill`, `:SetStatusBarDesaturated` / `:IsStatusBarDesaturated`, `:SetStatusBarDesaturation` / `:GetStatusBarDesaturation` |
| [Frame: Slider](docs/API.md#sliderenable--sliderdisable--sliderisenabled) | `:Enable` / `:Disable` / `:IsEnabled` |
| [Frame: Cooldown](docs/API.md#createframecooldown--name-parent-template) | `CreateFrame("Cooldown")`, `:SetCooldown`, `:SetCooldownDuration`, `:SetCooldownFromExpirationTime`, `:Clear`, `:GetCooldownTimes`, `:GetCooldownDuration`, `:GetCooldownDisplayDuration`, `:Pause` / `:Resume`, `:SetPaused` / `:IsPaused`, `:SetReverse` / `:GetReverse`, `:SetDrawEdge` / `:GetDrawEdge`, `:SetDrawSwipe` / `:GetDrawSwipe`, `:SetDrawBling` / `:GetDrawBling`, `:SetSwipeColor`, `:SetEdgeColor`, `:SetSwipeTexture`, `:SetEdgeTexture`, `:SetBlingTexture`, `:SetRotation` / `:GetRotation`, `:SetHideCountdownNumbers` / `:GetHideCountdownNumbers`, `:GetCountdownFontString`, `:SetCountdownAbbrevThreshold` / `:GetCountdownAbbrevThreshold`, `:SetCountdownMillisecondsThreshold` / `:GetCountdownMillisecondsThreshold`, `:SetMinimumCountdownDuration` / `:GetMinimumCountdownDuration`, `OnCooldownDone`, `CooldownFrame_Set`, `CooldownFrame_Clear`, `CooldownFrame_SetDisplayAsPercentage` |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled` |
| [Frame: ScrollingMessageFrame](docs/API.md#smfsethyperlinksenabledenabled--smfgethyperlinksenabled) | `:SetHyperlinksEnabled` / `:GetHyperlinksEnabled`, `:GetMessageInfo` (with the extra `:AddMessage` values), `:BackFillMessage` (adds a message as the oldest), `:RemoveMessagesByPredicate`, `:SetScrollOffset` / `:GetScrollOffset`, `:GetMaxScrollRange`, `:ScrollByAmount`, `:SetInsertMode` / `:GetInsertMode`, `:AddOnDisplayRefreshedCallback` |
| [Frame: scripts](docs/API.md#onenable-and-ondisable-scripts) | `OnEnable`, `OnDisable`, `OnMinMaxChanged`, `OnAttributeChanged`, `OnCharComposition`, `OnTextChanged` (`userInput`), `PreClick`, `PostClick`, `SetModernScriptArgs` / `GetModernScriptArgs` |
| [FriendList](docs/API.md#friendlist) | `AddOrRemoveFriend`, `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
@@ -99,6 +100,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Item: info](docs/API.md#getitemclassinfoclassid) | `GetItemClassInfo`, `GetItemSubClassInfo`, `GetItemIcon`, `IsConsumableItem`, `IsCurrentItem`, `IsDressableItem`, `IsEquippedItemType`, `IsHarmfulItem`, `IsHelpfulItem`, `IsUsableItem` |
| [Item: inventory](docs/API.md#getaverageitemlevel) | `GetAverageItemLevel`, `GetInventoryItemID`, `GetInventoryItemDurability`, `GetInventoryItemRepairCost`, `GetInventoryItemsForSlot`, `IsDualWielding`, `SetInventoryPortraitTexture` |
| [Item: item IDs](docs/API.md#getitemid--companions-to-the-engines-getitemlink-family) | `GetAuctionItemID`, `GetAuctionSellItemID`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeTargetItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID` |
| [Localization](docs/API.md#localization) | `AbbreviateLargeNumbers`, `AbbreviateNumbers`, `BreakUpLargeNumbers`, `C_StringUtil.GetDefaultAbbreviationBreakpoints` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootAllCorpses`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount`, `C_LossOfControl.GetSchoolLockout` |
@@ -130,7 +132,7 @@ In the Frame rows, a name that starts with `:` is a method of the widget that th
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTickTime`, `GetTimeCached` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes`, `GetTradeSkillDescription`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `GetTradeSkillListLink`, `GetCraftListLink`, `GetTradeSkillDescription`, `GetTradeSkillRecipeLink`, `IsTradeSkillLinked`, `IsCraftLinked` |
| [Trainer](docs/API.md#trainer) | `GetTrainerServiceItemLink` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `CanBeRaidTarget`, `ClosestUnitPosition`, `GetUnitSpeed`, `IsUnitToken`, `SetUnitCursorTexture`, `UnitClassBase`, `UnitCreatedBySpell`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHasIncomingResurrection`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSelectionColor`, `UnitSelectionType`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID`, `UnitTokenFromName` |
@@ -232,7 +234,9 @@ menu and click AddOn Options, then ClassicAPI.
| | `SoftTargetIconEnemy` / `SoftTargetIconFriend` | `0` | `1` shows an icon over `softenemy` / `softfriend`. |
| | `SoftTargetLowPriorityIcons` | `0` | `1` also shows the normal pointer as an icon. |
| | `SoftTargetNameplateSize` | `19` | Size of all soft-target icons. |
| [Localization](docs/API.md#breakuplargenumbers-cvar) | `breakUpLargeNumbers` | `1` | `1` puts a separator between each group of three digits in `BreakUpLargeNumbers`. `0` writes no separator. |
| [Cooldown](docs/API.md#createframecooldown--name-parent-template) | `countdownForCooldowns` | `0` | `1` shows the time left as numbers on cooldown frames. |
| `ScriptAnimationUtil` | `ShakeStrengthUI` | `1` | Scales how far `ScriptAnimationUtil.ShakeFrame` moves a frame. `0` turns the shake off. |
| [NamePlate](docs/API.md#nameplate) | `nameplateNotSelectedAlpha` | `0.5` | Alpha of a non-target default nameplate, `0` to `1`. |
| | `nameplateSelectedAlpha` | `1` | Alpha of the target's default nameplate, `0` to `1`. |
| [Text to speech](docs/API.md#tts-cvars) | `ttsVoice` | `0` | The voice that `C_VoiceChat.SpeakText` uses. |
@@ -313,6 +317,7 @@ when launching with `-console`), not as Lua functions. See the
| `QUEST_QUERY_COMPLETE` | *(none)* (Turtle WoW only) |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `SELF_RES_SPELL_CHANGED` | *(none)* |
| `SOUNDKIT_FINISHED` | `soundHandle` (only for sounds played with `runFinishCallback`) |
| `UNIT_AURA_DELTA` | `unitTarget, updateInfo` (the instance IDs of the auras added, updated, and removed) |
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
@@ -360,6 +365,7 @@ when launching with `-console`), not as Lua functions. See the
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
| Player swing type | `Enum.PlayerSwingType.{MainHand,OffHand,Ranged}` (0–2) |
| Self-resurrect option type | `Enum.SelfResurrectOptionType.{Spell,Item}` (0–1) |
</details>
@@ -429,8 +435,9 @@ functions but that consumer code still expects to find as globals —
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), `EventUtil`
(`ContinueOnAddOnLoaded` and more), `DataProvider`, `CircularBuffer`,
`EasingUtil`, `Interpolator`, `EnumUtil`, `StringUtil`, `AnchorUtil`,
(`ContinueOnAddOnLoaded` and more), `FormattingUtil` (`GetMoneyString`,
`FormatLargeNumber` and more), `FrameUtil`, `DataProvider`, `CircularBuffer`,
`EasingUtil`, `ScriptAnimationUtil`, `Interpolator`, `EnumUtil`, `StringUtil`, `AnchorUtil`,
`GridLayoutUtil`, and the layout frame templates (`VerticalLayoutFrame`,
`HorizontalLayoutFrame`, `ResizeLayoutFrame`, `GridLayoutFrame`,
`StaticGridLayoutFrame`). It also holds the conditional slash
+17 -4
View File
@@ -149,7 +149,7 @@ on a frame timer" goal landed as the request/event pair below:
cache (or synchronously if it was already loaded).
Together those replace the polyfill's 180-second polling frame
(`ItemEventListener` in `Util/ItemUtil.lua:254-372`) — addons can now
(`ItemEventListener`, now in `Util/AsyncCallbackSystem.lua`) — addons can now
fire `RequestLoadItemDataByID(id)` and listen for
`ITEM_DATA_LOAD_RESULT` instead.
@@ -4081,9 +4081,22 @@ Each entry still needs the usual check of how the engine does it before building
`GetRotatesTexture` (`CSimpleStatusBar.cpp:135`, the vertical texcoord path
at `:285`). Retail adds `SetFillStyle` (STANDARD / CENTER / REVERSE),
`SetColorFill`, `SetStatusBarDesaturated` / `Desaturation`.
- **ScrollingMessageFrame** `SetInsertMode` / `GetInsertMode`,
`GetMessageInfo`, `RemoveMessagesByAccessID`, `RemoveMessagesByExtraData`,
`SetScrollOffset` — in C in 4.3.4 (retail moved the type to Lua).
- **DONE** (`src/scrollingmessageframe/`, retail's shapes).
**ScrollingMessageFrame** `GetMessageInfo`, `SetScrollOffset` (1.12's
`SetScrollFromBottom`, 0x00793070, under its later name, clamped as
retail), `GetScrollOffset`, `GetMaxScrollRange`, `ScrollByAmount`,
`AddOnDisplayRefreshedCallback`, `SetInsertMode` / `GetInsertMode` (TOP
through a redirect of the layout's one line-anchor call, 0x00788975),
`BackFillMessage` (writes 4.3.4's addToTop slot of the ring, the free one
before the oldest; a full frame drops it, as retail's CircularBuffer
`PushBack` does — 4.3.4 overwrites the oldest there).
- **DONE** (`src/scrollingmessageframe/MessageInfo.cpp`).
**ScrollingMessageFrame** `RemoveMessagesByPredicate` (retail's shape; it
covers 4.3.4's `RemoveMessagesByAccessID` / `RemoveMessagesByExtraData`,
whose int fields our retail-shaped records do not have). The compaction
is 4.3.4's `RemoveLines` loop over 1.12's ring, then the engine's own
layout (0x00788750) at the kept scroll offset and re-arm (0x00788B80),
or Clear (0x007882B0) when nothing is kept.
- **DONE** (`src/editbox/Focus.cpp`, `src/animation/Framerate.cpp`).
**Globals** (`SimpleFrameScript_Scripts.cpp`): `GetCurrentKeyBoardFocus()`
(the focused edit box's wrapper, or nil); `SetMaxAnimFramerate(fps)` /
+765 -92
View File
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -86,9 +86,11 @@ The DLL hooks the engine's own text pipeline — no companion addon:
(`49934b0`, `b9d0705`).
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
- **Edit boxes** — render the icons in their text, as 4.3.4 does; the caret,
the mouse and the arrow keys treat an icon as one character, and Ctrl+C
drops icons (4.3.4's 0x1500 strip mask). Typed markup stays literal by the
engine's own input escape (below).
the mouse and the arrow keys treat an icon as one character, Ctrl+C
drops icons (4.3.4's 0x1500 strip mask), and a multi-line box's caret,
selection and click rows follow the lines a tall icon moves (below;
4.3.4's own rows do not). Typed markup stays literal by the engine's own
input escape (below).
- **Payload** — the full
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
@@ -377,7 +379,7 @@ funnel for exactly 4 callers:
- `Script_GetStringWidth` (0x0079E510)
- the GameTooltip auto-size (0x00530640)
- the editbox caret positioner (0x0077DE70)
- the editbox selection-piece placer (0x0077DE70, a whole last line's width)
- the layout effective-width vmethod (`FUN_00772930` = fs+0x24 vtbl +0x1C,
which feeds auto-width fontstring layout)
@@ -523,6 +525,22 @@ the same input escape, so ClassicAPI's edit boxes need no special case:
icon with the following character into one step; ours is its own step.
- **Caret x** — `FUN_0077DA80` measures the line through
`FUN_00772AE0`, whose co-hook adds the icons.
- **Rows (multi-line)** — `PlaceCaret` (`0x77da80`), `PlaceSelectionPiece`
(`0x77de70`) and `GetIndexAtPoint` (`0x77d0d0`) step rows of one height
(font height + spacing, pixel-aligned), as 4.3.4's `UpdateVisibleCursor`,
`UpdateHighlightArea` and `ConvertCoordinateToIndex` do — while the render
moves a tall-icon line and every line after it down by the overflow
(`InitializeText`; the emitter co-hook) and the box grows to match (the
string-height co-hook). 4.3.4 has that fault too; `EditBox::IconRows`
(`src/editbox/IconRows.cpp`) gives each placer the render's move, from
`Text::InlineTexture::LineIconShift` over the box's own line table: the
click's y is moved onto the uniform row of the icon-aware row under it
before the engine maps it; a selection piece is re-anchored after the
engine placed it, with the engine's own x; the caret likewise, then
`OnCursorChanged` fired once with the moved y through
`FUN_FRAME_RUN_SCRIPT_VARIADIC` exactly as `PlaceCaret` fires it (its own
fire muted for the call by emptying the slot), so
`ScrollingEditBoxMixin:ScrollCursorIntoView` scrolls to the real caret.
- **Click → cursor, single-line window** — `GetIndexAtPoint` (`0x77d0d0`),
`RebuildLabel` and `MeasureWindow` count characters with
`FUN_FONTSTRING_FIT_TEXT` (`0x772bc0` → `0x44d860` → `0x5c6c50`), whose
+18 -5
View File
@@ -401,12 +401,12 @@ void RegisterGlobalAlias(const char *alias, const char *tableName, const char *m
Api::Documentation::RecordGlobal(alias, doc);
}
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
const char *docSystem) {
// `_G[parent][sub] = { entries }`, creating `_G[parent]` if needed.
static bool SetIntegerTable(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count) {
void *L = State();
if (L == nullptr)
return;
return false;
EnsureGlobalTable(L, parent); // [parentTbl]
PushString(L, sub); // [parentTbl, subName]
NewTable(L); // [parentTbl, subName, subTbl]
@@ -417,7 +417,20 @@ void RegisterIntegerEnum(const char *parent, const char *sub,
}
SetTable(L, -3); // parentTbl[sub] = subTbl
SetTop(L, -2); // pop parentTbl
Api::Documentation::RecordEnum(parent, sub, entries, count, docSystem);
return true;
}
void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
const char *docSystem) {
if (SetIntegerTable(parent, sub, entries, count))
Api::Documentation::RecordEnum(parent, sub, entries, count, docSystem);
}
void RegisterConstants(const char *sub, const EnumIntegerEntry *entries, int count,
const char *docSystem) {
if (SetIntegerTable("Constants", sub, entries, count))
Api::Documentation::RecordConstants(sub, entries, count, docSystem);
}
void SetFieldNumber(void *L, const char *key, double value) {
+9 -3
View File
@@ -516,9 +516,9 @@ void RegisterTableFunction(const char *tableName, const char *methodName,
void RegisterGlobalAlias(const char *alias, const char *tableName, const char *methodName,
const Doc::Function *doc = nullptr);
// Key/value pair for `RegisterIntegerEnum`. `key` becomes a field name
// (PascalCase, matching Blizzard's `Enum.*` naming) and `value` is the
// integer the enum field maps to.
// Key/value pair for `RegisterIntegerEnum` and `RegisterConstants`. `key`
// becomes a field name (PascalCase for Blizzard's `Enum.*`, UPPER_CASE for
// its `Constants.*`) and `value` is the integer the field maps to.
struct EnumIntegerEntry {
const char *key;
int value;
@@ -533,6 +533,12 @@ void RegisterIntegerEnum(const char *parent, const char *sub,
const EnumIntegerEntry *entries, int count,
const char *docSystem = nullptr);
// Registers `Constants[sub] = { entries }`, Blizzard's engine constant
// tables (`Constants.MoneyFormattingConstants`). Documented as a "Constants"
// table of `docSystem`.
void RegisterConstants(const char *sub, const EnumIntegerEntry *entries, int count,
const char *docSystem);
// Set `t[key] = value` on the table currently at stack[-1] (the most
// common shape used when populating a struct-style table mid-build).
// Pushes the key + value, calls `SetTable(L, -3)`, which pops both and
+326 -13
View File
@@ -223,8 +223,8 @@ enum Offsets {
// VAR_SPELL_RECORDS whose record has `+0x0C == 3` ->
// FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP), else raises "Unknown link type"
// (`0x00532269`). A full `|H...|h` link fails the prefix test. Co-hooked
// by `Tooltip::Hyperlink` for full links, `spell:` / `quest:`, and
// trade-skill `enchant:` links. SuperWoW patches a jump at `0x00532210`
// by `Tooltip::Hyperlink` for full links, `spell:` / `quest:` / `trade:`,
// and trade-skill `enchant:` links. SuperWoW patches a jump at `0x00532210`
// (after the `item:` test) that adds `quest:` and resumes at `enchant:`.
FUN_SCRIPT_GAMETOOLTIP_SET_HYPERLINK = 0x00531FD0, // slot 12
// `SetAction(slot)`: cooldown via FUN_ACTION_SLOT_COOLDOWN, clear, then
@@ -324,12 +324,26 @@ enum Offsets {
// lua_pcall with **zero Lua args** and the engine error handler
// (VAR_FRAMESCRIPT_ERROR_HANDLER_REF) as errfunc. FUN_FRAME_INVOKE_SCRIPT
// is the same shape for the no-arg scripts (OnShow/OnHide/OnEnter/…).
// `Frame::ScriptArgs` co-hooks both to additionally pass the handler
// modern positional args (self, arg1..argN). The frame's own Lua object
// `Frame::ScriptArgs` co-hooks both and redirects their pcalls (below) to
// additionally pass the handler modern positional args (self,
// arg1..argN). The frame's own Lua object
// ref lives at frame+OFF_COBJECT_LUA_REF (refcount at
// OFF_COBJECT_LUA_REFCOUNT); FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER
// lazily creates it when the refcount is 0.
FUN_FRAME_RUN_SCRIPT_ARGS = 0x00704F10,
// Each runner's own `call LUA_PCALL` (E8, 5 bytes): lua_pcall(L, 0, 0, -2)
// with [errfunc, handler] on top, after the runner has set `this` and
// arg1..argN. Frame::ScriptArgs redirects both, so a format is only ever
// parsed by the runner's own switch (0x704F89: index byte table 0x7052B8
// for 'd'..'u', jump table 0x7052A4) — including a spec another DLL adds
// at runtime: SuperWoWhook points 'e' (byte 0x7052B9) at a new slot
// 0x7052CC whose handler pushes registry[ref] for a 4-byte ref, and fires
// CREATE_CHATBUBBLE with "%e%s". In the args runner at this call, [ebp+8] is handlerRef,
// [ebp+0Ch] the frame and [ebp-4] the number of argN it set (0x704F54,
// 0x705011; at most 19, 0x704F73). In the no-arg runner, [ebp-8] is
// handlerRef (0x704D5D) and EDI the frame (0x704D5B).
CALL_FRAME_RUN_SCRIPT_ARGS_PCALL = 0x00705199,
CALL_FRAME_INVOKE_SCRIPT_PCALL = 0x00704E79,
// The base ScriptObject's OnEvent handler slot — an 8-byte
// {handler, context} pair at frame+0x0C (from FUN_00702590, the base
// resolver every frame-type resolver chains through, and confirmed by
@@ -988,7 +1002,8 @@ enum Offsets {
// message. Every new message shifts the newest index, so EVERY visible slot
// is re-SetText'd -> its inline icons must be recomputed; the engine builds
// the line lazily at paint, so icons land one frame after the glyphs (the
// one-frame lag). Text::InlineTexture co-hooks this and, post-refresh,
// one-frame lag). ScrollingMessageFrame::Layout owns the hook and fans
// out after it; Text::InlineTexture listens and, post-refresh,
// forces each icon-bearing line to resolve+build and applies its icon
// placement synchronously — pre-render (the refresh runs from the SMF's
// Lua/event-driven methods, never mid-render), so icons draw with their
@@ -2425,6 +2440,16 @@ enum Offsets {
// right after that same unlock — Player::Equipment co-hooks it to do so.
FUN_INV_SLOT_CHANGED_OBSERVER = 0x005DDCF0,
// CGPlayer_C::AddActiveWatchers / its removal — `void __thiscall
// (CGPlayer_C *)`, plain `ret`. Register and remove the active player's
// bank-PLAYER observers (inventory, quests, coinage, skills, the
// 0x005DE4B0 event fields); 4.3.4's SetActiveMirrorHandlers (0x0056E430)
// / UnsetActiveMirrorHandlers. Add runs on arrival (0x005DEA58) and
// reactivation (0x005E9C00); remove only at 0x005E9B50, for the active
// player.
FUN_PLAYER_ADD_ACTIVE_WATCHERS = 0x005DD8A0,
FUN_PLAYER_REMOVE_ACTIVE_WATCHERS = 0x005DE5B0,
// PLAYER_FIELD_INV_SLOT_HEAD — first of the 19 equipment-slot u64 GUID
// fields in the player descriptor (0x4A8..0x538, stride 8; 0x540 is
// the first equipped-bag slot). 0-based slot = (offset - 0x4A8) >> 3;
@@ -4582,6 +4607,12 @@ enum Offsets {
// (see OFF_UNIT_FIELD_CHANNEL_SPELL below); PLAYER_FLAGS only
// exists on the +0xE68 sub-struct here.
OFF_CGPLAYER_INFO = 0xE68,
// PLAYER_SELF_RES_SPELL in that block (UpdateField table: player index
// 0x40C, owner-only): the spell the server offers a dead player to raise
// themselves (Soulstone Resurrection, Reincarnation), 0 for none. Also
// its offset in observer bank DESC_OBSERVER_BANK_PLAYER. Read by
// HasSoulstone (0x0048ACC6) and UseSoulstone (0x0048ADB6).
OFF_CGPLAYER_INFO_SELF_RES_SPELL = 0x1030,
// The engine's explored-area bit test (benilla `IsAreaExplored`):
// __thiscall(this = player + OFF_CGPLAYER_INFO, int areaBit) → nonzero
// when explored, ret 4. Reads `[this] + 0xE6C` (the 0x100-byte
@@ -4836,6 +4867,17 @@ enum Offsets {
// arrays: a 1-based backpack slot S maps to linear (S - 1) + 23. Matches
// PackBagSlot's `*param_3 += 0x17` for the backpack branch.
BACKPACK_LINEAR_BASE = 23,
// Lua container indices (bagID), as `PackBagSlot` reads them and FrameXML
// names them (KEYRING_CONTAINER, BANK_CONTAINER, BACKPACK_CONTAINER,
// NUM_BAG_SLOTS, NUM_BANKBAGSLOTS): keyring -2, bank -1, backpack 0,
// equipped bags 1..4, bank bags 5..10. `Enum.BagIndex` publishes them.
BAG_INDEX_KEYRING = -2,
BAG_INDEX_BANK = -1,
BAG_INDEX_BACKPACK = 0,
BAG_INDEX_BAG_FIRST = 1,
BAG_INDEX_BAG_LAST = 4,
BAG_INDEX_BANK_BAG_FIRST = 5,
BAG_INDEX_BANK_BAG_LAST = 10,
// Player inventory manager layout — used for direct GUID-array reads
// that bypass `GetItemBySlot`'s bank gate at `0x006228C1`.
// +0x00 uint32 max slot count
@@ -4993,6 +5035,16 @@ enum Offsets {
// target is passed, so passing zero (no target) is fine for most
// items. `flag` is 0 in every Script_UseContainerItem call site.
FUN_ITEM_USE = 0x005D8D00,
// The player's self-resurrect item — `CGItem_C *__thiscall(CGPlayer_C *)`,
// plain `ret`: the first carried item (equipment, bags, backpack,
// keyring) with an on-use spell of effect 94 (SELF_RESURRECT), or null.
// HasSoulstone and UseSoulstone ask it when PLAYER_SELF_RES_SPELL is 0.
FUN_PLAYER_SELF_RES_ITEM = 0x005ED630,
// Script_UseSoulstone — the Lua C function itself, `int __fastcall(L)`;
// reads no argument and pushes nothing. Sends CMSG_SELF_RES (0x2B3, no
// body) when PLAYER_SELF_RES_SPELL is set, else cancels the cast
// (0x006E4940(1, 1, 0x1C)) and uses FUN_PLAYER_SELF_RES_ITEM's item.
FUN_SCRIPT_USE_SOULSTONE = 0x0048AD70,
// `CGPlayer::AutoEquipCursorItem` — `__thiscall(CGPlayer *this, int flag)`.
// The engine-internal helper that `Script_AutoEquipCursorItem`
// (`0x0048A040`) is a thin wrapper around: that wrapper just
@@ -5503,6 +5555,25 @@ enum Offsets {
// 0x009BD3C0) does not match the subclass name of these rows.
ITEMSUBCLASS_FLAG_NO_SEARCH_NAME = 0x1,
// PaperDollItemFrame.dbc — the paper-doll slot table. Like ItemSubClass,
// the engine reads a parallel (records, count) pair at the class
// instance's +0x00 / +0x04: `Script_GetInventorySlotInfo` (0x004C81B0)
// walks [0x00C0DA00] for [0x00C0DA04] flat 0x10-byte rows. Columns
// (verified against the dumped DBC):
// +0x00 char* ItemSlotName ("HeadSlot", "Bag0Slot", "AmmoSlot", ...)
// +0x04 char* SlotIcon ("interface\paperdoll\UI-PaperDoll-Slot-Head.blp")
// +0x08 int SlotNumber (inventory slot: 1..19 equipment, 20..23 bag
// slots, 0 ammo)
// The engine flags SlotNumber 18 (ranged) as the relic-check slot
// (`SlotNumber - 1 == 0x11` at 0x004C8264).
VAR_PAPERDOLLITEMFRAME_RECORDS = 0x00C0DA00,
VAR_PAPERDOLLITEMFRAME_COUNT = 0x00C0DA04,
OFF_PAPERDOLLITEMFRAME_RECORD_STRIDE = 0x10,
OFF_PAPERDOLLITEMFRAME_SLOT_NAME = 0x00,
OFF_PAPERDOLLITEMFRAME_SLOT_ICON = 0x04,
OFF_PAPERDOLLITEMFRAME_SLOT_NUMBER = 0x08,
PAPERDOLLITEMFRAME_RELIC_SLOT_NUMBER = 18,
// ItemDisplayInfo.dbc — standard layout. Icon path char* at record +0x14
// (per the helper at 0x005D88B0 that Script_GetItemInfo uses).
VAR_ITEMDISPLAYINFO_RECORDS = 0x00C0DC10,
@@ -6041,8 +6112,8 @@ enum Offsets {
// rank-up / passive rows have both zero. Empirically: the
// Blacksmithing skill line has 343 SLA rows, 334 with a nonzero
// threshold — the 9 excluded are the Apprentice..Artisan rank
// spells and skill grants. This is the discriminator
// `TradeSkill::Link` uses to build the canonical recipe universe.
// spells and skill grants. The profession list builds read both
// for a row's difficulty.
OFF_SLA_TRIVIAL_SKILL_HIGH = 0x28,
OFF_SLA_TRIVIAL_SKILL_LOW = 0x2C,
// The engine's (race, class, spellID) -> SkillLineAbility record lookup:
@@ -10780,13 +10851,71 @@ enum Offsets {
// window is open. `FUN_004FC870` zeroes it on close (and fires
// event `0x13B`), so `> 0` reliably means "trade window open".
VAR_CURRENT_TRADESKILL_LINE = 0x00BDE040,
// Craft window (Enchanting, pet training) open-state flag — nonzero
// (3 for the enchanting-style craft) while open; `FUN_004F5FE0`
// zeroes it on close (fires event `0x169`). The Craft window has no
// skill-line-ID global — `TradeSkill::Link` derives the skill line
// from the first craft recipe — so this is the "craft open" gate.
// Craft window (Enchanting, pet training): the open craft type — the
// opening spell's EffectMiscValue[0], 3 for Enchanting, 1 for Beast
// Training — or 0 when closed. `FUN_004F5FE0` zeroes it on close (fires
// event `0x169`). The Craft window keeps no skill line.
VAR_CRAFT_WINDOW_STATE = 0x00BDCFB8,
// The profession windows' open, build and shutdown seams that
// `TradeSkill::Window` uses to show another player's profession from a
// `trade:` link, as 3.3.5's CGTradeSkillInfo does. Disassembly.
//
// `void __fastcall(uint32_t skillLine)`: opens the TradeSkill window on a
// skill line, or closes it when that line is already open. The only
// caller is TryCastSpell at CALL_TRY_CAST_TOGGLE_TRADE_SKILL, with the
// cast spell ID in ESI.
FUN_TOGGLE_TRADE_SKILL = 0x004FC910,
CALL_TRY_CAST_TOGGLE_TRADE_SKILL = 0x006E4C22,
// `void __fastcall(uint32_t craftType)`: the Craft window's twin. The
// only caller is TryCastSpell at CALL_TRY_CAST_TOGGLE_CRAFT, ESI = spell.
FUN_TOGGLE_CRAFT = 0x004F6000,
CALL_TRY_CAST_TOGGLE_CRAFT = 0x006E4C34,
// `void(void)`: clears the TradeSkill repeat latch and fires
// UPDATE_TRADESKILL_RECAST (3.3.5 CGTradeSkillInfo::StopRecast).
FUN_TRADE_SKILL_STOP_RECAST = 0x004FD790,
// The TradeSkill list build (BuildTradeSkillList, 0x004FCA20) reads per
// row: the open line's spell array from
// `CGPlayer_C::FindSkillLineSpells(player, skillLine)` (`__thiscall`,
// returns a growable array — count +0x04, int spell IDs at +0x08 — or
// null), each spell's ability row from FUN_SKILL_LINE_ABILITY_LOOKUP
// with the player's race and class, and the player's raw rank in the
// ability's line from the 33 bytes at SITE_BUILD_TRADE_SKILL_RANK
// (`call FindSkillIndex` .. `xor eax,eax`; ECX = player, the line
// pushed, rank left in EAX at 0x004FCC49). One call site each.
FUN_PLAYER_FIND_SKILL_LINE_SPELLS = 0x005EA2C0,
CALL_BUILD_TRADE_SKILL_SPELLS = 0x004FCB2A,
CALL_BUILD_TRADE_SKILL_ABILITY = 0x004FCBD2,
SITE_BUILD_TRADE_SKILL_RANK = 0x004FCC28,
// The Craft list build (BuildCraftList, 0x004F60C0) reads per row: the
// type's spell array from `CGPlayer_C::GetTabSpells(player, tab)`
// (`__thiscall`, the array at player + 0x1CD0 + tab * 16, same shape),
// each spell's ability row from `CGUnit_C::FindSkillLineAbility(unit,
// spell)` (`__thiscall`, the unit's race and class), and the rank from
// `CGPlayer_C::GetSkillTotal(player, skillLine)` (`__thiscall`, base plus
// temporary bonus). GetTabSpells' other caller is
// Script_GetCraftDisplaySkillLine (0x004F6CF4).
FUN_PLAYER_GET_TAB_SPELLS = 0x005EA330,
CALL_BUILD_CRAFT_SPELLS = 0x004F6118,
FUN_UNIT_FIND_SKILL_LINE_ABILITY = 0x0060C7C0,
CALL_BUILD_CRAFT_ABILITY = 0x004F61A8,
FUN_PLAYER_GET_SKILL_TOTAL = 0x005EA520,
CALL_BUILD_CRAFT_RANK = 0x004F620E,
// `void(void)` each: free the TradeSkill / Craft rows and zero their
// state. CGGameUI::ShutdownGame (0x00491180) calls them once per return
// to the character screen, as 3.3.5's calls CGTradeSkillInfo::ShutdownGame.
FUN_SHUTDOWN_TRADE_SKILL = 0x004FC790,
CALL_GAME_SHUTDOWN_TRADE_SKILL = 0x004911C8,
FUN_SHUTDOWN_CRAFT = 0x004F5F00,
CALL_GAME_SHUTDOWN_CRAFT = 0x004911D2,
// The window getters that answer for the player: GetTradeSkillLine and
// GetCraftDisplaySkillLine push (name, rank, maxRank) from the player's
// skill slot; GetCraftName pushes the name of the spell the player keeps
// for the open type. `int __fastcall(lua_State *)` each.
FUN_SCRIPT_GET_TRADE_SKILL_LINE = 0x004FDD40,
FUN_SCRIPT_GET_CRAFT_DISPLAY_SKILL_LINE = 0x004F6CB0,
FUN_SCRIPT_GET_CRAFT_NAME = 0x004F6C10,
// Trainer window (ClassTrainerFrame.cpp). Same slot-indirected shape as
// the trade-skill rows: `[VAR_TRAINER_SERVICES]` points at an array of
// 0x38-byte service records, the record's `+0x00` is the spell the
@@ -10861,6 +10990,14 @@ enum Offsets {
// Verified from Spell.dbc: 13262 "Disenchant" has Effect[0] = 99 (the
// only row carrying it). Effect 32 is TRIGGER_MISSILE, not disenchant.
SPELL_EFFECT_DISENCHANT = 99,
// A profession spell (Blacksmithing 2018, Enchanting 7411, Smelting
// 2656): casting it opens a profession window. TryCastSpell (0x006E4B60)
// tests Effect[0] for it (`cmp [edi+0xF4], 0x2F` at 0x006E4BCE) and
// routes on EffectMiscValue[0]: 0 opens the TradeSkill window for the
// spell's skill line, any other value is the Craft window's type (3
// Enchanting, 1 Beast Training). 3.3.5's GetTradeSkillListLink takes
// only spells whose Effect[0] is this.
SPELL_EFFECT_TRADE_SKILL = 47,
// EffectBasePoints[3] — the base magnitude of each effect, stored as
// (value - 1) for the fixed-die spells that back item stats (the
@@ -10927,6 +11064,15 @@ enum Offsets {
// SetHyperlink's `enchant:` on this bit, and its UnitCastingInfo reports
// it as isTradeskill.
SPELL_ATTR_TRADESPELL = 0x20,
// Column 3, castUI: the tab a learned spell without SPELL_ATTR_TRADESPELL
// is filed under. The learn function (0x005E9C20) puts a TRADESPELL
// spell in its skill line's list (CGPlayer_C::FindSkillLineSpells) and
// any other spell with castUI > 0 in tab array castUI
// (CGPlayer_C::GetTabSpells, `mov esi,[edi+0xC]` at 0x005E9D83), which
// the Craft window of that type lists. Extracted Spell.dbc: 187 of
// Enchanting's 192 SkillLineAbility spells hold 3, 138 of Beast
// Training's 140 hold 1.
OFF_SPELL_RECORD_CAST_UI = 0x0C,
// Attributes bits 2 (0x4) and 10 (0x400) — "on next melee swing" (the
// ability replaces the next white hit instead of sending its own attack,
// e.g. Heroic Strike / Maul / Raptor Strike). The CLIENT itself tests
@@ -12709,6 +12855,18 @@ enum Offsets {
CALL_TEXT_FIT_HEIGHT_STEP = 0x005C22B1,
CALL_TEXT_ENUMERATE_LINES_STEP = 0x005C24F9,
DRAW_BUILDER_FRAME_LINE_X = 0x48,
// The fit-to-height count's line test (0x005C21C0, after its stepper
// call): `fld [ebp+10h]` (the block height), then this `fadd dword ptr
// [VAR_TEXT_FIT_HEIGHT_EPSILON]` (D8 05 BC 26 80 00), then FCOMPP against
// the running height + font height; it stops at the first line that
// fails. [ebp-8] is the bytes fitted so far, 0 until a line fits. EAX is
// dead here (FNSTSW AX, then both paths reload it from [ebp-8]).
// Text::FitHeight replaces the fadd.
SITE_TEXT_FIT_HEIGHT_TOLERANCE = 0x005C22CB,
TEXT_FIT_HEIGHT_FRAME_FITTED_BYTES = 0x8,
// Exactly 2^-20 (0x35800000, .rdata): the fit's line margin above. A
// shared float, also read by collision, movement and matrix code.
VAR_TEXT_FIT_HEIGHT_EPSILON = 0x008026BC,
// The draw builder's right / centre x: `FUN_TEXT_SCREEN_TO_PIXEL_WIDTH(raw,
// -width)` (or -width / 2), `float __fastcall(int raw, float)`, `ret 4`,
// result in ST0, stored to the line x at 0x005CDE28.
@@ -12811,6 +12969,115 @@ enum Offsets {
OFF_SIMPLEHTML_FONTS = 0x350,
VAR_SIMPLEHTML_LUA_TYPE_ID = 0x00CF4F64,
FUN_SIMPLEHTML_READ_TEXT_TYPE = 0x00795D80,
// The ScrollingMessageFrame's message ring. GetNumMessages (0x007934D0)
// pushes the count. The ring's one writer, 0x00788150 (`__thiscall(smf,
// text, const CImVector *color, int id)`, `ret 0xC`), called only by the
// AddMessage method (FUN_SCRIPT_..._ADD_MESSAGE, `int __fastcall(L)`),
// raises the count up to the limit, advances the position (wrapping at
// the limit) and fills the record there. So message i, 1 the oldest, is
// record (position - count + i), plus the limit when negative. Records
// are 0x18 bytes: the text (an SStrDupA copy), the colour (a CImVector,
// the dword 0xAARRGGBB: AddMessage builds it R, G, B with alpha 0xFF at
// 0x00792ADD..0x00792B11), shown +0x8, the visible and fade times +0xC and
// +0x10, and the integer id +0x14 (benilla-re ui/messageframe).
FUN_SCRIPT_SCROLLINGMESSAGEFRAME_ADD_MESSAGE = 0x00792900,
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COUNT = 0x33C,
OFF_SCROLLINGMESSAGEFRAME_MAX_MESSAGES = 0x340,
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_POS = 0x344, // the newest record; -1 when empty
// The record the lines are laid out from (4.3.4's m_currentScroll);
// FUN_SMF_DISPLAY_REFRESH stores its argument here.
OFF_SCROLLINGMESSAGEFRAME_SCROLL_RECORD = 0x348,
// A ring that is not full holds records 0..count-1: the writer fills
// them in order from an empty ring (Clear: position -1), and only a full
// ring wraps. ScrollOffset and ScrollToOffset (below) wrap by the count,
// not the limit, and ScrollToTop (0x00788690) lays out from record 0
// unless the ring is full, so each is right only for that layout. A
// ClassicAPI writer must leave the ring in it.
OFF_SCROLLINGMESSAGEFRAME_MESSAGES = 0x394, // the record array
SCROLLINGMESSAGEFRAME_MESSAGE_STRIDE = 0x18,
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_TEXT = 0x0,
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COLOR = 0x4,
// The rest of the record as the ring writer 0x00788150 fills it: shown
// (1), the frame's visible and fade times, and the integer id.
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_SHOWN = 0x8,
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_VISIBLE_TIME = 0xC,
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_FADE_TIME = 0x10,
OFF_SCROLLINGMESSAGEFRAME_MESSAGE_ID = 0x14,
// The bottom flag (4.3.4's m_atBottom): the ring writer lays the lines
// out from the newest record when it is set (0x00788150 tail), and
// ScrollToBottom re-arms the shown lines instead of laying out
// (0x007886D0; benilla-re ui/messageframe "Scrolling brings lines back").
OFF_SCROLLINGMESSAGEFRAME_AT_BOTTOM = 0x354,
// The visible and fade times the writer copies into each record
// (0x00788150: record+0xC = +0x364, record+0x10 = +0x368; the setters
// 0x00788090 / 0x007880F0 store them).
OFF_SCROLLINGMESSAGEFRAME_VISIBLE_TIME = 0x364,
OFF_SCROLLINGMESSAGEFRAME_FADE_TIME = 0x368,
// The colour (CImVector) of the object at OFF_SCROLLINGMESSAGEFRAME_FONT,
// the message colour AddMessage takes without r, g, b
// (0x00792B3C..0x00792B45: `[[smf+0x35C]+0x58]`).
OFF_SCROLLINGMESSAGEFRAME_FONT_COLOR = 0x58,
// The frame's scroll in retail's units, the lines it is scrolled up from
// the newest message. ScrollToOffset (`void __thiscall(smf, int offset)`,
// `ret 4`) is the body of SetScrollFromBottom (0x00793070), 4.3.4's
// SetScrollOffset (0x0051E6F0) step for step: the record (position -
// offset + count) % count, then it re-arms the shown lines (0x00788B80)
// when that is the current scroll and lays the lines out from it
// (FUN_SMF_DISPLAY_REFRESH) otherwise. ScrollOffset (`int
// __thiscall(smf)`) is the inverse, (position - scroll + count) % count,
// 0 when empty; the GetCurrentScroll method pushes it.
FUN_SCROLLINGMESSAGEFRAME_SCROLL_TO_OFFSET = 0x00788700,
FUN_SCROLLINGMESSAGEFRAME_SCROLL_OFFSET = 0x00788AC0,
// ScrollUp / ScrollDown (`int __thiscall(smf)`, plain `ret`; 4.3.4's
// 0x0051E620 / 0x0051E660 step for step): one record older / newer and a
// layout, answering 1. Each answers 0 without moving when empty or at its
// end, and re-arms the shown lines there: ScrollDown at
// OFF_SCROLLINGMESSAGEFRAME_AT_BOTTOM, ScrollUp at +0x350 (4.3.4's
// m_atTop), which the layout sets when every message from the scroll
// record back to the oldest fit the height (0x00788A37: laid-out count ==
// count remaining). The layout runs only on a shown frame ([smf+0xD4],
// m_visible), so a hidden frame's +0x350 is stale and its ScrollUp wraps
// around the ring. PageUp (0x007885B0) is a loop of ScrollUp that ends on
// the first 0.
FUN_SCROLLINGMESSAGEFRAME_SCROLL_UP = 0x00788610,
FUN_SCROLLINGMESSAGEFRAME_SCROLL_DOWN = 0x00788650,
// Clear (`void __thiscall(smf)`, 4.3.4's Clear): count 0, position 0, a
// layout from 0, +0x350 = 0, position -1. The Clear method's body.
FUN_SCROLLINGMESSAGEFRAME_CLEAR = 0x007882B0,
// RearmLines (`void __thiscall(smf)`, 4.3.4's RefreshMessages): every
// shown line back to alpha 0xFF, shown, the frame's two times, and its
// node shown (benilla-re ui/messageframe "Scrolling brings lines back").
// FUN_SMF_DISPLAY_REFRESH (LayoutLines) owns +0x348 (the scroll record)
// and +0x354 (at bottom = record == position) itself, and re-arms only
// when it moves off the bottom; a layout at the bottom keeps the fade
// state, so 4.3.4 re-arms explicitly after a removal's layout.
FUN_SCROLLINGMESSAGEFRAME_REARM_LINES = 0x00788B80,
FUN_SCRIPT_SCROLLINGMESSAGEFRAME_GET_CURRENT_SCROLL = 0x00793650,
// The frame's OnFrameUpdate override (vtable+0x38, see
// FUN_FRAME_ON_UPDATE), `void __thiscall(smf, float elapsed)`, `ret 4`:
// FUN_FRAME_ON_UPDATE (OnUpdate), then the fader.
FUN_SCROLLINGMESSAGEFRAME_ON_UPDATE = 0x00788460,
// The line layout (FUN_SMF_DISPLAY_REFRESH) anchors a line once, when it
// creates the line's font string, through the one SetPoint call at
// CALL_SMF_LAYOUT_ANCHOR_LINE (`E8 rel32` to FUN_REGION_SET_POINT; ESI the
// frame, EDI the font string, its parent at OFF_REGION_PARENT already set
// by SetRegionParent 0x0077FD10): the first line (BOTTOMLEFT, the frame,
// BOTTOMLEFT, left inset, bottom inset), each next one (BOTTOMLEFT, the
// line before, TOPLEFT, 0, FUN_PIXEL_ALIGN_Y(spacing)), then
// FUN_LAYOUT_RESIZE(1). The layout is the only maker of lines (the array
// grow 0x00789060 and the node constructor 0x00788D90 have no other
// caller). OnRectChanged (0x00788310) destroys every line when the message
// area changes size and lays them out again; Clear (0x007882B0) only hides
// them. The insets are the four floats SetInsets (0x00787FB0) stores and
// OnRectChanged subtracts from the rect: bottom, left, top, right.
CALL_SMF_LAYOUT_ANCHOR_LINE = 0x00788975,
OFF_SCROLLINGMESSAGEFRAME_INSET_BOTTOM = 0x37C,
OFF_SCROLLINGMESSAGEFRAME_INSET_LEFT = 0x380,
OFF_SCROLLINGMESSAGEFRAME_INSET_TOP = 0x384,
OFF_FONTSTRING_SPACING = 0xF4, // float, the line spacing
// PixelAlignY - `float __stdcall(float)`, `ret 4`: rounds a vertical
// length up to whole device pixels (benilla-re ui/regions).
FUN_PIXEL_ALIGN_Y = 0x00766750,
// Focused-editbox global — holds the CSimpleEditBox that currently has
// keyboard focus (the one showing a cursor), or 0 when no input field is
@@ -12979,6 +13246,51 @@ enum Offsets {
// ending at 0x0077D49D.
PATCH_EDITBOX_TEXT_CHANGED_FIRE = 0x0077D485,
OFF_EDITBOX_ONTEXTCHANGED_SLOT = 0x418,
// --- Edit box rows (EditBox::IconRows) ---
// The mode flags byte (benilla-re ui/editbox): bit 0 autoFocus, 1
// multiLine, 2 numeric, 3 password, 4 ignoreArrows. Every row placer
// below tests it (`TEST byte ptr [ESI+0x318], 0x2 / 0x8`). Not the dirty
// word OFF_EDITBOX_FLAGS (+0x31c).
OFF_EDITBOX_MODE_FLAGS = 0x318,
EDITBOX_MODE_MULTILINE = 0x02,
EDITBOX_MODE_PASSWORD = 0x08,
// The password copy of the text (every byte '*'), the buffer the label
// and the rows read with EDITBOX_MODE_PASSWORD set (4.3.4's
// m_textHidden); OFF_EDITBOX_TEXT otherwise. RebuildLabel 0x0077D4D0
// refreshes it.
OFF_EDITBOX_TEXT_MASKED = 0x334,
// The visible window in bytes: its first byte and its length (4.3.4's
// m_visiblePosition / m_visibleLength). PlaceCaret hides the caret and
// fires nothing for a cursor outside it (0x0077DA89..0x0077DAA7).
OFF_EDITBOX_VISIBLE_START = 0x348,
OFF_EDITBOX_VISIBLE_LENGTH = 0x34C,
// The caret texture (4.3.4's m_cursorTexture), anchored by PlaceCaret.
OFF_EDITBOX_CARET = 0x368,
// OnCursorChanged's script slot; PlaceCaret fires it when non-zero
// (0x0077DD8C).
OFF_EDITBOX_ONCURSORCHANGED_SLOT = 0x428,
// The three row placers, all `__thiscall`: PlaceCaret (no args; 4.3.4's
// UpdateVisibleCursor), PlaceSelectionPiece `(editbox, texture, from, to)`
// `ret 0xC` (UpdateHighlightArea) and GetIndexAtPoint `(editbox, float x,
// float y, int *index)` `ret 0xC` (ConvertCoordinateToIndex). Each steps
// rows of FUN_PIXEL_ALIGN_Y(fontH × scale) + FUN_PIXEL_ALIGN_Y(spacing ×
// scale), the first two divided by the box's OFF_LAYOUT_SCALE (benilla-re
// ui/editbox "The caret" / "Selection"; Ghidra). Co-hooked by
// EditBox::IconRows: tall inline icons move the rows.
FUN_EDITBOX_PLACE_CARET = 0x0077DA80,
FUN_EDITBOX_PLACE_SELECTION_PIECE = 0x0077DE70,
FUN_EDITBOX_INDEX_AT_POINT = 0x0077D0D0,
// "%f%f%f%f" (.data; verified in the file at 0x479CB8) — the format
// PlaceCaret fires OnCursorChanged with through
// FUN_FRAME_RUN_SCRIPT_VARIADIC: x, y, width, height, each a double of
// FUN_COORD_DIVIDE_BY_SCREEN_SCALE_X(FUN_COORD_GET_SCALED_SCREEN_ASPECT()
// × [FLOAT_SCRIPT_COORD_SCALE] × value) — 4.3.4's
// RunOnCursorChangedScript (0x0077DDA0..0x0077DE20).
VAR_SCRIPT_FMT_FFFF = 0x00879CB8,
// 1024.0f (.rdata; verified in the file at 0x3FFD68): the anchor→script
// push factor every script coordinate push multiplies by (the `1024` of
// the GetFont note at FUN_COORD_GET_SCALED_SCREEN_ASPECT).
FLOAT_SCRIPT_COORD_SCALE = 0x007FFD68,
// `void __thiscall(editbox, int maxLines)` — the SetHistoryLines internal
// (FUN_0077cd90). Reallocates the history ring to maxLines; maxLines == 0
// frees the whole buffer and zeroes the container. ClearHistory calls it
@@ -12996,7 +13308,8 @@ enum Offsets {
// core FUN_0044D670(fontHandle fs+0xE0, text, len, fontH*[fs+0x7C], &out,
// fs+0x108, 0, flags fs+0x120), then `fs+0xFC = out / [fs+0x7C]`. Exactly 4
// callers: Script_GetStringWidth (0x0079E510), GameTooltip auto-size
// (0x00530640), the editbox caret positioner (0x0077DE70), and the layout
// (0x00530640), the editbox selection-piece placer (0x0077DE70, a whole
// last line's width), and the layout
// effective-width vmethod (FUN_00772930 = fs+0x24 vtbl slot +0x1C: explicit
// rect width else string width). Prologue PUSH EBX; PUSH ESI; MOV ESI,ECX;
// FLD [ESI+0xFC] — MinHook-safe. Co-hooked by Text::InlineTexture to add
+56 -9
View File
@@ -36,8 +36,8 @@
// listed by the coverage function
// - a frame method -> Blizzard's widget system for its registry
// ("SimpleTextureAPI", Type "ScriptObject")
// - an enum / structure /
// event -> the System named in its declaration
// - an enum / constants /
// structure / event -> the System named in its declaration
// A system's `Environment` is "All" when its functions were recorded in both
// Lua states, else "Game" or "Glue"; a function whose own environment is
// narrower than its system's gets a Documentation line saying so.
@@ -79,13 +79,20 @@ enum Kind : uint8_t {
KIND_TABLE, // RegisterTableFunction
KIND_FRAME_METHOD, // RegisterFrameMethods, one per entry
KIND_ENUM, // RegisterIntegerEnum
KIND_CONSTANTS, // RegisterConstants
KIND_ORPHAN_METHOD_DOC // a Doc::Method naming no entry in its table
};
// The value-table kinds, which share the enum* fields of Entry.
bool IsValueTable(Kind kind) {
return kind == KIND_ENUM || kind == KIND_CONSTANTS;
}
struct Entry {
Kind kind;
unsigned env; // Env bits the registration was recorded in
const char *table; // KIND_TABLE: the namespace; KIND_ENUM: the parent ("Enum")
const char *table; // KIND_TABLE: the namespace; KIND_ENUM: the parent ("Enum");
// KIND_CONSTANTS: "Constants"
const char *name; // registered name / method name / enum sub-name
const void *registry; // frame methods only
const Function *doc; // nullptr = undocumented
@@ -195,6 +202,7 @@ const char *SystemOf(const Entry &e) {
case KIND_FRAME_METHOD:
return WidgetSystemFor(e.registry);
case KIND_ENUM:
case KIND_CONSTANTS:
return e.enumSystem;
default:
return nullptr;
@@ -240,6 +248,7 @@ std::vector<SystemInfo> CollectSystems() {
AddSystem(systems, SystemOf(e), nullptr, "ScriptObject", e.env);
break;
case KIND_ENUM:
case KIND_CONSTANTS:
AddSystem(systems, e.enumSystem, nullptr, nullptr, 0);
break;
default:
@@ -394,6 +403,25 @@ void PushEnumeration(void *L, const Entry &e) {
Lua::SetTable(L, -3);
}
// Pushes { Name, Type = "Constants",
// Values = { { Name, Type = "number", Value } } }.
void PushConstants(void *L, const Entry &e) {
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.name);
Lua::SetFieldString(L, "Type", "Constants");
Lua::PushString(L, "Values");
Lua::NewTable(L);
for (int i = 0; i < e.enumCount; ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::NewTable(L);
Lua::SetFieldString(L, "Name", e.enumEntries[i].key);
Lua::SetFieldString(L, "Type", "number");
Lua::SetFieldNumber(L, "Value", e.enumEntries[i].value);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
}
// Pushes { Name, Type = "Structure" [, Documentation], Fields = { … } }.
void PushStructure(void *L, const Structure &s) {
Lua::NewTable(L);
@@ -479,10 +507,13 @@ void PushSystem(void *L, const SystemInfo &sys) {
Lua::SetTable(L, -3);
}
for (const Entry &e : g_entries) {
if (e.kind != KIND_ENUM || !SameTextNoCase(e.enumSystem, sys.name))
if (!IsValueTable(e.kind) || !SameTextNoCase(e.enumSystem, sys.name))
continue;
Lua::PushNumber(L, static_cast<double>(++n));
PushEnumeration(L, e);
if (e.kind == KIND_ENUM)
PushEnumeration(L, e);
else
PushConstants(L, e);
Lua::SetTable(L, -3);
}
Lua::SetTable(L, -3);
@@ -527,6 +558,7 @@ std::vector<std::string> Undocumented() {
break;
}
case KIND_ENUM:
case KIND_CONSTANTS:
if (e.enumSystem == nullptr)
out.push_back(std::string(e.table) + "." + e.name);
break;
@@ -662,18 +694,21 @@ void NameWidgetSystem(const void *registry, const char *system) {
g_ownWidgetSystems.push_back({reinterpret_cast<uintptr_t>(registry), system});
}
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
namespace {
void RecordValueTable(Kind kind, const char *parent, const char *sub,
const Game::Lua::EnumIntegerEntry *entries, int count,
const char *system) {
if (g_pass == ENV_NONE || sub == nullptr)
return;
if (Entry *e = Find(KIND_ENUM, parent, sub, nullptr)) {
if (Entry *e = Find(kind, parent, sub, nullptr)) {
e->env |= g_pass;
if (e->enumSystem == nullptr)
e->enumSystem = system;
return;
}
Entry e{};
e.kind = KIND_ENUM;
e.kind = kind;
e.env = g_pass;
e.table = parent;
e.name = sub;
@@ -683,6 +718,18 @@ void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntege
g_entries.push_back(e);
}
} // namespace
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system) {
RecordValueTable(KIND_ENUM, parent, sub, entries, count, system);
}
void RecordConstants(const char *sub, const Game::Lua::EnumIntegerEntry *entries, int count,
const char *system) {
RecordValueTable(KIND_CONSTANTS, "Constants", sub, entries, count, system);
}
} // namespace Api::Documentation
// The Structure constructor lives here so the list head is private to this
+2
View File
@@ -39,5 +39,7 @@ void RecordFrameMethods(const void *registry, const Game::Lua::FrameMethodEntry
void NameWidgetSystem(const void *registry, const char *system);
void RecordEnum(const char *parent, const char *sub, const Game::Lua::EnumIntegerEntry *entries,
int count, const char *system);
void RecordConstants(const char *sub, const Game::Lua::EnumIntegerEntry *entries, int count,
const char *system);
} // namespace Api::Documentation
+31 -1
View File
@@ -11,7 +11,8 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `securecall(func or name, ...)`, `securecallfunction(func, ...)` and
// `securecall(func or name, ...)`, `securecallfunction(func, ...)`,
// `securecallmethod(object, method, ...)` and
// `secureexecuterange(tbl, func, ...)` — the later clients' protected calls,
// less taint (1.12 has none). Retail, probed:
// - securecall returns the function's results; a string names a global
@@ -22,6 +23,11 @@
// - secureexecuterange calls func(key, value, ...) for each entry of tbl in
// `next` order; an error in one goes to the error handler and the walk
// goes on. It returns nothing.
// securecallmethod is from retail's API documentation, not a probe: it calls
// object[method](object, ...) with a raw lookup (no metatable), and an error
// in the lookup or the call goes to the error handler with no results. Here
// a lookup that finds no function, or an object that is not a table, errors
// the way calling that value does.
// The error handler is the one every engine script run passes
// (VAR_FRAMESCRIPT_ERROR_HANDLER_REF), as HookScript's wrapper uses.
//
@@ -70,6 +76,20 @@ int __fastcall Script_securecall(void *L) {
int __fastcall Script_securecallfunction(void *L) { return CallProtected(L); }
int __fastcall Script_securecallmethod(void *L) {
if (Game::Lua::GetTop(L) < 2)
Game::Lua::SetTop(L, 2);
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE) {
Game::Lua::PushValue(L, 2);
Game::Lua::RawGet(L, 1);
} else {
Game::Lua::PushNil(L);
}
Game::Lua::Remove(L, 2); // [object, args..., func]
Game::Lua::Insert(L, 1); // [func, object, args...]
return CallProtected(L);
}
int __fastcall Script_secureexecuterange(void *L) {
const int n = Game::Lua::GetTop(L); // tbl, func, extra args 3..n
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE)
@@ -97,6 +117,11 @@ const Game::Doc::Field kCallArgs[] = {
const Game::Doc::Field kCallRets[] = {
Game::Doc::Opt("...", "any", nullptr, "The function's results, or nothing after an error."),
};
const Game::Doc::Field kMethodArgs[] = {
Game::Doc::Req("object", "table", "The table that holds the method. The lookup ignores its metatable."),
Game::Doc::Req("method", "string", "The name of the method."),
Game::Doc::Opt("...", "any", nullptr, "The arguments after object."),
};
const Game::Doc::Field kRangeArgs[] = {
Game::Doc::Req("tbl", "table", "The table whose entries are visited."),
Game::Doc::Req("func", "function", "Called as func(key, value, ...) for each entry."),
@@ -108,6 +133,9 @@ const Game::Doc::Function kSecureCall{
const Game::Doc::Function kSecureCallFunction{
"Calls a function and returns its results. An error goes to the error handler and the call returns nothing.",
kCallArgs, kCallRets, "LuaGlobals"};
const Game::Doc::Function kSecureCallMethod{
"Calls object[method](object, ...) and returns its results. An error goes to the error handler and the call returns nothing.",
kMethodArgs, kCallRets, "LuaGlobals"};
const Game::Doc::Function kSecureExecuteRange{
"Calls a function for each entry of a table. An error goes to the error handler and the other entries still run.",
kRangeArgs, {}, "LuaGlobals"};
@@ -115,12 +143,14 @@ const Game::Doc::Function kSecureExecuteRange{
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("securecall", &Script_securecall, &kSecureCall);
Game::Lua::RegisterGlobalFunction("securecallfunction", &Script_securecallfunction, &kSecureCallFunction);
Game::Lua::RegisterGlobalFunction("securecallmethod", &Script_securecallmethod, &kSecureCallMethod);
Game::Lua::RegisterGlobalFunction("secureexecuterange", &Script_secureexecuterange, &kSecureExecuteRange);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("securecall", &Script_securecall, &kSecureCall);
Game::Lua::RegisterGlueFunction("securecallfunction", &Script_securecallfunction, &kSecureCallFunction);
Game::Lua::RegisterGlueFunction("securecallmethod", &Script_securecallmethod, &kSecureCallMethod);
Game::Lua::RegisterGlueFunction("secureexecuterange", &Script_secureexecuterange, &kSecureExecuteRange);
}
+53
View File
@@ -0,0 +1,53 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `Enum.BagIndex` — retail's BagIndexConstants enumeration, with Classic
// Era's names for the containers this client has and this client's own
// indices for them. Era numbers its bank bags from 6 because its reagent bag
// takes 5; here they start right after the equipped bags, as the stock bank
// frame numbers them. The reagent bag, reagent bank and account bank tabs
// have no entry.
#include "Game.h"
#include "Offsets.h"
namespace Container::BagIndex {
namespace {
const Game::Lua::EnumIntegerEntry kEntries[] = {
{"Keyring", Offsets::BAG_INDEX_KEYRING},
{"Bank", Offsets::BAG_INDEX_BANK},
{"Backpack", Offsets::BAG_INDEX_BACKPACK},
{"Bag_1", Offsets::BAG_INDEX_BAG_FIRST},
{"Bag_2", Offsets::BAG_INDEX_BAG_FIRST + 1},
{"Bag_3", Offsets::BAG_INDEX_BAG_FIRST + 2},
{"Bag_4", Offsets::BAG_INDEX_BAG_LAST},
{"BankBag_1", Offsets::BAG_INDEX_BANK_BAG_FIRST},
{"BankBag_2", Offsets::BAG_INDEX_BANK_BAG_FIRST + 1},
{"BankBag_3", Offsets::BAG_INDEX_BANK_BAG_FIRST + 2},
{"BankBag_4", Offsets::BAG_INDEX_BANK_BAG_FIRST + 3},
{"BankBag_5", Offsets::BAG_INDEX_BANK_BAG_FIRST + 4},
{"BankBag_6", Offsets::BAG_INDEX_BANK_BAG_LAST},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum("Enum", "BagIndex", kEntries,
sizeof(kEntries) / sizeof(kEntries[0]), "BagIndexConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::BagIndex
+4 -10
View File
@@ -208,13 +208,6 @@ bool IsItemFiltered(const uint8_t *cgItem) {
namespace {
// Lua's BACKPACK_CONTAINER, BANK_CONTAINER, KEYRING_CONTAINER and the last
// bank bag (NUM_BAG_SLOTS + NUM_BANKBAGSLOTS).
constexpr int kBackpack = 0;
constexpr int kBank = -1;
constexpr int kKeyring = -2;
constexpr int kLastBankBag = 10;
const Game::Doc::Event kSearchUpdateDoc{
"Container", "Fires when the bag search text changes.", {}};
const Event::Custom::AutoReserve _evt{"INVENTORY_SEARCH_UPDATE", &kSearchUpdateDoc};
@@ -225,11 +218,12 @@ const Event::Custom::AutoReserve _evt{"INVENTORY_SEARCH_UPDATE", &kSearchUpdateD
// get their fixed sizes. The keyring is the inventory's last range, so the
// whole-inventory bound is its own. Any other index is no container.
int SlotsToWalk(int bagID) {
if (bagID == kBackpack)
if (bagID == Offsets::BAG_INDEX_BACKPACK)
return Offsets::BACKPACK_NUM_SLOTS;
if (bagID == kBank)
if (bagID == Offsets::BAG_INDEX_BANK)
return Offsets::INVMGR_BANK_MAIN_LAST_SLOT - Offsets::INVMGR_BANK_MAIN_FIRST_SLOT + 1;
if (bagID == kKeyring || (bagID >= 1 && bagID <= kLastBankBag))
if (bagID == Offsets::BAG_INDEX_KEYRING ||
(bagID >= Offsets::BAG_INDEX_BAG_FIRST && bagID <= Offsets::BAG_INDEX_BANK_BAG_LAST))
return INT_MAX;
return 0;
}
+40
View File
@@ -0,0 +1,40 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `Constants.MoneyFormattingConstants`, retail's MoneyConstants system
// (Blizzard_APIDocumentationGenerated/MoneyConstantsDocumentation.lua):
// GOLD_REWARD_THRESHOLD_TO_HIDE_COPPER = 10, the gold amount above which
// FormattingUtil's GetMoneyString(..., checkGoldThreshold) drops the copper.
#include "Game.h"
namespace Currency::MoneyConstants {
namespace {
const Game::Lua::EnumIntegerEntry kMoneyFormattingConstants[] = {
{"GOLD_REWARD_THRESHOLD_TO_HIDE_COPPER", 10},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterConstants("MoneyFormattingConstants", kMoneyFormattingConstants,
sizeof(kMoneyFormattingConstants) /
sizeof(kMoneyFormattingConstants[0]),
"MoneyConstants");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Currency::MoneyConstants
+218
View File
@@ -0,0 +1,218 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_DeathInfo.GetSelfResurrectOptions()`, `C_DeathInfo.UseSelfResurrectOption
// (optionType, id)` and `SELF_RES_SPELL_CHANGED` — the self-resurrection that
// 1.12 serves through HasSoulstone (0x0048AC80) and UseSoulstone (0x0048AD70).
//
// 1.12 offers at most one way for a dead player to raise themselves, and both
// functions pick it the same way: PLAYER_SELF_RES_SPELL when the server set it
// (Soulstone Resurrection, Reincarnation), else the first carried item whose
// on-use spell is a self-resurrect. So the list holds that one option, under
// HasSoulstone's gate (a living player has none), and using it calls
// UseSoulstone itself. Nothing in 1.12 limits a self-resurrection, so every
// option can be used and none is limited.
//
// SELF_RES_SPELL_CHANGED is 4.3.4's: CGPlayer_C::SetActiveMirrorHandlers
// watches PLAYER_SELF_RES_SPELL with OnUpdatePlayerScriptEvent. 1.12 watches
// no such field, so the same watcher joins 1.12's AddActiveWatchers and
// leaves with its removal, the lifetime of the engine's own player watchers.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/Link.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Death::SelfResurrect {
namespace {
constexpr int OPTION_TYPE_SPELL = 0;
constexpr int OPTION_TYPE_ITEM = 1;
const Game::Doc::Event kChangedDoc{
"DeathInfo",
"The player's self-resurrection spell changed. Read C_DeathInfo.GetSelfResurrectOptions.",
{}};
const Event::Custom::AutoReserve kChanged{"SELF_RES_SPELL_CHANGED", &kChangedDoc};
struct Option {
const char *name;
int optionType;
int id;
};
// HasSoulstone's answer: the option, or false while the player lives or has
// none. `itemName` holds an item option's name.
bool CurrentOption(Option &out, char *itemName, size_t itemNameSize) {
const uint8_t *player = Unit::Identity::PlayerObject();
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (player == nullptr || desc == nullptr ||
Game::Read<int32_t>(desc, Offsets::OFF_UNIT_FIELD_HEALTH) > 0)
return false;
const auto *info = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO);
const int spellID =
info != nullptr ? Game::Read<int32_t>(info, Offsets::OFF_CGPLAYER_INFO_SELF_RES_SPELL) : 0;
if (spellID != 0) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
out = {rec != nullptr
? Game::Read<const char *>(rec, Offsets::OFF_SPELL_NAMES +
Game::Read<int>(Offsets::VAR_LOCALE_INDEX) *
4)
: "UNKNOWN",
OPTION_TYPE_SPELL, spellID};
return true;
}
using SelfResItem_t = const uint8_t *(__fastcall *)(const void *player);
const uint8_t *item =
reinterpret_cast<SelfResItem_t>(Offsets::FUN_PLAYER_SELF_RES_ITEM)(player);
if (item == nullptr)
return false;
Item::Link::NameFromCGItem(item, itemName, itemNameSize);
const auto *fields = Game::Read<const uint8_t *>(item, Offsets::OFF_UNIT_GUID_PTR);
out = {itemName, OPTION_TYPE_ITEM,
fields != nullptr ? Game::Read<int32_t>(fields, Offsets::OFF_OBJECT_FIELD_ENTRY) : 0};
return true;
}
int __fastcall Script_GetSelfResurrectOptions(void *L) {
if (Unit::Identity::PlayerObject() == nullptr)
return 0;
Game::Lua::NewTable(L);
char itemName[0x400];
Option option;
if (CurrentOption(option, itemName, sizeof(itemName))) {
Game::Lua::PushNumber(L, 1);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", option.name);
Game::Lua::SetFieldNumber(L, "optionType", option.optionType);
Game::Lua::SetFieldNumber(L, "id", option.id);
Game::Lua::SetFieldBool(L, "canUse", true);
Game::Lua::SetFieldBool(L, "isLimited", false);
Game::Lua::SetFieldNumber(L, "priority", 0);
Game::Lua::SetTable(L, -3);
}
return 1;
}
int __fastcall Script_UseSelfResurrectOption(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: C_DeathInfo.UseSelfResurrectOption(optionType, id)");
return 0;
}
char itemName[0x400];
Option option;
if (CurrentOption(option, itemName, sizeof(itemName)) &&
option.optionType == static_cast<int>(Game::Lua::ToNumber(L, 1)) &&
option.id == static_cast<int>(Game::Lua::ToNumber(L, 2)))
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_USE_SOULSTONE)(L);
return 0;
}
// Observer callback ABI per the FUN_DESC_OBSERVER_REGISTER block in
// Offsets.h: __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
// oldValues*, userArg), returns 1, callee cleans 0x10. It runs only when the
// field's bytes changed.
int __fastcall SelfResSpellChanged(uint32_t /*fieldOffset*/, uint32_t /*size*/,
uint32_t /*guidLo*/, uint32_t /*guidHi*/,
const void * /*oldValues*/, void * /*userArg*/) {
Event::Custom::Fire(kChanged.Slot(), "");
return 1;
}
using Register_t = void(__fastcall *)(int bank, uint32_t fieldOffset, uint32_t guidLo,
uint32_t guidHi, int size, const void *callback,
void *userArg1, void *userArg2);
using Unregister_t = void(__fastcall *)(int bank, uint32_t fieldOffset, uint32_t guidLo,
uint32_t guidHi, const void *callback, void *userArg1);
using Watchers_t = void(__fastcall *)(void *player, void *edx);
Watchers_t g_origAddWatchers = nullptr;
Watchers_t g_origRemoveWatchers = nullptr;
void __fastcall AddActiveWatchers_h(void *player, void *edx) {
g_origAddWatchers(player, edx);
const uint64_t guid = Unit::Identity::GuidForObject(player);
reinterpret_cast<Register_t>(Offsets::FUN_DESC_OBSERVER_REGISTER)(
Offsets::DESC_OBSERVER_BANK_PLAYER, Offsets::OFF_CGPLAYER_INFO_SELF_RES_SPELL,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 4,
reinterpret_cast<const void *>(&SelfResSpellChanged), nullptr, nullptr);
}
// Removed before the original, while the registry is as the engine's own
// removals find it.
void __fastcall RemoveActiveWatchers_h(void *player, void *edx) {
const uint64_t guid = Unit::Identity::GuidForObject(player);
reinterpret_cast<Unregister_t>(Offsets::FUN_DESC_OBSERVER_UNREGISTER)(
Offsets::DESC_OBSERVER_BANK_PLAYER, Offsets::OFF_CGPLAYER_INFO_SELF_RES_SPELL,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
reinterpret_cast<const void *>(&SelfResSpellChanged), nullptr);
g_origRemoveWatchers(player, edx);
}
const Game::HookAutoRegister _addHook{Offsets::FUN_PLAYER_ADD_ACTIVE_WATCHERS,
reinterpret_cast<void *>(&AddActiveWatchers_h),
reinterpret_cast<void **>(&g_origAddWatchers)};
const Game::HookAutoRegister _removeHook{Offsets::FUN_PLAYER_REMOVE_ACTIVE_WATCHERS,
reinterpret_cast<void *>(&RemoveActiveWatchers_h),
reinterpret_cast<void **>(&g_origRemoveWatchers)};
const Game::Lua::EnumIntegerEntry kOptionTypeEntries[] = {
{"Spell", OPTION_TYPE_SPELL},
{"Item", OPTION_TYPE_ITEM},
};
const Game::Doc::Field kOptionFields[] = {
Game::Doc::Req("name", "string"),
Game::Doc::Req("optionType", "SelfResurrectOptionType"),
Game::Doc::Req("id", "number", "The spell ID or item ID."),
Game::Doc::Req("canUse", "bool"),
Game::Doc::Req("isLimited", "bool"),
Game::Doc::Req("priority", "number", "Lower comes first."),
};
const Game::Doc::Structure kOption{"SelfResurrectOption", "DeathInfo", kOptionFields,
"A way for the dead player to resurrect themselves."};
const Game::Doc::Field kGetRets[] = {
Game::Doc::Req("options", "table", "Array of SelfResurrectOption; empty while alive."),
};
const Game::Doc::Function kGetSelfResurrectOptions{
"The ways the dead player can resurrect themselves.", {}, kGetRets};
const Game::Doc::Field kUseArgs[] = {
Game::Doc::Req("optionType", "SelfResurrectOptionType"),
Game::Doc::Req("id", "number"),
};
const Game::Doc::Function kUseSelfResurrectOption{
"Resurrects the dead player with one of the options from GetSelfResurrectOptions.",
kUseArgs, {}};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_DeathInfo", "GetSelfResurrectOptions",
&Script_GetSelfResurrectOptions, &kGetSelfResurrectOptions);
Game::Lua::RegisterTableFunction("C_DeathInfo", "UseSelfResurrectOption",
&Script_UseSelfResurrectOption, &kUseSelfResurrectOption);
Game::Lua::RegisterIntegerEnum("Enum", "SelfResurrectOptionType", kOptionTypeEntries,
sizeof(kOptionTypeEntries) / sizeof(kOptionTypeEntries[0]),
"DeathInfo");
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Death::SelfResurrect
+308
View File
@@ -0,0 +1,308 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Tall inline icons move the edit box's rows.
//
// A multi-line edit box places its caret, its selection pieces and a click's
// cursor by rows of one height — the label's font height plus its spacing,
// pixel-aligned (FUN_EDITBOX_PLACE_CARET, FUN_EDITBOX_PLACE_SELECTION_PIECE,
// FUN_EDITBOX_INDEX_AT_POINT; 4.3.4's UpdateVisibleCursor, UpdateHighlightArea
// and ConvertCoordinateToIndex step the same rows). The label's text leaves
// those rows once a line holds an icon taller than the font: the render moves
// that line and every line after it down by the overflow (4.3.4's
// InitializeText; the emitter co-hook in Text::InlineTexture), and the box
// grows by the same amount through the string-height co-hook. So below the
// first tall-icon line the caret and the highlight sat above their text by
// the summed overflow, and OnCursorChanged reported that y, which
// ScrollingEditBoxMixin:ScrollCursorIntoView scrolls by — pfUI's chat copy
// box with faction-emblem icons. 4.3.4 has the same fault; nothing it ships
// puts a tall icon in an edit box.
//
// Each placer keeps its engine code and gets the move the render makes, from
// Text::InlineTexture::LineIconShift over the box's own line table (the
// breaks the render lays out), split by the label's vertical justify as the
// emitter splits it:
// - the click: its y is moved onto the uniform row that the icon-aware row
// under it has, before the engine maps it;
// - a selection piece: re-anchored after the engine placed it, with the
// engine's own x, lower by the move;
// - the caret: the same re-anchor, then OnCursorChanged fired once with the
// moved y through the engine's own forwarder. The engine's fire is muted
// for the call by emptying the script slot: it reads the row y before
// anything can correct it.
// A single-line box has one row and needs nothing: its label is justified
// MIDDLE in a rect of fixed height, so its text stays centred on the caret.
#include "Game.h"
#include "Offsets.h"
#include "text/InlineTexture.h"
#include <cstdint>
namespace EditBox::IconRows {
namespace {
using PlaceCaret_t = void(__fastcall *)(void *editBox);
using PlaceSelectionPiece_t = void(__fastcall *)(void *editBox, void *edx, void *piece, int from, int to);
using IndexAtPoint_t = void(__fastcall *)(void *editBox, void *edx, float x, float y, int *index);
using FontHeight_t = float(__fastcall *)(void *fs, void *edx, int mode);
using MeasureRun_t = float(__fastcall *)(void *fs, void *edx, const uint8_t *text, int len);
using PixelAlignY_t = float(__stdcall *)(float length);
using GetRect_t = int(__fastcall *)(void *anchor, void *edx, float *out);
using ClearAllPoints_t = void(__fastcall *)(void *anchor);
using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor, int relPoint, float x, float y,
int flag);
using Resize_t = void(__fastcall *)(void *anchor, void *edx, int force);
using Extent_t = float(__fastcall *)(void *anchor, void *edx);
using ScreenAspect_t = float(__cdecl *)();
using DivideByScreenScaleX_t = float(__stdcall *)(float value);
using FireVariadic_t = void(__cdecl *)(void *frame, uint32_t *slotPtr, const char *fmt, ...);
PlaceCaret_t g_origPlaceCaret = nullptr;
PlaceSelectionPiece_t g_origPlaceSelectionPiece = nullptr;
IndexAtPoint_t g_origIndexAtPoint = nullptr;
void *AnchorOf(void *region) { return static_cast<uint8_t *>(region) + Offsets::OFF_REGION_ANCHOR; }
// A multi-line box's rows: its label, the buffer the line table indexes (the
// '*' copy of a password box) and the table — each line's first byte, the
// last entry the end.
struct Rows {
void *fs = nullptr;
const uint8_t *text = nullptr;
const uint32_t *starts = nullptr;
int count = 0; // entries; lines = count - 1
float scale = 1.0f; // the box's OFF_LAYOUT_SCALE
};
bool MultiLineRows(void *editBox, Rows &rows) {
const uint8_t mode = Game::Read<uint8_t>(editBox, Offsets::OFF_EDITBOX_MODE_FLAGS);
if ((mode & Offsets::EDITBOX_MODE_MULTILINE) == 0)
return false;
rows.fs = Game::Read<void *>(editBox, Offsets::OFF_EDITBOX_FONTSTRING);
rows.text = Game::Read<const uint8_t *>(editBox, (mode & Offsets::EDITBOX_MODE_PASSWORD)
? Offsets::OFF_EDITBOX_TEXT_MASKED
: Offsets::OFF_EDITBOX_TEXT);
rows.starts = Game::Read<const uint32_t *>(editBox, Offsets::OFF_EDITBOX_LINE_STARTS);
rows.count = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_LINE_COUNT);
rows.scale = Game::Read<float>(editBox, Offsets::OFF_LAYOUT_SCALE);
return rows.fs != nullptr && rows.text != nullptr && rows.starts != nullptr && rows.count >= 2 &&
rows.scale > 0.0f;
}
// The line holding byte `pos` — the placers' walk down the table.
int LineOf(const Rows &rows, uint32_t pos) {
int line = 0;
while (pos >= rows.starts[line + 1]) {
if (line >= rows.count - 2)
break;
++line;
}
return line;
}
bool LineShift(const Rows &rows, int line, float &before, float &after) {
before = 0.0f;
after = 0.0f;
const uint32_t start = rows.starts[line];
const uint32_t end = rows.starts[line + 1];
return end > start &&
Text::InlineTexture::LineIconShift(rows.fs, rows.text + start, static_cast<int>(end - start), before, after);
}
// How far the render moves line `line`'s text below its uniform row: the
// moves of every line above it, and its own text's move.
float ShiftOf(const Rows &rows, int line) {
float shift = 0.0f;
for (int i = 0; i <= line; ++i) {
float before = 0.0f;
float after = 0.0f;
if (!LineShift(rows, i, before, after))
continue;
shift += before;
if (i < line)
shift += after;
}
return shift;
}
// The placers' row in the box's parent units: the label's font height and
// spacing, each pixel-aligned at the box's scale (PlaceCaret
// 0x0077DAEB..0x0077DB0E).
float RowHeight(const Rows &rows) {
const float fontH = reinterpret_cast<FontHeight_t>(Offsets::FUN_FONTSTRING_FONT_HEIGHT)(rows.fs, nullptr, 1);
const float spacing = Game::Read<float>(rows.fs, Offsets::OFF_FONTSTRING_SPACING);
const auto align = reinterpret_cast<PixelAlignY_t>(Offsets::FUN_PIXEL_ALIGN_Y);
return align(fontH * rows.scale) + align(spacing * rows.scale);
}
float MeasureRun(const Rows &rows, uint32_t from, uint32_t to) {
return reinterpret_cast<MeasureRun_t>(Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING)(rows.fs, nullptr, rows.text + from,
static_cast<int>(to - from));
}
// The x the placers anchor byte `pos` of `line` at, and the label point they
// anchor to, by the label's horizontal justify (OFF_FONTSTRING_FLAGS & 7).
// False for none, which anchors nothing.
bool AnchorX(const Rows &rows, int line, uint32_t pos, float &x, int &relPoint) {
const uint32_t start = rows.starts[line];
const uint32_t end = rows.starts[line + 1];
switch (Game::Read<uint32_t>(rows.fs, Offsets::OFF_FONTSTRING_FLAGS) & 7u) {
case 1: // LEFT
x = (pos == start) ? 0.0f : MeasureRun(rows, start, pos);
relPoint = Offsets::FRAMEPOINT_TOPLEFT;
return true;
case 2: // CENTER
x = (pos == start) ? 0.0f : MeasureRun(rows, start, pos);
x -= MeasureRun(rows, start, end) * 0.5f;
relPoint = Offsets::FRAMEPOINT_TOP;
return true;
case 4: // RIGHT
x = (pos == end) ? 0.0f : -MeasureRun(rows, pos, end);
relPoint = Offsets::FRAMEPOINT_TOPRIGHT;
return true;
default:
return false;
}
}
// Anchors `region` as the placer did, at `y`.
void Anchor(const Rows &rows, void *region, int relPoint, float x, float y, int flag) {
void *anchor = AnchorOf(region);
reinterpret_cast<ClearAllPoints_t>(Offsets::FUN_LAYOUT_CLEAR_ALL_POINTS)(anchor);
reinterpret_cast<SetPoint_t>(Offsets::FUN_REGION_SET_POINT)(anchor, Offsets::FRAMEPOINT_TOPLEFT, AnchorOf(rows.fs),
relPoint, x, y, flag);
}
// --- the click ---------------------------------------------------------------
void __fastcall IndexAtPoint_h(void *editBox, void *edx, float x, float y, int *index) {
Rows rows;
if (!MultiLineRows(editBox, rows))
return g_origIndexAtPoint(editBox, edx, x, y, index);
float rect[4] = {0.0f, 0.0f, 0.0f, 0.0f}; // bottom, left, top, right
if (reinterpret_cast<GetRect_t>(Offsets::FUN_REGION_GET_RECT)(AnchorOf(rows.fs), nullptr, rect) == 0)
return g_origIndexAtPoint(editBox, edx, x, y, index);
// The engine walks rows down from the label's top while the click is
// below the row (strictly), stopping at the last line. Walk the rows as
// the render lays them out: each taller by its line's moves.
const float row = RowHeight(rows);
const float below = rect[2] - y;
int line = 0;
float top = 0.0f; // the icon-aware top of `line`, below the label's top
bool moved = false;
for (;;) {
float before = 0.0f;
float after = 0.0f;
if (LineShift(rows, line, before, after))
moved = true;
const float height = row + (before + after) * rows.scale;
if (!(below > top + height) || line >= rows.count - 2)
break;
top += height;
++line;
}
// Without a move the rows are the engine's own; with one, the click is
// moved onto the middle of the uniform row the engine maps to `line`.
const float yMapped = moved ? rect[2] - (static_cast<float>(line) + 0.5f) * row : y;
g_origIndexAtPoint(editBox, edx, x, yMapped, index);
}
// --- a selection piece --------------------------------------------------------
void __fastcall PlaceSelectionPiece_h(void *editBox, void *edx, void *piece, int from, int to) {
Rows rows;
if (!MultiLineRows(editBox, rows) || from < 0)
return g_origPlaceSelectionPiece(editBox, edx, piece, from, to);
const int line = LineOf(rows, static_cast<uint32_t>(from));
const float shift = ShiftOf(rows, line);
g_origPlaceSelectionPiece(editBox, edx, piece, from, to);
if (!(shift > 0.0f) || piece == nullptr)
return;
// The piece starts at `from`, clamped to its line.
uint32_t pos = static_cast<uint32_t>(from);
if (pos < rows.starts[line])
pos = rows.starts[line];
float x = 0.0f;
int relPoint = 0;
if (!AnchorX(rows, line, pos, x, relPoint))
return;
const float y = -(RowHeight(rows) / rows.scale) * static_cast<float>(line) - shift;
Anchor(rows, piece, relPoint, x, y, 0);
reinterpret_cast<Resize_t>(Offsets::FUN_LAYOUT_RESIZE)(AnchorOf(piece), nullptr, 1);
}
// --- the caret ----------------------------------------------------------------
void __fastcall PlaceCaret_h(void *editBox) {
Rows rows;
if (!MultiLineRows(editBox, rows))
return g_origPlaceCaret(editBox);
const int cursor = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_CURSOR_BYTE);
const int visibleStart = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_VISIBLE_START);
const int visibleLength = Game::Read<int>(editBox, Offsets::OFF_EDITBOX_VISIBLE_LENGTH);
// Outside the window the engine hides the caret and fires nothing.
if (cursor < visibleStart || cursor > visibleStart + visibleLength)
return g_origPlaceCaret(editBox);
const int line = LineOf(rows, static_cast<uint32_t>(cursor));
const float shift = ShiftOf(rows, line);
if (!(shift > 0.0f))
return g_origPlaceCaret(editBox);
uint32_t *slot = Game::Ptr<uint32_t>(editBox, Offsets::OFF_EDITBOX_ONCURSORCHANGED_SLOT);
const uint32_t handler = slot[0];
slot[0] = 0;
g_origPlaceCaret(editBox);
slot[0] = handler;
void *caret = Game::Read<void *>(editBox, Offsets::OFF_EDITBOX_CARET);
if (caret == nullptr)
return;
const float y = -(RowHeight(rows) / rows.scale) * static_cast<float>(line) - shift;
float x = 0.0f;
int relPoint = 0;
if (AnchorX(rows, line, static_cast<uint32_t>(cursor), x, relPoint))
Anchor(rows, caret, relPoint, x, y, 1);
if (handler == 0)
return;
// OnCursorChanged(x, y, width, height) as the engine fires it: each
// value by the anchor→script factor, pushed as a double.
void *anchor = AnchorOf(caret);
void **vtable = *static_cast<void ***>(anchor);
const float width = reinterpret_cast<Extent_t>(vtable[7])(anchor, nullptr); // +0x1C GetWidth
const float height = reinterpret_cast<Extent_t>(vtable[8])(anchor, nullptr); // +0x20 GetHeight
const auto aspect = reinterpret_cast<ScreenAspect_t>(Offsets::FUN_COORD_GET_SCALED_SCREEN_ASPECT);
const auto divide = reinterpret_cast<DivideByScreenScaleX_t>(Offsets::FUN_COORD_DIVIDE_BY_SCREEN_SCALE_X);
const float factor = Game::Read<float>(Offsets::FLOAT_SCRIPT_COORD_SCALE);
const auto push = [&](float value) { return static_cast<double>(divide(aspect() * factor * value)); };
const double args[4] = {push(x), push(y), push(width), push(height)};
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
reinterpret_cast<FireVariadic_t>(Offsets::FUN_FRAME_RUN_SCRIPT_VARIADIC)(
editBox, slot, reinterpret_cast<const char *>(Offsets::VAR_SCRIPT_FMT_FFFF), args[0], args[1], args[2],
args[3]);
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
const Game::HookAutoRegister _caretHook{Offsets::FUN_EDITBOX_PLACE_CARET, reinterpret_cast<void *>(&PlaceCaret_h),
reinterpret_cast<void **>(&g_origPlaceCaret)};
const Game::HookAutoRegister _pieceHook{Offsets::FUN_EDITBOX_PLACE_SELECTION_PIECE,
reinterpret_cast<void *>(&PlaceSelectionPiece_h),
reinterpret_cast<void **>(&g_origPlaceSelectionPiece)};
const Game::HookAutoRegister _clickHook{Offsets::FUN_EDITBOX_INDEX_AT_POINT,
reinterpret_cast<void *>(&IndexAtPoint_h),
reinterpret_cast<void **>(&g_origIndexAtPoint)};
} // namespace
} // namespace EditBox::IconRows
@@ -41,16 +41,20 @@ Util\ColorUtil.lua
Util\ItemLocation.lua
Util\PlayerLocation.lua
Util\EventUtil.lua
Util\AsyncCallbackSystem.lua
Util\Item.lua
Util\ItemUtil.lua
Util\ContainerFrame.lua
Util\AddOnCompat.lua
Util\FunctionUtil.lua
Util\CacheUtil.lua
Util\Pools.lua
Util\SecureTypes.lua
Util\ProxyUtil.lua
Util\Pools.lua
Util\FrameWatcher.lua
Util\TimedCallback.lua
Util\EasingUtil.lua
Util\ScriptAnimationUtil.lua
Util\Interpolator.lua
Util\AnchorUtil.lua
Util\RegionUtil.lua
+14 -1
View File
@@ -1,3 +1,16 @@
if not _G then
_G = getfenv(0)
end
end
-- Retail names for what this client already has. Retail's Deprecated files
-- alias the old globals to the C_ functions; here the old globals are the
-- implementations.
C_Container.GetContainerNumSlots = GetContainerNumSlots
C_Container.PickupContainerItem = PickupContainerItem
C_ActionBar = C_ActionBar or {}
-- Retail answers per game type. This client always has a keyring.
function C_ActionBar.ShouldShowKeyring()
return true
end
@@ -0,0 +1,116 @@
-- Backport of retail's Blizzard_ObjectAPI/Mainline/AsyncCallbackSystem.lua.
--
-- Differences from the retail source:
-- - No ASYNC_SPELL type and no SpellEventListener. This client keeps every
-- spell in its own data files, so there is no spell data to wait for and
-- no C_Spell.RequestLoadSpellData / SPELL_DATA_LOAD_RESULT.
--[[
Queries some data retrieval API (specifically where the data may not be currently available) and when it becomes available
calls a user-supplied function. The callback can be canceled if necessary (e.g. the frame that would use the data becomes
hidden before the data arrives).
The API is managed so that arbitrary query functions cannot be executed.
--]]
AsyncCallbackAPIType = {
ASYNC_QUEST = 1,
ASYNC_ITEM = 2,
}
local permittedAPI =
{
[AsyncCallbackAPIType.ASYNC_QUEST] = { event = "QUEST_DATA_LOAD_RESULT", accessor = C_QuestLog.RequestLoadQuestByID },
[AsyncCallbackAPIType.ASYNC_ITEM] = { event = "ITEM_DATA_LOAD_RESULT", accessor = C_Item.RequestLoadItemDataByID },
};
AsyncCallbackSystemMixin = {};
function AsyncCallbackSystemMixin:Init(apiType)
self.callbacks = {};
-- API Type should be set up from key value pairs before OnLoad.
self.api = permittedAPI[apiType];
self:SetScript("OnEvent",
function(self, event, ...)
if event == self.api.event then
local id, success = ...;
if success or apiType == AsyncCallbackAPIType.ASYNC_ITEM then
self:FireCallbacks(id, success);
else
self:ClearCallbacks(id);
end
end
end
);
self:RegisterEvent(self.api.event);
end
local CANCELED_SENTINEL = -1;
function AsyncCallbackSystemMixin:AddCallback(id, callbackFunction)
local callbacks = self:GetOrCreateCallbacks(id);
table.insert(callbacks, callbackFunction);
local needsAccessorCall = #callbacks == 1;
if needsAccessorCall then
self.api.accessor(id);
end
return #callbacks, callbacks;
end
function AsyncCallbackSystemMixin:AddCancelableCallback(id, callbackFunction)
-- NOTE: If the data is currently availble then the callback will be executed and callbacks cleared, so there will be nothing to cancel.
local index, callbacks = self:AddCallback(id, callbackFunction);
return function()
if #callbacks > 0 and callbacks[index] ~= CANCELED_SENTINEL then
callbacks[index] = CANCELED_SENTINEL;
return true;
end
return false;
end;
end
function AsyncCallbackSystemMixin:FireCallbacks(id, success)
local callbacks = self:GetCallbacks(id);
if callbacks then
self:ClearCallbacks(id);
for i, callback in ipairs(callbacks) do
if callback ~= CANCELED_SENTINEL then
xpcall(callback, CallErrorHandler, id, success);
end
end
-- The cancel functions have a reference to this table, so ensure that it's cleared out.
for i = #callbacks, 1, -1 do
callbacks[i] = nil;
end
end
end
function AsyncCallbackSystemMixin:ClearCallbacks(id)
self.callbacks[id] = nil;
end
function AsyncCallbackSystemMixin:GetCallbacks(id)
return self.callbacks[id];
end
function AsyncCallbackSystemMixin:GetOrCreateCallbacks(id)
local callbacks = self.callbacks[id];
if not callbacks then
callbacks = {};
self.callbacks[id] = callbacks;
end
return callbacks;
end
local function CreateListener(apiType)
local listener = Mixin(CreateFrame("Frame"), AsyncCallbackSystemMixin);
listener:Init(apiType);
return listener;
end
ItemEventListener = CreateListener(AsyncCallbackAPIType.ASYNC_ITEM);
QuestEventListener = CreateListener(AsyncCallbackAPIType.ASYNC_QUEST);
@@ -1,214 +1,267 @@
-- Backport of 3.3.5 FrameXML/CallbackRegistryMixin.lua to vanilla 1.12 / Lua 5.0.
-- Backport of retail Blizzard_SharedXMLBase/CallbackRegistry.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - 1.12 has no taint or Secure-Templates system, so the entire
-- AttributeDelegate/SetForbidden machinery for inserting event keys
-- into the callback tables is unnecessary. Sub-tables are allocated
-- lazily inside RegisterCallback instead.
-- - securecallfunction/secureexecuterange don't exist; they reduce to
-- direct calls / explicit for-loops here.
-- - GenerateClosure doesn't exist in 1.12; provide a local one that
-- captures registration-time varargs via SafePack-style storage.
-- - Lua 5.0 has no select() or `wipe()`; we use the implicit `arg`
-- table and assign-nil to clear tables.
-- Implementation differences from the retail source:
-- - No AttributeDelegate. Retail inserts event keys and creates deferred
-- callback tables through a forbidden frame's OnAttributeChanged, as a
-- taint barrier. 1.12 has no taint and no SetForbidden, so
-- SecureInsertEvent and GetMutableCallbacksByEvent do that work directly.
-- - TriggerEvent calls securecallfunction from its own loops, where retail
-- passes a vararg helper to secureexecuterange. The contract is the same
-- (an error goes to the error handler and the other callbacks still run),
-- but every call to a vararg function builds an `arg` table in Lua 5.0,
-- which would cost a table per callback per event.
-- - 1.12 has no global wipe, so UnregisterEvents uses table.wipe.
local generateOwnerID = CreateCounter()
local secureexecuterange = secureexecuterange;
local securecallfunction = securecallfunction;
local unpack = unpack;
local error = error;
local pairs = pairs;
local rawset = rawset;
local next = next;
local wipe = table.wipe;
local GetOrCreateTableEntry = GetOrCreateTableEntry;
local CallbackType = EnumUtil.MakeEnum("Closure", "Function")
local CallbackType = EnumUtil.MakeEnum("Closure", "Function");
-- Local GenerateClosure: binds `func` plus all of `...` so a later call
-- invokes func(boundArgs..., callArgs...). Mirrors 3.3.5's helper of the
-- same name. SafePack/SafeUnpack semantics so embedded nils survive.
local function GenerateClosure(func, ...)
local boundN = arg.n
local bound = {}
for i = 1, boundN do bound[i] = arg[i] end
return function(...)
local callN = arg.n
-- Forward bound + call args faithfully, embedded/leading nils and all.
-- The ONLY thing that makes unpack span holes on this Lua 5.0 build is
-- a `.n` field: unpack takes its count from getn (which returns t.n)
-- and ignores any explicit i/j, so a `.n`-less table stops dead at the
-- first hole (verified: unpack({1, 1, nil, 1}, 1, 4) yields just 1, 1).
local all = {}
local n = 0
for i = 1, boundN do n = n + 1; all[n] = bound[i] end
for i = 1, callN do n = n + 1; all[n] = arg[i] end
all.n = n
return func(unpack(all))
end
end
-- Callbacks can be registered without an owner as a matter of convenience. Generally this is fine when you never
-- intend to release the callback.
local generateOwnerID = CreateCounter();
CallbackRegistryMixin = CallbackRegistryMixin or {}
CallbackRegistryMixin = {};
function CallbackRegistryMixin:OnLoad()
local callbackTables = {}
for _, value in pairs(CallbackType) do
callbackTables[value] = {}
end
self.callbackTables = callbackTables
local callbackTables = {};
for callbackType, value in pairs(CallbackType) do
local callbacks = {};
callbackTables[value] = callbacks;
end
self.callbackTables = callbackTables;
self.executingEvents = {};
self.deferredCallbacks = {};
end
function CallbackRegistryMixin:SetUndefinedEventsAllowed(allowed)
self.isUndefinedEventAllowed = allowed
self.isUndefinedEventAllowed = allowed;
end
function CallbackRegistryMixin:GetCallbackTables()
return self.callbackTables
return self.callbackTables;
end
function CallbackRegistryMixin:GetCallbackTable(callbackType)
return self.callbackTables[callbackType]
return self.callbackTables[callbackType];
end
function CallbackRegistryMixin:GetCallbacksByEvent(callbackType, event)
local callbackTable = self:GetCallbackTable(callbackType)
return callbackTable[event]
local callbackTable = self:GetCallbackTable(callbackType);
return callbackTable[event];
end
-- Returns either the underlying callback table or a temporary table depending
-- on if the event is currently being dispatched. The callbacks stored in the
-- temporary table will be merged into the underlying callback table after the
-- dispatch is complete.
function CallbackRegistryMixin:GetMutableCallbacksByEvent(callbackType, event)
if securecallfunction(rawget, self.executingEvents, event) == nil then
return self:GetCallbacksByEvent(callbackType, event);
end
local deferrals = GetOrCreateTableEntry(self.deferredCallbacks, event);
return GetOrCreateTableEntry(deferrals, callbackType);
end
function CallbackRegistryMixin:HasRegistrantsForEvent(event)
for _, callbackTable in pairs(self:GetCallbackTables()) do
local callbacks = callbackTable[event]
if callbacks and next(callbacks) then
return true
end
end
return false
for callbackType, callbackTable in pairs(self:GetCallbackTables()) do
local callbacks = callbackTable[event];
if callbacks and securecallfunction(next, callbacks) then
return true;
end
end
return false;
end
-- Lazy allocation of per-event sub-tables. In 3.3.5 this went through a
-- forbidden attribute frame for taint-isolation reasons; that's a no-op
-- consideration on 1.12, so we just assign here.
function CallbackRegistryMixin:SecureInsertEvent(event)
for _, callbackTable in pairs(self:GetCallbackTables()) do
if not callbackTable[event] then
callbackTable[event] = {}
end
end
if not self:HasRegistrantsForEvent(event) then
for callbackType, callbackTable in pairs(self:GetCallbackTables()) do
if not callbackTable[event] then
rawset(callbackTable, event, {});
end
end
end
end
function CallbackRegistryMixin:RegisterCallback(event, func, owner, ...)
if type(event) ~= "string" then
error("CallbackRegistryMixin::RegisterCallback 'event' requires string type.")
elseif type(func) ~= "function" then
error("CallbackRegistryMixin::RegisterCallback 'func' requires function type.")
else
if owner == nil then
owner = generateOwnerID()
elseif type(owner) == "number" then
error("CallbackRegistryMixin:RegisterCallback 'owner' as number is reserved internally.")
end
end
if type(event) ~= "string" then
error("CallbackRegistryMixin::RegisterCallback 'event' requires string type.");
elseif type(func) ~= "function" then
error("CallbackRegistryMixin::RegisterCallback 'func' requires function type.");
else
if owner == nil then
owner = generateOwnerID();
elseif type(owner) == "number" then
error("CallbackRegistryMixin:RegisterCallback 'owner' as number is reserved internally.")
end
end
self:SecureInsertEvent(event)
-- Taint barrier for inserting event key into callback tables.
self:SecureInsertEvent(event);
for _, callbackTable in pairs(self:GetCallbackTables()) do
local callbacks = callbackTable[event]
callbacks[owner] = nil
end
-- An owner can have a single callback per event. The simpliest way to ensure
-- this is to remove all callbacks for the owner prior to new registration.
self:UnregisterCallback(event, owner);
if arg.n > 0 then
local callbacks = self:GetCallbacksByEvent(CallbackType.Closure, event)
callbacks[owner] = GenerateClosure(func, owner, unpack(arg))
else
local callbacks = self:GetCallbacksByEvent(CallbackType.Function, event)
callbacks[owner] = func
end
local count = select("#", ...);
if count > 0 then
local callbacks = self:GetMutableCallbacksByEvent(CallbackType.Closure, event);
callbacks[owner] = GenerateClosure(func, owner, ...);
else
local callbacks = self:GetMutableCallbacksByEvent(CallbackType.Function, event);
callbacks[owner] = func;
end
return owner
return owner;
end
local function CreateCallbackHandle(cbr, event, owner)
local handle =
{
Unregister = function()
cbr:UnregisterCallback(event, owner)
end,
}
return handle
-- Wrapped in a table for future flexibility.
local handle =
{
Unregister = function()
cbr:UnregisterCallback(event, owner);
end,
};
return handle;
end
function CallbackRegistryMixin:RegisterCallbackWithHandle(event, func, owner, ...)
owner = self:RegisterCallback(event, func, owner, unpack(arg))
return CreateCallbackHandle(self, event, owner)
owner = self:RegisterCallback(event, func, owner, ...);
return CreateCallbackHandle(self, event, owner);
end
function CallbackRegistryMixin:ReconcileDeferredCallbacks(event, closures, funcs)
self:CopyDeferredCallbacks(CallbackType.Closure, event, closures);
self:CopyDeferredCallbacks(CallbackType.Function, event, funcs);
self.deferredCallbacks[event] = nil;
end
function CallbackRegistryMixin:CopyDeferredCallbacks(callbackType, event, target)
local deferrals = self.deferredCallbacks[event];
if deferrals == nil then
return;
end
local callbacks = deferrals[callbackType];
if callbacks == nil then
return;
end
local function ExecuteAssignCallback(owner, callback)
target[owner] = callback;
end
secureexecuterange(callbacks, ExecuteAssignCallback);
end
function CallbackRegistryMixin:TriggerEvent(event, ...)
if type(event) ~= "string" then
error("CallbackRegistryMixin:TriggerEvent 'event' requires string type.")
elseif not self.isUndefinedEventAllowed and not (self.Event and self.Event[event]) then
error(string.format("CallbackRegistryMixin:TriggerEvent event '%s' doesn't exist.", event))
end
if type(event) ~= "string" then
error("CallbackRegistryMixin:TriggerEvent 'event' requires string type.");
elseif not self.isUndefinedEventAllowed and not self.Event[event] then
error(string.format("CallbackRegistryMixin:TriggerEvent event '%s' doesn't exist.", event));
end
local closures = self:GetCallbacksByEvent(CallbackType.Closure, event)
if closures then
for _, closure in pairs(closures) do
closure(unpack(arg))
end
end
-- TriggerEvent appears to need to support reentrant calls for now.
local count = (self.executingEvents[event] or 0) + 1;
local funcs = self:GetCallbacksByEvent(CallbackType.Function, event)
if funcs then
for owner, func in pairs(funcs) do
-- Stock 3.3.5 passes the owner as the first arg so callers can
-- distinguish multiple registrations of the same function. `arg`
-- carries its own `.n`, so unpack(arg) forwards the payload with
-- leading/embedded nils intact. Do NOT instead merge into a
-- `{ owner, ... }` table and unpack that WITHOUT setting `.n`: this
-- build's unpack counts via getn, so a `.n`-less table collapses at
-- the first hole (verified in-game: reload's PLAYER_ENTERING_WORLD,
-- arg1=nil arg2=1, dropped the payload).
func(owner, unpack(arg))
end
end
-- Set before invoking any callback so a reentrant call does not
-- attempt to call ReconcileDeferredCallbacks.
self.executingEvents[event] = count;
local closures = self:GetCallbacksByEvent(CallbackType.Closure, event);
if closures then
for owner, closure in pairs(closures) do
securecallfunction(closure, ...);
end
end
local funcs = self:GetCallbacksByEvent(CallbackType.Function, event);
if funcs then
for owner, func in pairs(funcs) do
securecallfunction(func, owner, ...);
end
end
-- The local count is the only value we care about for the purpose
-- of flushing the event key and reconciling the deferred callbacks.
if count == 1 then
self.executingEvents[event] = nil;
self:ReconcileDeferredCallbacks(event, closures, funcs);
end
end
function CallbackRegistryMixin:UnregisterCallback(event, owner)
if type(event) ~= "string" then
error("CallbackRegistryMixin:UnregisterCallback 'event' requires string type.")
elseif owner == nil then
error("CallbackRegistryMixin:UnregisterCallback 'owner' is required.")
end
if type(event) ~= "string" then
error("CallbackRegistryMixin:UnregisterCallback 'event' requires string type.");
elseif owner == nil then
error("CallbackRegistryMixin:UnregisterCallback 'owner' is required.");
end
for _, callbackTable in pairs(self:GetCallbackTables()) do
local callbacks = callbackTable[event]
if callbacks then
callbacks[owner] = nil
end
end
for callbackType, callbackTable in pairs(self:GetCallbackTables()) do
-- Only assign nil if the owner is present in the table, otherwise
-- its insertion could cause a rehash during iteration by secureexecuterange.
local callbacks = callbackTable[event];
if callbacks and callbacks[owner] then
callbacks[owner] = nil;
end
end
local deferrals = self.deferredCallbacks[event];
if deferrals then
for callbackType, callbacks in pairs(deferrals) do
-- Freely assign nil because deferredCallbacks are not subject
-- to secureexecuterange table mutilation stress disorder.
callbacks[owner] = nil;
end
end
end
function CallbackRegistryMixin:UnregisterEvents(eventTable)
if eventTable then
for _, callbackTable in pairs(self:GetCallbackTables()) do
for event in pairs(eventTable) do
if callbackTable[event] then
callbackTable[event] = nil
end
end
end
else
for _, callbackTable in pairs(self:GetCallbackTables()) do
for k in pairs(callbackTable) do
callbackTable[k] = nil
end
end
end
function CallbackRegistryMixin:UnregisterEvents()
for callbackType, callbackTable in pairs(self:GetCallbackTables()) do
wipe(callbackTable);
end
end
function CallbackRegistryMixin:GenerateCallbackEvents(events)
if not self.Event then
self.Event = {}
end
function CallbackRegistryMixin:UnregisterEventsByEventTable(eventTable)
if type(eventTable) ~= "table" then
error("CallbackRegistryMixin:UnregisterEventsByEventTable 'eventTable' requires table type.");
end
for eventIndex, eventName in ipairs(events) do
if self.Event[eventName] then
error(string.format("CallbackRegistryMixin:GenerateCallbackEvents: event '%s' already exists.", eventName))
end
self.Event[eventName] = eventName
end
for callbackType, callbackTable in pairs(self:GetCallbackTables()) do
for event in pairs(eventTable) do
callbackTable[event] = nil;
end
end
end
function CallbackRegistryMixin:GenerateCallbackEvents(eventTable)
if not self.Event then
self.Event = {};
end
if type(eventTable) ~= "table" then
error("CallbackRegistryMixin:GenerateCallbackEvents 'eventTable' requires table type.");
end
for eventIndex, eventName in ipairs(eventTable) do
if self.Event[eventName] then
error(string.format("CallbackRegistryMixin:GenerateCallbackEvents: event '%s' already exists.", eventName));
end
self.Event[eventName] = eventName;
end
end
function CallbackRegistryMixin.DoesFrameHaveEvent(frame, event)
return frame.Event and frame.Event[event]
return frame.Event and frame.Event[event];
end
@@ -0,0 +1,22 @@
-- Backport of the bag-count globals from retail's
-- Blizzard_UIPanels_Game/Mainline/ContainerFrame.lua.
--
-- Differences from the retail source:
-- - NUM_TOTAL_BAG_FRAMES is NUM_BAG_FRAMES: there is no reagent bag.
-- - ContainerFrame_GetContainerNumSlots gives the backpack no extra slots.
-- Retail counts 4 more for an account without an authenticator; here
-- the backpack's size does not depend on the account.
NUM_TOTAL_BAG_FRAMES = NUM_BAG_FRAMES;
function ContainerFrame_GetContainerNumSlots(bagId)
if bagId == KEYRING_CONTAINER then
local keyRingSize = GetKeyRingSize();
return keyRingSize, keyRingSize;
end
local currentNumSlots = C_Container.GetContainerNumSlots(bagId);
local maxNumSlots = currentNumSlots;
return maxNumSlots, currentNumSlots;
end
@@ -1,14 +1,12 @@
-- Backport of 3.3.5 FrameXML/EventUtil.lua to vanilla 1.12 / Lua 5.0.
-- Backport of retail Blizzard_SharedXML/EventUtil.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the 3.3.5 source:
-- - Lua 5.0 has no select(); we work off the implicit `arg` table
-- in vararg functions.
-- - 1.12 has no C_AddOns; the legacy global IsAddOnLoaded(name)
-- fills the same role.
-- - 1.12 has no secureexecuterange; queued callbacks are dispatched
-- by a plain ipairs loop.
-- - AreVariablesLoaded in 3.3.5 sniffs GlueParent / UIParent.firstTimeLoaded.
-- 1.12 has neither; we track a private flag flipped on VARIABLES_LOADED.
-- Implementation differences from the retail source:
-- - Retail's UIParent sets UIParent.variablesLoaded and calls
-- EventUtil.TriggerOnVariablesLoaded on VARIABLES_LOADED. 1.12's
-- FrameXML does neither, so the frame at the bottom of this file does
-- both.
-- - Varargs are read from the implicit `arg` table, which every Lua 5.0
-- vararg function already has, rather than walked with select().
EventUtil = {}
@@ -19,7 +17,7 @@ function ContinueAfterAllEventsMixin:Init(callback, ...)
for index = 1, arg.n do
local event = arg[index]
assert(C_EventUtils.IsEventValid(event), "Unknown event \"" .. event .. "\"")
assert(C_EventUtils.IsEventValid(event), ("Unknown event \"%s\""):format(event))
self.events[event] = false
local function OnEventReceived()
@@ -50,13 +48,8 @@ function EventUtil.ContinueAfterAllEvents(callback, ...)
obj:Init(callback, unpack(arg))
end
-- Tracked locally because 1.12 has no GlueParent / UIParent.firstTimeLoaded.
-- Bootstrap frame at the bottom of the file listens for VARIABLES_LOADED
-- and flips the flag, then drains the queued callbacks via the
-- TriggerOnVariablesLoaded path.
local variablesLoaded = false
function EventUtil.AreVariablesLoaded()
return variablesLoaded
return GlueParent or (UIParent and UIParent.variablesLoaded)
end
local eventUtilVariablesLoadedCallbacks = {}
@@ -69,25 +62,26 @@ function EventUtil.ContinueOnVariablesLoaded(callback)
table.insert(eventUtilVariablesLoadedCallbacks, callback)
end
local secureexecuterange = secureexecuterange
local eventUtilContinueOnVariablesLoadedTriggered = false
function EventUtil.TriggerOnVariablesLoaded()
if eventUtilContinueOnVariablesLoadedTriggered then
-- If TriggerOnVariablesLoaded has been called once already, don't do anything further.
return
end
eventUtilContinueOnVariablesLoadedTriggered = true
variablesLoaded = true
for _, callback in ipairs(eventUtilVariablesLoadedCallbacks) do
secureexecuterange(eventUtilVariablesLoadedCallbacks, function(_, callback)
callback()
end
end)
end
function EventUtil.ContinueOnAddOnLoaded(addOnName, callback)
local isLoadedOrLoading, isLoaded = C_AddOns.IsAddOnLoaded(addOnName)
if isLoaded then
callback();
return;
end
local isLoadedOrLoading, isLoaded = C_AddOns.IsAddOnLoaded(addOnName)
if isLoaded then
callback()
return
end
EventUtil.RegisterOnceFrameEventAndCallback("ADDON_LOADED", callback, addOnName)
end
@@ -125,6 +119,7 @@ function CallbackHandleContainerMixin:Init()
self.handles = {}
end
-- Accepts any CBR and stores a handle to the registered callback.
function CallbackHandleContainerMixin:RegisterCallback(cbr, event, callback, owner)
self:AddHandle(cbr:RegisterCallbackWithHandle(event, callback, owner))
end
@@ -141,7 +136,7 @@ function CallbackHandleContainerMixin:Unregister()
end
function CallbackHandleContainerMixin:IsEmpty()
return table.getn(self.handles) == 0
return #self.handles == 0
end
function EventUtil.CreateCallbackHandleContainer()
@@ -150,13 +145,13 @@ function EventUtil.CreateCallbackHandleContainer()
return cbrHandles
end
-- VARIABLES_LOADED bootstrap. Drains anything queued via
-- ContinueOnVariablesLoaded the first time the event fires. We use a
-- dedicated frame so we don't pollute EventRegistry's payload routing.
-- Retail UIParent's VARIABLES_LOADED work for EventUtil: mark the variables
-- loaded, then run what ContinueOnVariablesLoaded queued.
do
local f = CreateFrame("Frame")
f:RegisterEvent("VARIABLES_LOADED")
f:SetScript("OnEvent", function()
UIParent.variablesLoaded = true
EventUtil.TriggerOnVariablesLoaded()
f:UnregisterEvent("VARIABLES_LOADED")
end)
@@ -1,110 +1,244 @@
-- Backport of FrameXML/FormattingUtil.lua (feasible subset) to vanilla
-- 1.12 / Lua 5.0.
-- Backport of retail Blizzard_SharedXML/FormattingUtil.lua to vanilla 1.12 / Lua 5.0.
--
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
-- GetMoneyString gives the text-symbol form ("12g 34s 56c"). The coin-icon form
-- (GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString) is provided by the
-- ClassicAPI DLL in C++ (src/currency/CoinText.cpp) — like retail, where it's a
-- C function — now that the inline-texture backport renders |T markup in 1.12.
-- Implementation differences from the retail source:
-- - GetMoneyString always gives the coin-icon form, retail's default. Retail
-- switches to the text-symbol form ("12g 34s 56c") under its colorblind
-- mode, which 1.12 does not have.
-- - Not ported: GetCurrencyString / GetCurrenciesString (1.12 has no
-- currencies).
--
-- The FrameXML format-string globals these functions consume
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL,
-- GOLD_AMOUNT_TEXTURE, …) are defined in the locale layer (locales/enUS.lua),
-- which loads first.
-- The GlobalStrings these functions consume (LARGE_NUMBER_SEPERATOR,
-- PERCENTAGE_STRING, GOLD_AMOUNT_TEXTURE, SYMBOLIC_NEGATIVE_NUMBER, ...) are
-- defined in the locale layer (locales/*.lua), which loads first. The coin
-- formats there crop the one 1.12 sprite sheet, UI-MoneyIcons.
function SplitTextIntoLines(text, delimiter)
local lines = {};
local startIndex = 1;
local foundIndex = string.find(text, delimiter);
while foundIndex do
table.insert(lines, text:sub(startIndex, foundIndex - 1));
startIndex = foundIndex + 2;
foundIndex = string.find(text, delimiter, startIndex);
end
if startIndex <= #text then
table.insert(lines, text:sub(startIndex));
end
return lines;
end
function SplitTextIntoHeaderAndNonHeader(text)
local foundIndex = string.find(text, "|n");
if not foundIndex then
-- There was no newline...the whole thing is a header
return text;
elseif #text == 2 then
-- There was a newline, but that was all that was in the string.
return nil;
elseif foundIndex == 1 then
-- There was a newline at the very beginning...the whole rest of the string is a header
return text:sub(3);
elseif foundIndex == #text - 1 then
-- There was a newline at the very end...the whole rest of the string is a header
return text:sub(1, foundIndex - 1);
else
-- There was a newline somewhere in the middle...everything before it is the header and everything after it is the non-header
return text:sub(1, foundIndex - 1), text:sub(foundIndex + 2);
end
end
function FormatValueWithSign(value)
local formatString = value < 0 and SYMBOLIC_NEGATIVE_NUMBER or SYMBOLIC_POSITIVE_NUMBER;
return formatString:format(math.abs(value));
end
function FormatLargeNumber(amount)
amount = tostring(amount);
local newDisplay = "";
local strlen = amount:len();
--Add each thing behind a comma
for i=4, strlen, 3 do
newDisplay = LARGE_NUMBER_SEPERATOR..amount:sub(-(i - 1), -(i - 3))..newDisplay;
end
--Add everything before the first comma
newDisplay = amount:sub(1, (strlen % 3 == 0) and 3 or (strlen % 3))..newDisplay;
return newDisplay;
end
COPPER_PER_SILVER = 100;
SILVER_PER_GOLD = 100;
COPPER_PER_GOLD = COPPER_PER_SILVER * SILVER_PER_GOLD;
function FormatLargeNumber(amount)
amount = tostring(amount);
local newDisplay = "";
local strlen = string.len(amount);
-- Insert a separator before each group of three trailing digits.
for i = 4, strlen, 3 do
newDisplay = LARGE_NUMBER_SEPERATOR ..
string.sub(amount, -(i - 1), -(i - 3)) .. newDisplay;
end
-- Prepend everything before the first separator.
local lead = (math.mod(strlen, 3) == 0) and 3 or math.mod(strlen, 3);
newDisplay = string.sub(amount, 1, lead) .. newDisplay;
return newDisplay;
local COPPER_PER_SILVER = COPPER_PER_SILVER;
local SILVER_PER_GOLD = SILVER_PER_GOLD;
local COPPER_PER_GOLD = COPPER_PER_GOLD;
MoneyStringConstants =
{
SeparateThousands = true,
DontSeparateThousands = false,
CheckGoldThreshold = true,
DontCheckGoldThreshold = false,
ShowZeroAsGold = true,
ShowZeroAsLowerDenomination = false,
};
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold, color)
local goldString, silverString, copperString;
local gold = floor(money / (COPPER_PER_SILVER * SILVER_PER_GOLD));
local silver = floor((money - (gold * COPPER_PER_SILVER * SILVER_PER_GOLD)) / COPPER_PER_SILVER);
local copper = mod(money, COPPER_PER_SILVER);
if (separateThousands) then
goldString = GOLD_AMOUNT_TEXTURE_STRING:format(FormatLargeNumber(gold), 0, 0);
else
goldString = GOLD_AMOUNT_TEXTURE:format(gold, 0, 0);
end
silverString = SILVER_AMOUNT_TEXTURE:format(silver, 0, 0);
copperString = COPPER_AMOUNT_TEXTURE:format(copper, 0, 0);
if showZeroAsGold and ( money == 0 ) then
return color and color:WrapTextInColorCode(goldString) or goldString;
end
copper = FormatDisplayCopper(checkGoldThreshold, gold, silver, copper);
local moneyString = "";
local separator = "";
if ( gold > 0 ) then
moneyString = goldString;
separator = " ";
end
if ( silver > 0 ) then
moneyString = moneyString..separator..silverString;
separator = " ";
end
if ( copper > 0 or moneyString == "" ) then
moneyString = moneyString..separator..copperString;
end
return color and color:WrapTextInColorCode(moneyString) or moneyString;
end
function FormatDisplayCopper(checkGoldThreshold, gold, silver, copper)
return (checkGoldThreshold and gold > Constants.MoneyFormattingConstants.GOLD_REWARD_THRESHOLD_TO_HIDE_COPPER ) and 0 or copper;
end
function FormatPercentage(percentage, roundToNearestInteger)
if roundToNearestInteger then
percentage = Round(percentage * 100);
else
percentage = percentage * 100;
end
return string.format(PERCENTAGE_STRING, percentage);
if roundToNearestInteger then
percentage = Round(percentage * 100);
else
percentage = percentage * 100;
end
return PERCENTAGE_STRING:format(percentage);
end
function FormatPercentageRounded(value)
local roundToNearestInteger = true;
return FormatPercentage(value, roundToNearestInteger);
end
function FormatFraction(numerator, denominator)
return string.format(GENERIC_FRACTION_STRING, numerator, denominator);
return GENERIC_FRACTION_STRING:format(numerator, denominator);
end
function SplitTextIntoLines(text, delimiter)
local lines = {};
local startIndex = 1;
local foundIndex = string.find(text, delimiter);
while foundIndex do
table.insert(lines, string.sub(text, startIndex, foundIndex - 1));
startIndex = foundIndex + 2;
foundIndex = string.find(text, delimiter, startIndex);
end
if startIndex <= string.len(text) then
table.insert(lines, string.sub(text, startIndex));
end
return lines;
function GetHighlightedNumberDifferenceString(baseString, newString)
local outputString = "";
-- output string is being built from the new string
local newStringIndex = 1;
-- find a stretch of digits (including . and , because of different locales) - but has to end in a digit
local PATTERN = "([,%.%d]*%d+)";
local start1, end1, baseNumberString = string.find(baseString, PATTERN);
local start2, end2, newNumberString = string.find(newString, PATTERN);
while start1 and start2 do
-- add from the new string until the matched spot
outputString = outputString .. string.sub(newString, newStringIndex, start2 - 1);
newStringIndex = end2 + 1;
if baseNumberString ~= newNumberString then
-- need to remove , and . before comparing numbers because of locales
local scrubbedBaseNumberString = gsub(baseNumberString, "[,%.]", "");
local scrubbedNewNumberString = gsub(newNumberString, "[,%.]", "");
local baseNumber = tonumber(scrubbedBaseNumberString);
local newNumber = tonumber(scrubbedNewNumberString);
if baseNumber and newNumber then
local delta = newNumber - baseNumber;
if delta > 0 then
newNumberString = GREEN_FONT_COLOR_CODE..string.format(newNumberString)..FONT_COLOR_CODE_CLOSE;
elseif delta < 0 then
newNumberString = RED_FONT_COLOR_CODE..string.format(newNumberString)..FONT_COLOR_CODE_CLOSE;
end
end
end
outputString = outputString..newNumberString;
start1, end1, baseNumberString = string.find(baseString, PATTERN, end1 + 1);
start2, end2, newNumberString = string.find(newString, PATTERN, end2 + 1);
end
outputString = outputString .. string.sub(newString, newStringIndex, string.len(newString));
return outputString;
end
function SplitTextIntoHeaderAndNonHeader(text)
local foundIndex = string.find(text, "|n");
local len = string.len(text);
if not foundIndex then
return text; -- no newline: whole thing is a header
elseif len == 2 then
return nil; -- the string was just a newline
elseif foundIndex == 1 then
return string.sub(text, 3); -- leading newline: rest is a header
elseif foundIndex == len - 1 then
return string.sub(text, 1, foundIndex - 1); -- trailing newline
else
return string.sub(text, 1, foundIndex - 1), string.sub(text, foundIndex + 2);
end
function FormatUnreadMailTooltip(tooltip, headerText, senders)
for i, sender in ipairs(senders) do
headerText = headerText.."\n"..sender;
end
tooltip:SetText(headerText);
end
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
-- Text-symbol form ("12g 34s 56c"); for the coin-icon form use
-- GetCoinTextureString below. The checkGoldThreshold arg is accepted for
-- signature compatibility but not applied (it relied on modern Constants tables).
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
local gold = math.floor(money / COPPER_PER_GOLD);
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
local copper = math.mod(money, COPPER_PER_SILVER);
FormattingUtil = {};
local goldString = (separateThousands and FormatLargeNumber(gold) or tostring(gold)) ..
GOLD_AMOUNT_SYMBOL;
local silverString = silver .. SILVER_AMOUNT_SYMBOL;
local copperString = copper .. COPPER_AMOUNT_SYMBOL;
function FormattingUtil.GetCostString(icon, quantity, colorCode, abbreviate)
colorCode = colorCode or HIGHLIGHT_FONT_COLOR_CODE;
if showZeroAsGold and money == 0 then
return goldString;
end
local moneyString = "";
local separator = "";
if gold > 0 then
moneyString = goldString;
separator = " ";
end
if silver > 0 then
moneyString = moneyString .. separator .. silverString;
separator = " ";
end
if copper > 0 or moneyString == "" then
moneyString = moneyString .. separator .. copperString;
end
return moneyString;
local markup = CreateTextureMarkup(icon, 64, 64, 16, 16, 0, 1, 0, 1);
local amountString;
if abbreviate then
amountString = AbbreviateNumbers(quantity);
else
amountString = BreakUpLargeNumbers(quantity);
end
return ("%s%s %s|r"):format(colorCode, amountString, markup);
end
function FormattingUtil.GetItemCostString(itemID, quantity, colorCode, abbreviate)
local icon = C_Item.GetItemIconByID(itemID);
if icon then
return FormattingUtil.GetCostString(icon, quantity, colorCode, abbreviate);
end
return "";
end
function FormattingUtil.AddLeadingZeroes(digits, numZeroes, zeroesColor)
numZeroes = numZeroes or 7;
zeroesColor = zeroesColor or GRAY_FONT_COLOR;
local digitsString = tostring(digits);
local numDigits = #digitsString;
if numDigits < numZeroes then
local leadingZeroes = "";
for i = 1, (numZeroes - numDigits) do
leadingZeroes = leadingZeroes.."0";
end
leadingZeroes = zeroesColor:WrapTextInColorCode(leadingZeroes);
return leadingZeroes .. digits;
end
return digits;
end
function ReplaceGenderTokens(string, gender)
if not strfind(string, "%$") then
return string;
end
-- This is a very simple parser that will only handle $G/$g tokens
return gsub(string, "$[Gg]([^:]+):([^;]+);", "%"..gender);
end
+353 -50
View File
@@ -1,71 +1,191 @@
-- Backport of FrameXML/FrameUtil.lua (core subset) to vanilla 1.12.
-- Backport of retail Blizzard_SharedXMLBase/FrameUtil.lua to vanilla 1.12 / Lua 5.0.
--
-- Only the dependency-light, commonly-called helpers are ported. Omitted:
-- - the fade/flash functions (UIFrameFade*/UIFrameFlash*) are already
-- native in 1.12.
-- - the scale/layout/secure helpers (rely on APIs absent here).
-- Implementation differences from the retail source:
-- - Not ported: the fade and flash functions (UIFrameFade*, UIFrameFlash*,
-- FADEFRAMES, FLASHFRAMES). 1.12's FrameXML defines them in UIParent.lua,
-- and UIParent's OnUpdate drives them.
-- - Not ported: FrameUtil.RegisterFrameForEventCallbacks,
-- UnregisterFrameForEventCallbacks and RegisterFrameForEventCallbackFields
-- (no frame:RegisterEventCallback), and ApplyDefaultScale (no
-- GetDefaultScale).
-- - FrameUtil.SetParentMaintainRenderLayering does not carry the frame's
-- window over. 1.12 has one window and no GetWindow/SetWindow.
-- - IsScrollingMessageFrame, at the bottom, is from retail's
-- Blizzard_SharedXML/ScrollingMessageFrame.lua.
FrameUtil = FrameUtil or {};
FrameUtil = {};
function FrameUtil.RegisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:RegisterEvent(event);
end
function FrameUtil.RegisterUpdateFunction(frame, frequencySeconds, func)
-- Prevents the OnUpdate handler from running the same frame it was
-- removed.
frame.canUpdate = true;
local elapsed = frequencySeconds;
frame:SetScript("OnUpdate", function(self, dt)
if self.canUpdate then
elapsed = elapsed - dt;
if elapsed <= 0 then
elapsed = frequencySeconds;
func(frame, dt);
end
end
end);
end
function FrameUtil.RegisterFrameForUnitEvents(frame, events, ...)
for i, event in ipairs(events) do
frame:RegisterUnitEvent(event, unpack(arg));
end
function FrameUtil.UnregisterUpdateFunction(frame)
frame.canUpdate = false;
frame:SetScript("OnUpdate", nil);
end
function FrameUtil.RegisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:RegisterEvent(event);
end
end
function FrameUtil.UnregisterFrameForEvents(frame, events)
for i, event in ipairs(events) do
frame:UnregisterEvent(event);
end
for i, event in ipairs(events) do
frame:UnregisterEvent(event);
end
end
function FrameUtil.RegisterFrameForUnitEvents(frame, events, ...)
for i, event in ipairs(events) do
frame:RegisterUnitEvent(event, ...);
end
end
function FrameUtil.DialogStyleGlobalMouseDown(frame, buttonName, ...)
local mouseFoci = GetMouseFoci();
if DoesAncestryIncludeAny(frame, mouseFoci) then
return;
end
for i = 1, select("#", ...) do
local alternateFrame = select(i, ...);
if DoesAncestryIncludeAny(alternateFrame, mouseFoci) then
return;
end
end
frame:Hide();
end
local StandardScriptHandlerSet = {
OnLoad = true,
OnShow = true,
OnHide = true,
OnEvent = true,
OnEnter = true,
OnLeave = true,
OnClick = true,
OnDragStart = true,
OnReceiveDrag = true,
-- Other scripts can/should be added here as needed.
-- Many OnUpdates are set dynamically. Leave this off for now.
-- OnUpdate = false,
};
-- ... is a list of tables to mixin.
function FrameUtil.SpecializeFrameWithMixins(frame, ...)
Mixin(frame, ...);
FrameUtil.ReflectStandardScriptHandlers(frame);
end
function FrameUtil.ReflectStandardScriptHandlers(frame)
for scriptHandlerKey, shouldBeSet in pairs(StandardScriptHandlerSet) do
local scriptHandler = frame[scriptHandlerKey];
if scriptHandler ~= nil then
frame:SetScript(scriptHandlerKey, scriptHandler);
end
end
if frame.OnLoad then
frame:OnLoad();
end
if frame.OnShow and frame:IsVisible() then
frame:OnShow();
end
end
-- This doesn't strictly speaking require a frame, but that's the likely usage of it so I'm leaving it here for now.
function FrameUtil.RegisterForVariablesLoaded(frame, loadMethod)
if frame.savedVariablesEventRegistered then
error("Cannot re-register for variables loaded.");
return;
end
frame.savedVariablesEventRegistered = true;
local function VariablesLoadedCallback(callbackSelf)
loadMethod(frame);
end
EventUtil.ContinueOnVariablesLoaded(VariablesLoadedCallback);
end
-- Only expected to return differently in glues and in-game.
function FrameUtil.GetRootParent(frame)
local parent = frame:GetParent();
while parent do
local nextParent = parent:GetParent();
if not nextParent then
break;
end
parent = nextParent;
end
return parent;
local parent = frame:GetParent();
while parent do
local nextParent = parent:GetParent();
if not nextParent then
break;
end
parent = nextParent;
end
return parent;
end
-- Reparents while preserving the frame's render layering.
function FrameUtil.SetParentMaintainRenderLayering(frame, parent)
local origStrata = frame:GetFrameStrata();
local origFrameLevel = frame:GetFrameLevel();
frame:SetParent(parent);
frame:SetFrameStrata(origStrata);
frame:SetFrameLevel(origFrameLevel);
function FrameUtil.CreateFrame(name, parent, template)
-- NOTE: This use of the template type is not strictly correct, but should mostly work.
local templateInfo = C_XMLUtil.GetTemplateInfo(template);
local frameType = templateInfo and templateInfo.type or "Frame";
return CreateFrame(frameType, name, parent, template);
end
function FrameUtil.UpdateScaleForFit(frame, extraWidth, extraHeight)
extraWidth = extraWidth or 0;
extraHeight = extraHeight or 0;
FrameUtil.UpdateScaleForFitSpecific(frame, frame:GetWidth() + extraWidth, frame:GetHeight() + extraHeight);
end
function FrameUtil.UpdateScaleForFitSpecific(frame, specificWidth, specificHeight)
frame:SetScale(1);
local topLevelParent = GetAppropriateTopLevelParent();
local horizRatio = topLevelParent:GetWidth() / (specificWidth or frame:GetWidth());
local vertRatio = topLevelParent:GetHeight() / (specificHeight or frame:GetHeight());
if (horizRatio < 1 or vertRatio < 1) then
frame:SetScale(min(horizRatio, vertRatio));
end
end
function DoesAncestryInclude(ancestry, frame)
if ancestry then
local currentFrame = frame;
while currentFrame do
if currentFrame == ancestry then
return true;
end
currentFrame = currentFrame:GetParent();
end
end
return false;
if ancestry then
local currentFrame = frame;
while currentFrame do
if currentFrame == ancestry then
return true;
end
currentFrame = currentFrame:GetParent();
end
end
return false;
end
function DoesAncestryIncludeAny(ancestry, frames)
for _, frame in ipairs(frames) do
if DoesAncestryInclude(ancestry, frame) then
return true;
end
end
return false;
for _, frame in ipairs(frames) do
if DoesAncestryInclude(ancestry, frame) then
return true;
end
end
return false;
end
function GetUnscaledFrameRect(frame, scale)
@@ -79,6 +199,29 @@ function GetUnscaledFrameRect(frame, scale)
return frameLeft / scale, frameBottom / scale, frameWidth / scale, frameHeight / scale;
end
function GetScaledCenter(frame)
local x, y = frame:GetCenter();
local effectiveScale = frame:GetEffectiveScale();
if x == nil or y == nil or effectiveScale == nil then
-- Defaulted returned for diagnosing invalid rects in layout frames.
local defaulted = true;
return 0, 0, defaulted;
end
return x * effectiveScale, y * effectiveScale;
end
function FitToParent(parent, frame)
local horizRatio = parent:GetWidth() / frame:GetWidth();
local vertRatio = parent:GetHeight() / frame:GetHeight();
if ( horizRatio < 1 or vertRatio < 1 ) then
frame:SetScale(min(horizRatio, vertRatio));
frame:SetPoint("CENTER", 0, 0);
end
end
local alternateTopLevelParent;
function SetAlternateTopLevelParent(parent)
alternateTopLevelParent = parent;
@@ -105,11 +248,171 @@ function SetAppropriateTopLevelParent(frame)
frame:SetParent(parent);
end
end
function GetAppropriateTooltip()
return UIParent and GameTooltip or GlueTooltip;
end
-- Sets a frame parent, but retains old frame strata and frame level
function FrameUtil.SetParentMaintainRenderLayering(frame, parent)
local origStrata = frame:GetFrameStrata();
local origFrameLevel = frame:GetFrameLevel();
frame:SetParent(parent);
frame:SetFrameStrata(origStrata);
frame:SetFrameLevel(origFrameLevel);
end
-- Sets a frame to the appropriate top level parent.
function FrameUtil.RegisterForTopLevelParentChanged(frame)
EventRegistry:RegisterCallback("UI.AlternateTopLevelParentChanged", function (callbackFrame)
FrameUtil.UpdateTopLevelParent(frame);
end, frame);
end
-- Sets a frame to the appropriate top level parent.
function FrameUtil.UpdateTopLevelParent(frame)
local oldParent = frame:GetParent();
local newParent = GetAppropriateTopLevelParent();
if newParent and newParent ~= oldParent then
FrameUtil.SetParentMaintainRenderLayering(frame, newParent);
end
end
-- AnimTransitionMixin - A configurable mixin for animated transitions when showing and hiding.
-- Alpha and/or scale could be added in the future if needed.
-- Mixes in and initializes the AnimTransitionMixin to a frame to give it animated show/hide capabilities.
-- config: see DEFAULT_ANIM_TRANSITION_CONFIG for parameters.
function FrameUtil.InitFrameAnimTransition(frame, config)
Mixin(frame, AnimTransitionMixin);
frame:InitAnimTransition(config);
end
AnimTransitionMixinTransitionType = {
SlideLeft = 1,
SlideRight = 2,
SlideTop = 3,
SlideBottom = 4,
};
AnimTransitionMixin = {};
local DEFAULT_ANIM_TRANSITION_CONFIG = {
distanceX = nil, -- If nil, uses frame width
distanceY = nil, -- If nil, uses frame height
translationType = nil, -- Value from AnimTransitionMixinTransitionType enum
duration = 0.3, -- Animation duration in seconds
easingFunction = nil, -- Easing function from EasingUtil
hideOnAnimOut = false, -- Hide the frame when out animation completes
onAnimInFinish = nil, -- Callback when in animation finishes
onAnimOutFinish = nil, -- Callback when out animation finishes
};
-- Initialize the animation configuration for this instance
-- config: A table with optional properties matching DEFAULT_ANIM_TRANSITION_CONFIG
function AnimTransitionMixin:InitAnimTransition(config)
config = config or {};
local defaultConfig = CopyTable(DEFAULT_ANIM_TRANSITION_CONFIG);
self.animTransitionConfig = Mixin(defaultConfig, config);
end
function AnimTransitionMixin:StartAnimIn()
if not self.animTransitionConfig then
error("AnimTransitionMixin: Must call InitAnimTransition before StartAnimIn");
return;
end
self:StartAnimTransition(false);
end
function AnimTransitionMixin:StartAnimOut()
if not self.animTransitionConfig then
error("AnimTransitionMixin: Must call InitAnimTransition before StartAnimOut");
return;
end
self:StartAnimTransition(true);
end
function AnimTransitionMixin:CancelAnimTransition()
if self.animTransitionCancelFunc then
self.animTransitionCancelFunc();
self.animTransitionCancelFunc = nil;
end
end
-- Only meant to be used internally. Call StartAnimIn or StartAnimOut instead.
function AnimTransitionMixin:StartAnimTransition(isOut)
self:CancelAnimTransition();
local config = self.animTransitionConfig;
if not isOut then
self:Show();
end
local distanceX, distanceY = self:GetTranslationDeltas(config.translationType, isOut);
local needsClampRestore = false;
if distanceX ~= 0 or distanceY ~= 0 then
if self:IsClampedToScreen() then
needsClampRestore = true;
self:SetClampedToScreen(false);
end
-- Set starting state for "in" animation (reverse of the final state)
if not isOut then
self:AdjustPointsOffset(-distanceX, -distanceY);
end
end
local variationCallback = ScriptAnimationUtil.GenerateEasedVariationCallback(config.easingFunction, distanceX, distanceY);
local function OnAnimFinish()
if isOut and config.hideOnAnimOut then
self:Hide();
end
if needsClampRestore then
self:SetClampedToScreen(true);
end
if isOut then
if distanceX ~= 0 or distanceY ~= 0 then
self:AdjustPointsOffset(-distanceX, -distanceY);
end
end
self.animTransitionCancelFunc = nil;
local callback = isOut and config.onAnimOutFinish or config.onAnimInFinish;
if callback then
callback(self);
end
end
self.animTransitionCancelFunc = ScriptAnimationUtil.StartScriptAnimation(self, variationCallback, config.duration, OnAnimFinish);
end
function AnimTransitionMixin:GetTranslationDeltas(translationType, isOut)
if translationType == AnimTransitionMixinTransitionType.SlideLeft then
local width = self.animTransitionConfig.distanceX or self:GetWidth();
return isOut and -width or width, 0;
elseif translationType == AnimTransitionMixinTransitionType.SlideRight then
local width = self.animTransitionConfig.distanceX or self:GetWidth();
return isOut and width or -width, 0;
elseif translationType == AnimTransitionMixinTransitionType.SlideTop then
local height = self.animTransitionConfig.distanceY or self:GetHeight();
return 0, isOut and height or -height;
elseif translationType == AnimTransitionMixinTransitionType.SlideBottom then
local height = self.animTransitionConfig.distanceY or self:GetHeight();
return 0, isOut and -height or height;
end
return 0, 0;
end
-- From retail's Blizzard_SharedXML/ScrollingMessageFrame.lua, where the key
-- comes from the Lua intrinsic's XML. 1.12's scrolling message frames are
-- native and have no such key.
@@ -91,6 +91,13 @@ function GameTooltip_ShowCompareItem(self, shift)
end
end
-- Retail's automatic comparison (the alwaysCompareItems cvar) skips a
-- tooltip with this flag. Comparison here runs only when a caller asks for
-- it, so nothing reads the flag.
function GameTooltip_SuppressAutomaticCompareItem(tooltip)
tooltip.suppressAutomaticCompareItem = true;
end
function GameTooltip_SetTitle(tooltip, text, overrideColor, wrap)
tooltip:ClearLines();
GameTooltip_AddColoredLine(tooltip, text, overrideColor or HIGHLIGHT_FONT_COLOR, wrap)
@@ -13,6 +13,9 @@
EventRegistry = CreateFromMixins(CallbackRegistryMixin);
-- Keep every instance field in OnLoad. Addons derive their own registry from
-- this instance (pfUI: CreateFromMixins(EventRegistry) then :OnLoad()), and a
-- field set anywhere else would be shared with the global registry.
function EventRegistry:OnLoad()
CallbackRegistryMixin.OnLoad(self);
self:SetUndefinedEventsAllowed(true);
@@ -0,0 +1,354 @@
-- Backport of retail's Blizzard_ObjectAPI/Mainline/Item.lua.
--
-- Differences from the retail source:
-- - GetItemQualityColor reads ITEM_QUALITY_COLORS directly; retail's
-- ColorManager.GetColorDataForItemQuality returns the same entry.
Item = {};
ItemMixin = {};
--[[static]] function Item:CreateFromItemLocation(itemLocation)
if type(itemLocation) ~= "table" or type(itemLocation.HasAnyLocation) ~= "function" or not itemLocation:HasAnyLocation() then
error("Usage: Item:CreateFromItemLocation(notEmptyItemLocation)", 2);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemLocation(itemLocation);
return item;
end
--[[static]] function Item:CreateFromBagAndSlot(bagID, slotIndex)
if type(bagID) ~= "number" or type(slotIndex) ~= "number" then
error("Usage: Item:CreateFromBagAndSlot(bagID, slotIndex)", 2);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemLocation(ItemLocation:CreateFromBagAndSlot(bagID, slotIndex));
return item;
end
--[[static]] function Item:CreateFromEquipmentSlot(equipmentSlotIndex)
if type(equipmentSlotIndex) ~= "number" then
error("Usage: Item:CreateFromEquipmentSlot(equipmentSlotIndex)", 2);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemLocation(ItemLocation:CreateFromEquipmentSlot(equipmentSlotIndex));
return item;
end
--[[static]] function Item:CreateFromItemLink(itemLink)
if type(itemLink) ~= "string" then
error("Usage: Item:CreateFromItemLink(itemLinkString)", 2);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemLink(itemLink);
return item;
end
--[[static]] function Item:CreateFromItemID(itemID)
if type(itemID) ~= "number" then
error("Usage: Item:CreateFromItemID(itemID)", 2);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemID(itemID);
return item;
end
--[[static]] function Item:CreateFromItemGUID(itemGUID)
if type(itemGUID) ~= "string" then
error("Usage: Item:CreateFromItemGUID(itemGUIDString)", 2);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemGUID(itemGUID);
return item;
end
--[[static]] function Item:DoItemsMatch(item1, item2)
if not item1 or not item2 then
return false;
end
return item1:Matches(item2);
end
function ItemMixin:Matches(item)
if not item then
return false;
end
local itemID = item:GetItemID();
if (itemID ~= nil) and (self:GetItemID() == itemID) then
return true;
end
local itemLocation = item:GetItemLocation();
if (itemLocation ~= nil) and itemLocation:IsEqualTo(self:GetItemLocation()) then
return true;
end
return false;
end
function ItemMixin:SetItemLocation(itemLocation)
self:Clear();
self.itemLocation = itemLocation;
end
function ItemMixin:SetItemLink(itemLink)
self:Clear();
self.itemLink = itemLink;
end
function ItemMixin:SetItemID(itemID)
self:Clear();
self.itemID = itemID;
end
function ItemMixin:SetItemGUID(itemGUID)
self:Clear();
self.itemGUID = itemGUID;
end
function ItemMixin:GetItemLocation()
if self.itemLocation then
return self.itemLocation;
end
if self.itemGUID then
return C_Item.GetItemLocation(self.itemGUID);
end
return nil;
end
function ItemMixin:GetItemGUID()
if self.itemGUID then
return self.itemGUID;
end
if self.itemLocation then
return C_Item.GetItemGUID(self.itemLocation);
end
return nil;
end
function ItemMixin:HasItemLocation()
return self.itemLocation ~= nil;
end
function ItemMixin:Clear()
self.itemLocation = nil;
self.itemLink = nil;
self.itemID = nil;
self.itemGUID = nil;
end
function ItemMixin:IsItemEmpty()
if self:GetStaticBackingItem() then
return not C_Item.DoesItemExistByID(self:GetStaticBackingItem());
end
return not self:IsItemInPlayersControl();
end
function ItemMixin:GetStaticBackingItem()
return self.itemLink or self.itemID;
end
function ItemMixin:IsItemInPlayersControl()
local itemLocation = self:GetItemLocation();
return itemLocation and C_Item.DoesItemExist(itemLocation);
end
-- Item API
function ItemMixin:GetItemID()
local backingItem = self:GetStaticBackingItem();
if backingItem then
return C_Item.GetItemInfoInstant(backingItem) or C_Item.GetItemIDForItemInfo(backingItem);
end
if not self:IsItemEmpty() then
return C_Item.GetItemID(self:GetItemLocation());
end
return nil;
end
function ItemMixin:IsItemLocked()
return self:IsItemInPlayersControl() and C_Item.IsLocked(self:GetItemLocation());
end
function ItemMixin:LockItem()
if self:IsItemInPlayersControl() then
C_Item.LockItem(self:GetItemLocation());
end
end
function ItemMixin:UnlockItem()
if self:IsItemInPlayersControl() then
C_Item.UnlockItem(self:GetItemLocation());
end
end
function ItemMixin:GetItemIcon() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemIconByID(self:GetStaticBackingItem());
end
if not self:IsItemEmpty() then
return C_Item.GetItemIcon(self:GetItemLocation());
end
end
function ItemMixin:GetItemName() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemNameByID(self:GetStaticBackingItem());
end
if not self:IsItemEmpty() then
return C_Item.GetItemName(self:GetItemLocation());
end
return nil;
end
function ItemMixin:GetItemLink() -- requires item data to be loaded
if self.itemLink then
return self.itemLink;
end
if self.itemID then
return (select(2, C_Item.GetItemInfo(self.itemID)));
end
if not self:IsItemEmpty() then
return C_Item.GetItemLink(self:GetItemLocation());
end
return nil;
end
function ItemMixin:GetItemQuality() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemQualityByID(self:GetStaticBackingItem());
end
if not self:IsItemEmpty() then
return C_Item.GetItemQuality(self:GetItemLocation());
end
return nil;
end
function ItemMixin:GetStackCount() -- requires item data to be loaded
if not self:IsItemEmpty() then
return C_Item.GetStackCount(self:GetItemLocation());
end
return nil;
end
function ItemMixin:GetCurrentItemLevel() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return (C_Item.GetDetailedItemLevelInfo(self:GetStaticBackingItem()));
end
if not self:IsItemEmpty() then
return C_Item.GetCurrentItemLevel(self:GetItemLocation());
end
return nil;
end
function ItemMixin:GetItemQualityColor() -- requires item data to be loaded
local itemQuality = self:GetItemQuality();
return ITEM_QUALITY_COLORS[itemQuality]; -- may be nil if item data isn't loaded
end
function ItemMixin:GetItemQualityColorRGB() -- requires item data to be loaded
local colorTbl = self:GetItemQualityColor();
return colorTbl.color:GetRGB();
end
function ItemMixin:GetItemMaxStackSize() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemMaxStackSizeByID(self:GetStaticBackingItem());
end
if not self:IsItemEmpty() then
return C_Item.GetItemMaxStackSize(self:GetItemLocation());
end
return nil;
end
function ItemMixin:IsStackable() -- requires item data to be loaded
local maxStackSize = self:GetItemMaxStackSize();
return maxStackSize and maxStackSize > 1;
end
function ItemMixin:GetInventoryType()
if self:GetStaticBackingItem() then
return C_Item.GetItemInventoryTypeByID(self:GetStaticBackingItem());
end
if not self:IsItemEmpty() then
return C_Item.GetItemInventoryType(self:GetItemLocation());
end
return nil;
end
function ItemMixin:GetInventoryTypeName()
if not self:IsItemEmpty() then
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
end
end
function ItemMixin:IsItemDataCached()
if self:GetStaticBackingItem() then
return C_Item.IsItemDataCachedByID(self:GetStaticBackingItem());
end
if not self:IsItemEmpty() then
return C_Item.IsItemDataCached(self:GetItemLocation());
end
return true;
end
function ItemMixin:IsDataEvictable()
-- Item data could be evicted from the cache
return true;
end
function ItemMixin:ValidateForContinueOnItemLoad(methodName, callbackFunction)
if type(callbackFunction) ~= "function" then
error(("Usage: NonEmptyItem:%s(callbackFunction): invalid callbackFunction"):format(methodName), 3);
end
if self:IsItemEmpty() then
if self.itemLink then
error(("Usage: NonEmptyItem:%s(callbackFunction) invalid itemLink: <%s>"):format(methodName, self.itemLink), 3);
elseif self.itemID then
error(("Usage: NonEmptyItem:%s(callbackFunction) invalid itemID: <%d>"):format(methodName, self.itemID), 3);
end
error(("Usage: NonEmptyItem:%s(callbackFunction): invalid item"):format(methodName), 3);
else
if not self:GetItemID() then
error(("Usage: NonEmptyItem:%s(callbackFunction): item appeared non-empty, but had invalid itemID. itemID: <%s>, itemLink: <%s>"):format(methodName, tostring(self.itemID), tostring(self.itemLink)), 3);
end
end
end
-- Add a callback to be executed when item data is loaded, if the item data is already loaded then execute it immediately
function ItemMixin:ContinueOnItemLoad(callbackFunction)
self:ValidateForContinueOnItemLoad("ContinueOnItemLoad", callbackFunction);
ItemEventListener:AddCallback(self:GetItemID(), callbackFunction);
end
-- Same as ContinueOnItemLoad, except it returns a function that when called will cancel the continue
function ItemMixin:ContinueWithCancelOnItemLoad(callbackFunction)
self:ValidateForContinueOnItemLoad("ContinueWithCancelOnItemLoad", callbackFunction);
return ItemEventListener:AddCancelableCallback(self:GetItemID(), callbackFunction);
end
-- Generic aliases for use with ContinuableContainer
function ItemMixin:ContinueWithCancelOnRecordLoad(callbackFunction)
return self:ContinueWithCancelOnItemLoad(callbackFunction);
end
function ItemMixin:IsRecordDataCached()
return self:IsItemDataCached();
end
+154 -373
View File
@@ -1,415 +1,196 @@
-- Backport of 3.3.5 FrameXML/ItemUtil.lua to vanilla 1.12 / Lua 5.0.
-- Backport of retail's Blizzard_FrameXMLUtil/ItemUtil.lua: the ItemUtil table.
--
-- Implementation differences from the 3.3.5 source:
-- - Many C_Item.* calls the modern code expects are not exposed by
-- ClassicAPI.dll. We shim the missing ones at the top using the
-- stock 1.12 globals (GetItemInfo, GetInventoryItemLink,
-- GetContainerItemLink/Info). DLL-provided functions are left alone
-- via `or` guards so we never clobber a real implementation.
-- - Lua 5.0 has no select(); calls that previously did
-- `select(N, GetItemInfo(...))` use named locals to peel off the
-- return values instead.
-- - CallErrorHandler isn't in 1.12; we route through geterrorhandler().
-- - In 1.12 OnEvent scripts, event name + payload are exposed via the
-- `event`, `arg1`, `arg2`... globals rather than as function args.
-- - 1.12 has no concept of an item GUID and no public item-lock API,
-- so those return nil / no-op.
-- Differences from the retail source:
-- - No ItemButtonUtil and no ItemUtil.DoesAnyItemSlotMatchItemContext.
-- Retail sets the item context from frames this client does not have
-- (scrapping, runeforging, soulbinds, keystones, item upgrades,
-- recrafting, bank tabs), and the stock bag and character frames never
-- ask for it.
-- - No ItemUtil.CreateItemTransmogInfo or ItemTransmogInfoMixin: there is
-- no transmogrification.
-- - ItemUtil.GetItemDetails has no currency path: there are no currencies.
-- - No ItemUtil.GetCraftingReagentCount: it picks between the reagent bank
-- and the account bank, and neither exists.
----------------------------------------------------------------------
Item = {}
ItemMixin = {}
local ItemEventListener
--[[static]] function Item:CreateFromItemLocation(itemLocation)
if type(itemLocation) ~= "table" or type(itemLocation.HasAnyLocation) ~= "function" or not itemLocation:HasAnyLocation() then
error("Usage: Item:CreateFromItemLocation(notEmptyItemLocation)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLocation(itemLocation)
return item
ItemUtil = {};
function ItemUtil.GetItemDetails(itemLink, quantity)
local itemName, itemRarity, itemTexture, _;
itemName, itemLink, itemRarity, _, _, _, _, _, _, itemTexture = C_Item.GetItemInfo(itemLink);
return itemName, itemTexture, quantity, itemRarity, itemLink;
end
--[[static]] function Item:CreateFromBagAndSlot(bagID, slotIndex)
if type(bagID) ~= "number" or type(slotIndex) ~= "number" then
error("Usage: Item:CreateFromBagAndSlot(bagID, slotIndex)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLocation(ItemLocation:CreateFromBagAndSlot(bagID, slotIndex))
return item
function ItemUtil.GetItemHyperlink(itemID)
local _, link = C_Item.GetItemInfo(itemID);
return link;
end
--[[static]] function Item:CreateFromEquipmentSlot(equipmentSlotIndex)
if type(equipmentSlotIndex) ~= "number" then
error("Usage: Item:CreateFromEquipmentSlot(equipmentSlotIndex)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLocation(ItemLocation:CreateFromEquipmentSlot(equipmentSlotIndex))
return item
end
--[[static]] function Item:CreateFromItemLink(itemLink)
if type(itemLink) ~= "string" then
error("Usage: Item:CreateFromItemLink(itemLinkString)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemLink(itemLink)
return item
end
--[[static]] function Item:CreateFromItemID(itemID)
if type(itemID) ~= "number" then
error("Usage: Item:CreateFromItemID(itemID)", 2)
end
local item = CreateFromMixins(ItemMixin)
item:SetItemID(itemID)
return item
end
--[[static]] function Item:CreateFromItemGUID(itemGUID)
if type(itemGUID) ~= "string" then
error("Usage: Item:CreateFromItemGUID(itemGUIDString)", 2);
function ItemUtil.PickupBagItem(itemLocation)
local bag, slot = itemLocation:GetBagAndSlot();
if bag and slot then
C_Container.PickupContainerItem(bag, slot);
end
local item = CreateFromMixins(ItemMixin);
item:SetItemGUID(itemGUID);
return item;
end
--[[static]] function Item:DoItemsMatch(item1, item2)
if not item1 or not item2 then
return false;
function ItemUtil.IterateBagSlots(bag, callback)
-- Only includes the backpack and held bag slots.
for slot = 1, ContainerFrame_GetContainerNumSlots(bag) do
local itemLocation = ItemLocation:CreateFromBagAndSlot(bag, slot);
if C_Item.DoesItemExist(itemLocation) then
if callback(itemLocation) then
return true;
end
end
end
return false;
end
function ItemUtil.IterateInventorySlots(firstSlot, lastSlot, callback)
for slot = firstSlot, lastSlot do
local itemLocation = ItemLocation:CreateFromEquipmentSlot(slot);
if C_Item.DoesItemExist(itemLocation) then
if callback(itemLocation) then
return;
end
end
end
end
function ItemUtil.IteratePlayerInventory(callback)
-- Only includes the backpack and held bag slots.
for bag = Enum.BagIndex.Backpack, NUM_TOTAL_BAG_FRAMES do
if ItemUtil.IterateBagSlots(bag, callback) then
return true;
end
end
return item1:Matches(item2);
end
function ItemMixin:Matches(item)
if not item then
return false;
end
local itemID = item:GetItemID();
if (itemID ~= nil) and (self:GetItemID() == itemID) then
return true;
end
local itemLocation = item:GetItemLocation();
if (itemLocation ~= nil) and itemLocation:IsEqualTo(self:GetItemLocation()) then
return true;
if(C_ActionBar.ShouldShowKeyring()) then
if ItemUtil.IterateBagSlots(KEYRING_CONTAINER, callback) then
return true;
end
end
return false;
end
function ItemMixin:SetItemLocation(itemLocation)
self:Clear()
self.itemLocation = itemLocation
function ItemUtil.IteratePlayerInventoryAndEquipment(callback)
local found = ItemUtil.IteratePlayerInventory(callback);
if not found then
ItemUtil.IterateInventorySlots(INVSLOT_FIRST_EQUIPPED, INVSLOT_LAST_EQUIPPED, callback);
end
end
function ItemMixin:SetItemLink(itemLink)
self:Clear()
self.itemLink = itemLink
function ItemUtil.FilterOwnedItems(itemIDs)
local found = {};
ItemUtil.IteratePlayerInventory(function(itemLocation)
local itemID = C_Item.GetItemID(itemLocation);
TableUtil.TrySet(found, itemID);
end);
local filtered = {};
for index, itemID in ipairs(itemIDs) do
if found[itemID] then
table.insert(filtered, itemID);
end
end
return filtered;
end
function ItemMixin:SetItemID(itemID)
self:Clear()
self.itemID = itemID
function ItemUtil.TransformItemIDsToItems(itemIDs)
local items = {};
for index, itemID in ipairs(itemIDs) do
table.insert(items, Item:CreateFromItemID(itemID));
end
return items;
end
function ItemMixin:SetItemGUID(itemGUID)
self:Clear();
self.itemGUID = itemGUID;
function ItemUtil.TransformItemLocationsToItems(itemLocations)
local items = {};
for index, itemLocation in ipairs(itemLocations) do
table.insert(items, Item:CreateFromItemLocation(itemLocation));
end
return items;
end
function ItemMixin:GetItemLocation()
if self.itemLocation then
return self.itemLocation;
function ItemUtil.TransformItemGUIDsToItems(itemGUIDs)
local items = {};
for index, itemGUID in ipairs(itemGUIDs) do
local item = Item:CreateFromItemGUID(itemGUID);
table.insert(items, item);
end
return items;
end
function ItemUtil.TransformItemLocationItemsToGUIDItems(itemLocations)
local items = {};
for index, itemLocation in ipairs(itemLocations) do
table.insert(items, Item:CreateFromItemLocation(itemLocation));
end
return items;
end
function ItemUtil.GetValidatedItemLocation(equipSlot)
if equipSlot then
local itemLocation = ItemLocation:CreateFromEquipmentSlot(equipSlot);
if itemLocation:IsValid() then
return itemLocation;
end
end
if self.itemGUID then
return C_Item.GetItemLocation(self.itemGUID);
end
return nil;
end
function ItemMixin:HasItemLocation()
return self.itemLocation ~= nil
end
function ItemMixin:Clear()
self.itemLocation = nil
self.itemLink = nil
self.itemID = nil
self.itemGUID = nil
end
function ItemMixin:IsItemEmpty()
if self:GetStaticBackingItem() then
return not C_Item.DoesItemExistByID(self:GetStaticBackingItem())
end
return not self:IsItemInPlayersControl()
end
function ItemMixin:GetStaticBackingItem()
return self.itemLink or self.itemID
end
function ItemMixin:IsItemInPlayersControl()
local itemLocation = self:GetItemLocation()
return itemLocation and C_Item.DoesItemExist(itemLocation)
end
-- Item API
function ItemMixin:GetItemID()
if self:GetStaticBackingItem() then
return (C_Item.GetItemInfoInstant(self:GetStaticBackingItem()))
end
if not self:IsItemEmpty() then
return C_Item.GetItemID(self:GetItemLocation())
end
return nil
end
function ItemMixin:IsItemLocked()
return self:IsItemInPlayersControl() and C_Item.IsLocked(self:GetItemLocation())
end
function ItemMixin:LockItem()
if self:IsItemInPlayersControl() then
C_Item.LockItem(self:GetItemLocation())
end
end
function ItemMixin:UnlockItem()
if self:IsItemInPlayersControl() then
C_Item.UnlockItem(self:GetItemLocation())
end
end
function ItemMixin:GetItemIcon() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemIconByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.GetItemIcon(self:GetItemLocation())
end
end
function ItemMixin:GetItemName() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemNameByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.GetItemName(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetItemLink() -- requires item data to be loaded
if self.itemLink then
return self.itemLink
end
if self.itemID then
return (select(2, C_Item.GetItemInfo(self.itemID)));
end
if not self:IsItemEmpty() then
return C_Item.GetItemLink(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetItemQuality() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemQualityByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.GetItemQuality(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetCurrentItemLevel() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return (C_Item.GetDetailedItemLevelInfo(self:GetStaticBackingItem()))
end
if not self:IsItemEmpty() then
return C_Item.GetCurrentItemLevel(self:GetItemLocation())
end
return nil
end
function ItemMixin:GetItemQualityColor() -- requires item data to be loaded
local itemQuality = self:GetItemQuality()
return ITEM_QUALITY_COLORS[itemQuality] -- may be nil if item data isn't loaded
end
function ItemMixin:GetItemQualityColorRGB() -- requires item data to be loaded
local colorTbl = self:GetItemQualityColor()
return colorTbl.color:GetRGB()
end
function ItemMixin:GetItemMaxStackSize() -- requires item data to be loaded
if self:GetStaticBackingItem() then
return C_Item.GetItemMaxStackSizeByID(self:GetStaticBackingItem());
function ItemUtil.GetEmptyEquipSlotTooltipForSlotName(equipSlotName)
if equipSlotName then
return _G[strupper(equipSlotName)];
end
if not self:IsItemEmpty() then
return C_Item.GetItemMaxStackSize(self:GetItemLocation());
end
return nil;
end
function ItemMixin:IsStackable() -- requires item data to be loaded
local maxStackSize = self:GetItemMaxStackSize();
return maxStackSize and maxStackSize > 1;
function ItemUtil.GetEmptyEquipSlotTooltip(equipSlot)
if equipSlot then
local _, _, _, slotName = C_PaperDollInfo.GetInventorySlotInfoForInvSlot(equipSlot);
return ItemUtil.GetEmptyEquipSlotTooltipForSlotName(slotName);
end
return nil;
end
function ItemMixin:GetInventoryType()
if self:GetStaticBackingItem() then
return C_Item.GetItemInventoryTypeByID(self:GetStaticBackingItem())
end
function ItemUtil.DisplayEquipSlotTooltip(frame, tooltip, equipSlot, suppressComparison, checkRelic)
if suppressComparison then
GameTooltip_SuppressAutomaticCompareItem(tooltip);
end
if not self:IsItemEmpty() then
return C_Item.GetItemInventoryType(self:GetItemLocation())
end
return nil
local hasItem, hasCooldown, repairCost = tooltip:SetInventoryItem("player", equipSlot);
if not hasItem then
-- This SetOwner is needed because calling SetInventoryItem now hides tooltip if there is no item
tooltip:SetOwner(frame, "ANCHOR_RIGHT");
local asRelic = checkRelic and UnitHasRelicSlot("player");
if asRelic then
tooltip:SetText(RELICSLOT);
else
local text = ItemUtil.GetEmptyEquipSlotTooltip(equipSlot);
tooltip:SetText(text);
tooltip:Show();
end
end
end
function ItemMixin:GetItemGUID()
if self:GetStaticBackingItem() then
return nil
end
function ItemUtil.GetEquipSlotTexture(equipSlot)
-- First try to look up the equipped item's texture from the equipped item in the slot
-- If there's no equipment there, then use the paper doll slot texture for an empty slot
-- If this cooldown item doesn't refer to equipment, don't return a texture.
if equipSlot then
local itemLocation = ItemUtil.GetValidatedItemLocation(equipSlot);
if itemLocation then
local icon = C_Item.GetItemIcon(itemLocation);
return icon;
end
if not self:IsItemEmpty() then
return C_Item.GetItemGUID(self:GetItemLocation())
end
return nil
end
local _, icon = C_PaperDollInfo.GetInventorySlotInfoForInvSlot(equipSlot);
return icon;
end
function ItemMixin:GetInventoryTypeName()
if not self:IsItemEmpty() then
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
end
end
function ItemMixin:IsItemDataCached()
if self:GetStaticBackingItem() then
return C_Item.IsItemDataCachedByID(self:GetStaticBackingItem())
end
if not self:IsItemEmpty() then
return C_Item.IsItemDataCached(self:GetItemLocation())
end
return true
end
function ItemMixin:IsDataEvictable()
return true
end
-- Default error handler routing: stock 1.12 has no CallErrorHandler.
local function ItemUtilErrorHandler(err)
local h = geterrorhandler and geterrorhandler()
if h then return h(err) end
end
function ItemMixin:ContinueOnItemLoad(callbackFunction)
if type(callbackFunction) ~= "function" or self:IsItemEmpty() then
error("Usage: NonEmptyItem:ContinueOnLoad(callbackFunction)", 2)
end
ItemEventListener:AddCallback(self:GetItemID(), callbackFunction)
end
function ItemMixin:ContinueWithCancelOnItemLoad(callbackFunction)
if type(callbackFunction) ~= "function" or self:IsItemEmpty() then
error("Usage: NonEmptyItem:ContinueWithCancelOnItemLoad(callbackFunction)", 2)
end
return ItemEventListener:AddCancelableCallback(self:GetItemID(), callbackFunction)
end
--[ Item Event Listener ]
ItemEventListener = CreateFrame("Frame")
ItemEventListener.callbacks = {}
-- 1.12 OnEvent receives no function args; event name and payload live in
-- the `event`, `arg1`, ... globals.
ItemEventListener:SetScript("OnEvent", function()
if event == "ITEM_DATA_LOAD_RESULT" then
local itemID, success = arg1, arg2
if success then
ItemEventListener:FireCallbacks(itemID)
else
ItemEventListener:ClearCallbacks(itemID)
end
end
end)
ItemEventListener:RegisterEvent("ITEM_DATA_LOAD_RESULT")
local CANCELED_SENTINEL = -1
function ItemEventListener:AddCallback(itemID, callbackFunction)
local callbacks = self:GetOrCreateCallbacks(itemID)
table.insert(callbacks, callbackFunction)
C_Item.RequestLoadItemDataByID(itemID)
end
function ItemEventListener:AddCancelableCallback(itemID, callbackFunction)
local callbacks = self:GetOrCreateCallbacks(itemID)
table.insert(callbacks, callbackFunction)
C_Item.RequestLoadItemDataByID(itemID)
local index = table.getn(callbacks)
return function()
if table.getn(callbacks) > 0 and callbacks[index] ~= CANCELED_SENTINEL then
callbacks[index] = CANCELED_SENTINEL
return true
end
return false
end
end
function ItemEventListener:FireCallbacks(itemID)
local callbacks = self:GetCallbacks(itemID)
if callbacks then
self:ClearCallbacks(itemID)
for i, callback in ipairs(callbacks) do
if callback ~= CANCELED_SENTINEL then
xpcall(callback, ItemUtilErrorHandler)
end
end
for i = table.getn(callbacks), 1, -1 do
callbacks[i] = nil
end
end
end
function ItemEventListener:ClearCallbacks(itemID)
self.callbacks[itemID] = nil
end
function ItemEventListener:GetCallbacks(itemID)
return self.callbacks[itemID]
end
function ItemEventListener:GetOrCreateCallbacks(itemID)
local callbacks = self.callbacks[itemID]
if not callbacks then
callbacks = {}
self.callbacks[itemID] = callbacks
end
return callbacks
return nil;
end
+463 -118
View File
@@ -1,21 +1,26 @@
-- Backport of FrameXML/Pools.lua to vanilla 1.12 / Lua 5.0.
-- Backport of retail Blizzard_SharedXMLBase/Pools.lua to vanilla 1.12 / Lua 5.0.
--
-- Implementation differences from the modern source:
-- - 1.12 has no taint/secure system, so the entire Secure*/Proxy
-- machinery (SecureTypes, ProxyUtil, securecallfunction,
-- CreateForbiddenFrame) is dropped. Only the Unsecured pools and
-- pool collections exist; the canonical `CreateObjectPool` /
-- `CreateFramePool` / etc. globals are aliased to them at the
-- bottom of the file.
-- - FrameUtil.SpecializeFrameWithMixins isn't a thing in 1.12; the
-- "table specialization" branch is inlined as a plain Mixin().
-- - CreateMaskTexture and CreateActor aren't in 1.12 — the
-- corresponding pool creators are kept as stubs so chunk-load of
-- consumer addons doesn't fail; they'll error at Acquire() time
-- if anything actually invokes them.
-- - Lua 5.0 has no `...` as expression; the converter mixins use
-- `unpack(arg)` to forward varargs (spans arg[1..arg.n] via getn;
-- this build's unpack ignores explicit i/j — the count lives in `.n`).
-- Implementation differences from the retail source:
-- - 1.12 has no secret values, so SecureObjectPoolMixin does not override
-- CheckAllowReleaseObject to refuse them (as in SecureTypes.lua).
-- - 1.12 has no CreateForbiddenFrame, so a pool asked for forbidden frames
-- makes normal ones. It has no CreateActor, so an actor pool errors when
-- it creates an object.
local SpecializeFrameWithMixins = FrameUtil.SpecializeFrameWithMixins;
local CreateSecureMap = SecureTypes.CreateSecureMap;
local CreateSecureStack = SecureTypes.CreateSecureStack;
local CreateSecureNumber = SecureTypes.CreateSecureNumber;
local CreateProxy = ProxyUtil.CreateProxy;
local CreateProxyDirectory = ProxyUtil.CreateProxyDirectory;
local CreateProxyMixin = ProxyUtil.CreateProxyMixin;
local SetPrivateReference = ProxyUtil.SetPrivateReference;
local ReleasePrivateReference = ProxyUtil.ReleasePrivateReference;
local ProxyConvertablePrivateMixin = CreateFromMixins(ProxyConvertableMixin);
local CreateFromMixinsPrivate = CreateFromMixins;
local enableProxyReporting = false; -- For debugging purposes only.
local Proxies = CreateProxyDirectory("Pools.lua", enableProxyReporting);
local ObjectPoolBaseMixin = {};
@@ -36,9 +41,9 @@ end
local function GetObjectIsInvalidMsg(object, poolOrCollection)
if poolOrCollection:DoesObjectBelongToPool(object) then
return string.format("Attempted to release inactive object '%s'", tostring(object));
return ("Attempted to release inactive object '%s'"):format(tostring(object));
else
return string.format("Attempted to release object '%s' that doesn't belong to this pool", tostring(object));
return ("Attempted to release object '%s' that doesn't belong to this pool"):format(tostring(object));
end
end
@@ -58,6 +63,14 @@ function ObjectPoolBaseMixin:Acquire()
]]--
assert(type(object) == "table");
--[[
The reset function will error if forbidden actions are attempted insecurely,
particularly in scenarios involving forbidden and protected frames. If an error
is thrown, it will do so before we make any further modifications to this pool.
Note this does create a potential for a dangling frame or region, but that is less of a
concern than mutating the pool.
]]--
self:CallReset(object, new);
end
@@ -65,6 +78,12 @@ function ObjectPoolBaseMixin:Acquire()
return object, new;
end
function ObjectPoolBaseMixin:CheckAllowReleaseObject(object)
-- Override in a derived mixin if there's any conditional circumstances
-- to prevent releasing an object (and probably assert, too).
return true;
end
function ObjectPoolBaseMixin:Release(object, canFailToFindObject)
local active = self:IsActive(object);
@@ -76,11 +95,21 @@ function ObjectPoolBaseMixin:Release(object, canFailToFindObject)
the case where the object was not found in any pool.
]]--
if not canFailToFindObject then
assertsafe(active, GetObjectIsInvalidMsg(object, self));
assertsafe(active, GetObjectIsInvalidMsg, object, self);
end
if not self:CheckAllowReleaseObject(object) then
return false;
end
if active then
--[[
The reset function will error if forbidden actions are attempted insecurely,
particularly in scenarios involving forbidden and protected frames. If an error
is thrown, it will do so before we make any further modifications to this pool.
]]--
self:CallReset(object);
self:ReclaimObject(object);
end
@@ -93,7 +122,14 @@ function ObjectPoolBaseMixin:Dump()
end
end
local ObjectPoolMixin = CreateFromMixins(ObjectPoolBaseMixin);
--[[
ObjectPoolMixin is not exposed for use at this time. All code requiring pools or
pool collections are currently using the secure variants. If you need a proxyless pool
for any reason, you'll need to expose a function to create this.
You'll also need to create a PoolCollectionMixin that doesn't make use of any secure types.
]]--
local ObjectPoolMixin = CreateFromMixinsPrivate(ObjectPoolBaseMixin);
function ObjectPoolMixin:Init(createFunc, resetFunc, capacity)
self.createFunc = createFunc;
@@ -166,6 +202,93 @@ function ObjectPoolMixin:GetNumActive()
return self.activeObjectCount;
end
local SecureObjectPoolMixin = CreateFromMixinsPrivate(ObjectPoolBaseMixin, ProxyConvertablePrivateMixin);
function SecureObjectPoolMixin:Init(proxy, createFunc, resetFunc, capacity)
local tags = ProxyConvertablePrivateMixin.Init(self, proxy, Proxies);
tags[proxy] = "SecureObjectPoolMixin";
self.createFunc = createFunc;
self.resetFunc = resetFunc;
self.activeObjects = CreateSecureMap();
self.inactiveObjects = CreateSecureStack();
self.activeObjectCount = CreateSecureNumber();
Reserve(self, capacity);
end
function SecureObjectPoolMixin:CallReset(object, new)
self.resetFunc(self:ToProxy(), object, new);
end
function SecureObjectPoolMixin:CallCreate()
return self.createFunc(self:ToProxy());
end
function SecureObjectPoolMixin:PopInactiveObject()
return self.inactiveObjects:Pop();
end
function SecureObjectPoolMixin:AddObject(object)
local dummy = true;
self.activeObjects:SetValue(object, dummy);
self.activeObjectCount:Increment();
end
function SecureObjectPoolMixin:ReclaimObject(object)
self.inactiveObjects:Push(object);
self.activeObjects:ClearValue(object);
self.activeObjectCount:Decrement();
end
function SecureObjectPoolMixin:ReleaseAll()
for object in self.activeObjects:Enumerate() do
self:Release(object);
end
end
function SecureObjectPoolMixin:EnumerateActive()
return self.activeObjects:Enumerate();
end
function SecureObjectPoolMixin:GetNextActive(current)
return self.activeObjects:GetNext(current);
end
function SecureObjectPoolMixin:IsActive(object)
return self.activeObjects:HasKey(object);
end
function SecureObjectPoolMixin:DoesObjectBelongToPool(object)
if self:IsActive(object) then
return true;
end
return self.inactiveObjects:Contains(object);
end
function SecureObjectPoolMixin:GetNumActive()
return self.activeObjectCount:GetValue();
end
local ObjectPoolProxyMixin;
do
local Funcs =
{
"Acquire",
"ReleaseAll",
"Release",
"EnumerateActive",
"GetNextActive",
"IsActive",
"GetNumActive",
"DoesObjectBelongToPool",
};
ObjectPoolProxyMixin = CreateProxyMixin(Proxies, SecureObjectPoolMixin, Funcs);
ObjectPoolProxyMixin.__index = ObjectPoolProxyMixin;
end
local function GetPoolKey(template, specialization)
if specialization == nil then
return template.."nil";
@@ -238,9 +361,9 @@ function PoolCollectionBaseMixin:Dump()
end
end
local PoolCollectionMixin = CreateFromMixins(PoolCollectionBaseMixin);
local PoolCollectionMixin = CreateFromMixinsPrivate(PoolCollectionBaseMixin);
function PoolCollectionMixin:Init()
function PoolCollectionMixin:Init(proxy)
self.pools = {};
end
@@ -267,7 +390,7 @@ function PoolCollectionMixin:Release(object)
return;
end
end
assertsafe(false, GetObjectIsInvalidMsg(object, self));
assertsafe(false, GetObjectIsInvalidMsg, object, self);
end
function PoolCollectionMixin:DoesObjectBelongToPool(object)
@@ -307,21 +430,168 @@ function PoolCollectionMixin:EnumerateActive()
end, nil;
end
local SecurePoolCollectionMixin = CreateFromMixinsPrivate(PoolCollectionBaseMixin, ProxyConvertablePrivateMixin);
function SecurePoolCollectionMixin:Init(proxy)
local tags = ProxyConvertablePrivateMixin.Init(self, proxy, Proxies);
tags[proxy] = "SecurePoolCollectionMixin";
self.pools = CreateSecureMap();
end
do
local function Accumulate(poolKey, pool, total)
total:Add(pool:GetNumActive());
end
function SecurePoolCollectionMixin:GetNumActive()
local total = CreateSecureNumber();
self.pools:ExecuteRange(Accumulate, total);
return total:GetValue();
end
end
function SecurePoolCollectionMixin:SetPool(poolKey, pool)
self.pools:SetValue(poolKey, pool);
end
function SecurePoolCollectionMixin:GetPoolByKey(poolKey)
return self.pools:GetValue(poolKey);
end
function SecurePoolCollectionMixin:Release(object)
local canFailToFindObject = true;
for poolKey, pool in self.pools:Enumerate() do
if securecallmethod(pool, "Release", object, canFailToFindObject) then
return;
end
end
assertsafe(false, GetObjectIsInvalidMsg, object, self);
end
do
-- Taint protection reading insecure pool
local function ReleaseAll(poolKey, pool)
pool:ReleaseAll();
end
function SecurePoolCollectionMixin:ReleaseAll()
self.pools:ExecuteRange(ReleaseAll);
end
end
do
-- Taint protection reading insecure pool
local function GetNextActive(pool, currentObject)
return pool:GetNextActive(currentObject);
end
-- Warning: this function only returns the object, unlike a pool that also returns the key.
function SecurePoolCollectionMixin:EnumerateActive()
local currentObject = nil;
local currentPoolKey, currentPool = self.pools:GetNext(currentObject);
return function()
if currentPool then
currentObject = securecallfunction(GetNextActive, currentPool, currentObject);
while not currentObject do
currentPoolKey, currentPool = self.pools:GetNext(currentPoolKey);
if currentPool then
currentObject = securecallfunction(GetNextActive, currentPool, nil);
else
break;
end
end
end
return currentObject;
end, nil;
end
end
function Pool_HideAndClearAnchors(pool, region)
region:Hide();
region:ClearAllPoints();
end
function Pool_HideAndSetToDefaults(pool, region)
region:SetToDefaults();
region:Hide();
end
local function CreateSecureObjectPoolInstance(createFunc, resetFunc, capacity)
local pool = CreateFromMixinsPrivate(SecureObjectPoolMixin);
local proxy = CreateProxy(pool, ObjectPoolProxyMixin);
pool:Init(proxy, createFunc, resetFunc or nop, capacity);
return pool;
end
local function CreateSecureRegionPoolInstance(template, createFunc, resetFunc, capacity)
local pool = CreateSecureObjectPoolInstance(createFunc, resetFunc or Pool_HideAndClearAnchors, capacity);
local proxy = pool:ToProxy();
proxy.GetTemplate = function(self)
return template;
end
return pool;
end
local function CreateSecureFramePoolInstance(frameType, parent, template, resetFunc, forbidden, postCreate, capacity)
local function Create()
local createFrame = forbidden and CreateForbiddenFrame or CreateFrame;
local name = nil;
local frame = createFrame(frameType, name, parent, template);
if postCreate then
postCreate(frame);
end
return frame;
end
return CreateSecureRegionPoolInstance(template, Create, resetFunc, capacity);
end
function ActorPool_HideAndClearModel(actorPool, actor)
actor:ClearModel();
actor:Hide();
end
local function CreateSecureActorPoolInstance(parent, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateActor(name, template);
end
return CreateSecureRegionPoolInstance(template, Create, resetFunc or ActorPool_HideAndClearModel, capacity);
end
local function CreateSecureTexturePoolInstance(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateTexture(name, layer, template, subLayer);
end
return CreateSecureRegionPoolInstance(template, Create, resetFunc, capacity);
end
local function CreateSecureFontStringPoolInstance(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateFontString(name, layer, template, subLayer);
end
return CreateSecureRegionPoolInstance(template, Create, resetFunc, capacity);
end
local function CreateSecureMaskTexturePoolInstance(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateMaskTexture(name, layer, template, subLayer);
end
return CreateSecureRegionPoolInstance(template, Create, resetFunc, capacity);
end
--[[
In addition to a specialization being used to create separate pools for the same template, it can
be used to modify a frame after being created. On modern WoW this delegates to
FrameUtil.SpecializeFrameWithMixins (which also wires up conventional script handlers); on 1.12
we just Mixin() the table into the frame and let the consumer call SetScript() if needed.
be used to modify a frame after being created. See FrameUtil.SpecializeFrameWithMixins
for the behavior when providing tables with conventionally named script handlers included.
]]--
local function ConvertSpecializationToPostCreate(specialization)
local specializationType = type(specialization);
@@ -329,7 +599,7 @@ local function ConvertSpecializationToPostCreate(specialization)
return specialization;
elseif specializationType == "table" then
local function Initializer(frame)
Mixin(frame, specialization);
SpecializeFrameWithMixins(frame, specialization);
end
return Initializer;
end
@@ -337,8 +607,49 @@ local function ConvertSpecializationToPostCreate(specialization)
return nil;
end
--[[
Arguments are packed into a table for ease of implementation in PoolCollectionBaseMixin. which is derived with
different argument signatures. This will also be helpful if we want to access a particular field off the table,
such as 'parent' without dealing with argument position in ...
]]--
local function FramePoolCollection_ArgsToTable(frameType, parent, template, resetFunc, forbidden, specialization, capacity)
local args =
{
frameType = frameType,
parent = parent,
template = template,
resetFunc = resetFunc,
forbidden = forbidden,
specialization = specialization,
capacity = capacity,
};
return args;
end
local FramePoolCollectionConverterMixin = {};
local function SecureCreatePoolKeyFromPoolArgs(args)
return GetPoolKey(args.template, args.specialization);
end
function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
return securecallfunction(SecureCreatePoolKeyFromPoolArgs, args);
end
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
local args = FramePoolCollection_ArgsToTable(...);
return SecurePoolCollectionMixin.GetOrCreatePoolWithArgs(self, args);
end
function FramePoolCollectionConverterMixin:CreatePool(...)
local args = FramePoolCollection_ArgsToTable(...);
return SecurePoolCollectionMixin.CreatePoolWithArgs(self, args);
end
local FramePoolCollectionMixin = CreateFromMixinsPrivate(PoolCollectionMixin, FramePoolCollectionConverterMixin);
function CreateUnsecuredRegionPoolInstance(template, createFunc, resetFunc, capacity)
local pool = CreateFromMixins(ObjectPoolMixin);
local pool = CreateFromMixinsPrivate(ObjectPoolMixin);
pool:Init(createFunc, resetFunc or Pool_HideAndClearAnchors, capacity);
pool.GetTemplate = function(self)
return template;
@@ -347,46 +658,12 @@ function CreateUnsecuredRegionPoolInstance(template, createFunc, resetFunc, capa
return pool;
end
--[[
Args are packed into a table for ease of implementation in PoolCollectionBaseMixin, which is
derived with different argument signatures. This also makes it easy to access named fields
like `parent` off the table without dealing with argument position.
]]--
local function FramePoolCollection_ArgsToTable(frameType, parent, template, resetFunc, forbidden, specialization, capacity)
return {
frameType = frameType,
parent = parent,
template = template,
resetFunc = resetFunc,
forbidden = forbidden, -- ignored on 1.12 (no CreateForbiddenFrame)
specialization = specialization,
capacity = capacity,
};
end
local FramePoolCollectionConverterMixin = {};
function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
return GetPoolKey(args.template, args.specialization);
end
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
end
function FramePoolCollectionConverterMixin:CreatePool(...)
local args = FramePoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
end
local FramePoolCollectionMixin = CreateFromMixins(PoolCollectionMixin, FramePoolCollectionConverterMixin);
do
local function CreateFramePoolInstance(frameType, parent, template, resetFunc, postCreate, capacity)
local function CreateFramePoolInstance(frameType, parent, template, resetFunc, forbidden, postCreate, capacity)
local function Create()
local createFrame = forbidden and CreateForbiddenFrame or CreateFrame;
local name = nil;
local frame = CreateFrame(frameType, name, parent, template);
local frame = createFrame(frameType, name, parent, template);
if postCreate then
postCreate(frame);
@@ -400,18 +677,26 @@ do
function FramePoolCollectionMixin:CreatePoolInternal(args)
local postCreate = ConvertSpecializationToPostCreate(args.specialization);
return CreateFramePoolInstance(args.frameType, args.parent, args.template, args.resetFunc, postCreate, args.capacity);
return CreateFramePoolInstance(args.frameType, args.parent, args.template, args.resetFunc, args.forbidden, postCreate, args.capacity);
end
end
local FontStringPoolCollectionMixin = CreateFromMixins(PoolCollectionMixin);
local SecureFramePoolCollectionMixin = CreateFromMixinsPrivate(SecurePoolCollectionMixin, FramePoolCollectionConverterMixin);
function FontStringPoolCollectionMixin:CreatePoolKeyFromPoolArgs(args)
function SecureFramePoolCollectionMixin:CreatePoolInternal(args)
local postCreate = ConvertSpecializationToPostCreate(args.specialization);
return CreateSecureFramePoolInstance(args.frameType, args.parent, args.template, args.resetFunc, args.forbidden, postCreate, args.capacity);
end
local SecureFontStringPoolCollectionMixin = CreateFromMixinsPrivate(SecurePoolCollectionMixin);
function SecureFontStringPoolCollectionMixin:CreatePoolKeyFromPoolArgs(args)
return args.template;
end
local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, template, resetFunc, capacity)
return {
local args =
{
parent = parent,
layer = layer,
subLayer = subLayer,
@@ -419,36 +704,107 @@ local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, tem
resetFunc = resetFunc,
capacity = capacity,
};
return args;
end
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:GetOrCreatePoolWithArgs(args);
function SecureFontStringPoolCollectionMixin:GetOrCreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(...)
return SecurePoolCollectionMixin.GetOrCreatePoolWithArgs(self, args);
end
function FontStringPoolCollectionMixin:CreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
return self:CreatePoolWithArgs(args);
function SecureFontStringPoolCollectionMixin:CreatePool(...)
local args = FontStringPoolCollection_ArgsToTable(...);
return SecurePoolCollectionMixin.CreatePoolWithArgs(self, args);
end
function FontStringPoolCollectionMixin:CreatePoolInternal(args)
return CreateUnsecuredFontStringPool(args.parent, args.layer, args.subLayer, args.template, args.resetFunc, args.capacity);
function SecureFontStringPoolCollectionMixin:CreatePoolInternal(args)
return CreateSecureFontStringPoolInstance(args.parent, args.layer, args.subLayer, args.template, args.resetFunc, args.capacity);
end
function CreateSecureObjectPool(createFunc, resetFunc, capacity)
return CreateSecureObjectPoolInstance(createFunc, resetFunc, capacity):ToProxy();
end
function CreateSecureFramePool(frameType, parent, template, resetFunc, forbidden, postCreate, capacity)
return CreateSecureFramePoolInstance(frameType, parent, template, resetFunc, forbidden, postCreate, capacity):ToProxy();
end
function CreateSecureTexturePool(parent, layer, subLayer, template, resetFunc, capacity)
return CreateSecureTexturePoolInstance(parent, layer, subLayer, template, resetFunc, capacity):ToProxy();
end
function CreateSecureFontStringPool(parent, layer, subLayer, template, resetFunc, capacity)
return CreateSecureFontStringPoolInstance(parent, layer, subLayer, template, resetFunc, capacity):ToProxy();
end
function CreateSecureActorPool(parent, template, resetFunc, capacity)
return CreateSecureActorPoolInstance(parent, template, resetFunc, capacity):ToProxy();
end
function CreateSecureMaskTexturePool(parent, layer, subLayer, template, resetFunc, capacity)
return CreateSecureMaskTexturePoolInstance(parent, layer, subLayer, template, resetFunc, capacity):ToProxy();
end
do
local Funcs =
{
"GetNumActive",
"Acquire",
"Release",
"ReleaseAll",
"ReleaseAllByTemplate",
"EnumerateActiveByTemplate",
"EnumerateActive",
"IsActive",
"DoesObjectBelongToPool",
--"Dump",
};
local PoolCollectionProxyMixin = CreateProxyMixin(Proxies, SecurePoolCollectionMixin, Funcs);
PoolCollectionProxyMixin.__index = PoolCollectionProxyMixin;
function PoolCollectionProxyMixin:GetPool(...)
local poolCollection = Proxies:ToPrivate(self);
local pool = poolCollection:GetPool(...);
if not pool then
return nil;
end
return pool:ToProxy();
end
function PoolCollectionProxyMixin:CreatePool(...)
local poolCollection = Proxies:ToPrivate(self);
return poolCollection:CreatePool(...):ToProxy();
end
function PoolCollectionProxyMixin:GetOrCreatePool(...)
local poolCollection = Proxies:ToPrivate(self);
return poolCollection:GetOrCreatePool(...):ToProxy();
end
local function CreatePoolCollectionInstance(collectionMixin)
local poolCollection = CreateFromMixinsPrivate(collectionMixin);
local proxy = CreateProxy(poolCollection, PoolCollectionProxyMixin);
poolCollection:Init(proxy);
return poolCollection;
end
function CreateSecureFramePoolCollection()
return CreatePoolCollectionInstance(SecureFramePoolCollectionMixin):ToProxy();
end
function CreateSecureFontStringPoolCollection()
return CreatePoolCollectionInstance(SecureFontStringPoolCollectionMixin):ToProxy();
end
end
function CreateUnsecuredObjectPool(createFunc, resetFunc, capacity)
local pool = CreateFromMixins(ObjectPoolMixin);
local pool = CreateFromMixinsPrivate(ObjectPoolMixin);
pool:Init(createFunc, resetFunc, capacity);
return pool;
end
function CreateUnsecuredFramePool(frameType, parent, template, resetFunc, capacity)
local function Create()
local name = nil;
return CreateFrame(frameType, name, parent, template);
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
function CreateUnsecuredTexturePool(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
@@ -465,19 +821,20 @@ function CreateUnsecuredFontStringPool(parent, layer, subLayer, template, resetF
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
-- Stub: 1.12 has no CreateActor (model actors are Cataclysm+). Acquire()
-- will raise once it actually tries to call parent:CreateActor — kept so
-- that addon code merely referencing the symbol doesn't fail to load.
function CreateUnsecuredActorPool(parent, template, resetFunc, capacity)
function CreateUnsecuredFramePool(frameType, parent, template, resetFunc, capacity)
local function Create()
local name = nil;
return parent:CreateActor(name, template);
return CreateFrame(frameType, name, parent, template);
end
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc or ActorPool_HideAndClearModel, capacity);
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
function CreateUnsecuredFramePoolCollection()
local poolCollection = CreateFromMixinsPrivate(FramePoolCollectionMixin);
poolCollection:Init();
return poolCollection;
end
-- Stub: 1.12 has no CreateMaskTexture (mask textures are post-vanilla).
-- Same caveat as CreateUnsecuredActorPool.
function CreateUnsecuredMaskTexturePool(parent, layer, subLayer, template, resetFunc, capacity)
local function Create()
local name = nil;
@@ -486,28 +843,16 @@ function CreateUnsecuredMaskTexturePool(parent, layer, subLayer, template, reset
return CreateUnsecuredRegionPoolInstance(template, Create, resetFunc, capacity);
end
function CreateUnsecuredFramePoolCollection()
local poolCollection = CreateFromMixins(FramePoolCollectionMixin);
poolCollection:Init();
return poolCollection;
end
function CreateUnsecuredFontStringPoolCollection()
local poolCollection = CreateFromMixins(FontStringPoolCollectionMixin);
poolCollection:Init();
return poolCollection;
end
-- Canonical API aliases. On modern WoW these point at the Secure variants;
-- on 1.12 there's no taint system, so they all go straight to the Unsecured
-- implementations.
CreateObjectPool = CreateUnsecuredObjectPool;
CreateFramePool = CreateUnsecuredFramePool;
CreateTexturePool = CreateUnsecuredTexturePool;
CreateFontStringPool = CreateUnsecuredFontStringPool;
CreateActorPool = CreateUnsecuredActorPool;
CreateFramePoolCollection = CreateUnsecuredFramePoolCollection;
CreateFontStringPoolCollection = CreateUnsecuredFontStringPoolCollection;
CreateMaskTexturePool = CreateUnsecuredMaskTexturePool;
-- Aliases until we determine if we want to change any code to explicitly create
-- the secure or unsecured variant of pools and pool collections.
CreateObjectPool = CreateSecureObjectPool;
CreateFramePool = CreateSecureFramePool;
CreateTexturePool = CreateSecureTexturePool;
CreateFontStringPool = CreateSecureFontStringPool;
CreateActorPool = CreateSecureActorPool;
CreateFramePoolCollection = CreateSecureFramePoolCollection;
CreateFontStringPoolCollection = CreateSecureFontStringPoolCollection;
CreateMaskTexturePool = CreateSecureMaskTexturePool;
-- Script objects created from this file should have their source location set to the calling function, rather than generic Pools.lua internals
AddSourceLocationExclude("Pools.lua");
@@ -0,0 +1,199 @@
-- Verbatim backport of retail's Blizzard_SharedXML/ScriptAnimationUtil.lua.
-- ShakeFrame reads the ShakeStrengthUI cvar, which the DLL registers.
local function NoEasing(progress)
return progress;
end
ScriptAnimationUtil = {};
function ScriptAnimationUtil.GetScriptAnimationLock(region)
if region.scriptedAnimatedAnchorLock then
return false;
end
region.scriptedAnimatedAnchorLock = true;
return true;
end
function ScriptAnimationUtil.ReleaseScriptAnimationLock(region)
region.scriptedAnimatedAnchorLock = nil;
end
function ScriptAnimationUtil.IsScriptAnimationLockActive(region)
return region.scriptedAnimatedAnchorLock ~= nil;
end
-- Returns a callback to cancel the animation.
function ScriptAnimationUtil.StartFadeAnimation(region, duration, onFinish, easingFunction)
easingFunction = easingFunction or NoEasing;
local distanceX = 0;
local distanceY = 0;
local alphaVariation = -region:GetAlpha();
local scaleVariation = 0;
local variationCallback = ScriptAnimationUtil.GenerateEasedVariationCallback(easingFunction, distanceX, distanceY, alphaVariation, scaleVariation);
return ScriptAnimationUtil.StartScriptAnimation(region, variationCallback, duration, function()
region:SetAlpha(0);
if onFinish then
onFinish(region);
end
end);
end
function ScriptAnimationUtil.ShakeFrameRandom(region, magnitude, duration, frequency)
if duration == 0 then
return nop;
end
if frequency <= 0 or ScriptAnimationUtil.IsScriptAnimationLockActive(region) then
return nop;
end
local shake = {};
for i = 1, math.ceil(duration / frequency) do
local xVariation, yVariation = RandomFloatInRange(-magnitude, magnitude), RandomFloatInRange(-magnitude, magnitude);
shake[i] = { x = xVariation, y = yVariation };
end
return ScriptAnimationUtil.ShakeFrame(region, shake, duration, frequency);
end
function ScriptAnimationUtil.ShakeFrame(region, shake, maximumDuration, frequency)
local shakeStrength = tonumber(GetCVar("ShakeStrengthUI"));
if shakeStrength <= 0 or not ScriptAnimationUtil.GetScriptAnimationLock(region) then
return nop;
end
if shake[1] == nil then
return nop;
end
local point, relativeRegion, relativePoint, x, y = region:GetPoint(1);
local shakeIndex = 1;
local endTime = GetTime() + maximumDuration;
local function CancelShake()
region:SetPoint(point, relativeRegion, relativePoint, x, y);
region.shakeTicker:Cancel();
region.shakeTicker = nil;
ScriptAnimationUtil.ReleaseScriptAnimationLock(region);
end
region.shakeTicker = C_Timer.NewTicker(frequency, function()
local xVariation, yVariation = shake[shakeIndex].x * shakeStrength, shake[shakeIndex].y * shakeStrength;
region:SetPoint(point, relativeRegion, relativePoint, x + xVariation, y + yVariation);
shakeIndex = shakeIndex + 1;
if shakeIndex > #shake or GetTime() >= endTime then
CancelShake();
end
end);
return CancelShake;
end
function ScriptAnimationUtil.GenerateEasedVariationCallback(easingFunction, distanceX, distanceY, alpha, scale)
distanceX = distanceX or 0;
distanceY = distanceY or 0;
alpha = alpha or 0;
scale = scale or 0;
local function VariationCallback(elapsedTime, duration)
local progress = (easingFunction and easingFunction(elapsedTime / duration)) or (elapsedTime / duration);
return distanceX * progress, distanceY * progress, alpha * progress, scale * progress;
end
return VariationCallback;
end
function ScriptAnimationUtil.StartScriptAnimation(region, variationCallback, duration, onFinish)
if not ScriptAnimationUtil.GetScriptAnimationLock(region) then
if onFinish then
onFinish(region);
end
return nop;
end
variationCallback = variationCallback;
local point, relativeRegion, relativePoint, x, y = region:GetPoint(1);
local alpha = region:GetAlpha();
local scale = region:GetScale();
local function ApplyVariation(variationX, variationY, variationAlpha, variationScale)
region:SetPoint(point, relativeRegion, relativePoint, x + (variationX or 0), y + (variationY or 0));
region:SetAlpha(alpha + (variationAlpha or 0));
region:SetScale(scale + (variationScale or 0));
end
local startTime = GetTime();
local endTime = startTime + duration;
local elapsedTime = 0;
ApplyVariation(variationCallback(elapsedTime, duration));
local function CancelScriptAnimation()
ApplyVariation(variationCallback(duration, duration));
region.translationTicker:Cancel();
region.translationTicker = nil;
ScriptAnimationUtil.ReleaseScriptAnimationLock(region);
if onFinish then
onFinish(region);
end
end
local function TranslationTickerFunction()
local currentTime = GetTime();
local finished = GetTime() >= endTime;
if finished then
CancelScriptAnimation();
else
elapsedTime = currentTime - startTime;
ApplyVariation(variationCallback(elapsedTime, duration));
end
end
region.translationTicker = C_Timer.NewTicker(0.01, TranslationTickerFunction);
return CancelScriptAnimation;
end
function ScriptAnimationUtil.StartScriptAnimationGeneric(region, variationCallback, duration, frequency, onFinish)
if not ScriptAnimationUtil.GetScriptAnimationLock(region) then
if onFinish then
onFinish(region);
end
return nop;
end
local function CancelScriptAnimation()
if region.scriptAnimationTicker then
variationCallback(region, duration, duration);
region.scriptAnimationTicker:Cancel();
region.scriptAnimationTicker = nil;
ScriptAnimationUtil.ReleaseScriptAnimationLock(region);
if onFinish then
onFinish(region);
end
end
end
local startTime = GetTime();
local endTime = startTime + duration;
local function TranslationTickerFunction()
local currentTime = GetTime();
local finished = currentTime >= endTime;
if finished then
CancelScriptAnimation();
else
local elapsedTime = currentTime - startTime;
variationCallback(region, elapsedTime, duration);
end
end
region.scriptAnimationTicker = C_Timer.NewTicker(frequency or 0.01, TranslationTickerFunction);
return CancelScriptAnimation;
end
File diff suppressed because it is too large Load Diff
@@ -1,792 +1,27 @@
-- Trade-skill *list* links (`|Htrade:...|h`) — backport of the 2.x+ shareable
-- profession link. The link is built entirely in the DLL
-- (`GetTradeSkillListLink` / `GetCraftListLink`, see src/tradeskill/Link.cpp);
-- this file only intercepts clicks on received links and renders the linker's
-- recipe list in a scroll frame (the UI half).
--
-- link: |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
--
-- "Which recipes are known" is a bitfield over the skill line's canonical
-- recipe list (deterministic per DBC). The viewer lists the recipes the linker
-- knows (with an "X of Y" summary of the whole skill line), regardless of what
-- the reader can craft themselves.
-- Profession links (`|Htrade:...|h`), 3.3.5's UI half. The DLL builds the
-- link (GetTradeSkillListLink / GetCraftListLink, src/tradeskill/Link.cpp)
-- and opens a received one in the profession window itself
-- (src/tradeskill/Window.cpp), through ItemRefTooltip:SetHyperlink as
-- SetItemRef already calls it. This file ports the parts of 3.3.5's
-- Blizzard_TradeSkillUI that go with that:
-- - TradeSkillLinkButton: a button by the window title that puts the
-- window's link in the chat box, shown when the window has a link and
-- shows the player's own profession;
-- - TradeSkillFrame_SetSelection's linked branch: a window that shows
-- another player's profession hides what only works on the player's own
-- (Create, Create All and the count controls).
-- 3.3.5 never shows whose profession a linked window holds. 4.3.4 does, with
-- TradeSkillLinkNameButton and TradeSkillFrame_SetLinkName, ported here too.
-- The Craft window gets the same through GetCraftListLink and IsCraftLinked.
-- Frame geometry mirrors the real TradeSkillFrame. Turtle ships an HD
-- 768x512 two-column frame (TW-TradeSkill art); stock 1.12.1 only has the
-- vanilla 384x512 single-column art, so on that client we fall back to the
-- native Blizzard_TradeSkillUI geometry (list on top, detail below) rather
-- than stretch the wide art. LO holds every layout value that differs.
local TURTLE = (TURTLE_WOW_VERSION ~= nil);
local FRAME_W = TURTLE and 768 or 384;
local FRAME_H = 512;
local NUM_ROWS = TURTLE and 19 or 8;
local ROW_HEIGHT = 16;
local LO = TURTLE and {
hit = { 2, 82, 2, 73 },
quads = {
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopLeft", 512, 256, "TOPLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopRight", 256, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotLeft2", 512, 256, "BOTTOMLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotRight2", 256, 256, "BOTTOMRIGHT" },
},
close = { -77, -8 },
bar = { 596, 15, 76, -44 },
barBdr = { 604, 22 },
summary = { "TOPRIGHT", -110, -70 },
drop = { 40, -60 },
scroll = { 296, 330, 37, -98 },
-- detail scroll frame: { x, y, width, height }. Tall enough that the
-- content fits (scrollbar auto-hides) — Turtle's right column is roomy.
detail = { 371, -110, 340, 290 },
exit = { "CENTER", "BOTTOMRIGHT", -128, 92 },
} or {
hit = { 0, 34, 0, 75 },
quads = {
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft", 256, 256, "TOPLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight", 128, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\UI-TradeSkill-BotLeft", 256, 256, "BOTTOMLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight", 128, 256, "BOTTOMRIGHT" },
},
close = { -29, -8 },
bar = { 268, 15, 73, -37 },
barBdr = { 278, 22 },
summary = { "TOPRIGHT", -40, -58 },
drop = { 12, -62 },
scroll = { 296, 130, 21, -96 },
-- detail scroll frame { x, y, width, height } — native TradeSkillFrame's
-- TradeSkillDetailScrollFrame values. Small enough that many-reagent
-- recipes scroll (clipping partial rows), like native.
detail = { 20, -234, 297, 176 },
exit = { "CENTER", "TOPLEFT", 305, -422 },
sep = { 15, -221, 331 },
local TRADE_SKILL_CREATE_CONTROLS = {
"TradeSkillCreateButton",
"TradeSkillCreateAllButton",
"TradeSkillDecrementButton",
"TradeSkillInputBox",
"TradeSkillIncrementButton",
};
-- Reagents render in a 2-column grid inside the detail scroll child (see
-- the detail pane below). The detail is a real ScrollFrame like the
-- native TradeSkillFrame, so the whole pane pixel-scrolls and clips
-- partial reagent rows — `REAGENT_ROW_HEIGHT` is just the vertical stride
-- of one reagent row.
local REAGENT_ROW_HEIGHT = 43;
-- Scroll-frame templates. Stock mimics the native TradeSkillFrame, whose
-- list/detail scrolls use the ClassTrainer templates (they add the
-- decorative UI-ClassTrainer-ScrollBar track). Turtle's HD frame art
-- doesn't want that groove, so it keeps the plain templates. Both detail
-- templates are real ScrollFrames (ClassTrainerDetail... inherits
-- UIPanelScrollFrameTemplate), so the pixel-scroll logic is identical.
local LIST_SCROLL_TEMPLATE = TURTLE and "FauxScrollFrameTemplate"
or "ClassTrainerListScrollFrameTemplate";
local DETAIL_SCROLL_TEMPLATE = TURTLE and "UIPanelScrollFrameTemplate"
or "ClassTrainerDetailScrollFrameTemplate";
local PROFESSION_ICONS = {
[171] = "Trade_Alchemy",
[164] = "Trade_BlackSmithing",
[185] = "INV_Misc_Food_15", -- Cooking
[333] = "Trade_Engraving", -- Enchanting
[202] = "Trade_Engineering",
[129] = "Spell_Holy_SealOfSacrifice", -- First Aid
[165] = "INV_Misc_ArmorKit_17", -- Leatherworking
[197] = "Trade_Tailoring",
[186] = "Trade_Mining",
[182] = "Trade_Herbalism",
[393] = "INV_Misc_Pelt_Wolf_01", -- Skinning
[356] = "Trade_Fishing",
};
if TURTLE then
PROFESSION_ICONS[142] = "Trade_Survival"
PROFESSION_ICONS[755] = "INV_Misc_Gem_01" -- Jewelcrafting
end
local function ProfessionIcon(skillLineID)
local icon = PROFESSION_ICONS[skillLineID];
return icon and ("Interface\\Icons\\" .. icon)
or "Interface\\Icons\\INV_Misc_QuestionMark";
end
local function DifficultyBand(skill, hi, lo)
if not hi or hi <= 0 then
return 3;
end
if not lo or lo == 0 then
lo = (hi ~= 25) and (hi - 25) or 0;
end
local yellow = (hi + lo) / 2;
if skill < lo then
return 0; -- orange (optimal)
elseif skill < yellow then
return 1; -- yellow (medium)
elseif skill < hi then
return 2; -- green (easy)
end
return 3; -- grey (trivial)
end
local BAND_COLOR = {
[0] = DIFFICULT_DIFFICULTY_COLOR, -- orange
[1] = FAIR_DIFFICULTY_COLOR, -- yellow
[2] = EASY_DIFFICULTY_COLOR, -- green
[3] = TRIVIAL_DIFFICULTY_COLOR, -- grey
};
local function CraftableCount(entry)
local reagents = entry.reagents;
if not reagents or table.getn(reagents) == 0 then
return nil;
end
local best;
for i = 1, table.getn(reagents) do
local r = reagents[i];
local need = r.count or 1;
local n = math.floor((C_Item.GetItemCount(r.itemID) or 0) / need);
if not best or n < best then
best = n;
end
end
return best;
end
local function ParseTradeLink(link)
-- Current format, with the linker's name: trade:id:cur:max:Name:bits
local _, _, id, cur, max, who, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([^:]+):([%w%+/]*)$");
if not id then
-- Legacy format (no name): trade:id:cur:max:bits
who = nil;
_, _, id, cur, max, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([%w%+/]*)$");
end
if not id then
return nil;
end
return tonumber(id), tonumber(cur), tonumber(max), who, bits or "";
end
------------------------------------------------------------------------------
-- Display frame (built lazily on first use)
------------------------------------------------------------------------------
local frame;
local function ResolveSubclasses(list, onLoaded)
local present, subs, anyItem = {}, {}, false;
for i = 1, table.getn(list) do
local r = list[i];
if r.createdItem and r.createdItem > 0 then
anyItem = true;
if not r.subclass then
-- GetItemInfoInstant's 3rd return is the localized subclass
-- name (or nil when uncached).
local _, _, subType = C_Item.GetItemInfoInstant(r.createdItem);
if subType and subType ~= "" then
r.subclass = subType;
elseif onLoaded then
-- Not cached — re-derive when the item loads.
Item:CreateFromItemID(r.createdItem):ContinueOnItemLoad(onLoaded);
end
end
if r.subclass and not present[r.subclass] then
present[r.subclass] = true;
table.insert(subs, r.subclass);
end
end
end
table.sort(subs);
return subs, anyItem;
end
local SUBCLASS_ALL = {};
local function SubClassButton_OnClick()
local v = this.value;
if v == SUBCLASS_ALL then
v = nil;
end
frame.subclassFilter = v;
UIDropDownMenu_SetText(v or ALL_SUBCLASSES, frame.dropdown);
frame:ApplyFilter();
end
local function SubClassDropDown_Initialize()
local info = {};
info.text = ALL_SUBCLASSES;
info.value = SUBCLASS_ALL;
info.checked = (frame.subclassFilter == nil);
info.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(info);
local subs = frame.subclasses or {};
for i = 1, table.getn(subs) do
local b = {};
b.text = subs[i];
b.value = subs[i];
b.checked = (frame.subclassFilter == subs[i]);
b.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(b);
end
end
local function RefreshSubclasses()
if not (frame and frame:IsShown() and frame.recipes) then
return;
end
local subs, anyItem = ResolveSubclasses(frame.recipes, RefreshSubclasses);
frame.subclasses = subs;
if anyItem then
UIDropDownMenu_Initialize(frame.dropdown, SubClassDropDown_Initialize);
UIDropDownMenu_SetWidth(120, frame.dropdown);
UIDropDownMenu_SetText(frame.subclassFilter or ALL_SUBCLASSES, frame.dropdown);
frame.dropdown:Show();
else
frame.dropdown:Hide();
end
if frame.subclassFilter then
frame:ApplyFilter();
end
end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER");
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
f:RegisterForDrag("LeftButton");
f:SetScript("OnDragStart", function() this:StartMoving(); end);
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); end);
f:SetFrameStrata("DIALOG");
f:Hide();
tinsert(UISpecialFrames, f:GetName())
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
f.portrait = portrait;
for i = 1, table.getn(LO.quads) do
local q = LO.quads[i];
local tx = f:CreateTexture(nil, "BORDER");
tx:SetTexture(q[1]);
tx:SetSize(q[2], q[3]);
tx:SetPoint(q[4]);
end
-- Horizontal divider between the recipe list and the detail pane.
-- Stock only (Turtle's HD frame art already has its own).
if LO.sep then
-- Decoded the BLP: it's a thin divider rod whose graphic lives in the
-- top quarter of the 256x64 texture (a 2nd rod fills the next quarter,
-- bottom half empty). So sample the top quarter, stretched to width —
-- the same region the native TradeSkillFrame uses for this divider.
local bar = f:CreateTexture(nil, "ARTWORK");
bar:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-HorizontalBar");
bar:SetTexCoord(0, 1, 0, 0.25);
bar:SetSize(LO.sep[3], 16);
bar:SetPoint("TOPLEFT", LO.sep[1], LO.sep[2]);
end
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal");
title:SetPoint("TOP", 0, -18);
f.title = title;
local close = CreateFrame("Button", nil, f, "UIPanelCloseButton");
close:SetPoint("TOPRIGHT", LO.close[1], LO.close[2]);
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT);
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
bar:SetSize(LO.bar[1], LO.bar[2]);
bar:SetPoint("TOPLEFT", LO.bar[3], LO.bar[4]);
bar:SetStatusBarTexture("Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar");
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5);
bar:SetMinMaxValues(0, 1);
local barBg = bar:CreateTexture(nil, "BACKGROUND");
barBg:SetAllPoints(bar);
barBg:SetTexture(1, 1, 1, 0.2);
barBg:SetVertexColor(0.0, 0.0, 0.75, 0.5);
-- Border: tooltip edge around the bar (TradeSkillRankFrameBorder).
local border = CreateFrame("Frame", nil, bar);
border:SetSize(LO.barBdr[1], LO.barBdr[2]);
border:SetPoint("LEFT", -5, 1);
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 16,
});
border:SetBackdropBorderColor(0.4, 0.4, 0.4);
local barName = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
barName:SetPoint("LEFT", 6, 1);
local barRank = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
barRank:SetJustifyH("LEFT");
barRank:SetWidth(128);
barRank:SetPoint("LEFT", barName, "RIGHT", 13, 0);
f.bar = bar;
f.barName = barName;
f.barRank = barRank;
local summary = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
summary:SetPoint(LO.summary[1], LO.summary[2], LO.summary[3]);
f.summary = summary;
local dropdown = CreateFrame("Frame", "ClassicAPITradeSkillLinkSubClass", f,
"UIDropDownMenuTemplate");
dropdown:SetPoint("TOPLEFT", LO.drop[1], LO.drop[2]);
f.dropdown = dropdown;
f.subclassFilter = nil; -- nil = all subclasses
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
f, LIST_SCROLL_TEMPLATE);
scroll:SetSize(LO.scroll[1], LO.scroll[2]);
scroll:SetPoint("TOPLEFT", LO.scroll[3], LO.scroll[4]);
scroll:SetScript("OnVerticalScroll", function()
FauxScrollFrame_OnVerticalScroll(ROW_HEIGHT, function() f:Refresh(); end)
end)
f.scroll = scroll;
f.rows = {};
for i = 1, NUM_ROWS do
local rowName = "ClassicAPITradeSkillLinkRow" .. i;
local row = CreateFrame("Button", rowName, f,
"ClassTrainerSkillButtonTemplate");
row:SetWidth(290); -- height (16) comes from the template
if i == 1 then
row:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0);
else
row:SetPoint("TOPLEFT", f.rows[i - 1], "BOTTOMLEFT", 0, 0);
end
row:SetNormalTexture("");
row:SetDisabledTexture("");
row:SetHighlightTexture("");
local txt = getglobal(rowName .. "Text");
if txt then
txt:ClearAllPoints();
txt:SetPoint("LEFT", row, "LEFT", 4, 0);
end
local sel = row:CreateTexture(nil, "BACKGROUND");
sel:SetTexture("Interface\\Buttons\\UI-Listbox-Highlight2");
sel:SetAllPoints(row);
sel:Hide();
row.select = sel;
-- Override the template's trainer scripts with ours.
row:SetScript("OnClick", function()
if this.entry then
f:SelectRecipe(this.entry);
end
end);
f.rows[i] = row;
end
-- Detail pane: a real ScrollFrame (like the native TradeSkillFrame's
-- TradeSkillDetailScrollFrame) whose scroll child holds the product
-- header + reagent grid, so the whole pane pixel-scrolls — clipping
-- partial reagent rows — with the standard UIPanelScrollBar. The bar
-- auto-hides when the content fits (scrollBarHideable).
local detailScroll = CreateFrame("ScrollFrame",
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
-- the computed overflow). `scrollBarHideable` still lets the template's
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
detailScroll.scrollBarHideable = 1;
detailScroll:EnableMouseWheel(false);
f.detailScroll = detailScroll;
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
detailChild:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetScrollChild(detailChild);
f.detailChild = detailChild;
local prodIcon = CreateFrame("Button", nil, detailChild);
prodIcon:SetSize(37, 37); -- native TradeSkillSkillIcon (37x37 @ 8,-3)
prodIcon:SetPoint("TOPLEFT", 8, -3);
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local hdrLeft = detailChild:CreateTexture(nil, "BACKGROUND");
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
hdrRight:SetSize(64, 64);
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
local prodCount = prodIcon:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
prodCount:SetPoint("BOTTOMRIGHT", -5, 2);
prodIcon.count = prodCount;
f.prodIcon = prodIcon;
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(LO.detail[3] - 60);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(LO.detail[3] - 20);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
local reagentLabel = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall");
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
f.reagentLabel = reagentLabel;
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemID then
GameTooltip:SetHyperlink("item:"..this.itemID);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
-- Reagent buttons live in the scroll child (2 per row), built on
-- demand so any reagent count is handled and they scroll/clip with
-- the child.
f.reagents = {};
local function AcquireReagent(i)
if f.reagents[i] then
return f.reagents[i];
end
local rowN = math.floor((i - 1) / 2);
local col = (i - 1) - rowN * 2;
local btn = CreateFrame("Button", nil, detailChild);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
col * 134 - 5, -3 - rowN * REAGENT_ROW_HEIGHT);
-- Extend the hit area rightward over the name plate so the tooltip
-- fires anywhere on the element, not just the icon. Stop short of the
-- next column (pitch 134 − icon 39) so hovers don't bleed across.
btn:SetHitRectInsets(0, -(134 - 39), 0, 0);
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local plate = btn:CreateTexture(nil, "BACKGROUND");
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
plate:SetSize(114, 64);
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
btn.count = cnt;
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
nm:SetSize(80, 36);
nm:SetJustifyH("LEFT");
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemID then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink("item:"..this.itemID);
GameTooltip:Show();
end
end);
btn:SetScript("OnClick", function()
if this.itemLink and IsShiftKeyDown() and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(this.itemLink);
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
return btn;
end
-- Show the detail bar/borders only when the content overflows the pane.
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
-- auto-hide only fires on a range CHANGE (so content that fits from the
-- start never hides), and GetVerticalScrollRange isn't reliably readable
-- the same tick as UpdateScrollChildRect.
function f:SetDetailBarShown(show)
self.detailScroll:EnableMouseWheel(show);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(show) end
local top = getglobal(base .. "Top");
if top then top:SetShown(show) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(show) end
end
function f:RenderDetail(entry)
local function itemInfo(id)
local item = Item:CreateFromItemID(id);
if not item:IsItemDataCached() then
item:ContinueOnItemLoad(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
self:RenderDetail(entry);
end
end);
end
return item:GetItemName(), item:GetItemIcon(), item:GetItemLink();
end
local prodTex, prodText, prodLink;
local created = entry.createdItem or 0;
if created > 0 then
local name, tex, link = itemInfo(created);
prodText = name or entry.name;
prodTex = tex;
prodLink = link;
else
prodText = entry.name;
prodTex = C_Spell.GetSpellTexture(entry.spellID);
end
local descText = (created > 0) and ""
or (C_Spell.GetSpellDescription(entry.spellID) or "");
self.prodDesc:SetText(descText);
self.prodIcon:SetNormalTexture(prodTex or
"Interface\\Icons\\INV_Misc_QuestionMark");
self.prodName:SetText(prodText or "");
self.prodName:SetTextColor(NORMAL_FONT_COLOR:GetRGB());
self.prodIcon.itemLink = prodLink; -- item tooltip when created > 0
self.prodIcon.spellID = entry.spellID; -- else spell tooltip
local made = entry.numMade or 0;
self.prodIcon.count:SetText(made > 1 and tostring(made) or "");
self.prodIcon:Show();
-- Fill every reagent slot; the scroll child clips/scrolls the
-- overflow. Buttons past the count are hidden.
local reagents = entry.reagents or {};
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local reagent = reagents[i];
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.itemID = reagent.itemID
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
if have >= need then
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
else
local r, g, b = GRAY_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
btn:Show();
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
local function applyContentHeight()
local contentH = LO.detail[4];
if total > 0 then
local childTop = self.detailChild:GetTop();
local lastBtn = self.reagents[total];
local lastBottom = lastBtn and lastBtn:GetBottom();
if childTop and lastBottom then
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
contentH = (childTop - lastBottom) + 22;
end
end
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Recompute the scroll range from the child's new height, then
-- show the bar only if the content actually overflows the pane.
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
end
applyContentHeight();
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
RunNextFrame(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
applyContentHeight();
end
end)
end
function f:SelectRecipe(entry)
self.selectedSpellID = entry.spellID;
self.selectedEntry = entry;
self:RenderDetail(entry); -- also resets the detail scroll to top
self:Refresh();
end
function f:ClearDetail()
self.selectedSpellID = nil;
self.selectedEntry = nil;
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i].itemID = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(false);
self.detailScroll:SetVerticalScroll(0);
end
function f:Refresh()
local recipes = self.displayed or {};
local total = table.getn(recipes);
FauxScrollFrame_Update(self.scroll, total, NUM_ROWS, ROW_HEIGHT);
local offset = FauxScrollFrame_GetOffset(self.scroll);
for i = 1, NUM_ROWS do
local row = self.rows[i];
local entry = recipes[offset + i];
if entry then
-- Append " [N]" when the reader can craft any (Blizzard's list).
local craftable = CraftableCount(entry);
local label = entry.name or "";
if craftable and craftable > 0 then
label = label .. " [" .. craftable .. "]";
end
row:SetText(label);
local r, g, b = BAND_COLOR[entry.band or 3]:GetRGB();
if self.selectedSpellID and entry.spellID == self.selectedSpellID then
row.select:SetVertexColor(r, g, b);
row.select:Show();
row:SetTextColor(1, 1, 1);
else
row.select:Hide();
row:SetTextColor(r, g, b);
end
row.spellID = entry.spellID;
row.entry = entry;
row:Show();
else
row.spellID = nil;
row.entry = nil;
row.select:Hide();
row:Hide();
end
end
end
function f:ApplyFilter()
local src = self.recipes or {};
if not self.subclassFilter then
self.displayed = src;
else
local out = {};
for i = 1, table.getn(src) do
if src[i].subclass == self.subclassFilter then
table.insert(out, src[i]);
end
end
self.displayed = out;
end
local sb = getglobal("ClassicAPITradeSkillLinkScrollScrollBar");
if sb then
sb:SetValue(0);
end
self:Refresh();
end
f:RegisterEvent("BAG_UPDATE_DELAYED");
f:SetScript("OnEvent", function()
if not f:IsShown() then
return;
end
f:Refresh();
if f.selectedEntry then
f:RenderDetail(f.selectedEntry);
end
end);
return f;
end
local function ShowTradeSkillLink(link, text)
local id, cur, max, who, bits = ParseTradeLink(link);
if not id then
return false;
end
local recipes = C_TradeSkillUI.GetTradeSkillListRecipes(id, bits);
if not recipes then
return false;
end
local prof = text and string.match(text, "|h%[(.+)%]|h") or "Trade Skills";
local total = table.getn(recipes);
local knownList = {};
for i = 1, total do
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
table.sort(knownList, function(a, b)
if a.band ~= b.band then
return a.band < b.band;
end
return a.name < b.name;
end);
local known = table.getn(knownList);
if not frame then
frame = CreateFrame_TradeSkillLink();
end
frame.title:SetText(who and who ~= "" and (who .. " - " .. prof) or prof);
SetPortraitToTexture(frame.portrait, ProfessionIcon(id));
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
frame.bar:SetValue(cur or 0);
frame.barName:SetText(prof);
frame.barRank:SetFormattedText("%d/%d", cur or 0, max or 0);
frame.summary:SetFormattedText("%d of %d recipes known", known, total);
frame.recipes = knownList;
frame.subclassFilter = nil;
frame:ClearDetail();
frame:Show();
RefreshSubclasses();
frame:ApplyFilter();
local first = frame.displayed and frame.displayed[1];
if first then
frame:SelectRecipe(first);
end
return true;
end
------------------------------------------------------------------------------
-- Click interception: handle `trade:` before the engine's SetHyperlink (which
-- in 1.12 only knows item:/enchant: and would reject the link). Registered
-- on LinkUtil's SetItemRef dispatch (Util/LinkUtil.lua) instead of wrapping
-- SetItemRef a second time, so there is exactly one override to chain.
------------------------------------------------------------------------------
LinkUtil.RegisterLinkHandler("trade", function(link, text, linkData, contextData)
if IsShiftKeyDown() and ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(text);
return LinkProcessorResponse.Handled;
end
if ShowTradeSkillLink(link, text) then
return LinkProcessorResponse.Handled;
end
return LinkProcessorResponse.Unhandled;
end);
local function CreateProfessionLinkButton(parentName, getLink)
local parent = _G[parentName];
local btnName = parentName .. "LinkButton";
@@ -832,24 +67,84 @@ local function CreateProfessionLinkButton(parentName, getLink)
GameTooltip:Show();
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
return btn;
end
local TRADE_SKILL_LINKED_NAME_WIDTH = 120;
-- 4.3.4's TradeSkillLinkNameButton: "[name]" after the title of a linked
-- window, a whisper to that player on click. The returned function is
-- TradeSkillFrame_SetLinkName. It moves the title left by half the name, from
-- the title's own anchor, so the pair stays centered where the title was.
local function CreateLinkNameButton(parentName, isLinked)
local title = _G[parentName .. "TitleText"];
local point, relativeTo, relativePoint, x, y = title:GetPoint();
local btn = CreateFrame("Button", parentName .. "LinkNameButton", _G[parentName]);
btn:SetHeight(20);
btn:SetPoint("LEFT", title, "RIGHT", 5, 0);
local text = btn:CreateFontString(btn:GetName() .. "TitleText", "OVERLAY", "GameFontHighlight");
text:SetAllPoints(btn);
btn:SetScript("OnClick", function()
PlaySound("igMainMenuOptionCheckBoxOn");
if this.linkedName then
ChatFrame_OpenChat(SLASH_WHISPER1 .. " " .. this.linkedName .. " ", DEFAULT_CHAT_FRAME);
end
end);
btn:Hide();
return function()
local _, linkedName = isLinked();
title:ClearAllPoints();
if linkedName then
btn:Show();
text:SetText("[" .. linkedName .. "]");
local linkedNameWidth = math.min(text:GetStringWidth(), TRADE_SKILL_LINKED_NAME_WIDTH);
btn:SetWidth(linkedNameWidth);
btn.linkedName = linkedName;
title:SetPoint(point, relativeTo, relativePoint, x - linkedNameWidth / 2, y);
else
btn:Hide();
title:SetPoint(point, relativeTo, relativePoint, x, y);
end
end
end
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
local linkButton = CreateProfessionLinkButton("TradeSkillFrame", GetTradeSkillListLink);
local setLinkName = CreateLinkNameButton("TradeSkillFrame", IsTradeSkillLinked);
hooksecurefunc("TradeSkillFrame_SetSelection", function()
local linked = IsTradeSkillLinked();
for i = 1, table.getn(TRADE_SKILL_CREATE_CONTROLS) do
_G[TRADE_SKILL_CREATE_CONTROLS[i]]:SetShown(not linked);
end
if linkButton then
linkButton:SetShown(not linked and GetTradeSkillListLink() ~= nil);
end
setLinkName();
end);
end)
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink)
local linkButton = CreateProfessionLinkButton("CraftFrame", GetCraftListLink);
local setLinkName = CreateLinkNameButton("CraftFrame", IsCraftLinked);
hooksecurefunc("CraftFrame_SetSelection", function()
local linked = IsCraftLinked();
CraftCreateButton:SetShown(not linked);
if linkButton then
linkButton:SetShown(not linked and GetCraftListLink() ~= nil);
end
setLinkName();
end);
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow", function()
CreateProfessionLinkButton("ATSWFrame", function()
return atsw_oldmode and C_TradeSkillUI.GetCraftListLink() or C_TradeSkillUI.GetTradeSkillListLink()
return atsw_oldmode and GetCraftListLink() or GetTradeSkillListLink()
end)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow2", function()
CreateProfessionLinkButton("ATSWFrame", function()
return ATSW_TradeSkill() and C_TradeSkillUI.GetTradeSkillListLink() or C_TradeSkillUI.GetCraftListLink()
return ATSW_TradeSkill() and GetTradeSkillListLink() or GetCraftListLink()
end)
end)
@@ -100,6 +100,15 @@ ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Rüstungsdurchschlag";
ITEM_MOD_HEALTH_REGEN_SHORT = "Gesundheitsregeneration";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
DECIMAL_SEPERATOR = ",";
FIRST_NUMBER = "Tausend";
FIRST_NUMBER_CAP = " K";
FIRST_NUMBER_CAP_NO_SPACE = "K";
SECOND_NUMBER = "|7Million:Millionen;";
SECOND_NUMBER_CAP = " M";
SECOND_NUMBER_CAP_NO_SPACE = "M";
THIRD_NUMBER = "|7Milliarde:Milliarden;";
FOURTH_NUMBER = "|7Billion:Billionen;";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Ausrüstungsmanager";
PAPERDOLL_NEWEQUIPMENTSET = "Neues Set";
@@ -101,6 +101,15 @@ ITEMS_VARIABLE_QUANTITY = "%d |4Item:Items;";
ITEM_MISSING = "%s missing";
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
LARGE_NUMBER_SEPERATOR = ",";
DECIMAL_SEPERATOR = ".";
FIRST_NUMBER = "thousand";
FIRST_NUMBER_CAP = " K";
FIRST_NUMBER_CAP_NO_SPACE = "K";
SECOND_NUMBER = "million";
SECOND_NUMBER_CAP = " M";
SECOND_NUMBER_CAP_NO_SPACE = "M";
THIRD_NUMBER = "billion";
FOURTH_NUMBER = "trillion";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
@@ -180,6 +189,8 @@ SLASH_TARGET_NEAREST_RAID2 = "/targetraid";
SLASH_USE1 = "/use";
SLASH_USE2 = "/use";
STOP_AUTO_ATTACK = "Stop Auto Attack";
SYMBOLIC_NEGATIVE_NUMBER = "-%s";
SYMBOLIC_POSITIVE_NUMBER = "+%s";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
TEXT_TO_SPEECH = "Text To Speech";
@@ -100,6 +100,15 @@ ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Penetración de armadura";
ITEM_MOD_HEALTH_REGEN_SHORT = "Regeneración de salud";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
DECIMAL_SEPERATOR = ",";
FIRST_NUMBER = "mil";
FIRST_NUMBER_CAP = "mil";
FIRST_NUMBER_CAP_NO_SPACE = "mil";
SECOND_NUMBER = "|7millón:millones;";
SECOND_NUMBER_CAP = "mill.";
SECOND_NUMBER_CAP_NO_SPACE = "mill.";
THIRD_NUMBER = "mil millones";
FOURTH_NUMBER = "|7billón:billones;";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestor de equipamiento";
PAPERDOLL_NEWEQUIPMENTSET = "Nuevo conjunto";
@@ -100,6 +100,15 @@ ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "Pénétration d'armure";
ITEM_MOD_HEALTH_REGEN_SHORT = "Régénération des points de vie";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
DECIMAL_SEPERATOR = ",";
FIRST_NUMBER = "|7millier:milliers;";
FIRST_NUMBER_CAP = " K";
FIRST_NUMBER_CAP_NO_SPACE = " K";
SECOND_NUMBER = "|7million:millions;";
SECOND_NUMBER_CAP = " M";
SECOND_NUMBER_CAP_NO_SPACE = " M";
THIRD_NUMBER = "|7milliard:milliards;";
FOURTH_NUMBER = "|7millier:milliers; de milliards";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Gestionnaire d'équipement";
PAPERDOLL_NEWEQUIPMENTSET = "Nouvel ensemble";
@@ -0,0 +1,12 @@
if GetLocale() ~= "koKR" then return end
LARGE_NUMBER_SEPERATOR = ",";
DECIMAL_SEPERATOR = ".";
FIRST_NUMBER = "천";
FIRST_NUMBER_CAP = "만";
FIRST_NUMBER_CAP_NO_SPACE = "만";
SECOND_NUMBER = "만";
SECOND_NUMBER_CAP = "억";
SECOND_NUMBER_CAP_NO_SPACE = "억";
THIRD_NUMBER = "억";
FOURTH_NUMBER = "조";
@@ -3,6 +3,7 @@
<Script file="deDE.lua"/>
<Script file="esES.lua"/>
<Script file="frFR.lua"/>
<Script file="koKR.lua"/>
<Script file="ruRU.lua"/>
<Script file="zhCN.lua"/>
<Script file="zhTW.lua"/>
@@ -100,6 +100,15 @@ ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "к пробиванию брони";
ITEM_MOD_HEALTH_REGEN_SHORT = "к восполнению здоровья";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
DECIMAL_SEPERATOR = ",";
FIRST_NUMBER = "тыс.";
FIRST_NUMBER_CAP = "Т";
FIRST_NUMBER_CAP_NO_SPACE = "Т";
SECOND_NUMBER = "млн";
SECOND_NUMBER_CAP = "М";
SECOND_NUMBER_CAP_NO_SPACE = "М";
THIRD_NUMBER = "млрд";
FOURTH_NUMBER = "трлн";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "Комплекты экипировки";
PAPERDOLL_NEWEQUIPMENTSET = "Новый комплект";
@@ -100,6 +100,15 @@ ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "护甲穿透";
ITEM_MOD_HEALTH_REGEN_SHORT = "生命值恢复";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
DECIMAL_SEPERATOR = ".";
FIRST_NUMBER = "千";
FIRST_NUMBER_CAP = "万";
FIRST_NUMBER_CAP_NO_SPACE = "万";
SECOND_NUMBER = "万";
SECOND_NUMBER_CAP = "亿";
SECOND_NUMBER_CAP_NO_SPACE = "亿";
THIRD_NUMBER = "亿";
FOURTH_NUMBER = "兆";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "装备管理";
PAPERDOLL_NEWEQUIPMENTSET = "新的方案";
@@ -100,6 +100,15 @@ ITEM_MOD_ARMOR_PENETRATION_RATING_SHORT = "護甲穿透";
ITEM_MOD_HEALTH_REGEN_SHORT = "生命力恢復";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
DECIMAL_SEPERATOR = ".";
FIRST_NUMBER = "千";
FIRST_NUMBER_CAP = "萬";
FIRST_NUMBER_CAP_NO_SPACE = "萬";
SECOND_NUMBER = "萬";
SECOND_NUMBER_CAP = "億";
SECOND_NUMBER_CAP_NO_SPACE = "億";
THIRD_NUMBER = "億";
FOURTH_NUMBER = "兆";
MINUTES_SECONDS = "%.2d:%.2d";
PAPERDOLL_EQUIPMENTMANAGER = "裝備管理員";
PAPERDOLL_NEWEQUIPMENTSET = "新設定";
+7 -1
View File
@@ -17,6 +17,7 @@
#include "Offsets.h"
#include "debug/Log.h"
#include "frame/ScriptArgs.h"
#include "frame/UnitEvent.h"
#include "profile/ScriptTime.h"
#include <cstdint>
@@ -357,11 +358,16 @@ void FireValues(int slot, void *L, int firstArgIdx, int nargs) {
PushString(L, "event");
PushString(L, name);
SetTable(L, GLOBALS_INDEX);
// A string arg1 meets RegisterUnitEvent filters, which the engine's
// dispatches meet at the runner this walk never calls.
const char *unit = nargs > 0 && Type(L, firstArgIdx) == TYPE_STRING
? ToString(L, firstArgIdx)
: nullptr;
uintptr_t node = Game::Read<uintptr_t>(entry, Offsets::OFF_EVENT_ENTRY_HEAD);
while (node != 0 && (node & 1) == 0) {
const uintptr_t next = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT);
auto *frame = Game::Read<uint8_t *>(node + Offsets::OFF_EVENT_NODE_FRAME);
if (frame != nullptr)
if (frame != nullptr && (unit == nullptr || Frame::UnitEvent::Accepts(frame, name, unit)))
Frame::ScriptArgs::RunSlotWithValues(
L, reinterpret_cast<const uint32_t *>(frame + Offsets::OFF_FRAME_ONEVENT_SLOT),
frame, firstArgIdx, nargs);
+4 -3
View File
@@ -99,9 +99,10 @@ bool HasListeners(int slot);
// restored after, and each listener's OnEvent runs through
// `Frame::ScriptArgs::RunSlotWithValues` (so `arg1..argN`, `this` and the
// modern positional arguments are set the way the engine's runner sets
// them). The values stay on the stack; the caller pops them. No-op for
// `slot < 0` or an event nobody listens to. 4.3.4's equivalent is
// `FrameScript_SignalEvent(id, L, count)`.
// them). A string first value meets RegisterUnitEvent filters, as a `%s`
// arg1 does on the engine's path. The values stay on the stack; the caller
// pops them. No-op for `slot < 0` or an event nobody listens to. 4.3.4's
// equivalent is `FrameScript_SignalEvent(id, L, count)`.
void FireValues(int slot, void *L, int firstArgIdx, int nargs);
// Dispatches a custom event via the engine's printf-style event
+170 -236
View File
@@ -24,26 +24,19 @@
// the `this`/`arg1..argN` globals and then `lua_pcall(handler, /*nargs*/0)`.
// So `function(self, delta)` gets nothing today.
//
// We co-hook both runners and REPLACE their pcall tail: keep setting the same
// globals (so handlers reading `this`/`arg1` keep working), but push `self` +
// `arg1..argN` as real arguments and `pcall` with `nargs = 1 + N`. The
// reimplementation mirrors the engine's message-handler errfunc, but saves the
// clobbered globals on the Lua stack (not registry refs like the engine) so an
// out-of-memory longjmp mid-push unwinds them for free — no leaked refs, no
// per-call ref churn. See RunModern.
// We redirect each runner's own lua_pcall (RunnerPCall). By then the runner
// has done everything else — parsed its format, saved and set `this` and
// arg1..argN — so the values are read back from those globals and passed as
// `(self, arg1..argN)` with `nargs = 1 + N`. The format is never parsed here.
// The runner's switch is the only one, so a spec another DLL adds to it at
// runtime keeps working: SuperWoWhook's `%e` (a registry ref) crashed the copy
// of the switch this module used to carry.
//
// The reimplemented path is taken ONLY for a handler that can observe
// positional args: a Lua function that declares a parameter or is vararg
// (checked via its Proto), or a C function such as a frame method bound
// directly — see FunctionWantsArgs. A Lua handler that declares NO parameters
// can't observe positional args, so pushing
// them is a no-op — those route through the original engine runner unchanged:
// provably identical to vanilla, and OFF the engine's hottest Lua path (the vast
// majority of vanilla handlers, which read `this`/`arg1`, are param-less). So a
// param-less vanilla handler is untouched; only a param-declaring handler sees
// the `(self, [event,] arg1..argN)` shape. The one exception is a fire with
// more values than the engine runner's 19: 3.3.5 sets every argN, so that
// fire takes the reimplemented path whatever the handler.
// Positional args go only to a handler that can observe them: a Lua function
// that declares a parameter or is vararg (checked via its Proto), or a C
// function such as a frame method bound directly — see FunctionWantsArgs. Any
// other handler is called exactly as the engine calls it, so a param-less
// vanilla handler (the vast majority, reading `this`/`arg1`) is untouched.
//
// Each OnEvent handler also gets its own `event` global, saved before and
// restored after it (SaveEvent), as 3.3.5 does per handler.
@@ -66,13 +59,16 @@
// Runtime switch, default ON — modern handler signatures are a core 5.1
// feature, so ports that write `function(self, delta)` work out of the box.
// `SetModernScriptArgs(false)` disables it, `GetModernScriptArgs()` reads the
// state. While disabled both detours are a straight passthrough to the original
// runner (exact vanilla behavior, zero reimplementation risk) — the OFF escape
// valve, since this lands on the hottest Lua path in the engine
// (FUN_FRAME_RUN_SCRIPT_ARGS fires for every OnUpdate frame).
// state. While disabled the redirected pcall is the engine's call unchanged —
// exact vanilla behavior.
//
// Scripts the engine never runs (RunWithValues: custom events,
// OnAttributeChanged, …) carry arbitrary Lua values, which no runner format
// can, so RunCore runs those itself, as 3.3.5's FrameScript_Execute does.
#include "frame/ScriptArgs.h"
#include "Common.h"
#include "Game.h"
#include "HookSecureFunc.h"
#include "Offsets.h"
@@ -85,9 +81,9 @@ namespace Frame::ScriptArgs {
namespace {
// Runtime switch. Default ON (core 5.1 behavior). When false, both detours
// tail-call the original runner unchanged (exact vanilla behavior, no
// reimplementation risk) — the OFF escape valve for this hot path.
// Runtime switch. Default ON (core 5.1 behavior). When false, the redirected
// pcall makes the engine's own call and RunWithValues passes no positional
// args — exact vanilla behavior.
bool g_enabled = true;
// Chained at static-init time by each `AutoObserve` constructor.
@@ -101,109 +97,11 @@ void Notify(bool begin, int handlerRef, void *frame) {
// Chained at static-init time by each `AutoTrailingBool` constructor.
AutoTrailingBool *g_trailing = nullptr;
// The engine runner sets only arg1..arg19 (0x704F10 stops at the 19th spec,
// `cmp [ebp-4],0x13; jge` at 0x704F73). 3.3.5's FrameScript_Execute has no
// limit, so a fire with more specs than this takes the reimplemented path.
constexpr int kEngineArgLimit = 19;
using Game::Lua::RawGetI;
// Writes "arg" + k into buf.
void ArgName(char (&buf)[16], int k) { std::snprintf(buf, sizeof buf, "arg%d", k); }
// Counts the values in the runner's printf-style format: each %d/%u/%f/%s.
// Any other letter after `%` is skipped and consumes no vararg, as in the
// engine runner's switch.
int CountArgs(const char *fmt) {
if (fmt == nullptr)
return 0;
int n = 0;
for (const char *p = fmt; *p != '\0'; ++p) {
if (*p != '%')
continue;
++p;
if (*p == '\0')
break;
if (*p == 'd' || *p == 'u' || *p == 'f' || *p == 's')
++n;
}
return n;
}
// Where RunCore's arguments come from: the runner's printf-style format over
// its packed vararg buffer (%d/%u/%s 4 bytes, %f 8), or `n` Lua values already
// on the stack from absolute index `stackBase` (they sit below everything
// RunCore pushes, so the index holds). `hasTrailing` appends one boolean after
// them (see AutoTrailingBool).
struct ArgSource {
const char *fmt = nullptr;
const void *vaPtr = nullptr;
int stackBase = 0;
int n = 0;
bool hasTrailing = false;
bool trailing = false;
int Count() const { return n + (hasTrailing ? 1 : 0); }
};
// Pushes an ArgSource's values in order, one per PushNext; a new cursor
// starts again from the first.
class ArgCursor {
public:
explicit ArgCursor(const ArgSource &src)
: src_(src), p_(src.fmt), cur_(static_cast<const char *>(src.vaPtr)) {}
void PushNext(void *L) {
using namespace Game::Lua;
if (k_ >= src_.n) {
PushBool(L, src_.trailing);
++k_;
return;
}
if (p_ == nullptr) {
PushValue(L, src_.stackBase + k_++);
return;
}
for (; *p_ != '\0'; ++p_) {
if (*p_ != '%')
continue;
++p_;
const char spec = *p_;
if (spec == '\0')
return;
switch (spec) {
case 'd':
PushNumber(L, static_cast<double>(*reinterpret_cast<const int *>(cur_)));
cur_ += 4;
break;
case 'u':
PushNumber(L, static_cast<double>(
*reinterpret_cast<const unsigned int *>(cur_)));
cur_ += 4;
break;
case 'f':
PushNumber(L, *reinterpret_cast<const double *>(cur_));
cur_ += 8;
break;
case 's': // a null string pushes nil, as lua_pushstring does
PushString(L, *reinterpret_cast<const char *const *>(cur_));
cur_ += 4;
break;
default:
continue; // unknown spec — consume nothing
}
++p_;
++k_;
return;
}
}
private:
const ArgSource &src_;
const char *p_;
const char *cur_;
int k_ = 0;
};
// OnEvent dispatch? The event dispatchers invoke the frame's OnEvent slot
// (frame+0x0C); matching the handler ref against that slot identifies it
// exactly.
@@ -239,53 +137,42 @@ void RestoreEvent(void *L, int saved) {
SetTop(L, saved - 1);
}
// Reimplements the runner tail: set the `this`/`arg1..argN` globals, invoke the
// handler with modern positional args `(self, [event,] arg1..argN)` under the
// engine's own message-handler errfunc, then restore the globals. `fmt`/`vaPtr`
// are null for the no-arg runner (N == 0).
// Runs a handler with the `n` Lua values at absolute index `firstArgIdx` (they
// sit below everything this pushes, so the index holds): set the
// `this`/`arg1..argN` globals, invoke the handler with modern positional args
// `(self, [event,] arg1..argN)` under the engine's own message-handler
// errfunc, then restore the globals.
//
// Unlike the engine (and our first cut), the old global values are saved on the
// Lua STACK, not in the registry via luaL_ref. A registry save leaks its ref if
// Unlike the engine runners, the old global values are saved on the Lua
// STACK, not in the registry via luaL_ref. A registry save leaks its ref if
// any push before the protected call longjmps — and out-of-memory, the one
// failure that actually fires here, does exactly that. Stack slots need no
// cleanup: an unwinding error resets `L->top` past them for free, so nothing
// leaks and there's no per-call ref churn on the OnUpdate hot path. The cost is
// cleanup: an unwinding error resets `L->top` past them for free. The cost is
// a deeper stack (up to ~2*N slots live at once), so we CheckStack up front.
//
// Returns false WITHOUT side effects when it declines (no Lua state, or the
// stack can't grow); the caller then runs the original. Every false path is
// before the first push, so falling back never double-runs the handler.
//
// `positional` false sets the globals but calls the handler with no
// arguments — the vanilla shape, for RunWithValues while the switch is off.
bool RunCore(void *L, int handlerRef, void *frame, const ArgSource &src,
bool positional) {
// arguments — the vanilla shape, while the switch is off.
void RunCore(void *L, int handlerRef, void *frame, int firstArgIdx, int n, bool positional) {
using namespace Game::Lua;
// Headroom for the saved-old block plus the transient set/call pushes.
if (CheckStack(L, 2 * src.Count() + 16) == 0)
return false;
if (CheckStack(L, 2 * n + 16) == 0)
return;
const bool haveThis = frame != nullptr;
// When so, `event` (a global the dispatcher has set) is prepended as the
// first positional to mirror retail's (self, event, arg1..argN).
const bool isOnEvent = IsOnEvent(handlerRef, frame);
const int selfRef = haveThis ? Game::Lua::ObjectLuaRef(frame) : 0;
const int n = src.Count();
const int selfRef = Game::Lua::ObjectLuaRef(frame);
char argName[16];
// --- save the globals we're about to clobber, onto the stack ------------
// Absolute indices, live until the closing SetTop(base):
// base+1 = old _G.this (if haveThis)
// savedArgBase+1 .. +n = old _G.arg1..argN
// base+1 = old _G.this
// base+2 .. base+1+n = old _G.arg1..argN
// Raw reads, as 3.3.5's FrameScript_Execute saves them: a `_G` __index
// (a strict-globals guard) never runs for an unset argN.
const int base = GetTop(L);
if (haveThis) {
PushString(L, "this");
RawGet(L, GLOBALS_INDEX); // [.. oldThis]
}
const int savedArgBase = base + (haveThis ? 1 : 0);
PushString(L, "this");
RawGet(L, GLOBALS_INDEX); // [.. oldThis]
for (int k = 0; k < n; ++k) {
ArgName(argName, k + 1);
PushString(L, argName);
@@ -293,16 +180,13 @@ bool RunCore(void *L, int handlerRef, void *frame, const ArgSource &src,
}
// --- set the new globals (each op is stack-neutral) ---------------------
if (haveThis) {
PushString(L, "this");
RawGetI(L, REGISTRY_INDEX, selfRef); // frame object
SetTable(L, GLOBALS_INDEX);
}
ArgCursor globals(src);
PushString(L, "this");
RawGetI(L, REGISTRY_INDEX, selfRef); // frame object
SetTable(L, GLOBALS_INDEX);
for (int k = 0; k < n; ++k) {
ArgName(argName, k + 1);
PushString(L, argName);
globals.PushNext(L);
PushValue(L, firstArgIdx + k);
SetTable(L, GLOBALS_INDEX);
}
@@ -318,17 +202,13 @@ bool RunCore(void *L, int handlerRef, void *frame, const ArgSource &src,
RawGetI(L, REGISTRY_INDEX, handlerRef); // handler
int nargs = 0;
if (positional) {
if (haveThis)
RawGetI(L, REGISTRY_INDEX, selfRef); // self
else
PushNil(L);
RawGetI(L, REGISTRY_INDEX, selfRef); // self
if (isOnEvent) { // event goes before the args, retail-style
PushString(L, "event");
RawGet(L, GLOBALS_INDEX);
}
ArgCursor args(src);
for (int k = 0; k < n; ++k)
args.PushNext(L);
PushValue(L, firstArgIdx + k);
nargs = 1 + (isOnEvent ? 1 : 0) + n;
}
PCall(L, nargs, 0, errIdx);
@@ -338,41 +218,22 @@ bool RunCore(void *L, int handlerRef, void *frame, const ArgSource &src,
for (int k = 0; k < n; ++k) {
ArgName(argName, k + 1);
PushString(L, argName);
PushValue(L, savedArgBase + 1 + k);
SetTable(L, GLOBALS_INDEX);
}
if (haveThis) {
PushString(L, "this");
PushValue(L, base + 1);
PushValue(L, base + 2 + k);
SetTable(L, GLOBALS_INDEX);
}
PushString(L, "this");
PushValue(L, base + 1);
SetTable(L, GLOBALS_INDEX);
SetTop(L, base);
return true;
}
// Asks the providers for this run's trailing boolean.
void FindTrailing(int handlerRef, void *frame, ArgSource &src) {
for (AutoTrailingBool *t = g_trailing; t != nullptr; t = t->next) {
bool value = false;
if (t->fn(handlerRef, frame, &value)) {
src.hasTrailing = true;
src.trailing = value;
return;
}
}
}
// The runner path: arguments from the engine's printf-style format.
bool RunModern(int handlerRef, void *frame, const char *fmt, const void *vaPtr) {
void *L = Game::Lua::State();
if (L == nullptr)
return false;
ArgSource src;
src.fmt = fmt;
src.vaPtr = vaPtr;
src.n = CountArgs(fmt);
FindTrailing(handlerRef, frame, src);
return RunCore(L, handlerRef, frame, src, /*positional*/ true);
// Asks the providers for this run's trailing boolean; false when none takes
// one.
bool FindTrailing(int handlerRef, void *frame, bool *value) {
for (AutoTrailingBool *t = g_trailing; t != nullptr; t = t->next)
if (t->fn(handlerRef, frame, value))
return true;
return false;
}
// Lua 5.0 internal offsets for reading a handler's arity — verified against the
@@ -442,69 +303,144 @@ bool FunctionWantsArgs(const void *tv, int depth) {
return false;
}
// Does this handler actually observe positional args? A vanilla handler reads
// this/arg1 globals and declares NO parameters, so pushing positional args to
// it is observably identical to vanilla — those route through the original
// engine runner instead (exact vanilla, and off the hottest Lua path). Only a
// handler FunctionWantsArgs accepts takes the reimplemented positional path.
// --- The runners' lua_pcall, redirected -------------------------------------
// Each runner calls `lua_pcall(L, nargs, nresults, errfunc)` with [errfunc,
// handler] on top, `this` and arg1..arg`count` already set. A handler that
// observes positional args (FunctionWantsArgs, read straight off the top) gets
// `(self, [event,] arg1..argN [, trailing])`, each argN read back raw from the
// global the runner just set; any other handler, and every run while the
// switch is off, gets the engine's call unchanged.
//
// Reads the closures directly (the engine exposes no arity via the C API);
// every read is bounded — a non-function ref or a null Proto falls through to
// "no".
bool HandlerWantsArgs(void *L, int handlerRef) {
RawGetI(L, Game::Lua::REGISTRY_INDEX, handlerRef); // push registry[handlerRef]
const void *tv = reinterpret_cast<const void *>(
// A trailing boolean (AutoTrailingBool) also becomes global arg`count+1` for
// the call, as 3.3.5 sets it. Its old value waits on the stack below the
// errfunc, where the runner's own pops after the call never reach it.
int __fastcall RunnerPCall(void *L, int nargs, int nresults, int errfunc, int handlerRef,
void *frame, int count) {
using namespace Game::Lua;
const void *handler = reinterpret_cast<const void *>(
Game::Read<uintptr_t>(L, OFF_LUASTATE_TOP) - kTValueSize);
const bool wants = FunctionWantsArgs(tv, 0);
Game::Lua::SetTop(L, -2); // pop the handler
return wants;
// Headroom for self, event, the args, the trailing value and its save.
if (!g_enabled || !FunctionWantsArgs(handler, 0) || CheckStack(L, count + 8) == 0)
return PCall(L, nargs, nresults, errfunc);
bool trailing = false;
const bool hasTrailing = FindTrailing(handlerRef, frame, &trailing);
char trailingName[16];
int saved = 0;
if (hasTrailing) {
ArgName(trailingName, count + 1);
PushString(L, trailingName);
RawGet(L, GLOBALS_INDEX); // [.. errfunc handler old]
Insert(L, -3); // [.. old errfunc handler]
saved = GetTop(L) - 2;
PushString(L, trailingName);
PushBool(L, trailing);
SetTable(L, GLOBALS_INDEX);
}
int pushed = 1;
if (frame != nullptr)
RawGetI(L, REGISTRY_INDEX, Game::Lua::ObjectLuaRef(frame)); // self
else
PushNil(L);
if (IsOnEvent(handlerRef, frame)) { // event goes before the args, retail-style
PushString(L, "event");
RawGet(L, GLOBALS_INDEX);
++pushed;
}
char argName[16];
for (int k = 1; k <= count; ++k) {
ArgName(argName, k);
PushString(L, argName);
RawGet(L, GLOBALS_INDEX);
}
pushed += count;
if (hasTrailing) {
PushBool(L, trailing);
++pushed;
}
// Both runners pass errfunc = -2, relative to the top: it moves down by
// every argument pushed above it.
const int status = PCall(L, nargs + pushed, nresults, errfunc - pushed);
if (hasTrailing) {
PushString(L, trailingName);
PushValue(L, saved);
SetTable(L, GLOBALS_INDEX);
Remove(L, saved);
}
return status;
}
// --- FUN_FRAME_RUN_SCRIPT_ARGS co-hook (scripts with values) ----------------
// The runners reach RunnerPCall through these. ECX/EDX (L, nargs) and the
// pcall's two stack arguments pass through; each thunk adds the run's handler
// ref, frame and argument count from the runner's own frame (see
// CALL_FRAME_RUN_SCRIPT_ARGS_PCALL), then returns as lua_pcall does (`ret 8`).
//
// FUN_FRAME_RUN_SCRIPT_ARGS: [ebp+8] handlerRef, [ebp+0Ch] frame, [ebp-4] the
// number of argN its switch set — every spec it ran, one added at runtime
// included.
__declspec(naked) void RunArgsPCallThunk() {
__asm {
push dword ptr [ebp - 4] // count
push dword ptr [ebp + 0Ch] // frame
push dword ptr [ebp + 8] // handlerRef
push dword ptr [esp + 14h] // errfunc
push dword ptr [esp + 14h] // nresults
call RunnerPCall
ret 8
}
}
// FUN_FRAME_INVOKE_SCRIPT: [ebp-8] handlerRef, EDI frame; it sets no argN.
__declspec(naked) void InvokePCallThunk() {
__asm {
push 0 // count
push edi // frame
push dword ptr [ebp - 8] // handlerRef
push dword ptr [esp + 14h] // errfunc
push dword ptr [esp + 14h] // nresults
call RunnerPCall
ret 8
}
}
// Runs on both registration passes, so the glue screen's scripts get the
// positional args too. Idempotent.
void PatchCode() {
Common::RedirectCall(Offsets::CALL_FRAME_RUN_SCRIPT_ARGS_PCALL, Offsets::LUA_PCALL,
reinterpret_cast<const void *>(&RunArgsPCallThunk));
Common::RedirectCall(Offsets::CALL_FRAME_INVOKE_SCRIPT_PCALL, Offsets::LUA_PCALL,
reinterpret_cast<const void *>(&InvokePCallThunk));
}
const Game::GlueModuleAutoRegister _gluePatch{&PatchCode};
// --- Runner co-hooks: observers and the per-handler `event` ------------------
using RunArgs_t = void(__cdecl *)(int handlerRef, void *frame, const char *fmt,
const void *vaPtr);
RunArgs_t g_origRunArgs = nullptr;
void RunArgs(void *L, int handlerRef, void *frame, const char *fmt,
const void *vaPtr) {
// Past the engine's limit the original would drop arg20 onward, so even a
// handler that declares no parameters takes the uncapped path.
if (g_enabled && L != nullptr && handlerRef != 0 && fmt != nullptr &&
(HandlerWantsArgs(L, handlerRef) || CountArgs(fmt) > kEngineArgLimit) &&
RunModern(handlerRef, frame, fmt, vaPtr))
return;
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
}
void __cdecl RunArgs_h(int handlerRef, void *frame, const char *fmt,
const void *vaPtr) {
void *L = Game::Lua::State();
Notify(true, handlerRef, frame);
const int savedEvent = SaveEvent(L, handlerRef, frame);
RunArgs(L, handlerRef, frame, fmt, vaPtr);
g_origRunArgs(handlerRef, frame, fmt, vaPtr);
RestoreEvent(L, savedEvent);
Notify(false, handlerRef, frame);
}
// --- FUN_FRAME_INVOKE_SCRIPT co-hook (no-arg scripts) -----------------------
// Same address Tooltip::SetEvents calls directly to fire OnTooltipSet*; with
// the switch enabled those fire with (self) too, which is the modern shape.
using Invoke_t = void(__fastcall *)(int handlerRef, void *frame);
Invoke_t g_origInvoke = nullptr;
void Invoke(void *L, int handlerRef, void *frame) {
if (g_enabled && L != nullptr && handlerRef != 0 && frame != nullptr &&
HandlerWantsArgs(L, handlerRef) &&
RunModern(handlerRef, frame, /*fmt*/ nullptr, /*vaPtr*/ nullptr))
return;
g_origInvoke(handlerRef, frame);
}
void __fastcall Invoke_h(int handlerRef, void *frame) {
void *L = Game::Lua::State();
Notify(true, handlerRef, frame);
const int savedEvent = SaveEvent(L, handlerRef, frame);
Invoke(L, handlerRef, frame);
g_origInvoke(handlerRef, frame);
RestoreEvent(L, savedEvent);
Notify(false, handlerRef, frame);
}
@@ -532,6 +468,7 @@ int __fastcall Script_GetModernScriptArgs(void *L) {
}
void RegisterLuaFunctions() {
PatchCode();
Game::Lua::RegisterGlobalFunction("SetModernScriptArgs",
&Script_SetModernScriptArgs);
Game::Lua::RegisterGlobalFunction("GetModernScriptArgs",
@@ -545,12 +482,9 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
void RunWithValues(void *L, int handlerRef, void *frame, int firstArgIdx, int nargs) {
if (handlerRef == 0 || frame == nullptr)
return;
ArgSource src;
src.stackBase = firstArgIdx;
src.n = nargs;
Notify(true, handlerRef, frame);
const int savedEvent = SaveEvent(L, handlerRef, frame);
RunCore(L, handlerRef, frame, src, /*positional*/ g_enabled);
RunCore(L, handlerRef, frame, firstArgIdx, nargs, /*positional*/ g_enabled);
RestoreEvent(L, savedEvent);
Notify(false, handlerRef, frame);
}
+2 -1
View File
@@ -18,7 +18,8 @@
namespace Frame::ScriptArgs {
// Runs a frame-script handler with arbitrary Lua values as its arguments —
// the engine's runners can only pass %d/%u/%f/%s. `nargs` values at absolute
// the engine's runners pass only what a printf-style format can carry (no
// booleans). `nargs` values at absolute
// stack index `firstArgIdx` become `arg1..argN` (globals, saved and restored
// around the call) and, while modern script args are on, the positional
// `(self, ...)`. Sets `this`, runs under the engine's script error handler,
+34 -17
View File
@@ -37,6 +37,8 @@
// of its tokens, we return without invoking the handler. Suppressing at the
// runner also suppresses HookScript chains and Frame::ScriptArgs' positional
// path — they all live downstream of it, which is what retail does too.
// Event::Custom::FireValues walks the chain itself and never reaches that
// runner, so it asks `Accepts` (UnitEvent.h) for each frame instead.
//
// Semantics mirror Blizzard's 5.4.8 implementation (Ghidra-verified), where the
// filter lives ON the subscriber node and the node's kind is fixed when it is
@@ -66,6 +68,8 @@
// we never see), fires as with RegisterEvent — one rule that holds on every
// dispatch path, whose failure mode is an extra event rather than a lost one.
#include "UnitEvent.h"
#include "Game.h"
#include "Offsets.h"
#include "frame/RunnerHook.h"
@@ -249,6 +253,24 @@ int __fastcall Script_IsEventRegistered(void *L) {
// ---- the gate ------------------------------------------------------------------
// The frame's filter for the event being dispatched. `eventName` is the
// event-table entry's own name, which Filter::event was copied from, so a
// plain strcmp matches it.
const Filter *DispatchFilter(const std::vector<Filter> &filters, const char *eventName) {
for (const Filter &f : filters)
if (std::strcmp(f.event.c_str(), eventName) == 0)
return &f;
return nullptr;
}
bool MatchesUnit(const Filter &f, const char *arg1) {
if (arg1 != nullptr)
for (const std::string &u : f.units)
if (EqualsIgnoreCase(arg1, u.c_str()))
return true;
return false;
}
// True (and `*out` = arg1) when the runner's first conversion spec is `%s` —
// the unit token of every unit event. Anything else means arg1 isn't a string
// and the filter does not apply.
@@ -292,24 +314,9 @@ bool OnRun(void *frame, uint32_t *slotPtr, const char *fmt, void *varargs) {
Game::Lua::PushString(L, "event");
Game::Lua::RawGet(L, Game::Lua::GLOBALS_INDEX);
const char *ev = Game::Lua::ToString(L, -1);
const Filter *match = nullptr;
if (ev != nullptr) {
for (const Filter &f : it->second) {
if (std::strcmp(f.event.c_str(), ev) == 0) {
match = &f;
break;
}
}
}
const Filter *match = ev != nullptr ? DispatchFilter(it->second, ev) : nullptr;
Game::Lua::SetTop(L, -2);
if (match == nullptr)
return false;
if (arg1 != nullptr) {
for (const std::string &u : match->units)
if (EqualsIgnoreCase(arg1, u.c_str()))
return false;
}
return true; // no token matched — suppress
return match != nullptr && !MatchesUnit(*match, arg1); // no token matched — suppress
}
// ---- unregister co-hooks (the filter dies with the node) ------------------------
@@ -367,4 +374,14 @@ const Game::HookAutoRegister _hookUnregisterAll{
} // namespace
bool Accepts(const void *frame, const char *eventName, const char *arg1) {
if (g_filters.empty() || frame == nullptr || eventName == nullptr)
return true;
auto it = g_filters.find(frame);
if (it == g_filters.end())
return true;
const Filter *match = DispatchFilter(it->second, eventName);
return match == nullptr || MatchesUnit(*match, arg1);
}
} // namespace Frame::UnitEvent
+26
View File
@@ -0,0 +1,26 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Frame::UnitEvent {
// The RegisterUnitEvent filter for one fire whose first argument is the
// string `arg1`: false when `frame` registered `eventName` (the event-table
// entry's own name) with units and `arg1` matches none of them. True for a
// plain registration. The engine's dispatches meet this filter at the runner;
// a dispatcher of our own that walks the subscriber chain itself
// (Event::Custom::FireValues) asks here for each frame.
bool Accepts(const void *frame, const char *eventName, const char *arg1);
} // namespace Frame::UnitEvent
+1 -1
View File
@@ -42,7 +42,7 @@ static int __fastcall Script_C_Item_DoesItemExist(void *L) {
// optimistically accept any positive itemID; callers needing the
// "is the cache populated?" check should use `IsItemDataCachedByID`.
//
// This split matters for `ItemMixin:IsItemEmpty` (FrameXML ItemUtil),
// This split matters for `ItemMixin:IsItemEmpty` (Util/Item.lua),
// which uses `DoesItemExistByID` to gate `ContinueOnItemLoad` — the
// whole point of `ContinueOnItemLoad` is to wait for an uncached item
// to load, so the existence check must succeed for items that aren't
+24
View File
@@ -120,6 +120,28 @@ static int __fastcall Script_GetItemInfoInstant(void *L) {
return 7;
}
// `C_Item.GetItemIDForItemInfo(itemInfo)` — the itemID alone, from the same
// resolver `GetItemInfoInstant` echoes: an itemID, an `"item:N"` string or an
// item link. ItemMixin:GetItemID falls back to it when GetItemInfoInstant
// returns nothing. Returns nothing for input that names no item.
static const Game::Doc::Field kGetItemIDForItemInfoArgs[] = {
Game::Doc::Req("itemInfo", "ItemInfo"),
};
static const Game::Doc::Field kGetItemIDForItemInfoRets[] = {
Game::Doc::Req("itemID", "number"),
};
static const Game::Doc::Function kGetItemIDForItemInfo{
"Returns the item ID of an item ID, item string or item link.", kGetItemIDForItemInfoArgs,
kGetItemIDForItemInfoRets};
static int __fastcall Script_GetItemIDForItemInfo(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 1;
}
// Pushes the icon path for `itemID` and returns 1, or returns 0 (= nil
// to Lua) if the item isn't cached, the displayInfoID is missing, or
// the icon record's path slot is empty. Shared by all three
@@ -315,6 +337,8 @@ static int __fastcall Script_C_Item_GetItemInfo(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfo", &Script_C_Item_GetItemInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfoInstant", &Script_GetItemInfoInstant);
Game::Lua::RegisterTableFunction("C_Item", "GetItemIDForItemInfo",
&Script_GetItemIDForItemInfo, &kGetItemIDForItemInfo);
Game::Lua::RegisterGlobalFunction("GetItemIcon", &Script_GetItemIcon);
Game::Lua::RegisterTableFunction("C_Item", "GetItemIcon", &Script_C_Item_GetItemIcon);
Game::Lua::RegisterTableFunction("C_Item", "GetItemIconByID", &Script_C_Item_GetItemIconByID);
+52 -1
View File
@@ -28,6 +28,7 @@ constexpr int INVSLOT_RANGED = 18;
struct EnchantInfo {
bool has;
bool hasExpiration;
uint32_t expirationMs;
uint32_t charges;
uint32_t enchantID;
@@ -47,7 +48,7 @@ struct EnchantInfo {
// — the same block `spell/BonusDamage.cpp` walks for enchant-granted
// spell power.
EnchantInfo ReadTempEnchant(const uint8_t *cgItem) {
EnchantInfo r{false, 0, 0, 0};
EnchantInfo r{false, false, 0, 0, 0};
if (cgItem == nullptr)
return r;
const uint8_t *desc = Item::ObjectFields(cgItem);
@@ -66,6 +67,7 @@ EnchantInfo ReadTempEnchant(const uint8_t *cgItem) {
desc, kSlot + Offsets::DESCRIPTOR_ENCHANTMENT_CHARGES_DELTA);
if (Game::Read<uint32_t>(
desc, kSlot + Offsets::DESCRIPTOR_ENCHANTMENT_DURATION_DELTA) != 0) {
r.hasExpiration = true;
using TimeLeft_t = uint32_t(__fastcall *)(const uint8_t *item, void *edx, int slot);
r.expirationMs = reinterpret_cast<TimeLeft_t>(
static_cast<uintptr_t>(Offsets::FUN_ITEM_ENCHANTMENT_TIME_LEFT))(
@@ -157,11 +159,60 @@ int __fastcall Script_C_Item_GetItemTempEnchantInfo(void *L) {
return 4;
}
// `C_PaperDollInfo.GetTemporaryEnchantmentInfo(slot)` — retail's per-slot
// temporary enchant, the one its deprecated `GetWeaponEnchantInfo` is now
// built from. `slot` is an equipment slot (INVSLOT_*). Returns a
// `TemporaryItemEnchantInfo` table, or nothing when the slot has no
// temporary enchant (or no item, or is not an equipment slot).
// `hasExpirationTime` is the descriptor duration gate `ReadTempEnchant`
// reads; without it `remainingTimeMs` is 0.
const Game::Doc::Field kTemporaryItemEnchantInfoFields[] = {
Game::Doc::Req("enchantID", "number"),
Game::Doc::Req("remainingTimeMs", "number"),
Game::Doc::Req("chargesRemaining", "number"),
Game::Doc::Req("hasExpirationTime", "bool"),
};
const Game::Doc::Structure kTemporaryItemEnchantInfo{
"TemporaryItemEnchantInfo", "PaperDollInfo", kTemporaryItemEnchantInfoFields};
const Game::Doc::Field kGetTemporaryEnchantmentInfoArgs[] = {
Game::Doc::Req("slot", "number", "An INVSLOT_ constant."),
};
const Game::Doc::Field kGetTemporaryEnchantmentInfoRets[] = {
Game::Doc::Req("enchantInfo", "TemporaryItemEnchantInfo"),
};
const Game::Doc::Function kGetTemporaryEnchantmentInfo{
"Returns the temporary enchantment on an equipped item, or nothing if there is none.",
kGetTemporaryEnchantmentInfoArgs, kGetTemporaryEnchantmentInfoRets};
int __fastcall Script_GetTemporaryEnchantmentInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_PaperDollInfo.GetTemporaryEnchantmentInfo(slot)");
return 0;
}
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (slot < Offsets::EQUIPMENT_SLOT_FIRST || slot > Offsets::EQUIPMENT_SLOT_LAST)
return 0;
const EnchantInfo e = ReadEquippedTempEnchant(slot);
if (!e.has)
return 0;
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "enchantID", static_cast<double>(e.enchantID));
Game::Lua::SetFieldNumber(L, "remainingTimeMs", static_cast<double>(e.expirationMs));
Game::Lua::SetFieldNumber(L, "chargesRemaining", static_cast<double>(e.charges));
Game::Lua::SetFieldBool(L, "hasExpirationTime", e.hasExpiration);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetWeaponEnchantInfo",
&Script_C_Item_GetWeaponEnchantInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemTempEnchantInfo",
&Script_C_Item_GetItemTempEnchantInfo);
Game::Lua::RegisterTableFunction("C_PaperDollInfo", "GetTemporaryEnchantmentInfo",
&Script_GetTemporaryEnchantmentInfo,
&kGetTemporaryEnchantmentInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+446
View File
@@ -0,0 +1,446 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Retail's number formatters (LocalizationDocumentation.lua):
// `BreakUpLargeNumbers`, `AbbreviateNumbers`, `AbbreviateLargeNumbers` and
// `C_StringUtil.GetDefaultAbbreviationBreakpoints`. Retail moved them from
// FrameXML into C (12.0.0) and that C is not available, so what follows is
// retail's behavior as probed in an enUS retail client (2026-10-30).
//
// BreakUpLargeNumbers(n [, natural])
// - A whole number, or one 1000 or more in size, loses its fraction (toward
// zero) and gets LARGE_NUMBER_SEPERATOR every three digits while the
// breakUpLargeNumbers cvar is on: 1234567.891 -> "1,234,567",
// 1234567890 -> "1,234,567,890", -1234.5 -> "-1,234"; cvar off ->
// "1234567".
// - Any other number keeps one decimal, its tenths rounded half away from
// zero, ungrouped: 999.96 -> "1000.0", 0.25 -> "0.3", -5.55 -> "-5.6",
// 5.04 -> "5.0", 123.456 -> "123.5".
// - natural: a value of a million or more becomes the largest number word
// that fits, with one decimal below 100, truncated, and ".0" dropped:
// 1290000 -> "1.2 million", 12345678 -> "12.3 million", 99990000 ->
// "99.9 million", 123456789 -> "123 million", 2000000 -> "2 million",
// 1.5e13 -> "15 trillion". Anything smaller, and every negative, is the
// whole number grouped: 0.5 -> "0", 999999 -> "999,999", -1234567 ->
// "-1,234,567".
// The Asian locales' words are 10^4-based (SECOND_NUMBER is 万), so their
// scales are 1e4 / 1e8 / 1e12. That is read off the strings, not probed.
// AbbreviateNumbers(n [, options])
// - The first breakpoint n reaches gives floor(n / significandDivisor) /
// fractionDivisor, written as Lua writes a number, then the abbreviation:
// 1234 -> "1.2K", 12345 -> "12K", 1999999 -> "1.9M". Below every
// breakpoint, so for every negative, n is written as is: 999.9 ->
// "999.9", -12345 -> "-12345", 0 -> "0".
// - The abbreviation is the global string it names unless
// abbreviationIsGlobal is false (12.3h from {100, "h", 10, 10}); a name
// with no global adds nothing. Retail's defaults name THIRD_ and
// FOURTH_NUMBER_CAP_NO_SPACE, which its clients do not define, so
// 1.5e10 -> "15" and 1.5e12 -> "1.5".
// - options.breakpointData replaces the defaults. options.locale picks the
// defaults (the Asian table for koKR, zhCN and zhTW) and falls back to the
// client's locale: 12345 with locale "zhCN" -> "1.2K".
// AbbreviateLargeNumbers(n [, options])
// - The same walk over its own defaults, FrameXML's old thresholds
// (123456 -> "123 K", 12345678 -> "12345 K", 123456789 -> "123 M",
// 1234567890 -> "1234 M"), and BreakUpLargeNumbers(n) below them: 1234.5
// -> "1,234", -12345 -> "-12,345".
// - Outside the Asian locales the value is rounded to a whole number: 123456
// over {100, "h", 10, 10} -> "1235h". The Asian default keeps its
// decimals: 123456789 with locale "zhCN" -> "1.23 M".
// options.config (CreateAbbreviateConfig's cached object) is not backported.
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <iterator>
#include <string>
namespace Localization::Numbers {
namespace {
namespace Lua = Game::Lua;
struct Breakpoint {
double breakpoint;
const char *abbreviation;
double significandDivisor;
double fractionDivisor;
};
// C_StringUtil.GetDefaultAbbreviationBreakpoints(), as retail returns it.
constexpr Breakpoint kNumberBreakpoints[] = {
{1e13, "FOURTH_NUMBER_CAP_NO_SPACE", 1e12, 1},
{1e12, "FOURTH_NUMBER_CAP_NO_SPACE", 1e11, 10},
{1e10, "THIRD_NUMBER_CAP_NO_SPACE", 1e9, 1},
{1e9, "THIRD_NUMBER_CAP_NO_SPACE", 1e8, 10},
{1e7, "SECOND_NUMBER_CAP_NO_SPACE", 1e6, 1},
{1e6, "SECOND_NUMBER_CAP_NO_SPACE", 1e5, 10},
{1e4, "FIRST_NUMBER_CAP_NO_SPACE", 1e3, 1},
{1e3, "FIRST_NUMBER_CAP_NO_SPACE", 1e2, 10},
};
// C_StringUtil.GetDefaultAbbreviationBreakpoints("zhCN").
constexpr Breakpoint kNumberBreakpointsAsian[] = {
{1e9, "SECOND_NUMBER_CAP_NO_SPACE", 1e8, 1},
{1e8, "SECOND_NUMBER_CAP_NO_SPACE", 1e7, 10},
{1e5, "FIRST_NUMBER_CAP_NO_SPACE", 1e4, 1},
{1e4, "FIRST_NUMBER_CAP_NO_SPACE", 1e3, 10},
};
// AbbreviateLargeNumbers' defaults: FrameXML's digit-count thresholds
// (more than 8 digits -> millions, more than 5 -> thousands).
constexpr Breakpoint kLargeBreakpoints[] = {
{1e8, "SECOND_NUMBER_CAP", 1e6, 1},
{1e5, "FIRST_NUMBER_CAP", 1e3, 1},
};
// FrameXML's LocalizeNumberAbbreviation_Asian thresholds: 11+ digits ->
// 亿, 9+ -> 亿 with two decimals, 7+ -> 万.
constexpr Breakpoint kLargeBreakpointsAsian[] = {
{1e10, "SECOND_NUMBER_CAP", 1e8, 1},
{1e8, "SECOND_NUMBER_CAP", 1e6, 100},
{1e6, "FIRST_NUMBER_CAP", 1e4, 1},
};
struct Scale {
double value;
const char *word;
};
constexpr Scale kScales[] = {
{1e12, "FOURTH_NUMBER"},
{1e9, "THIRD_NUMBER"},
{1e6, "SECOND_NUMBER"},
};
constexpr Scale kScalesAsian[] = {
{1e12, "FOURTH_NUMBER"},
{1e8, "THIRD_NUMBER"},
{1e4, "SECOND_NUMBER"},
};
CVar::Factory::Handle g_breakUpLargeNumbers = nullptr;
// VAR_LOCALE_INDEX order: enUS, koKR, frFR, deDE, zhCN, zhTW, esES, esMX, ruRU.
bool ClientIsAsian() {
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
return locale == 1 || locale == 4 || locale == 5;
}
bool IsAsianLocale(const char *locale) {
return std::strcmp(locale, "koKR") == 0 || std::strcmp(locale, "zhCN") == 0 ||
std::strcmp(locale, "zhTW") == 0;
}
// `_G[name]` when it is a string, else "".
std::string GlobalString(void *L, const char *name) {
Lua::PushString(L, name);
Lua::GetTable(L, Lua::GLOBALS_INDEX);
std::string s;
if (Lua::Type(L, -1) == Lua::TYPE_STRING)
s = Lua::ToString(L, -1);
Lua::SetTop(L, -2);
return s;
}
// A number as Lua's tostring writes it.
std::string LuaNumber(double v) {
char buf[64];
std::snprintf(buf, sizeof buf, "%.14g", v);
return buf;
}
std::string Whole(double w) {
char buf[400];
std::snprintf(buf, sizeof buf, "%.0f", w);
return buf;
}
// The whole number `w` (>= 0), grouped while the cvar is on.
std::string Grouped(void *L, double w) {
const std::string digits = Whole(w);
if (g_breakUpLargeNumbers != nullptr && CVar::Factory::GetInt(g_breakUpLargeNumbers, 1) == 0)
return digits;
const std::string sep = GlobalString(L, "LARGE_NUMBER_SEPERATOR");
std::string out;
const size_t n = digits.size();
for (size_t i = 0; i < n; ++i) {
if (i > 0 && (n - i) % 3 == 0)
out += sep;
out += digits[i];
}
return out;
}
// `v` truncated toward zero and grouped.
std::string SignedWhole(void *L, double v) {
const double w = std::floor(std::fabs(v));
return (v < 0 && w != 0 ? "-" : "") + Grouped(L, w);
}
std::string BreakUp(void *L, double v, bool natural) {
if (natural) {
const bool asian = ClientIsAsian();
for (const Scale &s : asian ? kScalesAsian : kScales) {
if (v < s.value)
continue;
const double tenths = std::floor(v / (s.value / 10));
const double whole = std::floor(tenths / 10);
std::string num = Whole(whole);
const double digit = tenths - whole * 10;
if (tenths < 1000 && digit != 0)
num += GlobalString(L, "DECIMAL_SEPERATOR") + Whole(digit);
return num + " " + GlobalString(L, s.word);
}
return SignedWhole(L, v);
}
const double a = std::fabs(v);
if (a >= 1000 || a == std::floor(a))
return SignedWhole(L, v);
const double tenths = std::floor(a * 10 + 0.5);
const double whole = std::floor(tenths / 10);
return (v < 0 ? "-" : "") + Whole(whole) + GlobalString(L, "DECIMAL_SEPERATOR") +
Whole(tenths - whole * 10);
}
// One breakpoint's result for `v`, which reached it.
std::string Abbreviate(double v, double significandDivisor, double fractionDivisor,
const std::string &label, bool roundWhole) {
double q = std::floor(v / significandDivisor) / fractionDivisor;
if (roundWhole)
q = std::floor(q + 0.5);
return LuaNumber(q) + label;
}
// The options table's fields this module reads.
struct Options {
bool asian;
int breakpointData; // stack index of options.breakpointData, 0 when absent
};
// Reads the options at stack index 2; may push options.breakpointData.
Options ReadOptions(void *L) {
Options o{ClientIsAsian(), 0};
if (Lua::Type(L, 2) != Lua::TYPE_TABLE)
return o;
Lua::PushString(L, "locale");
Lua::GetTable(L, 2);
if (Lua::Type(L, -1) == Lua::TYPE_STRING)
o.asian = IsAsianLocale(Lua::ToString(L, -1));
Lua::SetTop(L, -2);
Lua::PushString(L, "breakpointData");
Lua::GetTable(L, 2);
if (Lua::Type(L, -1) == Lua::TYPE_TABLE)
o.breakpointData = Lua::GetTop(L);
else
Lua::SetTop(L, -2);
return o;
}
double FieldNumber(void *L, int table, const char *key, const char *fn) {
Lua::PushString(L, key);
Lua::GetTable(L, table);
if (!Lua::IsNumber(L, -1))
Lua::Error(L, "%s: breakpointData entry needs a number '%s'", fn, key);
const double v = Lua::ToNumber(L, -1);
Lua::SetTop(L, -2);
return v;
}
// Walks options.breakpointData; true with `out` set at the first entry `v`
// reaches.
bool WalkData(void *L, int data, double v, bool roundWhole, const char *fn, std::string &out) {
for (int i = 1;; ++i) {
Lua::RawGetI(L, data, i);
if (Lua::Type(L, -1) == Lua::TYPE_NIL) {
Lua::SetTop(L, -2);
return false;
}
if (Lua::Type(L, -1) != Lua::TYPE_TABLE)
Lua::Error(L, "%s: breakpointData entry %d is not a table", fn, i);
const int entry = Lua::GetTop(L);
const double breakpoint = FieldNumber(L, entry, "breakpoint", fn);
if (v >= breakpoint) {
const double significandDivisor = FieldNumber(L, entry, "significandDivisor", fn);
const double fractionDivisor = FieldNumber(L, entry, "fractionDivisor", fn);
Lua::PushString(L, "abbreviation");
Lua::GetTable(L, entry);
if (Lua::Type(L, -1) != Lua::TYPE_STRING)
Lua::Error(L, "%s: breakpointData entry needs a string 'abbreviation'", fn);
const std::string abbreviation = Lua::ToString(L, -1);
Lua::PushString(L, "abbreviationIsGlobal");
Lua::GetTable(L, entry);
const bool isGlobal = Lua::Type(L, -1) == Lua::TYPE_NIL || Lua::ToBoolean(L, -1);
out = Abbreviate(v, significandDivisor, fractionDivisor,
isGlobal ? GlobalString(L, abbreviation.c_str()) : abbreviation,
roundWhole);
Lua::SetTop(L, entry - 1);
return true;
}
Lua::SetTop(L, entry - 1);
}
}
template <size_t N>
bool WalkDefaults(void *L, const Breakpoint (&data)[N], double v, bool roundWhole,
std::string &out) {
for (const Breakpoint &b : data) {
if (v >= b.breakpoint) {
out = Abbreviate(v, b.significandDivisor, b.fractionDivisor,
GlobalString(L, b.abbreviation), roundWhole);
return true;
}
}
return false;
}
double CheckNumber(void *L, const char *usage) {
if (!Lua::IsNumber(L, 1))
Lua::Error(L, "Usage: %s", usage);
return Lua::ToNumber(L, 1);
}
int __fastcall Script_BreakUpLargeNumbers(void *L) {
const double v = CheckNumber(L, "BreakUpLargeNumbers(largeNumber [, natural])");
const std::string s = BreakUp(L, v, Lua::ToBoolean(L, 2) != 0);
Lua::PushString(L, s.c_str());
return 1;
}
int __fastcall Script_AbbreviateNumbers(void *L) {
constexpr const char *kFn = "AbbreviateNumbers";
const double v = CheckNumber(L, "AbbreviateNumbers(number [, options])");
const Options o = ReadOptions(L);
std::string s;
const bool hit =
o.breakpointData != 0
? WalkData(L, o.breakpointData, v, false, kFn, s)
: (o.asian ? WalkDefaults(L, kNumberBreakpointsAsian, v, false, s)
: WalkDefaults(L, kNumberBreakpoints, v, false, s));
if (!hit)
s = LuaNumber(v);
Lua::PushString(L, s.c_str());
return 1;
}
int __fastcall Script_AbbreviateLargeNumbers(void *L) {
constexpr const char *kFn = "AbbreviateLargeNumbers";
const double v = CheckNumber(L, "AbbreviateLargeNumbers(number [, options])");
const Options o = ReadOptions(L);
const bool roundWhole = !o.asian;
std::string s;
const bool hit =
o.breakpointData != 0
? WalkData(L, o.breakpointData, v, roundWhole, kFn, s)
: (o.asian ? WalkDefaults(L, kLargeBreakpointsAsian, v, roundWhole, s)
: WalkDefaults(L, kLargeBreakpoints, v, roundWhole, s));
if (!hit)
s = BreakUp(L, v, false);
Lua::PushString(L, s.c_str());
return 1;
}
int __fastcall Script_GetDefaultAbbreviationBreakpoints(void *L) {
const bool asian = Lua::Type(L, 1) == Lua::TYPE_STRING ? IsAsianLocale(Lua::ToString(L, 1))
: ClientIsAsian();
const Breakpoint *data = asian ? kNumberBreakpointsAsian : kNumberBreakpoints;
const size_t count = asian ? std::size(kNumberBreakpointsAsian) : std::size(kNumberBreakpoints);
Lua::NewTable(L);
for (size_t i = 0; i < count; ++i) {
Lua::PushNumber(L, static_cast<double>(i + 1));
Lua::NewTable(L);
Lua::SetFieldNumber(L, "breakpoint", data[i].breakpoint);
Lua::SetFieldString(L, "abbreviation", data[i].abbreviation);
Lua::SetFieldNumber(L, "significandDivisor", data[i].significandDivisor);
Lua::SetFieldNumber(L, "fractionDivisor", data[i].fractionDivisor);
Lua::SetFieldBool(L, "abbreviationIsGlobal", true);
Lua::SetTable(L, -3);
}
return 1;
}
const Game::Doc::Field kBreakpointFields[] = {
Game::Doc::Req("breakpoint", "number", "The smallest value this entry applies to."),
Game::Doc::Req("abbreviation", "string",
"The name of the global string to add, or the text itself."),
Game::Doc::Req("significandDivisor", "number",
"The value is divided by this and rounded down."),
Game::Doc::Req("fractionDivisor", "number",
"The rounded result is divided by this, which leaves its decimals."),
Game::Doc::Opt("abbreviationIsGlobal", "bool", "true",
"False to add the abbreviation text as it is."),
};
const Game::Doc::Structure kBreakpointStructure{"NumberAbbreviationBreakpoint", "Localization",
kBreakpointFields};
const Game::Doc::Field kOptionsFields[] = {
Game::Doc::Opt("breakpointData", "table", nullptr,
"NumberAbbreviationBreakpoint entries, largest first. Replaces the defaults."),
Game::Doc::Opt("locale", "string", nullptr,
"The locale whose defaults to use. koKR, zhCN and zhTW use the Asian ones."),
};
const Game::Doc::Structure kOptionsStructure{"NumberAbbrevOptions", "Localization",
kOptionsFields};
const Game::Doc::Field kBreakUpArgs[] = {
Game::Doc::Req("largeNumber", "number"),
Game::Doc::Opt("natural", "bool", "false",
"True to write a million or more in words, such as \"1.2 million\"."),
};
const Game::Doc::Field kResultRets[] = {
Game::Doc::Req("result", "string"),
};
const Game::Doc::Function kBreakUpLargeNumbers{
"Formats a number with separators between groups of three digits.", kBreakUpArgs,
kResultRets, "Localization"};
const Game::Doc::Field kAbbreviateArgs[] = {
Game::Doc::Req("number", "number"),
Game::Doc::Opt("options", "NumberAbbrevOptions"),
};
const Game::Doc::Function kAbbreviateNumbers{
"Shortens a number with an abbreviation, such as \"1.2K\".", kAbbreviateArgs, kResultRets,
"Localization"};
const Game::Doc::Function kAbbreviateLargeNumbers{
"Shortens a large number to whole thousands or millions, such as \"123 K\".",
kAbbreviateArgs, kResultRets, "Localization"};
const Game::Doc::Field kDefaultsArgs[] = {
Game::Doc::Opt("locale", "string", nullptr, "The locale; the client's when not given."),
};
const Game::Doc::Field kDefaultsRets[] = {
Game::Doc::Req("breakpoints", "table", "NumberAbbreviationBreakpoint entries, largest first."),
};
const Game::Doc::Function kGetDefaultAbbreviationBreakpoints{
"Returns the breakpoints AbbreviateNumbers uses by default.", kDefaultsArgs, kDefaultsRets};
void RegisterLuaFunctions() {
g_breakUpLargeNumbers =
CVar::Factory::Register("breakUpLargeNumbers", "Toggles using commas in large numbers",
Offsets::CVAR_CATEGORY_GAME, "1");
Lua::RegisterGlobalFunction("BreakUpLargeNumbers", &Script_BreakUpLargeNumbers,
&kBreakUpLargeNumbers);
Lua::RegisterGlobalFunction("AbbreviateNumbers", &Script_AbbreviateNumbers,
&kAbbreviateNumbers);
Lua::RegisterGlobalFunction("AbbreviateLargeNumbers", &Script_AbbreviateLargeNumbers,
&kAbbreviateLargeNumbers);
Lua::RegisterTableFunction("C_StringUtil", "GetDefaultAbbreviationBreakpoints",
&Script_GetDefaultAbbreviationBreakpoints,
&kGetDefaultAbbreviationBreakpoints);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Localization::Numbers
+86
View File
@@ -0,0 +1,86 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_PaperDollInfo.GetInventorySlotInfoForInvSlot(invSlot)` — the stock
// `GetInventorySlotInfo(slotName)` looked up the other way: from a slot
// number to its paper-doll row. It walks the same PaperDollItemFrame.dbc rows
// the engine's own function walks (Script_GetInventorySlotInfo, 0x004C81B0),
// in file order, takes the first row with that SlotNumber, and returns what
// the engine returns for it plus the row's name:
//
// invSlot the SlotNumber
// slotTexture the SlotIcon path as stored (this client has no file IDs)
// checkRelic true for the ranged slot, the engine's own relic test
// slotName the ItemSlotName ("HeadSlot", "MainHandSlot", ...)
//
// A slot number with no row returns nothing.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace PaperDoll::SlotInfo {
namespace {
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("invSlotValue", "number", "An inventory slot number, not a luaIndex."),
};
const Game::Doc::Field kRets[] = {
Game::Doc::Req("invSlot", "number"),
Game::Doc::Req("slotTexture", "string", "The empty-slot texture path."),
Game::Doc::Req("checkRelic", "bool"),
Game::Doc::Req("slotName", "cstring"),
};
const Game::Doc::Function kGetInventorySlotInfoForInvSlot{
"Returns the paper doll slot for an inventory slot number, or nothing if there is none.",
kArgs, kRets};
int __fastcall Script_GetInventorySlotInfoForInvSlot(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_PaperDollInfo.GetInventorySlotInfoForInvSlot(invSlotValue)");
return 0;
}
const int invSlot = static_cast<int>(Game::Lua::ToNumber(L, 1));
const auto *records = Game::Read<const uint8_t *>(Offsets::VAR_PAPERDOLLITEMFRAME_RECORDS);
const int count = Game::Read<int>(Offsets::VAR_PAPERDOLLITEMFRAME_COUNT);
if (records == nullptr)
return 0;
for (int i = 0; i < count; ++i) {
const uint8_t *row = records + i * Offsets::OFF_PAPERDOLLITEMFRAME_RECORD_STRIDE;
const int slotNumber = Game::Read<int>(row, Offsets::OFF_PAPERDOLLITEMFRAME_SLOT_NUMBER);
if (slotNumber != invSlot)
continue;
Game::Lua::PushNumber(L, static_cast<double>(slotNumber));
Game::Lua::PushString(L, Game::Read<const char *>(row, Offsets::OFF_PAPERDOLLITEMFRAME_SLOT_ICON));
Game::Lua::PushBool(L, slotNumber == Offsets::PAPERDOLLITEMFRAME_RELIC_SLOT_NUMBER);
Game::Lua::PushString(L, Game::Read<const char *>(row, Offsets::OFF_PAPERDOLLITEMFRAME_SLOT_NAME));
return 4;
}
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_PaperDollInfo", "GetInventorySlotInfoForInvSlot",
&Script_GetInventorySlotInfoForInvSlot,
&kGetInventorySlotInfoForInvSlot);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace PaperDoll::SlotInfo
@@ -0,0 +1,182 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `smf:AddOnDisplayRefreshedCallback(callback)`, retail's: callback(frame)
// runs each time the frame refreshes what it shows.
//
// Retail's mixin (Blizzard_SharedXML/ScrollingMessageFrame.lua) marks its
// display dirty on each change - a message, a scroll, a resize, a fade reset -
// and refreshes in its OnPostUpdate. So the callbacks run once per update of
// a shown frame that changed, in the order they were added, each under the
// error handler (secureexecuterange). The mark is cleared after them, so a
// change a callback makes (a scroll bar setting the offset) does not run them
// again.
//
// 1.12 refreshes what the frame shows in its line layout
// (ScrollingMessageFrame::Layout), which marks the frame. A message added
// while the frame is scrolled up changes no line and runs no layout, but it
// moves the scroll offset and range, so AddMessage marks the frame too, as
// ScrollByAmount does for retail's fade reset. The callbacks run after the
// frame's own update (FUN_SCROLLINGMESSAGEFRAME_ON_UPDATE: OnUpdate, then the
// fader), which the engine's update pass calls once per frame for each shown
// frame - where retail's OnPostUpdate runs.
#include "scrollingmessageframe/DisplayRefreshed.h"
#include "Game.h"
#include "Offsets.h"
#include "scrollingmessageframe/Layout.h"
#include <unordered_map>
namespace ScrollingMessageFrame::DisplayRefreshed {
namespace {
// Frames with callbacks, and whether each has a refresh pending. Every frame
// is rebuilt on /reload, so the map is cleared then.
std::unordered_map<void *, bool> g_frames;
// The frame-table → callback-list table, in the registry of the Lua state
// that made it. LUA_NOREF until first use in each state.
constexpr int kNoRef = -2;
int g_callbacksRef = kNoRef;
// Runs the callbacks of the frame, each as callback(frame) under the
// engine's script error handler.
void RunCallbacks(void *smf) {
using namespace Game::Lua;
void *L = State();
if (L == nullptr || g_callbacksRef == kNoRef)
return;
const int top = GetTop(L);
PushObject(L, smf);
const int frame = GetTop(L);
RawGetI(L, REGISTRY_INDEX, g_callbacksRef);
PushValue(L, frame);
RawGet(L, -2);
const int callbacks = GetTop(L);
if (Type(L, callbacks) == TYPE_TABLE) {
const int errRef = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
for (int i = 1;; ++i) {
int errIdx = 0;
if (errRef > 0) {
RawGetI(L, REGISTRY_INDEX, errRef);
errIdx = GetTop(L);
}
RawGetI(L, callbacks, i);
if (Type(L, -1) == TYPE_NIL)
break;
PushValue(L, frame);
PCall(L, 1, 0, errIdx);
SetTop(L, callbacks);
}
}
SetTop(L, top);
}
using OnUpdate_t = void(__fastcall *)(void *smf, void *edx, float elapsed);
OnUpdate_t g_origOnUpdate = nullptr;
void __fastcall OnUpdate_h(void *smf, void *edx, float elapsed) {
g_origOnUpdate(smf, edx, elapsed);
const auto it = g_frames.find(smf);
if (it == g_frames.end() || !it->second)
return;
RunCallbacks(smf);
const auto again = g_frames.find(smf); // a callback can add frames
if (again != g_frames.end())
again->second = false;
}
const Game::HookAutoRegister _updateHook{Offsets::FUN_SCROLLINGMESSAGEFRAME_ON_UPDATE,
reinterpret_cast<void *>(&OnUpdate_h),
reinterpret_cast<void **>(&g_origOnUpdate)};
const ScrollingMessageFrame::Layout::AutoSubscribe _layoutSub{&MarkDirty};
int __fastcall Script_AddOnDisplayRefreshedCallback(void *L) {
using namespace Game::Lua;
void *frame = ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
if (frame == nullptr)
return 0;
if (Type(L, 2) != TYPE_FUNCTION) {
Error(L, "Usage: %s:AddOnDisplayRefreshedCallback(callback)", ObjectDisplayName(frame));
return 0;
}
if (g_callbacksRef == kNoRef) {
NewTable(L);
g_callbacksRef = Ref(L, REGISTRY_INDEX);
}
RawGetI(L, REGISTRY_INDEX, g_callbacksRef);
const int frames = GetTop(L);
PushValue(L, 1);
RawGet(L, frames);
if (Type(L, -1) != TYPE_TABLE) {
SetTop(L, frames);
NewTable(L);
PushValue(L, 1);
PushValue(L, -2);
RawSet(L, frames); // frames[self] = callbacks
}
const int callbacks = GetTop(L);
int n = 1;
for (;; ++n) {
RawGetI(L, callbacks, n);
const bool end = Type(L, -1) == TYPE_NIL;
SetTop(L, -2);
if (end)
break;
}
PushNumber(L, n);
PushValue(L, 2);
RawSet(L, callbacks);
g_frames.emplace(frame, false);
return 0;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"AddOnDisplayRefreshedCallback", &Script_AddOnDisplayRefreshedCallback},
};
const Game::Doc::Field kArgs[] = {
Game::Doc::Req("callback", "function", "Called as callback(frame)."),
};
const Game::Doc::Function kAddOnDisplayRefreshedCallback{
"Adds a function that runs after the frame refreshes the messages it shows.", kArgs, {}};
const Game::Doc::Method kDocs[] = {
{"AddOnDisplayRefreshedCallback", &kAddOnDisplayRefreshedCallback},
};
void RegisterLuaFunctions() {
// Each registration pass is a new Lua state; the old state's ref is gone.
g_callbacksRef = kNoRef;
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_SCROLLINGMESSAGEFRAME_METHOD_REGISTRY),
kMethods, 1, kDocs, 1);
}
void PrepareForReload() { g_frames.clear(); }
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ReloadAutoRegister _reload{&PrepareForReload};
} // namespace
void MarkDirty(void *smf) {
const auto it = g_frames.find(smf);
if (it != g_frames.end())
it->second = true;
}
} // namespace ScrollingMessageFrame::DisplayRefreshed
@@ -0,0 +1,22 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace ScrollingMessageFrame::DisplayRefreshed {
// Marks a change to what the frame shows that its line layout does not
// report; the frame's display-refreshed callbacks run at its next update.
void MarkDirty(void *smf);
} // namespace ScrollingMessageFrame::DisplayRefreshed
+194
View File
@@ -0,0 +1,194 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Retail's insert mode on ScrollingMessageFrame: `SetInsertMode(mode)`,
// `GetInsertMode()` and the SCROLLING_MESSAGE_FRAME_INSERT_MODE_* globals.
//
// Retail's mixin (Blizzard_SharedXML/ScrollingMessageFrame.lua, RefreshLayout)
// stacks the lines up from the bottom in BOTTOM mode, the newest message at
// the bottom, and down from the top in TOP mode: the first line's TOPLEFT on
// the frame's TOPLEFT, each next line's TOPLEFT on the BOTTOMLEFT of the line
// before, the spacing below it. The scroll offset counts from the newest
// message in both modes. Setting the mode the frame has does nothing; a change
// lays every line out again and refreshes the display.
//
// 1.12's line layout is retail's BOTTOM mode. It anchors a line once, when it
// makes the line, through one SetPoint call (Offsets.h,
// CALL_SMF_LAYOUT_ANCHOR_LINE), so that call is redirected: a line of a frame
// in TOP mode takes the TOP anchors, every other line the engine's own. The
// layout makes the lines again when the frame changes size, which the
// redirect covers too. When a frame's mode changes, its lines are anchored
// again in order with the layout's own steps: clear the points, set the
// point, lay the line out now. The hyperlink buttons and inline textures are
// anchored to the lines, so they move with them. Retail's frame has no text
// insets; the TOP mode's first line sits the top inset below the frame's top,
// as the BOTTOM mode's sits the bottom inset above its bottom.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "frame/Destruction.h"
#include "scrollingmessageframe/DisplayRefreshed.h"
#include <cstdint>
#include <unordered_set>
namespace ScrollingMessageFrame::InsertMode {
namespace {
// SCROLLING_MESSAGE_FRAME_INSERT_MODE_TOP / _BOTTOM.
constexpr int kInsertModeTop = 1;
constexpr int kInsertModeBottom = 2;
using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor, int relPoint, float x, float y,
int flag);
using ClearAllPoints_t = void(__fastcall *)(void *anchor);
using Resize_t = void(__fastcall *)(void *anchor, void *edx, int force);
using PixelAlignY_t = float(__stdcall *)(float length);
// The frames in TOP mode. Every frame is rebuilt on /reload, so the set is
// cleared then.
std::unordered_set<void *> g_topFrames;
bool IsTop(void *smf) { return g_topFrames.count(smf) != 0; }
void *AnchorOf(void *region) { return static_cast<uint8_t *>(region) + Offsets::OFF_REGION_ANCHOR; }
// Sets a line's anchor as the layout gives it - (BOTTOMLEFT, the frame,
// BOTTOMLEFT, x, y) for the first line, (BOTTOMLEFT, the line before, TOPLEFT,
// x, y) for the others - or, for a frame in TOP mode, its mirror.
void SetLinePoint(void *smf, void *anchor, int point, void *relative, int relativePoint, float x, float y,
int flag) {
if (IsTop(smf)) {
point = Offsets::FRAMEPOINT_TOPLEFT;
if (relativePoint == Offsets::FRAMEPOINT_BOTTOMLEFT) { // the first line, on the frame
relativePoint = Offsets::FRAMEPOINT_TOPLEFT;
y = -Game::Read<float>(smf, Offsets::OFF_SCROLLINGMESSAGEFRAME_INSET_TOP);
} else {
relativePoint = Offsets::FRAMEPOINT_BOTTOMLEFT;
y = -y;
}
}
reinterpret_cast<SetPoint_t>(Offsets::FUN_REGION_SET_POINT)(anchor, point, relative, relativePoint, x, y, flag);
}
// The layout's SetPoint for a line it has just made; the line's parent is
// the frame.
void __fastcall AnchorNewLine(void *anchor, void *, int point, void *relative, int relativePoint, float x, float y,
int flag) {
void *line = static_cast<uint8_t *>(anchor) - Offsets::OFF_REGION_ANCHOR;
SetLinePoint(Game::Read<void *>(line, Offsets::OFF_REGION_PARENT), anchor, point, relative, relativePoint, x, y,
flag);
}
// Anchors the frame's lines again, in order, with the layout's arguments and
// steps.
void AnchorLines(void *smf) {
const int count = Game::Read<int>(smf, Offsets::OFF_SMF_LINE_COUNT);
const auto *lines = Game::Read<const uint8_t *>(smf, Offsets::OFF_SMF_LINE_ARRAY);
void *previous = nullptr;
for (int i = 0; i < count; ++i) {
void *line = Game::Read<void *>(lines + i * Offsets::SMF_LINE_STRIDE, Offsets::OFF_SMF_LINE_FONTSTRING);
void *anchor = AnchorOf(line);
reinterpret_cast<ClearAllPoints_t>(Offsets::FUN_LAYOUT_CLEAR_ALL_POINTS)(anchor);
if (previous == nullptr)
SetLinePoint(smf, anchor, Offsets::FRAMEPOINT_BOTTOMLEFT, AnchorOf(smf), Offsets::FRAMEPOINT_BOTTOMLEFT,
Game::Read<float>(smf, Offsets::OFF_SCROLLINGMESSAGEFRAME_INSET_LEFT),
Game::Read<float>(smf, Offsets::OFF_SCROLLINGMESSAGEFRAME_INSET_BOTTOM), 1);
else
SetLinePoint(smf, anchor, Offsets::FRAMEPOINT_BOTTOMLEFT, AnchorOf(previous), Offsets::FRAMEPOINT_TOPLEFT,
0.0f,
reinterpret_cast<PixelAlignY_t>(Offsets::FUN_PIXEL_ALIGN_Y)(
Game::Read<float>(line, Offsets::OFF_FONTSTRING_SPACING)),
1);
reinterpret_cast<Resize_t>(Offsets::FUN_LAYOUT_RESIZE)(anchor, nullptr, 1);
previous = line;
}
}
int __fastcall Script_SetInsertMode(void *L) {
void *frame = Game::Lua::ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
if (frame == nullptr)
return 0;
const double mode = Game::Lua::IsNumber(L, 2) ? Game::Lua::ToNumber(L, 2) : 0;
if (mode != kInsertModeTop && mode != kInsertModeBottom) {
Game::Lua::Error(L,
"Usage: %s:SetInsertMode(SCROLLING_MESSAGE_FRAME_INSERT_MODE_TOP or "
"SCROLLING_MESSAGE_FRAME_INSERT_MODE_BOTTOM)",
Game::Lua::ObjectDisplayName(frame));
return 0;
}
const bool top = mode == kInsertModeTop;
if (top == IsTop(frame))
return 0;
if (top)
g_topFrames.insert(frame);
else
g_topFrames.erase(frame);
AnchorLines(frame);
DisplayRefreshed::MarkDirty(frame);
return 0;
}
int __fastcall Script_GetInsertMode(void *L) {
void *frame = Game::Lua::ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
if (frame == nullptr)
return 0;
Game::Lua::PushNumber(L, IsTop(frame) ? kInsertModeTop : kInsertModeBottom);
return 1;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"SetInsertMode", &Script_SetInsertMode},
{"GetInsertMode", &Script_GetInsertMode},
};
const Game::Doc::Field kModeArgs[] = {
Game::Doc::Req("mode", "number",
"SCROLLING_MESSAGE_FRAME_INSERT_MODE_TOP or SCROLLING_MESSAGE_FRAME_INSERT_MODE_BOTTOM."),
};
const Game::Doc::Field kModeRets[] = {
Game::Doc::Req("mode", "number", "BOTTOM until SetInsertMode changes it."),
};
const Game::Doc::Function kSetInsertMode{
"Sets where the newest message shows: at the top, with older messages below it, or at the bottom.", kModeArgs,
{}};
const Game::Doc::Function kGetInsertMode{"Returns where the newest message shows.", {}, kModeRets};
const Game::Doc::Method kDocs[] = {
{"SetInsertMode", &kSetInsertMode},
{"GetInsertMode", &kGetInsertMode},
};
void RegisterLuaFunctions() {
void *L = Game::Lua::State();
Game::Lua::SetGlobalNumber(L, "SCROLLING_MESSAGE_FRAME_INSERT_MODE_TOP", kInsertModeTop);
Game::Lua::SetGlobalNumber(L, "SCROLLING_MESSAGE_FRAME_INSERT_MODE_BOTTOM", kInsertModeBottom);
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_SCROLLINGMESSAGEFRAME_METHOD_REGISTRY),
kMethods, 2, kDocs, 2);
Common::RedirectCall(Offsets::CALL_SMF_LAYOUT_ANCHOR_LINE, Offsets::FUN_REGION_SET_POINT,
reinterpret_cast<const void *>(&AnchorNewLine));
}
void PrepareForReload() { g_topFrames.clear(); }
void OnFrameDestroyed(void *frame) { g_topFrames.erase(frame); }
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ReloadAutoRegister _reload{&PrepareForReload};
const Frame::Destruction::AutoSubscribe _destructionSub{&OnFrameDestroyed};
} // namespace
} // namespace ScrollingMessageFrame::InsertMode
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "scrollingmessageframe/Layout.h"
#include "Game.h"
#include "Offsets.h"
namespace ScrollingMessageFrame::Layout {
namespace {
AutoSubscribe *g_head = nullptr;
using Layout_t = void(__fastcall *)(void *smf, void *edx, int newestRecord);
Layout_t g_origLayout = nullptr;
void __fastcall Layout_h(void *smf, void *edx, int newestRecord) {
g_origLayout(smf, edx, newestRecord);
for (const AutoSubscribe *s = g_head; s != nullptr; s = s->next)
s->fn(smf);
}
const Game::HookAutoRegister _hook{Offsets::FUN_SMF_DISPLAY_REFRESH, reinterpret_cast<void *>(&Layout_h),
reinterpret_cast<void **>(&g_origLayout)};
} // namespace
AutoSubscribe::AutoSubscribe(Listener fn) : fn(fn), next(g_head) { g_head = this; }
} // namespace ScrollingMessageFrame::Layout
+35
View File
@@ -0,0 +1,35 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// Shared observer for the ScrollingMessageFrame line layout
// FUN_SMF_DISPLAY_REFRESH (Offsets.h), which lays out the lines the frame
// shows after every change to them: a message added at the bottom, a scroll,
// a resize, a font change, Clear. A listener runs after it, with the frame.
//
// MinHook allows ONE hook per address and more than one feature follows the
// layout (Text::InlineTexture's early icon apply, the display-refreshed
// callbacks), so this module owns the hook and fans out. Declare a file-scope
// static const ScrollingMessageFrame::Layout::AutoSubscribe _sub{&OnLayout};
namespace ScrollingMessageFrame::Layout {
using Listener = void (*)(void *smf);
struct AutoSubscribe {
explicit AutoSubscribe(Listener fn);
Listener fn;
AutoSubscribe *next;
};
} // namespace ScrollingMessageFrame::Layout
+542
View File
@@ -0,0 +1,542 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `ScrollingMessageFrame:GetMessageInfo(index)`, retail's: the message's
// text, its colour, then every argument AddMessage took after the colour.
//
// Retail's ScrollingMessageFrame is a Lua mixin whose AddMessage(message, r,
// g, b, ...) packs the call into an entry and GetMessageInfo unpacks it
// (Blizzard_SharedXML/ScrollingMessageFrame.lua). Index 1 is the oldest
// message; the extra arguments come back as given, nils and count included;
// an index outside 1..GetNumMessages() returns nothing. (4.3.4's C method
// returns text, accessID, typeID, extraData instead.)
//
// The text and colour come from the engine's own record of the message
// (Offsets.h, OFF_SCROLLINGMESSAGEFRAME_MESSAGES), so a colour
// UpdateColorByID changed reads back changed, as retail's AdjustMessageColors
// does. The record keeps the colour as bytes, so a component reads back as
// byte/255. It keeps the first extra argument only as an integer (0 for
// anything else), so the extra arguments are kept beside the ring instead.
//
// AddMessage is registered again on the ScrollingMessageFrame registry, over
// the engine's. The registrar (0x00701D80) adds a node for the name whatever
// is there; Storm's TSHashTable::InternalNew links a new node at the head of
// its bucket (NewNode(1)), and the method lookup (0x00702000) returns the
// first match, so this one answers. It runs the engine's AddMessage, then
// records the extra arguments for the record the engine wrote. The engine
// adds a message exactly when the text is a nonempty string
// (0x0079299C..0x007929C7: lua_isstring, lua_tostring, first byte); a bad
// `self` raises a Lua error instead. The ring's one writer has no other
// caller, so every record was written through here.
//
// The extra arguments live in a registry table keyed by the frame's Lua table,
// then by record. A record's table is refilled when the record is overwritten,
// so a full ring makes no garbage.
//
// `BackFillMessage(text, r, g, b, ...)`, retail's, adds a message as the
// OLDEST — the combat log's refilter walks its history newest to oldest and
// fills the frame behind the lines it already shows (Blizzard_CombatLog's
// RefilterUpdate). It writes the ring itself; see the function.
//
// `RemoveMessagesByPredicate(predicate)`, retail's, removes the messages the
// predicate selects by their GetMessageInfo values. The ring has no
// compaction of its own; 4.3.4's RemoveLines loop is run over it, the
// extra-argument tables moving with their records. See the function.
#include "Game.h"
#include "Offsets.h"
#include "scrollingmessageframe/DisplayRefreshed.h"
#include <cstdint>
#include <cstring>
#include <vector>
namespace ScrollingMessageFrame::MessageInfo {
namespace {
constexpr int kFirstExtra = 6; // self, text, r, g, b, then the extra arguments
// The frame-table → record → extra-arguments table, in the registry of the Lua
// state that made it. LUA_NOREF until first use in each state.
constexpr int kNoRef = -2;
int g_extrasRef = kNoRef;
// Pushes the record → extra-arguments table of the frame whose Lua table is
// at stack slot 1, or nil when it has none and `create` is false.
void PushRecords(void *L, bool create) {
using namespace Game::Lua;
if (g_extrasRef == kNoRef) {
if (!create) {
PushNil(L);
return;
}
NewTable(L);
g_extrasRef = Ref(L, REGISTRY_INDEX);
}
RawGetI(L, REGISTRY_INDEX, g_extrasRef); // [frames]
PushValue(L, 1);
RawGet(L, -2); // [frames, records]
if (Type(L, -1) != TYPE_TABLE && create) {
SetTop(L, -2);
NewTable(L);
PushValue(L, 1);
PushValue(L, -2);
RawSet(L, -4); // frames[self] = records
}
Remove(L, -2); // [records]
}
// Pushes the extra-argument table of record `record` of the frame whose Lua
// table is at stack slot 1, or nil when it has none and `create` is false.
void PushExtras(void *L, int record, bool create) {
using namespace Game::Lua;
PushRecords(L, create); // [records]
if (Type(L, -1) != TYPE_TABLE)
return;
RawGetI(L, -1, record); // [records, extras]
if (Type(L, -1) != TYPE_TABLE && create) {
SetTop(L, -2);
NewTable(L);
PushNumber(L, record);
PushValue(L, -2);
RawSet(L, -4); // records[record] = extras
}
Remove(L, -2); // [extras]
}
// The `n` of the extra-argument table at absolute stack slot `extras`.
int ExtraCount(void *L, int extras) {
using namespace Game::Lua;
PushString(L, "n");
RawGet(L, extras);
const int n = static_cast<int>(ToNumber(L, -1));
SetTop(L, -2);
return n;
}
// Moves the records of slots 0..span-1 so that slot i holds what slot
// (i + by) % span held, with their extra-argument tables and the scroll
// record. Whole records move, so every text keeps one owner, a free slot's
// too (Clear and the writer leave a free slot's old text for the writer to
// free when it reuses the slot). The lines still show the records they were
// laid out from; the caller lays them out again. Self at stack slot 1.
void RotateRecords(void *L, void *frame, int span, int by) {
using namespace Game::Lua;
by %= span;
if (span <= 1 || by == 0)
return;
constexpr size_t kStride = Offsets::SCROLLINGMESSAGEFRAME_MESSAGE_STRIDE;
uint8_t *records = Game::Read<uint8_t *>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGES);
const std::vector<uint8_t> was(records, records + span * kStride);
for (int i = 0; i < span; ++i)
std::memcpy(records + i * kStride, was.data() + ((i + by) % span) * kStride, kStride);
int &scroll = Game::Ref<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_SCROLL_RECORD);
if (scroll >= 0 && scroll < span)
scroll = (scroll - by + span) % span;
PushRecords(L, false);
const int table = GetTop(L);
if (Type(L, table) == TYPE_TABLE) {
NewTable(L);
const int moved = GetTop(L);
for (int i = 0; i < span; ++i) {
PushNumber(L, i);
RawGetI(L, table, (i + by) % span);
RawSet(L, moved);
}
for (int i = 0; i < span; ++i) {
PushNumber(L, i);
RawGetI(L, moved, i);
RawSet(L, table);
}
}
SetTop(L, table - 1);
}
// Stores stack slots kFirstExtra..top as the extra arguments of `record`.
void StoreExtras(void *L, int record, int top) {
using namespace Game::Lua;
const int count = top >= kFirstExtra ? top - kFirstExtra + 1 : 0;
PushExtras(L, record, count > 0);
const int extras = GetTop(L);
if (Type(L, extras) == TYPE_TABLE) {
const int was = ExtraCount(L, extras);
for (int i = 1; i <= count; ++i) {
PushNumber(L, i);
PushValue(L, kFirstExtra - 1 + i);
RawSet(L, extras);
}
for (int i = count + 1; i <= was; ++i) {
PushNumber(L, i);
PushNil(L);
RawSet(L, extras);
}
PushString(L, "n");
PushNumber(L, count);
RawSet(L, extras);
}
SetTop(L, -2);
}
int __fastcall Script_AddMessage(void *L) {
const int top = Game::Lua::GetTop(L);
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_SCROLLINGMESSAGEFRAME_ADD_MESSAGE)(L);
const char *text = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
if (text == nullptr || *text == '\0')
return 0;
void *frame = Game::Lua::ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
if (frame == nullptr)
return 0;
StoreExtras(L, Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_POS), top);
// Scrolled up, the engine adds the message without a layout.
DisplayRefreshed::MarkDirty(frame);
return 0;
}
// AddMessage's colour byte (0x00792A1B..0x00792ACC): the value clamped to
// 0..1 (NaN to 1), then trunc(v × 255 + 0.5).
uint32_t ColorByte(double value) {
double clamped = 1.0;
if (value < 0.0)
clamped = 0.0;
else if (value < 1.0)
clamped = static_cast<float>(value);
return static_cast<uint32_t>(static_cast<int>(clamped * 255.0 + 0.5)) & 0xFFu;
}
// The colour AddMessage builds from stack slots 3..5 — alpha 0xFF, R, G, B —
// or the frame font's when they are not all numbers (0x007929CD..0x00792B45).
uint32_t MessageColor(void *L, void *frame) {
using namespace Game::Lua;
if (IsNumber(L, 3) && IsNumber(L, 4) && IsNumber(L, 5))
return 0xFF000000u | (ColorByte(ToNumber(L, 3)) << 16) | (ColorByte(ToNumber(L, 4)) << 8) |
ColorByte(ToNumber(L, 5));
const void *font = Game::Read<const void *>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_FONT);
return font != nullptr ? Game::Read<uint32_t>(font, Offsets::OFF_SCROLLINGMESSAGEFRAME_FONT_COLOR) : 0xFFFFFFFFu;
}
// `ScrollingMessageFrame:BackFillMessage(text, r, g, b, ...)`, retail's: the
// message becomes the OLDEST. Retail's mixin pushes the entry at the back of
// its history buffer and drops it when the buffer is full ("Won't overwrite
// front"). The ring is then not full, so the engine keeps it in records
// 0..count-1 (Offsets.h, OFF_SCROLLINGMESSAGEFRAME_MESSAGES), and the new
// oldest goes in record 0: every record moves up one, the free record at the
// count coming round to 0. (4.3.4's AddMessage(..., addToTop) writes the
// record before the oldest instead, which wraps to the end of the array and
// leaves 4.3.4's own count-wrapped scroll math wrong.) The record is written
// as the ring writer 0x00788150 writes it: the text a Storm copy (the ring
// frees its texts with SMemFree), AddMessage's colour, shown, the frame's two
// times, id 0. The lines are laid out again: at the bottom from the newest,
// as the writer does, which shows the new line when there is room above the
// others; scrolled up from the scroll record, which moved with its message,
// so the view stays.
int __fastcall Script_BackFillMessage(void *L) {
using namespace Game::Lua;
using StormAlloc_t = void *(__stdcall *)(size_t size, const char *file, int line, int flags);
using StormFree_t = int(__stdcall *)(void *ptr, const char *file, int line, int flags);
using Layout_t = void(__fastcall *)(void *smf, void *edx, int newestRecord);
static const char kStormFile[] = "ClassicAPI ScrollingMessageFrame:BackFillMessage";
const int top = GetTop(L);
void *frame = ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
if (frame == nullptr)
return 0;
const char *text = IsString(L, 2) ? ToString(L, 2) : nullptr;
if (text == nullptr || *text == '\0')
return 0;
const int max = Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MAX_MESSAGES);
int &count = Game::Ref<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COUNT);
if (max <= 0 || count >= max)
return 0; // full: the message is older than every kept one
const uint32_t color = MessageColor(L, frame);
const size_t bytes = std::strlen(text) + 1;
char *copy = static_cast<char *>(
reinterpret_cast<StormAlloc_t>(Offsets::FUN_STORM_SMEM_ALLOC)(bytes, kStormFile, __LINE__, 0));
if (copy == nullptr)
return 0;
std::memcpy(copy, text, bytes);
RotateRecords(L, frame, count + 1, count);
++count;
int &pos = Game::Ref<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_POS);
pos = count - 1;
uint8_t *record = Game::Read<uint8_t *>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGES);
char *&stored = *reinterpret_cast<char **>(record + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_TEXT);
if (stored != nullptr)
reinterpret_cast<StormFree_t>(Offsets::FUN_STORM_SMEM_FREE)(stored, kStormFile, __LINE__, 0);
stored = copy;
*reinterpret_cast<uint32_t *>(record + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COLOR) = color;
*reinterpret_cast<int *>(record + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_SHOWN) = 1;
*reinterpret_cast<uint32_t *>(record + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_VISIBLE_TIME) =
Game::Read<uint32_t>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_VISIBLE_TIME);
*reinterpret_cast<uint32_t *>(record + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_FADE_TIME) =
Game::Read<uint32_t>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_FADE_TIME);
*reinterpret_cast<int *>(record + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_ID) = 0;
StoreExtras(L, 0, top);
reinterpret_cast<Layout_t>(Offsets::FUN_SMF_DISPLAY_REFRESH)(
frame, nullptr,
Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_AT_BOTTOM) != 0
? pos
: Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_SCROLL_RECORD));
DisplayRefreshed::MarkDirty(frame);
return 0;
}
// The record of message `index`, 1 the oldest: (position - count + index),
// plus the limit when negative.
int RecordOf(void *frame, int index) {
const int count = Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COUNT);
int record = Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_POS) - count + index;
if (record < 0)
record += Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MAX_MESSAGES);
return record;
}
// Pushes a record's GetMessageInfo values — its text, r, g, b, then its extra
// arguments — for the frame whose Lua table is at stack slot 1, and returns
// how many.
int PushMessage(void *L, void *frame, int record) {
using namespace Game::Lua;
const auto *message = Game::Read<const uint8_t *>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGES) +
record * Offsets::SCROLLINGMESSAGEFRAME_MESSAGE_STRIDE;
const uint32_t color = Game::Read<uint32_t>(message, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COLOR);
PushExtras(L, record, false);
const int extras = GetTop(L);
const int n = Type(L, extras) == TYPE_TABLE ? ExtraCount(L, extras) : 0;
CheckStack(L, 4 + n);
PushString(L, Game::Read<const char *>(message, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_TEXT));
PushNumber(L, ((color >> 16) & 0xFF) / 255.0);
PushNumber(L, ((color >> 8) & 0xFF) / 255.0);
PushNumber(L, (color & 0xFF) / 255.0);
for (int i = 1; i <= n; ++i)
RawGetI(L, extras, i);
Remove(L, extras);
return 4 + n;
}
int __fastcall Script_GetMessageInfo(void *L) {
using namespace Game::Lua;
void *frame = ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
if (frame == nullptr)
return 0;
if (!IsNumber(L, 2)) {
Error(L, "Usage: %s:GetMessageInfo(index)", ObjectDisplayName(frame));
return 0;
}
const double index = ToNumber(L, 2);
const int count = Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COUNT);
if (!(index >= 1 && index <= count) || index != static_cast<int>(index))
return 0;
return PushMessage(L, frame, RecordOf(frame, static_cast<int>(index)));
}
// `ScrollingMessageFrame:RemoveMessagesByPredicate(predicate)`, retail's: the
// predicate is called with each message's GetMessageInfo values, oldest
// first, and the messages it returns true for are removed; the rest keep
// their order. Retail's mixin rebuilds its history buffer and re-renders at
// the kept scroll offset. The removal is 4.3.4's RemoveLines over 1.12's
// ring: every kept record moves forward in ring order to the first free slot
// (freeing the text it lands on, nulling the one it left), the position moves
// back by the removed count, and the extra-argument tables move with their
// records. The ring is turned first so that the oldest is record 0, so the
// kept ones end in records 0..kept-1, the engine's layout of a ring that is
// not full (Offsets.h, OFF_SCROLLINGMESSAGEFRAME_MESSAGES); 4.3.4 compacts
// from the oldest's record, which leaves a full ring's survivors anywhere.
// Then the engine's own layout from the record at the kept offset
// (clamped to the oldest) and its re-arm — a layout at the bottom keeps the
// fade state, so 4.3.4 re-arms after — or its Clear when nothing is kept.
//
// The predicate runs for every message before anything moves (retail builds
// its new list first). A predicate that errors keeps that message, as
// retail's safesecurecall does; the engine's error handler reports it.
int __fastcall Script_RemoveMessagesByPredicate(void *L) {
using namespace Game::Lua;
using Layout_t = void(__fastcall *)(void *smf, void *edx, int newestRecord);
using Smf_t = void(__fastcall *)(void *smf);
using ScrollOffset_t = int(__fastcall *)(void *smf);
using StormFree_t = int(__stdcall *)(void *ptr, const char *file, int line, int flags);
static const char kStormFile[] = "ClassicAPI ScrollingMessageFrame:RemoveMessagesByPredicate";
void *frame = ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
if (frame == nullptr)
return 0;
if (Type(L, 2) != TYPE_FUNCTION) {
Error(L, "Usage: %s:RemoveMessagesByPredicate(predicate)", ObjectDisplayName(frame));
return 0;
}
const int count = Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COUNT);
const int max = Game::Read<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MAX_MESSAGES);
if (count <= 0 || max <= 0)
return 0;
const int top = GetTop(L);
// The predicate over every message, through the engine's error handler.
int errIdx = 0;
const int errRef = Game::Read<int>(static_cast<uintptr_t>(Offsets::VAR_FRAMESCRIPT_ERROR_HANDLER_REF));
if (errRef > 0) {
RawGetI(L, REGISTRY_INDEX, errRef);
errIdx = GetTop(L);
}
std::vector<uint8_t> remove(static_cast<size_t>(count), 0);
int kept = count;
for (int i = 0; i < count; ++i) {
CheckStack(L, 2);
PushValue(L, 2);
const int args = PushMessage(L, frame, RecordOf(frame, i + 1));
if (PCall(L, args, 1, errIdx) == 0 && ToBoolean(L, -1) != 0) {
remove[static_cast<size_t>(i)] = 1;
--kept;
}
SetTop(L, -2);
}
SetTop(L, top);
if (kept == count)
return 0;
// 4.3.4's RemoveLines: compact the kept records forward in ring order,
// the oldest first turned to record 0.
const int offset = reinterpret_cast<ScrollOffset_t>(Offsets::FUN_SCROLLINGMESSAGEFRAME_SCROLL_OFFSET)(frame);
int &pos = Game::Ref<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_POS);
int oldest = pos - count + 1;
if (oldest < 0)
oldest += max;
RotateRecords(L, frame, max, oldest);
pos = count - 1;
uint8_t *records = Game::Read<uint8_t *>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGES);
PushRecords(L, false);
const int table = GetTop(L);
const bool extras = Type(L, table) == TYPE_TABLE;
int src = 0;
int dst = 0;
for (int i = 0; i < count; ++i) {
if (!remove[static_cast<size_t>(i)]) {
if (src != dst) {
uint8_t *to = records + dst * Offsets::SCROLLINGMESSAGEFRAME_MESSAGE_STRIDE;
uint8_t *from = records + src * Offsets::SCROLLINGMESSAGEFRAME_MESSAGE_STRIDE;
char *&landedOn = *reinterpret_cast<char **>(to + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_TEXT);
if (landedOn != nullptr)
reinterpret_cast<StormFree_t>(Offsets::FUN_STORM_SMEM_FREE)(landedOn, kStormFile, __LINE__, 0);
std::memcpy(to, from, Offsets::SCROLLINGMESSAGEFRAME_MESSAGE_STRIDE);
*reinterpret_cast<char **>(from + Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_TEXT) = nullptr;
if (extras) {
PushNumber(L, dst);
RawGetI(L, table, src);
RawSet(L, table); // records[dst] = records[src]
PushNumber(L, src);
PushNil(L);
RawSet(L, table); // records[src] = nil
}
}
dst = (dst + 1) % max;
} else if (extras) {
PushNumber(L, src);
PushNil(L);
RawSet(L, table); // records[src] = nil
}
src = (src + 1) % max;
}
SetTop(L, top);
if (kept == 0) {
reinterpret_cast<Smf_t>(Offsets::FUN_SCROLLINGMESSAGEFRAME_CLEAR)(frame);
DisplayRefreshed::MarkDirty(frame);
return 0;
}
pos += kept - count;
if (pos < 0)
pos += max;
Game::Ref<int>(frame, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COUNT) = kept;
// The lines again from the record at the kept offset, then re-armed.
const int shown = (offset < kept) ? (offset < 0 ? 0 : offset) : kept - 1;
int record = pos - shown;
if (record < 0)
record += max;
reinterpret_cast<Layout_t>(Offsets::FUN_SMF_DISPLAY_REFRESH)(frame, nullptr, record);
reinterpret_cast<Smf_t>(Offsets::FUN_SCROLLINGMESSAGEFRAME_REARM_LINES)(frame);
DisplayRefreshed::MarkDirty(frame);
return 0;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"AddMessage", &Script_AddMessage},
{"BackFillMessage", &Script_BackFillMessage},
{"GetMessageInfo", &Script_GetMessageInfo},
{"RemoveMessagesByPredicate", &Script_RemoveMessagesByPredicate},
};
const Game::Doc::Field kAddMessageArgs[] = {
Game::Doc::Req("text", "string"),
Game::Doc::Opt("r", "number", nullptr, "Without r, g and b the message takes the frame's font colour."),
Game::Doc::Opt("g", "number"),
Game::Doc::Opt("b", "number"),
Game::Doc::Vararg("any", "Kept with the message; GetMessageInfo returns them after its colour."),
};
const Game::Doc::Function kAddMessage{"Adds a message to the frame.", kAddMessageArgs, {}};
const Game::Doc::Field kBackFillMessageArgs[] = {
Game::Doc::Req("text", "string"),
Game::Doc::Opt("r", "number", nullptr, "Without r, g and b the message takes the frame's font colour."),
Game::Doc::Opt("g", "number"),
Game::Doc::Opt("b", "number"),
Game::Doc::Vararg("any", "Kept with the message; GetMessageInfo returns them after its colour."),
};
const Game::Doc::Function kBackFillMessage{
"Adds a message as the oldest, to fill in history behind the messages the frame has. A frame at "
"its line limit drops it.",
kBackFillMessageArgs, {}};
const Game::Doc::Field kGetMessageInfoArgs[] = {
Game::Doc::Req("index", "luaIndex", "1 is the oldest message."),
};
const Game::Doc::Field kGetMessageInfoRets[] = {
Game::Doc::Req("text", "string"),
Game::Doc::Req("r", "number"),
Game::Doc::Req("g", "number"),
Game::Doc::Req("b", "number"),
Game::Doc::Vararg("any", "The arguments AddMessage took after the colour."),
};
const Game::Doc::Function kGetMessageInfo{
"Returns a message's text, its colour, and the extra arguments it was added with, or nothing "
"when there is no message at that index.",
kGetMessageInfoArgs, kGetMessageInfoRets};
const Game::Doc::Field kRemoveMessagesByPredicateArgs[] = {
Game::Doc::Req("predicate", "function",
"Called with each message's GetMessageInfo values, oldest first; true removes the message."),
};
const Game::Doc::Function kRemoveMessagesByPredicate{
"Removes the messages the predicate returns true for. The rest keep their order, and the frame "
"keeps its scroll offset.",
kRemoveMessagesByPredicateArgs, {}};
const Game::Doc::Method kDocs[] = {
{"AddMessage", &kAddMessage},
{"BackFillMessage", &kBackFillMessage},
{"GetMessageInfo", &kGetMessageInfo},
{"RemoveMessagesByPredicate", &kRemoveMessagesByPredicate},
};
void RegisterLuaFunctions() {
// Each registration pass is a new Lua state; the old state's ref is gone.
g_extrasRef = kNoRef;
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_SCROLLINGMESSAGEFRAME_METHOD_REGISTRY),
kMethods, 4, kDocs, 4);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace ScrollingMessageFrame::MessageInfo
+168
View File
@@ -0,0 +1,168 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Retail's scroll offset on ScrollingMessageFrame: `SetScrollOffset(offset)`,
// `GetScrollOffset()`, `GetMaxScrollRange()` and `ScrollByAmount(amount)`.
//
// Retail's offset (Blizzard_SharedXML/ScrollingMessageFrame.lua) counts the
// lines the frame is scrolled up from the newest message, 0 at the bottom, up
// to GetMaxScrollRange() = max(GetNumMessages() - 1, 0), where the oldest
// message is the bottom line - the place 1.12's own ScrollToTop goes. 1.12
// keeps the same offset: ScrollToOffset and ScrollOffset (Offsets.h) are its
// two directions, and GetScrollOffset is the engine's GetCurrentScroll under
// retail's name. Retail clamps the offset to its range; the engine wraps one
// outside it, so the setters clamp first. SetScrollOffset to the offset the
// frame has does nothing, as on retail.
//
// Retail's ScrollUp and ScrollDown are ScrollByAmount(1) and (-1), so
// ScrollByAmount steps the engine's own ScrollUp / ScrollDown, the loop its
// PageUp runs. The engine's ScrollUp stops once the oldest message is on
// screen, short of the range's end, where that message sits alone on the
// bottom line under empty space; moving the offset straight there scrolls
// chat up into that space (pfUI's chat wheel did). The clamped target still
// bounds the steps, since a hidden frame never lays out and its ScrollUp never
// finds the top. Every step re-arms the shown lines' fade, and an amount that
// takes no step re-arms through ScrollToOffset: retail's ResetAllFadeTimes.
#include "Game.h"
#include "Offsets.h"
#include "scrollingmessageframe/DisplayRefreshed.h"
namespace ScrollingMessageFrame::Scroll {
namespace {
using ScrollToOffset_t = void(__fastcall *)(void *smf, void *edx, int offset);
using SmfInt_t = int(__fastcall *)(void *smf);
int ScrollOffset(void *smf) {
return reinterpret_cast<SmfInt_t>(Offsets::FUN_SCROLLINGMESSAGEFRAME_SCROLL_OFFSET)(smf);
}
// One step of the engine's ScrollUp / ScrollDown; false at their end.
bool ScrollUp(void *smf) {
return reinterpret_cast<SmfInt_t>(Offsets::FUN_SCROLLINGMESSAGEFRAME_SCROLL_UP)(smf) != 0;
}
bool ScrollDown(void *smf) {
return reinterpret_cast<SmfInt_t>(Offsets::FUN_SCROLLINGMESSAGEFRAME_SCROLL_DOWN)(smf) != 0;
}
int MaxScrollRange(void *smf) {
const int count = Game::Read<int>(smf, Offsets::OFF_SCROLLINGMESSAGEFRAME_MESSAGE_COUNT);
return count > 1 ? count - 1 : 0;
}
// `offset` clamped to 0..MaxScrollRange (NaN to 0).
int Clamped(void *smf, double offset) {
const int max = MaxScrollRange(smf);
return offset >= 0 ? (offset < max ? static_cast<int>(offset) : max) : 0;
}
void ScrollTo(void *smf, int offset) {
reinterpret_cast<ScrollToOffset_t>(Offsets::FUN_SCROLLINGMESSAGEFRAME_SCROLL_TO_OFFSET)(smf, nullptr, offset);
}
void *ResolveSmf(void *L) {
return Game::Lua::ResolveTypedObject(L, 1, Offsets::VAR_SCROLLINGMESSAGEFRAME_LUA_TYPE_ID);
}
int __fastcall Script_SetScrollOffset(void *L) {
void *frame = ResolveSmf(L);
if (frame == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: %s:SetScrollOffset(offset)", Game::Lua::ObjectDisplayName(frame));
return 0;
}
const int offset = Clamped(frame, Game::Lua::ToNumber(L, 2));
if (offset != ScrollOffset(frame))
ScrollTo(frame, offset);
return 0;
}
int __fastcall Script_GetMaxScrollRange(void *L) {
void *frame = ResolveSmf(L);
if (frame == nullptr)
return 0;
Game::Lua::PushNumber(L, MaxScrollRange(frame));
return 1;
}
int __fastcall Script_ScrollByAmount(void *L) {
void *frame = ResolveSmf(L);
if (frame == nullptr)
return 0;
if (!Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: %s:ScrollByAmount(amount)", Game::Lua::ObjectDisplayName(frame));
return 0;
}
const int offset = ScrollOffset(frame);
int steps = Clamped(frame, offset + Game::Lua::ToNumber(L, 2)) - offset;
if (steps == 0)
ScrollTo(frame, offset);
while (steps > 0 && ScrollUp(frame))
--steps;
while (steps < 0 && ScrollDown(frame))
++steps;
DisplayRefreshed::MarkDirty(frame);
return 0;
}
const Game::Lua::FrameMethodEntry kMethods[] = {
{"SetScrollOffset", &Script_SetScrollOffset},
{"GetScrollOffset",
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_SCROLLINGMESSAGEFRAME_GET_CURRENT_SCROLL)},
{"GetMaxScrollRange", &Script_GetMaxScrollRange},
{"ScrollByAmount", &Script_ScrollByAmount},
};
const Game::Doc::Field kOffsetArgs[] = {
Game::Doc::Req("offset", "number", "Lines from the newest message; clamped to 0..GetMaxScrollRange()."),
};
const Game::Doc::Field kOffsetRets[] = {
Game::Doc::Req("offset", "number", "Lines from the newest message; 0 when not scrolled."),
};
const Game::Doc::Field kRangeRets[] = {
Game::Doc::Req("maxScrollRange", "number"),
};
const Game::Doc::Field kAmountArgs[] = {
Game::Doc::Req("amount", "number", "Positive scrolls toward older messages."),
};
const Game::Doc::Function kSetScrollOffset{"Scrolls the frame to a number of lines from the newest message.",
kOffsetArgs, {}};
const Game::Doc::Function kGetScrollOffset{"Returns how many lines the frame is scrolled from the newest message.",
{}, kOffsetRets};
const Game::Doc::Function kGetMaxScrollRange{"Returns the largest scroll offset: one less than the message count.",
{}, kRangeRets};
const Game::Doc::Function kScrollByAmount{
"Scrolls the frame by a number of lines, stopping where ScrollUp and ScrollDown stop, and shows the faded lines "
"again.",
kAmountArgs, {}};
const Game::Doc::Method kDocs[] = {
{"SetScrollOffset", &kSetScrollOffset},
{"GetScrollOffset", &kGetScrollOffset},
{"GetMaxScrollRange", &kGetMaxScrollRange},
{"ScrollByAmount", &kScrollByAmount},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(reinterpret_cast<void *>(Offsets::VAR_SCROLLINGMESSAGEFRAME_METHOD_REGISTRY),
kMethods, 4, kDocs, 4);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace ScrollingMessageFrame::Scroll
+84
View File
@@ -0,0 +1,84 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// A font string as tall as its text keeps all of its lines.
//
// A font string with a rect height cuts its text to the lines that fit and
// ends it with "..." (CSimpleFontString::FitText, 0x771EC0). The count of
// what fits (GxuFontGetMaxCharsWithinHeight, 0x5C21C0) adds each line's
// height and gap to a running float sum, and stops at the first line where
// `blockHeight + 2^-20 < sum + fontHeight`. A font string with no height of
// its own gets its text's height (0x5C2070), and that height is a product,
// n * fontHeight + (n - 1) * gap. Over many lines the sum drifts from the
// product by more than 2^-20 at most UI scales. Then the last line "does not
// fit", and FitText cuts the text: a long one to its first 4092 bytes plus
// "...", with the rest of the box blank. A multi-line edit box shows this
// once its text passes 4 KB (ScrollingEditBoxTemplate, chat copy boxes).
//
// 4.3.4 keeps the product and lets the margin grow with the lines tried:
// `blockHeight + numLines * 2^-20` (GxuFontGetMaxCharsWithinWidthAndHeight).
// The thunk below does the same with the engine's own 2^-20. The first line
// keeps 1.12's margin, and each line adds a millionth of the screen height,
// so text that really overflows is still cut. A float32 model of the chain
// (fit after the round trip through the font string's cached height) lost
// the last line in 1276 of 3000 multi-line fits with 1.12's margin and in
// none with 4.3.4's.
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Text::FitHeight {
namespace {
// 4.3.4's numLines: the line the fit is trying.
int g_line = 0;
constexpr uintptr_t addrEpsilon = Offsets::VAR_TEXT_FIT_HEIGHT_EPSILON;
static_assert(Offsets::TEXT_FIT_HEIGHT_FRAME_FITTED_BYTES == 8, "LineMarginThunk reads [ebp - 8]");
// Stands in for `fadd dword ptr [VAR_TEXT_FIT_HEIGHT_EPSILON]`, with the
// block height in ST0. The bytes fitted so far ([ebp - 8]) are 0 only while
// the first line is tried.
__declspec(naked) void LineMarginThunk() {
__asm {
cmp dword ptr [ebp - 8], 0
jne counted
mov dword ptr [g_line], 0
counted:
inc dword ptr [g_line]
fild dword ptr [g_line]
mov eax, addrEpsilon
fmul dword ptr [eax]
faddp st(1), st(0)
ret
}
}
// Runs on every registration pass; a site already patched no longer matches
// and is skipped.
void Apply() {
static const uint8_t kMarginAdd[6] = {0xD8, 0x05, 0xBC, 0x26, 0x80, 0x00};
Common::ReplaceWithCall(Offsets::SITE_TEXT_FIT_HEIGHT_TOLERANCE, kMarginAdd, sizeof kMarginAdd,
reinterpret_cast<const void *>(&LineMarginThunk));
}
const Game::ModuleAutoRegister _autoreg{&Apply};
const Game::GlueModuleAutoRegister _glueAutoreg{&Apply};
} // namespace
} // namespace Text::FitHeight
+31 -9
View File
@@ -79,6 +79,7 @@
#include "Common.h"
#include "Game.h"
#include "Offsets.h"
#include "scrollingmessageframe/Layout.h"
#include "text/InlineTexturePool.h"
#include "text/PtrProbe.h"
#include "texture/Atlas.h"
@@ -2085,7 +2086,8 @@ static const Game::HookAutoRegister _paintHook{Offsets::FUN_TEXT_PAINT,
// So a line's icons are recorded — and its regions placed — one frame AFTER its
// glyphs draw, because a frame's textures (our icon regions) draw before its
// fontstring glyphs and the placement isn't computed until the glyph build runs
// mid-paint. This co-hook closes it: after the refresh sets up the lines (still
// mid-paint. This layout listener (ScrollingMessageFrame::Layout, which owns
// the hook) closes it: after the refresh sets up the lines (still
// PRE-RENDER — the refresh runs from the SMF's Lua/event-driven methods, never
// mid-render), force each icon-bearing line to resolve+build and apply its icon
// placement synchronously, so the icons draw with their glyphs the same frame.
@@ -2096,9 +2098,6 @@ static const Game::HookAutoRegister _paintHook{Offsets::FUN_TEXT_PAINT,
// disabled independently of the whole feature; the SEH latch trips it on a fault.
bool g_earlyApply = true;
using SmfRefresh_t = void(__fastcall *)(void *smf, void *edx, int newestIdx);
SmfRefresh_t g_smfRefreshOriginal = nullptr;
using EnsureBuilt_t = int(__fastcall *)(void *node);
using RealizeAnchor_t = void(__thiscall *)(void *anchor, int flag);
using RebuildStringFs_t = void(__fastcall *)(void *fs);
@@ -2183,15 +2182,12 @@ void SafeSmfEarlyApply(void *smf) {
}
}
void __fastcall SmfRefresh_h(void *smf, void *edx, int newestIdx) {
g_smfRefreshOriginal(smf, edx, newestIdx);
void OnSmfLayout(void *smf) {
if (g_inlineEnabled && g_earlyApply)
SafeSmfEarlyApply(smf);
}
static const Game::HookAutoRegister _smfRefreshHook{
Offsets::FUN_SMF_DISPLAY_REFRESH, reinterpret_cast<void *>(&SmfRefresh_h),
reinterpret_cast<void **>(&g_smfRefreshOriginal)};
static const ScrollingMessageFrame::Layout::AutoSubscribe _smfLayoutSub{&OnSmfLayout};
// --- Lua control surface ---------------------------------------------------
@@ -2238,6 +2234,32 @@ void PrepareForReload() {
g_nodeBakedColor.clear();
}
bool LineIconShift(void *fs, const uint8_t *text, int len, float &before, float &after) {
before = 0.0f;
after = 0.0f;
if (!g_inlineEnabled || !LooksReadable(fs) || !LooksReadable(text) || len <= 0 ||
!HasInlineTexture(text, len))
return false;
FsPenFont pf;
if (!ResolveFsPenFont(fs, pf))
return false;
const float overflow = IconOverflowPx(text, len, pf.fontHPen, pf.layoutScale);
if (!(overflow > 0.0f))
return false;
// The node's justify-V as the block creator (FUN_0044d420) reads the
// style word: TOP (8) wins, then BOTTOM (0x20), else middle — the value
// the emitter reads from the node to split the move.
const uint32_t style = Game::Read<uint32_t>(fs, Offsets::OFF_FONTSTRING_FLAGS);
const int justifyV = (style & 0x8u) ? 0 : (style & 0x20u) ? 2 : 1;
const float move = (justifyV == 1) ? std::floor(overflow * 0.5f) : overflow;
const float toFs = pf.fontHInt / pf.fontHPen; // pen px → the getter's units
if (justifyV != 0)
before = move * toFs;
if (justifyV != 2)
after = move * toFs;
return true;
}
static const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
} // namespace Text::InlineTexture
+11
View File
@@ -13,6 +13,8 @@
#pragma once
#include <cstdint>
namespace Text::InlineTexture {
// Called from FrameScript_Initialize_h before the /reload teardown: the reload
@@ -20,4 +22,13 @@ namespace Text::InlineTexture {
// icon records are dropped (they rebuild as the reloaded UI re-emits its text).
void PrepareForReload();
// The room the tall inline icons on the line [text, text + len) take when
// `fs` renders it, in the font string's own units (its height getter's):
// `before` is how far the render moves that line's text down, `after` how
// far it moves the lines after it — 4.3.4's InitializeText offsets, split by
// the font string's vertical justify (top: none / all; middle: half / half,
// floored; bottom: all / none). Both 0, and false, without an icon taller
// than the font or with the feature off.
bool LineIconShift(void *fs, const uint8_t *text, int len, float &before, float &after);
} // namespace Text::InlineTexture
+12 -2
View File
@@ -11,8 +11,8 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `GameTooltip:SetHyperlink(link)` takes a full chat link, and `spell:` and
// `quest:` links, as on 3.x clients.
// `GameTooltip:SetHyperlink(link)` takes a full chat link, and `spell:`,
// `quest:` and `trade:` links, as on 3.x clients.
//
// Vanilla's Script_GameTooltip_SetHyperlink knows two payloads, `item:` and
// `enchant:`, matched against the start of the argument. Anything else -
@@ -43,6 +43,10 @@
// the tooltip empty and queues the server query the quest log would
// make (once per quest - see Quest::Cache::Lookup's `unique`); the next
// SetHyperlink after the answer shows it.
// - `trade:` opens the linked profession in the profession window
// (TradeSkill::Link::Open) and leaves the tooltip alone, as 3.3.5's
// SetHyperlink does. That is how a click on a chat link opens it: the
// SetItemRef of both 1.12 and 3.3.5 ends in ItemRefTooltip:SetHyperlink.
// - Everything else - `item:`, other `enchant:` links, and the error for link types
// vanilla has no data for (`achievement:`, `talent:`, ...) - is the
// engine's own, run on the payload.
@@ -56,11 +60,13 @@
#include "spell/Lookup.h"
#include "spell/Tooltip.h"
#include "tooltip/Lines.h"
#include "tradeskill/Link.h"
#include "ui/ColorData.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <string>
namespace Tooltip::Hyperlink {
@@ -165,6 +171,10 @@ int __fastcall SetHyperlink_h(void *L) {
ShowQuest(L, static_cast<uint32_t>(std::strtoul(payload + 6, nullptr, 10)));
return 0;
}
if (HasPrefix(payload, len, "trade:")) {
TradeSkill::Link::Open(std::string(payload + 6, len - 6).c_str());
return 0;
}
if (payload != link) {
// Push the payload while the full link is still on the stack (the
+232 -381
View File
@@ -11,437 +11,288 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Trade-skill *list* links (`|Htrade:...|h`) — the shareable "here's my
// profession and which recipes I know" link. Vanilla 1.12 has NO such link
// type (the engine's SetHyperlink parser only knows `item:` and `enchant:`);
// 2.x introduced it. We backport the whole thing: the link is built entirely
// in C here; only the click-to-display viewer lives in the embedded addon
// (Util/TradeSkillLink.lua), since that's UI.
// The `trade:` link: a player's profession and the recipes they know, built
// and read as 3.3.5 does.
//
// Wire format (a ClassicAPI-private shape — nothing native emits/reads it, so
// we're free to define it; modeled on the 2.x/3.3.5 `trade:` link, with the
// linker's name in the slot 2.x used for the GUID):
// |cffffd000|Htrade:<spellID>:<rank>:<maxRank>:<GUID>:<bits>|h[<spell name>]|h|r
//
// |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
// Mirrors 3.3.5's CGTradeSkillInfo::GetTradeSkillListLink (0x005DB060) and
// SetLink (0x005DE300):
// - `spellID` is the profession spell the window was opened with (Effect[0]
// SPELL_EFFECT_TRADE_SKILL). The reader maps it to a skill line through
// the SkillLineAbility row for the linker's race and class.
// - `rank` / `maxRank` are the linker's, as GetTradeSkillLine gives them.
// - `GUID` is the linker's, `%I64X`. The reader takes the linker's name,
// race and class from the name cache, and asks the server for a linker
// it does not hold, opening the link when the answer arrives.
// - `bits` hold one bit per row of the skill line's link rows (Abilities),
// six per base64 character, low bit first. A bit is set when the linker
// knows the row's spell and the row's race and class masks take the
// linker; 3.3.5's encoder ignores the rows' exclude flags.
//
// - skillLineID — SkillLine.dbc row (e.g. 164 = Blacksmithing)
// - curRank/maxRank — the linker's skill level (cosmetic; viewer title)
// - linkerName — the linking character's name
// - base64 bits — a bitfield over the skill line's *canonical recipe list*:
// one bit per possible recipe, set = the linker knows it.
// The link rows are 3.3.5's SpellTableGetSkillLineLinkInfo: the run of
// SkillLineAbility records of the skill line. 3.3.5's file holds each
// profession in a single run, so the run is every row of the line in file
// order (checked against ChromieCraft's SkillLineAbility.dbc: one run per
// linkable line). 1.12's file is sorted by row ID and scatters a line
// (Blacksmithing over 73 runs), so here the rows are every row of the line in
// ID order: the same set, in an order every client with this DBC agrees on.
// The reader rejects bits whose length does not match its own row count.
//
// The canonical recipe list is every SkillLineAbility.dbc row for the skill
// line whose spell actually produces a craft (see IsCraftRecipe — an item,
// enchant, or Turtle LEARN_SPELL recipe; this excludes profession abilities
// like Disenchant), in ascending record-ID order. That ordering is
// deterministic and identical on every client with the same DBC, so builder
// and reader agree on the bit-index space without any handshake. "Which
// recipes does the linker know" comes from the player-spell bitmap
// (VAR_PLAYER_SPELL_BITMAP — the same store IsPlayerSpell reads, which covers
// profession recipes), NOT from the trade-skill window's server list.
// What links: a profession spell, on both ends. 3.3.5 also requires the
// skill line's canLink column, which 1.12's SkillLine.dbc does not have, so
// every profession spell links: Smelting, Poisons and Beast Training too.
//
// The TradeSkill and Craft windows are separate frames with separate storage
// and can both be open at once, so each gets its own builder (matching
// WotLK) — each reads only its own window's open-state global (both zeroed by
// the engine on close), so "both open" is unambiguous and neither guesses:
//
// - `C_TradeSkillUI.GetTradeSkillListLink()` — the TradeSkill window
// - `C_TradeSkillUI.GetCraftListLink()` — the Craft window (Enchanting)
// - `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)` — decoder
// for the viewer.
// The reader shows the link in the engine's own profession windows
// (Window.cpp). GetTradeSkillListLink() is 3.3.5's, for the TradeSkill
// window. GetCraftListLink() is its twin for the Craft window (Enchanting),
// which 3.3.5 does not have.
#include "Link.h"
#include "Game.h"
#include "Offsets.h"
#include "Window.h"
#include "dbc/Lookup.h"
#include "spell/Known.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace TradeSkill::Link {
namespace {
// 6 recipes per character, LSB-first (mirrors 3.x's encoder).
constexpr char kBase64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
constexpr int kRecipesPerChar = 6;
constexpr int kMaxRecipes = 1024; // largest vanilla skill line is ~340
constexpr int kBitsBufSize = kMaxRecipes / kRecipesPerChar + 2;
using AbilityLookup_t = const uint8_t *(__fastcall *)(uint32_t race, uint32_t classId,
uint32_t spellID);
using SlotForSkill_t = int(__thiscall *)(const void *player, uint32_t skillLine);
using NameCacheGet_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo, uint32_t hi,
const uint64_t *owner, void *callback,
void *userData, char dedupe);
constexpr int kRowsPerChar = 6;
// 3.3.5's s_base64Encode (0x00A1B640).
constexpr char kBase64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// 3.3.5's s_base64Decode (0x00A1B688): an unknown character reads as 0.
int Base64Value(char c) {
for (int i = 0; i < 64; ++i)
if (kBase64[i] == c)
return i;
return -1;
}
// A spell is a craftable recipe (vs. a profession ability like Disenchant, or
// a rank-up / skill-grant spell) iff one of its effects produces a craft: an
// item (CREATE_ITEM), an enchant (ENCHANT_ITEM / _TEMPORARY), or Turtle's
// custom recipes (LEARN_SPELL). Filtering on the skill-up threshold alone
// wrongly kept Disenchant (which carries thresholds but isn't a recipe).
bool IsCraftRecipe(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return false;
const int32_t *eff = Game::Ptr<const int32_t>(
rec, Offsets::OFF_SPELL_RECORD_EFFECT);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int e = eff[i];
if (e == Offsets::SPELL_EFFECT_CREATE_ITEM ||
e == Offsets::SPELL_EFFECT_LEARN_SPELL ||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM ||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY)
return true;
}
return false;
}
// Fills `outSpell` with the recipe spellIDs for `skillLineID`, in canonical
// (ascending record-ID) order, and — when non-null — `outGrey`/`outGreen` with
// the skill levels the recipe turns grey / green at (its difficulty tiers; the
// required-skill field 7 is uselessly 1 for nearly every trainer-taught
// recipe). Returns the count (capped at `maxOut`). Same walk builder and reader
// use, so the bit-index space is identical.
int BuildRecipeList(uint32_t skillLineID, int *outSpell, int *outGrey,
int *outGreen, int maxOut) {
auto *const *records = Game::Read<const uint8_t *const *>(
Offsets::VAR_SKILL_LINE_ABILITY_RECORDS);
const int count = Game::Read<int>(
Offsets::VAR_SKILL_LINE_ABILITY_COUNT);
if (records == nullptr || count <= 0)
return 0;
int n = 0;
for (int i = 1; i <= count && n < maxOut; ++i) {
const uint8_t *rec = records[i];
if (rec == nullptr)
continue;
if (static_cast<uint32_t>(Game::Read<int>(
rec, Offsets::OFF_SLA_SKILL_ID)) != skillLineID)
continue;
const int spellID =
Game::Read<int>(rec, Offsets::OFF_SLA_SPELL_ID);
if (!IsCraftRecipe(spellID))
continue;
if (outGrey != nullptr)
outGrey[n] = Game::Read<int>(
rec, Offsets::OFF_SLA_TRIVIAL_SKILL_HIGH);
if (outGreen != nullptr)
outGreen[n] = Game::Read<int>(
rec, Offsets::OFF_SLA_TRIVIAL_SKILL_LOW);
outSpell[n++] = spellID;
}
return n;
}
// Same bit the engine (and our IsPlayerSpell) reads — covers profession
// recipes, not just trained abilities.
bool IsKnownSpell(int spellID) {
if (spellID <= 0)
return false;
auto *bitmap = Game::Read<const uint32_t *>(
Offsets::VAR_PLAYER_SPELL_BITMAP);
if (bitmap == nullptr)
return false;
return (bitmap[static_cast<uint32_t>(spellID) >> 5] &
(1u << (spellID & 31))) != 0;
}
// Skill line (SkillLine.dbc row) of the first SLA row that teaches `spellID`,
// or 0 if none. Used to identify the Craft window's profession, which — unlike
// the TradeSkill window — has no skill-line global (the engine derives it from
// the recipe, see `GetCraftDisplaySkillLine` at `0x004F6CB0`).
uint32_t SkillForSpell(int spellID) {
if (spellID <= 0)
return 0;
auto *const *records = Game::Read<const uint8_t *const *>(
Offsets::VAR_SKILL_LINE_ABILITY_RECORDS);
const int count = Game::Read<int>(
Offsets::VAR_SKILL_LINE_ABILITY_COUNT);
if (records == nullptr || count <= 0)
return 0;
for (int i = 1; i <= count; ++i) {
const uint8_t *rec = records[i];
if (rec != nullptr &&
Game::Read<int>(rec, Offsets::OFF_SLA_SPELL_ID) ==
spellID)
return static_cast<uint32_t>(
Game::Read<int>(rec, Offsets::OFF_SLA_SKILL_ID));
}
if (c >= 'A' && c <= 'Z')
return c - 'A';
if (c >= 'a' && c <= 'z')
return c - 'a' + 26;
if (c >= '0' && c <= '9')
return c - '0' + 52;
if (c == '+')
return 62;
if (c == '/')
return 63;
return 0;
}
// Skill line of the open Craft window (Enchanting, pet training) — derived
// from its first real recipe entry, mirroring the engine. 0 if none open.
uint32_t CraftSkillLine() {
const int count = Game::Read<int>(Offsets::VAR_CRAFT_COUNT);
if (count <= 0)
return 0;
const int cap = (count < kMaxRecipes) ? count : kMaxRecipes;
for (int slot = 0; slot < cap; ++slot) {
const int spellID = Spell::Lookup::RecipeSlotSpellID(
Offsets::VAR_CRAFT_ENTRIES, Offsets::VAR_CRAFT_COUNT, slot);
if (spellID > 0) {
const uint32_t skill = SkillForSpell(spellID);
if (skill != 0)
return skill;
}
}
return 0;
// 3.3.5's SpellTableMatchRaceClass with no exclusions, as its encoder calls it.
bool MatchRaceClass(uint32_t race, uint32_t classId, uint32_t raceMask, uint32_t classMask) {
return (raceMask == 0 || (raceMask & (1u << (race - 1))) != 0) &&
(classMask == 0 || (classMask & (1u << (classId - 1))) != 0);
}
// Encodes the local player's known-recipe bitfield for `skillLineID` into
// `out` (must hold kBitsBufSize). Returns the recipe count, or -1 if the skill
// line has no recipes.
int EncodeKnownBits(uint32_t skillLineID, char *out) {
int spells[kMaxRecipes];
const int n = BuildRecipeList(skillLineID, spells, nullptr, nullptr,
kMaxRecipes);
if (n <= 0)
return -1;
int nb = 0;
unsigned acc = 0;
int used = 0;
for (int i = 0; i < n; ++i) {
if (IsKnownSpell(spells[i]))
acc |= (1u << used);
if (++used == kRecipesPerChar) {
out[nb++] = kBase64[acc];
acc = 0;
used = 0;
}
}
if (used != 0)
out[nb++] = kBase64[acc];
out[nb] = '\0';
return n;
}
// Current / max skill rank for the local player in `skillLine`, read from the
// CGPlayer `+0xE68` sub-struct exactly as the engine's own accessors do (see
// the FUN_SKILL_LINE_TO_SLOT block in Offsets.h). Leaves 0/0 when the player
// isn't resolvable or doesn't have the skill.
void ReadPlayerSkillRank(int skillLine, int *outCur, int *outMax) {
*outCur = 0;
*outMax = 0;
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
using SlotFn = int(__thiscall *)(const void *, int);
const int slot = reinterpret_cast<SlotFn>(
Offsets::FUN_SKILL_LINE_TO_SLOT)(player, skillLine);
// The player's rank and maximum in `skillLine` as GetTradeSkillLine pushes
// them: each plus the permanent bonus when it is not 0, both 0 without a
// slot (3.3.5's GetSkillValue / GetSkillMaxRank, which its encoder writes).
void PlayerRank(const uint8_t *player, uint32_t skillLine, int *rank, int *maxRank) {
*rank = 0;
*maxRank = 0;
const int slot =
reinterpret_cast<SlotForSkill_t>(Offsets::FUN_SKILL_LINE_TO_SLOT)(player, skillLine);
if (slot < 0)
return;
const uint8_t *base = Game::Read<const uint8_t *>(
player, Offsets::OFF_CGPLAYER_INFO);
if (base == nullptr)
return;
const uint8_t *rec =
base + Offsets::OFF_SKILL_INFO_TABLE + slot * Offsets::SKILL_INFO_STRIDE;
uint32_t cur = Game::Read<uint16_t>(rec, Offsets::OFF_SKILL_INFO_CUR);
uint32_t mx = Game::Read<uint16_t>(rec, Offsets::OFF_SKILL_INFO_MAX);
const uint32_t bonus =
Game::Read<uint16_t>(rec, Offsets::OFF_SKILL_INFO_BONUS);
if (cur != 0)
cur += bonus;
if (mx != 0)
mx += bonus;
*outCur = static_cast<int>(cur);
*outMax = static_cast<int>(mx);
const uint8_t *skills = Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO) +
Offsets::OFF_SKILL_INFO_TABLE + slot * Offsets::SKILL_INFO_STRIDE;
const int bonus = Game::Read<uint16_t>(skills, Offsets::OFF_SKILL_INFO_BONUS);
const int cur = Game::Read<uint16_t>(skills, Offsets::OFF_SKILL_INFO_CUR);
const int max = Game::Read<uint16_t>(skills, Offsets::OFF_SKILL_INFO_MAX);
*rank = cur != 0 ? cur + bonus : 0;
*maxRank = max != 0 ? max + bonus : 0;
}
// The local player's character name via the engine's object name getter.
const char *PlayerCharacterName() {
// 3.3.5's CGTradeSkillInfo::GetTradeSkillListLink: the local player's link
// for profession `spellID` into `buf`, or false.
bool Build(int spellID, char *buf, size_t size) {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return "";
using GetName_t = const char *(__thiscall *)(const void *, int *);
const char *name = reinterpret_cast<GetName_t>(
Offsets::FUN_OBJECT_GET_NAME)(player, nullptr);
return (name != nullptr) ? name : "";
return false;
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr ||
Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_EFFECT) !=
Offsets::SPELL_EFFECT_TRADE_SKILL)
return false;
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (desc == nullptr)
return false;
const uint32_t race = desc[Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE];
const uint32_t classId = desc[Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE];
const uint8_t *ability = reinterpret_cast<AbilityLookup_t>(
Offsets::FUN_SKILL_LINE_ABILITY_LOOKUP)(race, classId, static_cast<uint32_t>(spellID));
if (ability == nullptr)
return false;
const uint32_t skillLine = Game::Read<uint32_t>(ability, Offsets::OFF_SLA_SKILL_ID);
if (DBC::Record(Offsets::VAR_SKILL_LINE_RECORDS, Offsets::VAR_SKILL_LINE_COUNT, skillLine) ==
nullptr)
return false;
const uint8_t *rows[MAX_ABILITIES];
const int count = Abilities(skillLine, rows);
if (count == 0)
return false;
int rank, maxRank;
PlayerRank(player, skillLine, &rank, &maxRank);
const int prefix = std::snprintf(buf, size, "|cffffd000|Htrade:%d:%d:%d:%I64X:", spellID,
rank, maxRank, Unit::Identity::PlayerGuid());
if (prefix < 0 || static_cast<size_t>(prefix + BitChars(count) + 1) >= size)
return false;
char *out = buf + prefix;
unsigned bits = 0;
int bit = 0;
for (int i = 0; i < count; ++i) {
if (Spell::Known::IsKnown(Game::Read<uint32_t>(rows[i], Offsets::OFF_SLA_SPELL_ID)) &&
MatchRaceClass(race, classId, Game::Read<uint32_t>(rows[i], Offsets::OFF_SLA_RACE_MASK),
Game::Read<uint32_t>(rows[i], Offsets::OFF_SLA_CLASS_MASK)))
bits |= 1u << bit;
if (++bit == kRowsPerChar) {
*out++ = kBase64[bits];
bits = 0;
bit = 0;
}
}
if (bit != 0)
*out++ = kBase64[bits];
const char *name = DBC::LocalizedField(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID), Offsets::OFF_SPELL_NAMES);
std::snprintf(out, static_cast<size_t>(buf + size - out), "|h[%s]|h|r",
name != nullptr ? name : "");
return true;
}
// Builds the full `|Htrade:...|h` link for whichever window `useCraft`
// selects. Pushes the link string (returns 1), or nothing (returns 0 → nil)
// when that window is closed or has no recipes.
int BuildLink(void *L, bool useCraft) {
int skillLine;
if (useCraft) {
// Craft-window open-state flag; zeroed by the engine on close.
if (Game::Read<int>(Offsets::VAR_CRAFT_WINDOW_STATE) == 0)
return 0;
skillLine = static_cast<int>(CraftSkillLine());
} else {
// TradeSkill skill-line global; zeroed by the engine on close.
skillLine = Game::Read<int>(
Offsets::VAR_CURRENT_TRADESKILL_LINE);
}
if (skillLine <= 0)
return 0;
// 3.3.5's TradeSkillLinkNameCallback: the name query for a link's linker was
// answered; `data` is the copy of the link Open made.
void __stdcall LinkerNameLoaded(void *data, char success) {
if (success)
Open(static_cast<const char *>(data));
std::free(data);
}
char bits[kBitsBufSize];
if (EncodeKnownBits(static_cast<uint32_t>(skillLine), bits) < 0)
return 0;
const char *prof = DBC::LocalizedField(
Offsets::VAR_SKILL_LINE_RECORDS, Offsets::VAR_SKILL_LINE_COUNT,
static_cast<uint32_t>(skillLine), Offsets::OFF_SKILL_LINE_NAME);
if (prof == nullptr || *prof == '\0')
return 0;
int cur = 0, mx = 0;
ReadPlayerSkillRank(skillLine, &cur, &mx);
const char *who = PlayerCharacterName();
char link[512];
std::snprintf(link, sizeof link,
"|cffffd000|Htrade:%d:%d:%d:%s:%s|h[%s]|h|r", skillLine, cur,
mx, who, bits, prof);
Game::Lua::PushString(L, link);
int PushLink(void *L, int spellID) {
char link[256];
if (Build(spellID, link, sizeof link))
Game::Lua::PushString(L, link);
else
Game::Lua::PushNil(L);
return 1;
}
// `C_TradeSkillUI.GetTradeSkillListLink()` -> link for the open TradeSkill
// window, or nil.
// `GetTradeSkillListLink()` — 3.3.5's Script_GetTradeSkillListLink.
int __fastcall Script_GetTradeSkillListLink(void *L) {
return BuildLink(L, /*useCraft=*/false);
return PushLink(L, Window::TradeSkillSpell());
}
// `C_TradeSkillUI.GetCraftListLink()` -> link for the open Craft window, or nil.
int __fastcall Script_GetCraftListLink(void *L) {
return BuildLink(L, /*useCraft=*/true);
}
// `GetCraftListLink()` — the Craft window's twin.
int __fastcall Script_GetCraftListLink(void *L) { return PushLink(L, Window::CraftSpell()); }
// `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)`
// -> { { spellID, isKnown, trivialLevel, greenLevel, createdItem,
// numMade, reagents = { { itemID, count }, ... } }, ... }
// `trivialLevel` / `greenLevel` are the skill levels the recipe turns
// grey / green at — its difficulty tiers, for sorting and colouring.
// `createdItem` is the crafted item's ID (0 for enchants) — the viewer
// derives its subclass filter from it. `numMade` is how many the recipe
// yields (CREATE_ITEM base points + dice; 0 for enchants). `reagents` lists
// each consumed item + required count in DBC slot order (stopping at the
// first empty slot, the engine's own convention), for the detail pane.
//
// Rebuilds the canonical recipe list for `skillLineID` and decodes `bits`
// against it. Recipes past the end of `bits` (shorter/garbled string) decode
// as not-known rather than erroring. The Lua caller (the viewer) turns each
// spellID into a name/icon via GetSpellInfo and each reagent itemID into
// name/texture/on-hand-count via GetItemInfo / GetItemCount.
//
// The real 3.3.5 reagent API (GetTradeSkillReagentInfo etc.) can't serve this:
// it's indexed by position in the player's *own* open trade window, so it
// can't describe a linked recipe from someone else's profession. We read the
// reagents straight from Spell.dbc instead.
int __fastcall Script_GetTradeSkillListRecipes(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(
L, "Usage: C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)");
return 0;
}
const uint32_t skillLine =
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const char *bits = Game::Lua::ToString(L, 2);
const int blen = (bits != nullptr) ? static_cast<int>(std::strlen(bits)) : 0;
// --- Documentation ----------------------------------------------------------
int spells[kMaxRecipes];
int greys[kMaxRecipes];
int greens[kMaxRecipes];
const int n = BuildRecipeList(skillLine, spells, greys, greens, kMaxRecipes);
Game::Lua::NewTable(L);
for (int i = 0; i < n; ++i) {
const int ci = i / kRecipesPerChar;
const int bi = i % kRecipesPerChar;
bool known = false;
if (ci < blen) {
const int v = Base64Value(bits[ci]);
if (v >= 0)
known = (v & (1 << bi)) != 0;
}
// The recipe's crafted item (0 for enchants) — the viewer groups the
// subclass filter by this item's subclass — plus its yield count.
const uint8_t *srec = Spell::Lookup::RecordForID(spells[i]);
int createdItem = 0;
int numMade = 0;
if (srec != nullptr) {
createdItem = Game::Read<int>(
srec, Offsets::OFF_SPELL_RECORD_CREATED_ITEM);
// Yield: the CREATE_ITEM effect's magnitude (base points + dice),
// mirroring the engine's item-creation math. 0 for enchants.
const int32_t *eff = Game::Ptr<const int32_t>(
srec, Offsets::OFF_SPELL_RECORD_EFFECT);
const int32_t *basePts = Game::Ptr<const int32_t>(
srec, Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
const int32_t *baseDice = Game::Ptr<const int32_t>(
srec, Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
for (int e = 0; e < Offsets::SPELL_RECORD_EFFECT_COUNT; ++e) {
if (eff[e] == Offsets::SPELL_EFFECT_CREATE_ITEM) {
numMade = basePts[e] + baseDice[e];
break;
}
}
}
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(spells[i]));
Game::Lua::SetFieldBool(L, "isKnown", known);
Game::Lua::SetFieldNumber(L, "trivialLevel",
static_cast<double>(greys[i]));
Game::Lua::SetFieldNumber(L, "greenLevel",
static_cast<double>(greens[i]));
Game::Lua::SetFieldNumber(L, "createdItem",
static_cast<double>(createdItem));
Game::Lua::SetFieldNumber(L, "numMade", static_cast<double>(numMade));
// reagents = { {itemID, count}, ... } — nested array under "reagents"
// (no SetField helper takes a table value, so push key + build + SetTable).
Game::Lua::PushString(L, "reagents");
Game::Lua::NewTable(L);
if (srec != nullptr) {
const int32_t *rids = Game::Ptr<const int32_t>(
srec, Offsets::OFF_SPELL_REAGENT_ID);
const int32_t *rcounts = Game::Ptr<const int32_t>(
srec, Offsets::OFF_SPELL_REAGENT_COUNT);
int outIdx = 0;
for (int r = 0; r < Offsets::SPELL_MAX_REAGENTS; ++r) {
if (rids[r] == 0)
break; // first empty slot ends the list
Game::Lua::PushNumber(L, static_cast<double>(++outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID",
static_cast<double>(rids[r]));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(rcounts[r]));
Game::Lua::SetTable(L, -3); // reagents[outIdx] = {itemID,count}
}
}
Game::Lua::SetTable(L, -3); // entry.reagents = { ... }
Game::Lua::SetTable(L, -3);
}
return 1;
}
const Game::Doc::Field kListLinkRets[] = {
Game::Doc::Opt("link", "string", nullptr,
"A trade: link; nil when the window is closed or its profession cannot be "
"linked."),
};
const Game::Doc::Function kGetTradeSkillListLink{
"Returns a link to the player's profession in the trade-skill window, with the recipes the "
"player knows.",
{}, kListLinkRets, "TradeSkillGlobals"};
const Game::Doc::Function kGetCraftListLink{
"Returns a link to the player's profession in the craft window, with the recipes the player "
"knows.",
{}, kListLinkRets, "TradeSkillGlobals", true};
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetTradeSkillListLink",
&Script_GetTradeSkillListLink);
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetCraftListLink",
&Script_GetCraftListLink);
Game::Lua::RegisterTableFunction("C_TradeSkillUI",
"GetTradeSkillListRecipes",
&Script_GetTradeSkillListRecipes);
Game::Lua::RegisterGlobalFunction("GetTradeSkillListLink", &Script_GetTradeSkillListLink,
&kGetTradeSkillListLink);
Game::Lua::RegisterGlobalFunction("GetCraftListLink", &Script_GetCraftListLink,
&kGetCraftListLink);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
int Abilities(uint32_t skillLine, const uint8_t **out) {
auto *const *records =
Game::Read<const uint8_t *const *>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS);
const int count = Game::Read<int>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT);
if (records == nullptr)
return 0;
int n = 0;
for (int id = 1; id <= count && n < MAX_ABILITIES; ++id) {
const uint8_t *row = records[id];
if (row != nullptr && Game::Read<uint32_t>(row, Offsets::OFF_SLA_SKILL_ID) == skillLine)
out[n++] = row;
}
return n;
}
int BitChars(int count) { return (count + kRowsPerChar - 1) / kRowsPerChar; }
bool IsSet(const char *bits, int index) {
return (Base64Value(bits[index / kRowsPerChar]) & (1 << (index % kRowsPerChar))) != 0;
}
void Open(const char *data) {
if (data == nullptr || *data == '\0')
return;
const int spellID = std::atoi(data);
if (spellID == 0)
return;
const char *fields = std::strchr(data, ':');
if (fields == nullptr)
return;
++fields;
// 3.3.5 reads `%d:%d:%I64X:`; a field after a malformed one stays 0.
int rank = 0;
int maxRank = 0;
uint64_t guid = 0;
char *end = nullptr;
rank = static_cast<int>(std::strtol(fields, &end, 10));
if (*end == ':') {
maxRank = static_cast<int>(std::strtol(end + 1, &end, 10));
if (*end == ':')
guid = std::strtoull(end + 1, nullptr, 16);
}
if (guid == 0)
return;
// A miss keeps the copy with the query and opens the link on the answer.
char *copy = _strdup(data);
if (copy == nullptr)
return;
const uint64_t none = 0;
const uint8_t *linker = reinterpret_cast<NameCacheGet_t>(Offsets::FUN_PLAYER_INFO_LOOKUP)(
reinterpret_cast<void *>(Offsets::VAR_PLAYER_NAME_CACHE), static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), &none, reinterpret_cast<void *>(&LinkerNameLoaded),
copy, 1);
if (linker == nullptr)
return;
std::free(copy);
if (const char *bits = std::strrchr(fields, ':'))
Window::ShowLinked(spellID, guid, bits + 1, rank, maxRank);
}
} // namespace TradeSkill::Link
+42
View File
@@ -0,0 +1,42 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// The `trade:` profession link, 3.3.5's format and bit space. See Link.cpp.
namespace TradeSkill::Link {
// Upper bound on a skill line's SkillLineAbility rows (Blacksmithing, the
// largest, has 343).
constexpr int MAX_ABILITIES = 1024;
// Fills `out` with the SkillLineAbility records a link's bits cover for
// `skillLine`, one bit each, in DBC order; returns the count (at most
// MAX_ABILITIES). 3.3.5's SpellTableGetSkillLineLinkInfo.
int Abilities(uint32_t skillLine, const uint8_t **out);
// The number of bit characters a link carries for `count` rows.
int BitChars(int count);
// Whether row `index` is set in a link's bit characters.
bool IsSet(const char *bits, int index);
// 3.3.5's CGTradeSkillInfo::SetLink: shows the profession a received link
// carries. `data` is the link payload after `trade:`. A linker the name
// cache does not hold yet is queried, and the window opens when the answer
// arrives.
void Open(const char *data);
} // namespace TradeSkill::Link
+1 -29
View File
@@ -12,8 +12,7 @@
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The trade-skill window's recipe functions from 3.x:
// `GetTradeSkillRecipeLink(index)`, `GetTradeSkillDescription(index)` and
// `IsTradeSkillLinked()`.
// `GetTradeSkillRecipeLink(index)` and `GetTradeSkillDescription(index)`.
//
// `GetTradeSkillRecipeLink(index)` links the recipe in row `index`: the
// recipe spell, as an `enchant:` link. Vanilla's window links only the item a
@@ -44,15 +43,6 @@
// then the spell-text parser into a 1024-byte buffer (Spell::Description).
// Most recipes that make an item have no description; enchants do. 1.12's
// Craft window already has this as GetCraftDescription.
//
// `IsTradeSkillLinked()` says whether the window shows a profession opened
// from another player's link, and whose. On this client it never does. A
// received `trade:` link opens ClassicAPI's own viewer
// (Util/TradeSkillLink.lua), which the trade-skill functions never describe,
// and the window shows the player's own professions only. So it returns
// (nil, nil), 3.3.5's answer for the player's own window. Addons check it to
// keep a linked profession out of the player's own records; Zygor skips its
// recipe scan on it.
#include "Game.h"
#include "Offsets.h"
@@ -160,12 +150,6 @@ int __fastcall Script_GetTradeSkillDescription(void *L) {
return 1;
}
int __fastcall Script_IsTradeSkillLinked(void *L) {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
return 2;
}
// --- Documentation ----------------------------------------------------------
const Game::Doc::Field kRecipeLinkArgs[] = {
@@ -187,24 +171,12 @@ const Game::Doc::Function kGetTradeSkillDescription{
"Returns the description of the recipe in a row of the trade-skill window.",
kRecipeLinkArgs, kDescriptionRets, "TradeSkillGlobals"};
const Game::Doc::Field kLinkedRets[] = {
Game::Doc::Opt("isLinked", "bool", nullptr,
"True when the window shows another player's linked profession."),
Game::Doc::Opt("linkedPlayerName", "string", nullptr,
"The name of the player whose profession is shown."),
};
const Game::Doc::Function kIsTradeSkillLinked{
"Whether the trade-skill window shows a profession opened from another player's link.",
{}, kLinkedRets, "TradeSkillGlobals"};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetTradeSkillRecipeLink", &Script_GetTradeSkillRecipeLink,
&kGetTradeSkillRecipeLink);
Game::Lua::RegisterGlobalFunction("GetTradeSkillDescription",
&Script_GetTradeSkillDescription,
&kGetTradeSkillDescription);
Game::Lua::RegisterGlobalFunction("IsTradeSkillLinked", &Script_IsTradeSkillLinked,
&kIsTradeSkillLinked);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+460
View File
@@ -0,0 +1,460 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Whose profession the TradeSkill and Craft windows show: the player's own,
// or another player's from a `trade:` link, as 3.3.5's trade-skill window
// does.
//
// 3.3.5's CGTradeSkillInfo keeps whose profession the window shows
// (m_playerGuid, m_spellID, m_skillRank / m_skillMaxRank, m_linkData), and its
// list build RefreshList (0x005DD5A0) takes the rows from the link when
// m_linkData is set. 1.12's BuildTradeSkillList (0x004FCA20) is the same build
// without the link branch. This module adds the branch at the build's three
// per-row reads, and the same for the Craft window (Enchanting), which 3.3.5
// does not have:
// - The spell list: the link's rows instead of the player's list
// (CGPlayer_C::FindSkillLineSpells, or GetTabSpells for the Craft
// window). The rows are the set bits whose spell would be in that list
// had the linker learned it: the engine's learn function (0x005E9C20)
// files a SPELL_ATTR_TRADESPELL spell under its skill line and any other
// under its castUI tab. RefreshList keeps the TRADESPELL rows, the same
// filter.
// - The ability row: looked up for the linker's race and class. RefreshList
// takes the link's row itself.
// - The rank: the linker's, unless the link is the player's own.
// Everything else in the builds runs as for the player's own window:
// available counts from the player's bags, headers, filters, order, events.
//
// Opening (3.3.5 SetTradeSkill, 0x005DE120): a link opens the window its
// spell opens when cast (TryCastSpell routes on EffectMiscValue[0]), through
// the engine's own toggle, with the window marked closed first so that a link
// to the line already open replaces it instead of closing it. Casting a
// profession while a link shows opens the player's own list, again without
// closing. Both casts reach the toggles from one call each in TryCastSpell,
// whose cast spell (ESI) is 3.3.5's m_spellID. As in 3.3.5, closing keeps the
// link, so IsTradeSkillLinked answers until the next open, and the return to
// the character screen drops it (CGTradeSkillInfo::ShutdownGame; here
// CGGameUI::ShutdownGame's calls of the two window shutdowns).
//
// The Lua getters that answer for the player answer for the linker while a
// link shows: GetTradeSkillLine (3.3.5's), and the Craft window's
// GetCraftDisplaySkillLine and GetCraftName. IsTradeSkillLinked is 3.3.5's;
// IsCraftLinked is its Craft window twin.
//
// Each window takes links only when every one of its seams is patched, so a
// seam another DLL holds can't show a link half-way.
#include "Window.h"
#include "Common.h"
#include "Game.h"
#include "Link.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
namespace TradeSkill::Window {
namespace {
using Toggle_t = void(__fastcall *)(uint32_t key);
using Void_t = void(__cdecl *)();
using AbilityLookup_t = const uint8_t *(__fastcall *)(uint32_t race, uint32_t classId,
uint32_t spellID);
using PlayerList_t = const void *(__thiscall *)(void *player, uint32_t key);
using UnitAbility_t = const uint8_t *(__thiscall *)(void *unit, uint32_t spellID);
using SkillTotal_t = int(__thiscall *)(void *player, uint32_t skillLine);
using SlotForSkill_t = int(__thiscall *)(const void *player, uint32_t skillLine);
using Script_t = int(__fastcall *)(void *L);
// The only Craft type GetCraftDisplaySkillLine answers for (Enchanting).
constexpr uint32_t kCraftTypeEnscribe = 3;
// The engine's growable array, as the list builds read it.
struct SpellArray {
uint32_t capacity;
uint32_t count;
const int *data;
uint32_t granularity;
};
static_assert(offsetof(SpellArray, count) == 0x04 && offsetof(SpellArray, data) == 0x08,
"the builds read the count at +0x04 and the spells at +0x08");
// What a window shows: 3.3.5's CGTradeSkillInfo state.
struct Shown {
int spellID = 0; // the profession spell (m_spellID)
uint64_t guid = 0; // whose profession (m_playerGuid)
bool linked = false;
uint32_t opened = 0; // what the window opened on: the skill line, or the Craft type
uint32_t skillLine = 0;
uint32_t race = 0; // the linker's
uint32_t classId = 0;
int rank = 0; // the linker's (m_skillRank / m_skillMaxRank)
int maxRank = 0;
std::vector<int> spells; // the link's rows
SpellArray list{};
};
Shown g_tradeSkill;
Shown g_craft;
bool g_tradeSkillReady = false;
bool g_craftReady = false;
uint32_t TradeSkillOpen() { return Game::Read<uint32_t>(Offsets::VAR_CURRENT_TRADESKILL_LINE); }
uint32_t CraftOpen() { return Game::Read<uint32_t>(Offsets::VAR_CRAFT_WINDOW_STATE); }
const uint8_t *Ability(uint32_t race, uint32_t classId, uint32_t spellID) {
return reinterpret_cast<AbilityLookup_t>(Offsets::FUN_SKILL_LINE_ABILITY_LOOKUP)(race, classId,
spellID);
}
const char *SpellName(int spellID) {
return DBC::LocalizedField(Offsets::VAR_SPELL_RECORDS, Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID), Offsets::OFF_SPELL_NAMES);
}
// The window open on `open` builds its rows from the link.
bool BuildsLink(const Shown &s, uint32_t open) { return s.linked && open == s.opened; }
// The window open on `open` shows another player's link, whose ranks it
// answers with (3.3.5: m_playerGuid is not the active player).
bool ShowsOtherLink(const Shown &s, uint32_t open) {
return open != 0 && BuildsLink(s, open) && s.guid != Unit::Identity::PlayerGuid();
}
void Unlink(Shown &s) {
s.linked = false;
s.spells.clear();
s.list = {};
}
// Whether the player's own list for the window holds `spellID` once learned:
// the TradeSkill window's list takes TRADESPELL spells, a Craft window's the
// other spells of its castUI tab.
bool InPlayerList(int spellID, uint32_t craftType) {
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
return false;
const bool tradeSpell = (Game::Read<uint32_t>(record, Offsets::OFF_SPELL_RECORD_ATTRIBUTES) &
Offsets::SPELL_ATTR_TRADESPELL) != 0;
if (craftType == 0)
return tradeSpell;
return !tradeSpell && Game::Read<uint32_t>(record, Offsets::OFF_SPELL_RECORD_CAST_UI) == craftType;
}
// --- Opening ------------------------------------------------------------------
// The player cast profession `spellID`, which opens the window on `key`.
void Cast(Shown &s, uint32_t key, int spellID, uintptr_t openVar, uintptr_t toggle) {
if (s.linked) {
Unlink(s);
if (Game::Read<uint32_t>(openVar) == key)
Game::Ref<uint32_t>(openVar) = 0;
}
s.spellID = spellID;
s.guid = Unit::Identity::PlayerGuid();
s.opened = key;
reinterpret_cast<Toggle_t>(toggle)(key);
}
void __fastcall CastTradeSkill(uint32_t skillLine, int spellID) {
Cast(g_tradeSkill, skillLine, spellID, Offsets::VAR_CURRENT_TRADESKILL_LINE,
Offsets::FUN_TOGGLE_TRADE_SKILL);
}
void __fastcall CastCraft(uint32_t craftType, int spellID) {
Cast(g_craft, craftType, spellID, Offsets::VAR_CRAFT_WINDOW_STATE, Offsets::FUN_TOGGLE_CRAFT);
}
// Stand in for TryCastSpell's toggle calls: the toggle's key in ECX, the cast
// spell in ESI.
__declspec(naked) void CastTradeSkillThunk() {
__asm {
mov edx, esi
jmp CastTradeSkill
}
}
__declspec(naked) void CastCraftThunk() {
__asm {
mov edx, esi
jmp CastCraft
}
}
// --- The list builds ----------------------------------------------------------
// CALL_BUILD_TRADE_SKILL_SPELLS: CGPlayer_C::FindSkillLineSpells.
const void *__fastcall TradeSkillSpells(void *player, void *, uint32_t skillLine) {
if (BuildsLink(g_tradeSkill, skillLine))
return &g_tradeSkill.list;
return reinterpret_cast<PlayerList_t>(Offsets::FUN_PLAYER_FIND_SKILL_LINE_SPELLS)(player,
skillLine);
}
// CALL_BUILD_TRADE_SKILL_ABILITY: the ability lookup for the player's race
// and class.
const uint8_t *__fastcall TradeSkillAbility(uint32_t race, uint32_t classId, uint32_t spellID) {
const Shown &s = g_tradeSkill;
if (BuildsLink(s, TradeSkillOpen()))
return Ability(s.race, s.classId, spellID);
return Ability(race, classId, spellID);
}
// SITE_BUILD_TRADE_SKILL_RANK: the player's raw rank in the row's line, 0
// without a slot, as the 33 bytes it replaces read it.
int __fastcall TradeSkillRank(const uint8_t *player, void *, uint32_t skillLine) {
if (ShowsOtherLink(g_tradeSkill, TradeSkillOpen()))
return g_tradeSkill.rank;
const int slot =
reinterpret_cast<SlotForSkill_t>(Offsets::FUN_SKILL_LINE_TO_SLOT)(player, skillLine);
if (slot < 0)
return 0;
return Game::Read<uint16_t>(Game::Read<const uint8_t *>(player, Offsets::OFF_CGPLAYER_INFO) +
Offsets::OFF_SKILL_INFO_TABLE + slot * Offsets::SKILL_INFO_STRIDE,
Offsets::OFF_SKILL_INFO_CUR);
}
// CALL_BUILD_CRAFT_SPELLS: CGPlayer_C::GetTabSpells.
const void *__fastcall CraftSpells(void *player, void *, uint32_t tab) {
if (BuildsLink(g_craft, tab))
return &g_craft.list;
return reinterpret_cast<PlayerList_t>(Offsets::FUN_PLAYER_GET_TAB_SPELLS)(player, tab);
}
// CALL_BUILD_CRAFT_ABILITY: CGUnit_C::FindSkillLineAbility on the player.
const uint8_t *__fastcall CraftAbility(void *unit, void *, uint32_t spellID) {
const Shown &s = g_craft;
if (BuildsLink(s, CraftOpen()))
return Ability(s.race, s.classId, spellID);
return reinterpret_cast<UnitAbility_t>(Offsets::FUN_UNIT_FIND_SKILL_LINE_ABILITY)(unit,
spellID);
}
// CALL_BUILD_CRAFT_RANK: CGPlayer_C::GetSkillTotal.
int __fastcall CraftRank(void *player, void *, uint32_t skillLine) {
if (ShowsOtherLink(g_craft, CraftOpen()))
return g_craft.rank;
return reinterpret_cast<SkillTotal_t>(Offsets::FUN_PLAYER_GET_SKILL_TOTAL)(player, skillLine);
}
// CGGameUI::ShutdownGame's calls of the window shutdowns.
void __cdecl ShutdownTradeSkill() {
g_tradeSkill = Shown{};
reinterpret_cast<Void_t>(Offsets::FUN_SHUTDOWN_TRADE_SKILL)();
}
void __cdecl ShutdownCraft() {
g_craft = Shown{};
reinterpret_cast<Void_t>(Offsets::FUN_SHUTDOWN_CRAFT)();
}
// --- Lua ----------------------------------------------------------------------
int PushLinked(void *L, const Shown &s) {
if (!s.linked) {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
return 2;
}
Game::Lua::PushNumber(L, 1.0);
if (const char *name = Unit::Identity::NameForGuid(s.guid))
Game::Lua::PushString(L, name);
else
Game::Lua::PushNil(L);
return 2;
}
// `IsTradeSkillLinked()` — 3.3.5's Script_IsTradeSkillLinked (0x005DA310).
int __fastcall Script_IsTradeSkillLinked(void *L) { return PushLinked(L, g_tradeSkill); }
// `IsCraftLinked()` — the Craft window's twin.
int __fastcall Script_IsCraftLinked(void *L) { return PushLinked(L, g_craft); }
// `GetTradeSkillLine()`: 3.3.5 answers with the linker's ranks.
int __fastcall Script_GetTradeSkillLine(void *L) {
const int pushed = reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GET_TRADE_SKILL_LINE)(L);
if (ShowsOtherLink(g_tradeSkill, TradeSkillOpen())) {
Game::Lua::SetTop(L, -3); // the player's rank and maximum
Game::Lua::PushNumber(L, g_tradeSkill.rank);
Game::Lua::PushNumber(L, g_tradeSkill.maxRank);
}
return pushed;
}
// `GetCraftDisplaySkillLine()`: the engine reads the line from the player's
// own list, which may not hold it.
int __fastcall Script_GetCraftDisplaySkillLine(void *L) {
const Shown &s = g_craft;
const uint32_t open = CraftOpen();
const char *name = open == kCraftTypeEnscribe && ShowsOtherLink(s, open)
? DBC::LocalizedField(Offsets::VAR_SKILL_LINE_RECORDS,
Offsets::VAR_SKILL_LINE_COUNT, s.skillLine,
Offsets::OFF_SKILL_LINE_NAME)
: nullptr;
if (name == nullptr)
return reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GET_CRAFT_DISPLAY_SKILL_LINE)(L);
Game::Lua::PushString(L, name);
Game::Lua::PushNumber(L, s.rank);
Game::Lua::PushNumber(L, s.maxRank);
return 3;
}
// `GetCraftName()`: the engine names the spell the player keeps for the type,
// which may be none.
int __fastcall Script_GetCraftName(void *L) {
const char *name = ShowsOtherLink(g_craft, CraftOpen()) ? SpellName(g_craft.spellID) : nullptr;
if (name == nullptr)
return reinterpret_cast<Script_t>(Offsets::FUN_SCRIPT_GET_CRAFT_NAME)(L);
Game::Lua::PushString(L, name);
return 1;
}
// --- Documentation ------------------------------------------------------------
const Game::Doc::Field kLinkedRets[] = {
Game::Doc::Opt("isLinked", "number", nullptr,
"1 when the window shows a profession from a link, else nil."),
Game::Doc::Opt("linkedPlayerName", "string", nullptr,
"The name of the player whose profession the window shows."),
};
const Game::Doc::Function kIsTradeSkillLinked{
"Returns whether the trade-skill window shows a profession from a link, and whose.", {},
kLinkedRets, "TradeSkillGlobals"};
const Game::Doc::Function kIsCraftLinked{
"Returns whether the craft window shows a profession from a link, and whose.", {},
kLinkedRets, "TradeSkillGlobals", true};
void Register() {
static const uint8_t kRankRead[33] = {
0xE8, 0xC3, 0xD7, 0x0E, 0x00, // call FindSkillIndex
0x85, 0xC0, // test eax, eax
0x7C, 0x16, // jl -> xor eax, eax
0x8D, 0x94, 0x40, 0x13, 0x02, 0x00, 0x00, // lea edx, [eax+eax*2+0x213]
0x8B, 0x45, 0xF8, // mov eax, [ebp-8] (player)
0x8B, 0x88, 0x68, 0x0E, 0x00, 0x00, // mov ecx, [eax+0xE68]
0x0F, 0xB7, 0x04, 0x91, // movzx eax, word [ecx+edx*4]
0xEB, 0x02, // jmp past
0x33, 0xC0, // xor eax, eax
};
g_tradeSkillReady =
Common::RedirectCall(Offsets::CALL_GAME_SHUTDOWN_TRADE_SKILL,
Offsets::FUN_SHUTDOWN_TRADE_SKILL,
reinterpret_cast<const void *>(&ShutdownTradeSkill)) &&
Common::RedirectCall(Offsets::CALL_TRY_CAST_TOGGLE_TRADE_SKILL,
Offsets::FUN_TOGGLE_TRADE_SKILL,
reinterpret_cast<const void *>(&CastTradeSkillThunk)) &&
Common::RedirectCall(Offsets::CALL_BUILD_TRADE_SKILL_SPELLS,
Offsets::FUN_PLAYER_FIND_SKILL_LINE_SPELLS,
reinterpret_cast<const void *>(&TradeSkillSpells)) &&
Common::RedirectCall(Offsets::CALL_BUILD_TRADE_SKILL_ABILITY,
Offsets::FUN_SKILL_LINE_ABILITY_LOOKUP,
reinterpret_cast<const void *>(&TradeSkillAbility)) &&
Common::ReplaceWithCall(Offsets::SITE_BUILD_TRADE_SKILL_RANK, kRankRead, sizeof kRankRead,
reinterpret_cast<const void *>(&TradeSkillRank));
g_craftReady =
Common::RedirectCall(Offsets::CALL_GAME_SHUTDOWN_CRAFT, Offsets::FUN_SHUTDOWN_CRAFT,
reinterpret_cast<const void *>(&ShutdownCraft)) &&
Common::RedirectCall(Offsets::CALL_TRY_CAST_TOGGLE_CRAFT, Offsets::FUN_TOGGLE_CRAFT,
reinterpret_cast<const void *>(&CastCraftThunk)) &&
Common::RedirectCall(Offsets::CALL_BUILD_CRAFT_SPELLS, Offsets::FUN_PLAYER_GET_TAB_SPELLS,
reinterpret_cast<const void *>(&CraftSpells)) &&
Common::RedirectCall(Offsets::CALL_BUILD_CRAFT_ABILITY,
Offsets::FUN_UNIT_FIND_SKILL_LINE_ABILITY,
reinterpret_cast<const void *>(&CraftAbility)) &&
Common::RedirectCall(Offsets::CALL_BUILD_CRAFT_RANK, Offsets::FUN_PLAYER_GET_SKILL_TOTAL,
reinterpret_cast<const void *>(&CraftRank));
Game::Lua::RegisterGlobalFunction("IsTradeSkillLinked", &Script_IsTradeSkillLinked,
&kIsTradeSkillLinked);
Game::Lua::RegisterGlobalFunction("IsCraftLinked", &Script_IsCraftLinked, &kIsCraftLinked);
Game::Lua::RegisterGlobalFunction("GetTradeSkillLine", &Script_GetTradeSkillLine);
Game::Lua::RegisterGlobalFunction("GetCraftDisplaySkillLine", &Script_GetCraftDisplaySkillLine);
Game::Lua::RegisterGlobalFunction("GetCraftName", &Script_GetCraftName);
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
int TradeSkillSpell() { return TradeSkillOpen() != 0 ? g_tradeSkill.spellID : 0; }
int CraftSpell() { return CraftOpen() != 0 ? g_craft.spellID : 0; }
void ShowLinked(int spellID, uint64_t guid, const char *bits, int rank, int maxRank) {
if (Unit::Identity::PlayerObject() == nullptr)
return;
const uint8_t *linker = Unit::Identity::PlayerInfoRecord(guid);
const uint8_t *record = Spell::Lookup::RecordForID(spellID);
if (linker == nullptr || record == nullptr ||
Game::Read<int>(record, Offsets::OFF_SPELL_RECORD_EFFECT) !=
Offsets::SPELL_EFFECT_TRADE_SKILL)
return;
const uint32_t race = Game::Read<uint32_t>(linker, Offsets::OFF_PLAYER_INFO_RACE);
const uint32_t classId = Game::Read<uint32_t>(linker, Offsets::OFF_PLAYER_INFO_CLASS);
const uint8_t *ability = Ability(race, classId, static_cast<uint32_t>(spellID));
if (ability == nullptr)
return;
const uint32_t skillLine = Game::Read<uint32_t>(ability, Offsets::OFF_SLA_SKILL_ID);
if (DBC::Record(Offsets::VAR_SKILL_LINE_RECORDS, Offsets::VAR_SKILL_LINE_COUNT, skillLine) ==
nullptr)
return;
const uint8_t *rows[Link::MAX_ABILITIES];
const int count = Link::Abilities(skillLine, rows);
if (count == 0 || static_cast<size_t>(Link::BitChars(count)) != std::strlen(bits))
return;
const uint32_t craftType =
Game::Read<uint32_t>(record, Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
if (!(craftType == 0 ? g_tradeSkillReady : g_craftReady))
return;
Shown &s = craftType == 0 ? g_tradeSkill : g_craft;
s.spells.clear();
for (int i = 0; i < count; ++i) {
const int spell = Game::Read<int>(rows[i], Offsets::OFF_SLA_SPELL_ID);
if (Link::IsSet(bits, i) && InPlayerList(spell, craftType))
s.spells.push_back(spell);
}
const uint32_t n = static_cast<uint32_t>(s.spells.size());
s.list = {n, n, s.spells.data(), 0};
s.spellID = spellID;
s.guid = guid;
s.linked = true;
s.opened = craftType == 0 ? skillLine : craftType;
s.skillLine = skillLine;
s.race = race;
s.classId = classId;
s.rank = rank;
s.maxRank = maxRank;
if (craftType == 0) {
reinterpret_cast<Void_t>(Offsets::FUN_TRADE_SKILL_STOP_RECAST)();
if (TradeSkillOpen() == skillLine)
Game::Ref<uint32_t>(Offsets::VAR_CURRENT_TRADESKILL_LINE) = 0;
reinterpret_cast<Toggle_t>(Offsets::FUN_TOGGLE_TRADE_SKILL)(skillLine);
} else {
if (CraftOpen() == craftType)
Game::Ref<uint32_t>(Offsets::VAR_CRAFT_WINDOW_STATE) = 0;
reinterpret_cast<Toggle_t>(Offsets::FUN_TOGGLE_CRAFT)(craftType);
}
}
} // namespace TradeSkill::Window
+34
View File
@@ -0,0 +1,34 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Whose profession the TradeSkill and Craft windows show. See Window.cpp.
namespace TradeSkill::Window {
// The profession spell the TradeSkill window shows (3.3.5's
// CGTradeSkillInfo::m_spellID), or 0 while the window is closed.
int TradeSkillSpell();
// The Craft window's twin of TradeSkillSpell.
int CraftSpell();
// 3.3.5's CGTradeSkillInfo::SetTradeSkill for a received link: shows
// profession `spellID` of player `guid`, at `rank` / `maxRank`, with the
// rows set in the link's `bits`, in the window the spell opens when cast.
// The name cache must hold `guid`.
void ShowLinked(int spellID, uint64_t guid, const char *bits, int rank, int maxRank);
} // namespace TradeSkill::Window
+36
View File
@@ -0,0 +1,36 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// The `ShakeStrengthUI` cvar. Nothing in the engine reads it: the embedded
// addon's ScriptAnimationUtil.ShakeFrame scales each shake offset by it and
// skips the shake at 0 or less. Help, category and default are retail's.
#include "Game.h"
#include "Offsets.h"
#include "cvar/Factory.h"
namespace UI::ShakeStrength {
namespace {
void Register() {
CVar::Factory::Register("ShakeStrengthUI",
"Motion sickness control for how much effects can shake in 2D UI",
Offsets::CVAR_CATEGORY_GAME, "1.000000");
}
const Game::ModuleAutoRegister _autoreg{&Register};
} // namespace
} // namespace UI::ShakeStrength