mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-12 12:57:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af02ddadb1 | ||
|
|
93f3131307 | ||
|
|
653a2c6e3a | ||
|
|
85c5c924a9 | ||
|
|
f2af2734f0 | ||
|
|
dc13d7bbfc | ||
|
|
99c0ab3ba3 | ||
|
|
87acb70c5b | ||
|
|
92a959c381 | ||
|
|
efc6b29b6b | ||
|
|
b09db4d5a5 | ||
|
|
0ce9d159e9 | ||
|
|
23b622acff | ||
|
|
f806f17f40 | ||
|
|
e9407f6564 | ||
|
|
f42f7459e2 | ||
|
|
968335debe | ||
|
|
04d4bf3326 | ||
|
|
c54556f6a1 | ||
|
|
bd14a93a11 | ||
|
|
5978d7dd62 | ||
|
|
67172c95f7 | ||
|
|
59c3bf9e0f | ||
|
|
54782df95e | ||
|
|
3ab690ac53 | ||
|
|
6b13368e65 | ||
|
|
5456295e2e | ||
|
|
9b34ceff9e | ||
|
|
a373a775cf | ||
|
|
e9014801b6 | ||
|
|
dc8a510611 | ||
|
|
d514dac465 | ||
|
|
621d1a9db9 | ||
|
|
7d566f3787 | ||
|
|
acc9a85bc6 | ||
|
|
207d47c8b8 | ||
|
|
76f48de67f | ||
|
|
72c4185026 | ||
|
|
d366fec43e | ||
|
|
c6d99cf843 | ||
|
|
39d22ac124 | ||
|
|
b497bb7fb0 | ||
|
|
4d5fe8d41b | ||
|
|
46cae9d465 | ||
|
|
93360ce482 | ||
|
|
b879730dfb | ||
|
|
9cbf185b3c | ||
|
|
68f6a687cc | ||
|
|
2d5ecdc493 | ||
|
|
783ffd05bf | ||
|
|
f559eb4d07 | ||
|
|
2de7176b94 | ||
|
|
13008ab8aa | ||
|
|
0df8885045 | ||
|
|
8fa344a084 | ||
|
|
2612118ff6 | ||
|
|
76e40fec96 | ||
|
|
b72c52d9f7 | ||
|
|
1447317cd3 | ||
|
|
f293653837 | ||
|
|
ba1e12fca9 | ||
|
|
867478f410 | ||
|
|
caeb598b78 | ||
|
|
37ca209210 | ||
|
|
94c5eaafd2 | ||
|
|
3f09b3f883 | ||
|
|
9f640de74b | ||
|
|
67b968c36c | ||
|
|
f833aad769 | ||
|
|
b38673f51f | ||
|
|
f2161265be | ||
|
|
eefcbb17f2 | ||
|
|
599a65d80c | ||
|
|
228c5daa0f | ||
|
|
cc6953a530 | ||
|
|
84da9c23df | ||
|
|
6e58cb81d1 | ||
|
|
8a065481b4 | ||
|
|
2a7cbf75ca | ||
|
|
ff5ce5cdcb | ||
|
|
47b6a66b96 | ||
|
|
e78c36bb70 | ||
|
|
41fb17502d | ||
|
|
a989499cae | ||
|
|
722c257acb | ||
|
|
a6c7098106 | ||
|
|
7caee620e8 | ||
|
|
954533a170 | ||
|
|
dbe5ab40ee | ||
|
|
f3b328247c | ||
|
|
470361fbce | ||
|
|
2d266a9b76 | ||
|
|
8e3e207f83 | ||
|
|
1fd1295679 | ||
|
|
e4a75e68ca | ||
|
|
28bf41c647 | ||
|
|
ac97f9b960 | ||
|
|
7f6c6e2dd4 | ||
|
|
9d09266fc6 | ||
|
|
d972433ab0 | ||
|
|
b5513c6b22 | ||
|
|
8a434fea0e | ||
|
|
dbbba88aad | ||
|
|
b8bd0bce4e | ||
|
|
c3a80053e9 | ||
|
|
5a3badb5bd | ||
|
|
aae4eb8a2b | ||
|
|
0a31ee8a89 | ||
|
|
969273f5f7 | ||
|
|
a4760386ed | ||
|
|
f9ef12bc9b | ||
|
|
89d86bffb7 | ||
|
|
a0d8d6d723 | ||
|
|
e0e4fbcf84 | ||
|
|
1da06d4a73 | ||
|
|
960b37d7e6 | ||
|
|
0c27d61839 | ||
|
|
f02f576e70 | ||
|
|
0bd85dd4f3 | ||
|
|
29e3dab24a | ||
|
|
555ea02447 | ||
|
|
e3f5318747 | ||
|
|
cd674aa115 | ||
|
|
f91ffd3189 | ||
|
|
8361ae01eb | ||
|
|
5d785ea0d3 | ||
|
|
e84673dde5 | ||
|
|
a2c5db7b76 | ||
|
|
46597f6587 | ||
|
|
4e1c1863e9 | ||
|
|
a4c57614bc | ||
|
|
e1a9d9b5c4 | ||
|
|
cd280ac307 | ||
|
|
564495248c | ||
|
|
1f3f3cb9f6 | ||
|
|
7ae9b83ad7 | ||
|
|
26e5fdc052 | ||
|
|
4281cd8253 | ||
|
|
9239756153 | ||
|
|
f13aa1943d | ||
|
|
438ad97f8f | ||
|
|
7f4b20c6ec | ||
|
|
3f11f942bd | ||
|
|
9622ea7742 | ||
|
|
659dd7e159 | ||
|
|
1ae102b554 | ||
|
|
a5b6cf367d | ||
|
|
b496568670 | ||
|
|
a37c590a72 | ||
|
|
60479677c1 | ||
|
|
b5038dc3fd | ||
|
|
0bbf802316 | ||
|
|
0fcaa2e064 | ||
|
|
6724ddd2bd | ||
|
|
50d001a71f | ||
|
|
3aad341a91 | ||
|
|
3141acf547 | ||
|
|
05e7bda5da | ||
|
|
c4b48f7f42 | ||
|
|
4d97b1c449 | ||
|
|
e447c70ca3 | ||
|
|
72fa36956e | ||
|
|
40813a715c | ||
|
|
a14ccfacbb | ||
|
|
4afe0b9eae | ||
|
|
c01dceabb8 | ||
|
|
365df6b882 | ||
|
|
72de6c0960 | ||
|
|
eeba4ada9d | ||
|
|
5f13c63c99 | ||
|
|
afa1f62720 | ||
|
|
c369373efe | ||
|
|
1c86d0dead | ||
|
|
cedbd04553 | ||
|
|
3b1bba2131 | ||
|
|
786428c9fb | ||
|
|
f5ecdc69c5 | ||
|
|
7531223b9a | ||
|
|
cb4e428592 | ||
|
|
e7e2fe05dc | ||
|
|
d7078827fd | ||
|
|
c571c1a852 | ||
|
|
a7b205cd56 | ||
|
|
a3f224876f | ||
|
|
a40236eb84 | ||
|
|
538beda499 | ||
|
|
af01a4b408 | ||
|
|
33c8c3e37c | ||
|
|
c5d5415afe | ||
|
|
39dcfc73de | ||
|
|
6e5f7c3e43 | ||
|
|
2e05e86362 | ||
|
|
bcc631c4ed | ||
|
|
f9fb32186d | ||
|
|
60adbba7c3 | ||
|
|
c35d883af0 | ||
|
|
c202c1000f | ||
|
|
98b4ae14e7 | ||
|
|
8d957eb424 | ||
|
|
78618ea2cc | ||
|
|
1a4583e16e | ||
|
|
a20e1206fb | ||
|
|
9f67cade87 | ||
|
|
8629e13e1f | ||
|
|
ca9bfbab5e | ||
|
|
8fdc767cc7 | ||
|
|
aa31c25be5 | ||
|
|
29aafe88f8 | ||
|
|
461f15a4a6 | ||
|
|
6245d72694 | ||
|
|
bd14228ad7 | ||
|
|
5b15172894 | ||
|
|
b5470e8d3d | ||
|
|
c601967fdb | ||
|
|
5c063ade73 |
+80
-16
@@ -4,48 +4,112 @@ on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
# Manual escape hatch (e.g. when push webhooks are throttled during a
|
||||
# GitHub Actions incident). Run from the Actions UI:
|
||||
# - select a `vX.Y.Z` tag ref -> release build of that tag, OR
|
||||
# - run from a branch and pass `tag: vX.Y.Z` -> release build that
|
||||
# creates/attaches the release to that tag.
|
||||
# Leave `tag` blank on a branch run for a plain dev build (artifact).
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build (e.g. v2.0.0). Blank = dev build.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Decide which runner the build lands on. `runs-on` is fixed before a job
|
||||
# starts and a job pointed at an offline self-hosted runner queues forever
|
||||
# instead of failing over — so we probe first and hand the label down.
|
||||
pick-runner:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
label: ${{ steps.pick.outputs.label }}
|
||||
steps:
|
||||
- name: Probe for an online self-hosted runner
|
||||
id: pick
|
||||
env:
|
||||
# Listing runners needs repo-admin access; the default GITHUB_TOKEN
|
||||
# does NOT have it. Create a PAT (classic: `repo`; fine-grained:
|
||||
# Administration → read) and store it as the RUNNER_CHECK_TOKEN
|
||||
# secret. If the secret is missing, we fall back to windows-latest.
|
||||
GH_TOKEN: ${{ secrets.RUNNER_CHECK_TOKEN }}
|
||||
run: |
|
||||
label=windows-latest
|
||||
if [ -n "$GH_TOKEN" ]; then
|
||||
online=$(gh api "repos/${{ github.repository }}/actions/runners" \
|
||||
--jq '[.runners[] | select(.status=="online") | select([.labels[].name] | index("self-hosted"))] | length' \
|
||||
2>/dev/null || echo 0)
|
||||
if [ "${online:-0}" -gt 0 ]; then
|
||||
label=self-hosted
|
||||
fi
|
||||
fi
|
||||
echo "Selected runner: $label"
|
||||
echo "label=$label" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
needs: pick-runner
|
||||
runs-on: ${{ needs.pick-runner.outputs.label }}
|
||||
# Windows PowerShell 5.1 is always present; the runner's default `pwsh`
|
||||
# (PowerShell Core) is not installed by default, and `shell: bash` on a
|
||||
# self-hosted box resolves to the WSL launcher in System32.
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Stamp embedded TOC version
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
# Resolve the effective version once so every step below agrees.
|
||||
# is_release=true when a vX.Y.Z tag is in play (pushed tag,
|
||||
# dispatched-from-tag, or a `tag` input); the embedded addon's
|
||||
# `## Version:` and the compiled CLASSIC_API_VERSION are both
|
||||
# stamped from it at embed/compile time (see cmake/embed_addon.cmake)
|
||||
# — no source edit.
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
$version = '${{ github.ref_name }}' -replace '^v',''
|
||||
$toc = 'AddOns/!!!ClassicAPI/!!!ClassicAPI.toc'
|
||||
(Get-Content $toc) -replace '^## Version:.*', "## Version: $version" |
|
||||
Set-Content $toc
|
||||
if ($env:GITHUB_EVENT_NAME -eq 'workflow_dispatch' -and $env:INPUT_TAG) {
|
||||
$tag = $env:INPUT_TAG
|
||||
} elseif ($env:GITHUB_REF -like 'refs/tags/v*') {
|
||||
$tag = $env:GITHUB_REF_NAME
|
||||
} else {
|
||||
$tag = ''
|
||||
}
|
||||
$isRelease = if ($tag) { 'true' } else { 'false' }
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value "tag=$tag" -Encoding utf8
|
||||
Add-Content -Path $env:GITHUB_OUTPUT -Value "is_release=$isRelease" -Encoding utf8
|
||||
|
||||
- name: Configure (tag)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ github.ref_name }}"
|
||||
- name: Configure (release)
|
||||
if: steps.ver.outputs.is_release == 'true'
|
||||
run: cmake -B build -A Win32 "-DCLASSICAPI_TAG=${{ steps.ver.outputs.tag }}"
|
||||
|
||||
- name: Configure (branch)
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
- name: Configure (dev)
|
||||
if: steps.ver.outputs.is_release != 'true'
|
||||
run: cmake -B build -A Win32
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build --config Release
|
||||
|
||||
- name: Upload artifact
|
||||
if: "!startsWith(github.ref, 'refs/tags/v')"
|
||||
if: steps.ver.outputs.is_release != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ClassicAPI-${{ github.sha }}
|
||||
path: build/Release/ClassicAPI.dll
|
||||
|
||||
- name: Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: steps.ver.outputs.is_release == 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: build/Release/ClassicAPI.dll
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
files: |
|
||||
build/Release/ClassicAPI.dll
|
||||
build/Release/ClassicAPI.map
|
||||
generate_release_notes: true
|
||||
|
||||
+3
-1
@@ -45,4 +45,6 @@
|
||||
# debug information files
|
||||
*.dwo
|
||||
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
|
||||
/AddOns/!!!ClassicAPI/.classicapi-dev
|
||||
|
||||
@@ -38,3 +38,11 @@ Util\LinkUtil.lua
|
||||
Util\TradeSkillLink.lua
|
||||
Util\IconDataProvider.lua
|
||||
Util\GameTooltip.lua
|
||||
Util\Rectangle.lua
|
||||
Util\Vector2D.lua
|
||||
Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UIParent.lua
|
||||
Util\AuraDurationModifiers.lua
|
||||
Util\SecureUnitMenu.lua
|
||||
Util\TextureUtil.lua
|
||||
|
||||
@@ -38,4 +38,46 @@ if C_AddOns.DoesAddOnExist('pfUI') then
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
|
||||
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
|
||||
end
|
||||
|
||||
-- ShaguTweaks libpredict register's TBC events that ClassicAPI backports.
|
||||
if C_AddOns.DoesAddOnExist("ShaguTweaks") then
|
||||
EventUtil.ContinueOnAddOnLoaded("ShaguTweaks", function()
|
||||
local libp = ShaguTweaks.libpredict
|
||||
if libp then
|
||||
libp.sender:UnregisterEvent("COMBAT_LOG_EVENT_UNFILTERED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_START")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_STOP")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_FAILED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_INTERRUPTED")
|
||||
libp.sender:UnregisterEvent("UNIT_SPELLCAST_SENT")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Guard UPDATE_MOUSEOVER_UNIT handlers (issue #12).
|
||||
function CAPI_MouseoverClearedCompat(frame)
|
||||
if not frame then return end
|
||||
local originalOnEvent = frame:GetScript("OnEvent")
|
||||
frame:SetScript("OnEvent", function()
|
||||
if event == "UPDATE_MOUSEOVER_UNIT" and not UnitExists("mouseover") then
|
||||
return
|
||||
end
|
||||
|
||||
if originalOnEvent then
|
||||
originalOnEvent()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
CAPI_MouseoverClearedCompat(GameTooltip)
|
||||
|
||||
if C_AddOns.DoesAddOnExist("Puppeteer") then
|
||||
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
|
||||
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Server-side DoT-duration adjustments the 1.12 client is never told about.
|
||||
--
|
||||
-- ClassicAPI derives C_UnitAuras expirationTime for a debuff on another unit
|
||||
-- from the cast packet (SMSG_SPELL_GO). The vanilla server never retransmits a
|
||||
-- LATER server-side duration change on a non-self unit -- verified in the
|
||||
-- server source: the change runs through SetAuraDuration / RefreshHolder, and
|
||||
-- the only duration packet (SMSG_UPDATE_AURA_DURATION) is self-scoped to the
|
||||
-- aura-bearer, so an observing caster hears nothing (and on a mob the packet
|
||||
-- isn't even built). We can't hear the change, but we DO see the *triggering*
|
||||
-- cast, so a registered rule mirrors the server's edit off that trigger.
|
||||
--
|
||||
-- A trigger keyed to a whole class of spells (rather than a named ability) is
|
||||
-- registered from Lua with RegisterAuraDurationModifierByTrigger, matched by
|
||||
-- SpellFamilyName + school index (see Shadow Weaving below). Exact-spellID
|
||||
-- triggers are registered from C++ (Aura::Source::AddDurationMod): Turtle's
|
||||
-- Conflagrate -> Immolate and Molten Blast -> Flame Shock live in the DLL
|
||||
-- (src/turtle/DurationMods.cpp), as does Carnage, whose per-combo-point roll no
|
||||
-- rule can express (src/turtle/Carnage.cpp).
|
||||
|
||||
-- Shadow Weaving (priest): any shadow-school priest cast refreshes the caster's
|
||||
-- Shadow Vulnerability (15258, 15s, stacks to 5). The 1->5 stack changes fire
|
||||
-- OnAuraStacksChanged (already handled); the 5->5 refresh is the silent blind
|
||||
-- spot this covers. Matched by family(6) + school(5) so it spans every priest
|
||||
-- shadow spell/rank incl. Turtle additions, excluding holy/discipline casts
|
||||
-- (Smite, heals) which are priest-family but not shadow-school.
|
||||
--
|
||||
-- Gated on the 5/5 talent (15334), where the proc is 100% -> deterministic;
|
||||
-- below 5/5 it's chance-based and inferring on every cast would over-refresh.
|
||||
-- Deferred to SPELLS_CHANGED because talent/spell state isn't ready at load,
|
||||
-- and re-checked there so a respec INTO 5/5 registers without a reload.
|
||||
-- Caveat: DoT *ticks* (SW:P / Devouring Plague) refresh it server-side too but
|
||||
-- emit no cast packet, so a pure DoT-only phase (no direct casts) under-counts.
|
||||
local PRIEST, SHADOW = 6, 5
|
||||
local SW_FLAG, SW_ICON = tonumber("4000000", 16), 9 -- 15258 SpellFamilyFlags + icon
|
||||
|
||||
EventUtil.RegisterOnceFrameEventAndCallback("SPELLS_CHANGED", function()
|
||||
if IsPlayerSpell(15334) then
|
||||
C_UnitAuras.RegisterAuraDurationModifierByTrigger(PRIEST, SHADOW, PRIEST, SW_FLAG, SW_ICON, "refresh")
|
||||
end
|
||||
end)
|
||||
@@ -26,10 +26,17 @@ local function GenerateClosure(func, ...)
|
||||
for i = 1, boundN do bound[i] = arg[i] end
|
||||
return function(...)
|
||||
local callN = arg.n
|
||||
local merged = {}
|
||||
for i = 1, boundN do merged[i] = bound[i] end
|
||||
for i = 1, callN do merged[boundN + i] = arg[i] end
|
||||
return func(unpack(merged, 1, boundN + callN))
|
||||
-- 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
|
||||
|
||||
@@ -145,11 +152,14 @@ function CallbackRegistryMixin:TriggerEvent(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.
|
||||
local n = arg.n
|
||||
local merged = { owner }
|
||||
for i = 1, n do merged[i + 1] = arg[i] end
|
||||
func(unpack(merged, 1, n + 1))
|
||||
-- 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
|
||||
end
|
||||
|
||||
@@ -24,8 +24,14 @@ function ColorMixin:GetRGB()
|
||||
return self.r, self.g, self.b
|
||||
end
|
||||
|
||||
function ColorMixin:GetHSL()
|
||||
local r, g, b, a = self.r, self.g, self.b, self.a
|
||||
local h, s, l = C_ColorUtil.ConvertHSVToHSL(C_ColorUtil.ConvertRGBToHSV(r, g, b))
|
||||
return h, s, l, a or 1
|
||||
end
|
||||
|
||||
function ColorMixin:GetRGBAsBytes()
|
||||
return self.r * 255, self.g * 255, self.b * 255
|
||||
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255)
|
||||
end
|
||||
|
||||
function ColorMixin:GetRGBA()
|
||||
@@ -33,14 +39,14 @@ function ColorMixin:GetRGBA()
|
||||
end
|
||||
|
||||
function ColorMixin:GetRGBAAsBytes()
|
||||
return self.r * 255, self.g * 255, self.b * 255, (self.a or 1) * 255
|
||||
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255), Round((self.a or 1) * 255)
|
||||
end
|
||||
|
||||
function ColorMixin:SetRGBA(r, g, b, a)
|
||||
self.r = r
|
||||
self.g = g
|
||||
self.b = b
|
||||
self.a = a
|
||||
self.a = a or 1
|
||||
end
|
||||
|
||||
function ColorMixin:SetRGB(r, g, b)
|
||||
@@ -48,56 +54,27 @@ function ColorMixin:SetRGB(r, g, b)
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColor()
|
||||
return string.format("ff%.2x%.2x%.2x", self:GetRGBAsBytes())
|
||||
return C_ColorUtil.GenerateTextColorCode(self)
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColorNoAlpha()
|
||||
return string.format("%.2X%.2X%.2X", self:GetRGBAsBytes())
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColorMarkup()
|
||||
return "|c"..self:GenerateHexColor()
|
||||
end
|
||||
|
||||
function ColorMixin:GenerateHexColorNoAlpha()
|
||||
return string.format("%.2x%.2x%.2x", self:GetRGBAsBytes())
|
||||
end
|
||||
|
||||
function ColorMixin:GetHSL()
|
||||
local r, g, b, a = self.r, self.g, self.b, self.a
|
||||
local max, min = math.max(r, g, b), math.min(r, g, b)
|
||||
local h, s, l
|
||||
l = (max + min) / 2
|
||||
if max == min then
|
||||
h, s = 0, 0 -- achromatic
|
||||
else
|
||||
local d = max - min
|
||||
if l > 0.5 then
|
||||
s = d / (2 - max - min)
|
||||
else
|
||||
s = d / (max + min)
|
||||
end
|
||||
if max == r then
|
||||
h = (g - b) / d
|
||||
if g < b then
|
||||
h = h + 6
|
||||
end
|
||||
elseif max == g then
|
||||
h = (b - r) / d + 2
|
||||
elseif max == b then
|
||||
h = (r - g) / d + 4
|
||||
end
|
||||
h = h / 6
|
||||
end
|
||||
return h, s, l, a or 1
|
||||
end
|
||||
|
||||
function ColorMixin:WrapTextInColorCode(text)
|
||||
return WrapTextInColorCode(text, self:GenerateHexColor())
|
||||
return C_ColorUtil.WrapTextInColor(text, self)
|
||||
end
|
||||
|
||||
function WrapTextInColorCode(text, colorHexString)
|
||||
return string.format("|c%s%s|r", colorHexString, text)
|
||||
return C_ColorUtil.WrapTextInColorCode(text, colorHexString)
|
||||
end
|
||||
|
||||
function WrapTextInColor(text, color)
|
||||
return WrapTextInColorCode(text, color:GenerateHexColor())
|
||||
return C_ColorUtil.WrapTextInColor(text, color)
|
||||
end
|
||||
|
||||
function ColorMixin:WrapTextInColorTableCode(text)
|
||||
@@ -108,11 +85,14 @@ for _, v in pairs(ITEM_QUALITY_COLORS) do
|
||||
v.color = CreateColor(v.r, v.g, v.b, 1)
|
||||
end
|
||||
|
||||
do
|
||||
function CAPI_ApplyStandardColorGlobals()
|
||||
local envTbl = _G
|
||||
for _, dbColor in ipairs(C_UIColor.GetColors()) do
|
||||
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
|
||||
envTbl[dbColor.baseTag] = color
|
||||
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
|
||||
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
|
||||
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
|
||||
envTbl[dbColor.baseTag] = color
|
||||
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
|
||||
end
|
||||
end
|
||||
end
|
||||
CAPI_ApplyStandardColorGlobals()
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
COLOR_FORMAT_RGBA = "RRGGBBAA"
|
||||
COLOR_FORMAT_RGB = "RRGGBB"
|
||||
COLOR_FORMAT_ARGB = "AARRGGBB"
|
||||
FONT_COLOR_CODE_CLOSE = "|r"
|
||||
|
||||
do
|
||||
local ColorMixin = ColorMixin
|
||||
for _, classColor in pairs(RAID_CLASS_COLORS) do
|
||||
@@ -12,30 +17,40 @@ function ExtractColorValueFromHex(str, index)
|
||||
end
|
||||
|
||||
function CreateColorFromHexString(hexColor)
|
||||
if string.len(hexColor) == 8 then
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_ARGB) then
|
||||
local a, r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
|
||||
return CreateColor(r, g, b, a);
|
||||
else
|
||||
error("CreateColorFromHexString input must be hexadecimal digits in this format: AARRGGBB.", 2);
|
||||
end
|
||||
error("CreateColorFromHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_ARGB..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromRGBAHexString(hexColor)
|
||||
if string.len(hexColor) == 8 then
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
|
||||
local r, g, b, a = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
|
||||
return CreateColor(r, g, b, a);
|
||||
else
|
||||
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: RRGGBBAA.", 2);
|
||||
end
|
||||
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGBA..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromRGBHexString(hexColor)
|
||||
if string.len(hexColor) == 6 then
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
|
||||
local r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5);
|
||||
return CreateColor(r, g, b, 1);
|
||||
else
|
||||
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: RRGGBB.", 2);
|
||||
end
|
||||
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGB..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromBestRGBHexString(hexColor)
|
||||
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
|
||||
return CreateColorFromRGBAHexString(hexColor);
|
||||
elseif string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
|
||||
return CreateColorFromRGBHexString(hexColor);
|
||||
end
|
||||
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".", 2);
|
||||
return nil;
|
||||
end
|
||||
|
||||
function CreateColorFromBytes(r, g, b, a)
|
||||
@@ -68,9 +83,7 @@ end
|
||||
function GetClassColoredTextForUnit(unit, text)
|
||||
local _, classFilename = UnitClass(unit);
|
||||
local color = GetClassColorObj(classFilename);
|
||||
if (color) then
|
||||
return color:WrapTextInColorCode(text);
|
||||
end
|
||||
return color and color:WrapTextInColorCode(text) or text;
|
||||
end
|
||||
|
||||
function GetFactionColor(factionGroupTag)
|
||||
@@ -82,7 +95,7 @@ function GetFactionColor(factionGroupTag)
|
||||
end
|
||||
|
||||
function RGBToColorCode(r, g, b)
|
||||
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
|
||||
return string.format("|cff%02x%02x%02x", r * 255, g * 255, b * 255);
|
||||
end
|
||||
|
||||
function RGBTableToColorCode(rgbTable)
|
||||
|
||||
@@ -132,4 +132,9 @@ INSCRIPTION_AVAILABLE = false;
|
||||
EQUIPPED_FIRST = 1;
|
||||
EQUIPPED_LAST = 19;
|
||||
|
||||
BASE_MOVEMENT_SPEED = 7;
|
||||
BASE_MOVEMENT_SPEED = 7;
|
||||
|
||||
UPPER_LEFT_VERTEX = 1;
|
||||
LOWER_LEFT_VERTEX = 2;
|
||||
UPPER_RIGHT_VERTEX = 3;
|
||||
LOWER_RIGHT_VERTEX = 4;
|
||||
@@ -2,12 +2,15 @@
|
||||
-- 1.12 / Lua 5.0.
|
||||
--
|
||||
-- 5.0 notes: no `#`/`%`/string-method syntax — uses string.*, math.mod.
|
||||
-- The currency/cost helpers (C_CurrencyInfo etc.) are omitted. GetMoneyString
|
||||
-- uses text symbols, not coin texture markup (markup doesn't render in 1.12).
|
||||
-- GetMoneyString gives the text-symbol form ("12g 34s 56c"). The coin-icon form
|
||||
-- (GetCoinTextureString / C_CurrencyInfo.GetCoinTextureString) is provided by the
|
||||
-- ClassicAPI DLL in C++ (src/currency/CoinText.cpp) — like retail, where it's a
|
||||
-- C function — now that the inline-texture backport renders |T markup in 1.12.
|
||||
--
|
||||
-- The FrameXML format-string globals these functions consume
|
||||
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL, …) are
|
||||
-- defined in the locale layer (locales/enUS.lua), which loads first.
|
||||
-- (LARGE_NUMBER_SEPERATOR, PERCENTAGE_STRING, GOLD_AMOUNT_SYMBOL,
|
||||
-- GOLD_AMOUNT_TEXTURE, …) are defined in the locale layer (locales/enUS.lua),
|
||||
-- which loads first.
|
||||
|
||||
COPPER_PER_SILVER = 100;
|
||||
SILVER_PER_GOLD = 100;
|
||||
@@ -73,10 +76,9 @@ function SplitTextIntoHeaderAndNonHeader(text)
|
||||
end
|
||||
|
||||
-- (money, separateThousands [, checkGoldThreshold, showZeroAsGold])
|
||||
-- Text-symbol form ("12g 34s 56c"); the coin-texture form from the
|
||||
-- reference is dropped since texture markup doesn't render in 1.12. The
|
||||
-- checkGoldThreshold arg is accepted for signature compatibility but not
|
||||
-- applied (it relied on modern Constants tables).
|
||||
-- Text-symbol form ("12g 34s 56c"); for the coin-icon form use
|
||||
-- GetCoinTextureString below. The checkGoldThreshold arg is accepted for
|
||||
-- signature compatibility but not applied (it relied on modern Constants tables).
|
||||
function GetMoneyString(money, separateThousands, checkGoldThreshold, showZeroAsGold)
|
||||
local gold = math.floor(money / COPPER_PER_GOLD);
|
||||
local silver = math.floor(math.mod(money, COPPER_PER_GOLD) / COPPER_PER_SILVER);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
--
|
||||
-- Implementation differences from the modern source:
|
||||
-- - Lua 5.0 has no select() and no `...` as expression; use the
|
||||
-- implicit `arg` table (arg.n + arg[i]) and unpack(arg, 1, arg.n).
|
||||
-- implicit `arg` table (arg.n + arg[i]). unpack(arg) already spans
|
||||
-- arg[1..arg.n] via getn — this build's unpack ignores explicit i/j
|
||||
-- args, so the count must live in the table's `.n`.
|
||||
-- - Lua 5.0 has no `#` operator; use table.getn().
|
||||
-- - 5.0's xpcall is strictly (f, handler) — no trailing args. We wrap
|
||||
-- the script call in a local closure that captures frame + arg.
|
||||
@@ -32,7 +34,7 @@ function ExecuteFrameScript(frame, scriptName, ...)
|
||||
if script then
|
||||
local args = arg;
|
||||
local function invoke()
|
||||
return script(frame, unpack(args, 1, args.n));
|
||||
return script(frame, unpack(args));
|
||||
end
|
||||
xpcall(invoke, CallErrorHandler);
|
||||
end
|
||||
@@ -45,7 +47,7 @@ function CallMethodOnNearestAncestor(self, methodName, ...)
|
||||
end
|
||||
|
||||
if ancestor then
|
||||
return true, ancestor[methodName](ancestor, unpack(arg, 1, arg.n));
|
||||
return true, ancestor[methodName](ancestor, unpack(arg));
|
||||
end
|
||||
|
||||
return false;
|
||||
@@ -58,7 +60,7 @@ function GetValueOrCallFunction(tbl, key, ...)
|
||||
|
||||
local value = tbl[key];
|
||||
if type(value) == "function" then
|
||||
return value(unpack(arg, 1, arg.n));
|
||||
return value(unpack(arg));
|
||||
else
|
||||
return value;
|
||||
end
|
||||
@@ -117,12 +119,12 @@ function IterateTables(iteratorFunction, ...)
|
||||
end
|
||||
|
||||
local s_passThroughClosureGenerators = {
|
||||
function(f) return function(...) return f(unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a) return function(...) return f(a, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b) return function(...) return f(a, b, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg, 1, arg.n)); end; end,
|
||||
function(f) return function(...) return f(unpack(arg)); end; end,
|
||||
function(f, a) return function(...) return f(a, unpack(arg)); end; end,
|
||||
function(f, a, b) return function(...) return f(a, b, unpack(arg)); end; end,
|
||||
function(f, a, b, c) return function(...) return f(a, b, c, unpack(arg)); end; end,
|
||||
function(f, a, b, c, d) return function(...) return f(a, b, c, d, unpack(arg)); end; end,
|
||||
function(f, a, b, c, d, e) return function(...) return f(a, b, c, d, e, unpack(arg)); end; end,
|
||||
};
|
||||
|
||||
local s_flatClosureGenerators = {
|
||||
@@ -138,7 +140,7 @@ local function GenerateClosureInternal(generatorArray, f, ...)
|
||||
local count = arg.n;
|
||||
local generator = generatorArray[count + 1];
|
||||
if generator then
|
||||
return generator(f, unpack(arg, 1, count));
|
||||
return generator(f, unpack(arg));
|
||||
end
|
||||
|
||||
assertsafe(false, "Closure generation does not support more than " .. (table.getn(generatorArray) - 1) .. " parameters");
|
||||
@@ -147,14 +149,14 @@ end
|
||||
|
||||
-- Syntactic sugar for function(...) return f(a, b, c, ...); end
|
||||
function GenerateClosure(f, ...)
|
||||
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg, 1, arg.n));
|
||||
return GenerateClosureInternal(s_passThroughClosureGenerators, f, unpack(arg));
|
||||
end
|
||||
|
||||
-- Generates a closure with the specified arguments that will ignore extra arguments when called later. Useful for passing
|
||||
-- through callbacks to APIs where we don't want extra arguments to be passed through, i.e. simple OnClick scripts.
|
||||
-- This is equivalent to: function() return f(a, b, c); end INSTEAD OF function(...) return f(a, b, c, ...); end
|
||||
function GenerateFlatClosure(f, ...)
|
||||
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg, 1, arg.n));
|
||||
return GenerateClosureInternal(s_flatClosureGenerators, f, unpack(arg));
|
||||
end
|
||||
|
||||
function RunNextFrame(callback)
|
||||
@@ -166,7 +168,7 @@ FunctionUtil = {};
|
||||
function FunctionUtil.SafeInvokeMethod(table, methodName, ...)
|
||||
local method = table[methodName];
|
||||
if method then
|
||||
return method(table, unpack(arg, 1, arg.n));
|
||||
return method(table, unpack(arg));
|
||||
end
|
||||
|
||||
return nil;
|
||||
|
||||
@@ -22,6 +22,18 @@ function EventRegistry:OnLoad()
|
||||
-- Lua-5.0 varargs, so we forward through a small wrapper.
|
||||
self:DispatchGameEvent(event, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9)
|
||||
end)
|
||||
|
||||
-- Stop delivering events once logout begins: a callback firing during the
|
||||
-- engine's teardown can touch state that's already gone. A dedicated frame
|
||||
-- owns PLAYER_LOGOUT so this fires regardless of what consumers registered
|
||||
-- — folding the check into frameEventFrame's OnEvent would depend on the
|
||||
-- ref-counted event set still holding PLAYER_LOGOUT at logout time.
|
||||
self.logoutFrame = CreateFrame("Frame")
|
||||
self.logoutFrame:RegisterEvent("PLAYER_LOGOUT")
|
||||
self.logoutFrame:SetScript("OnEvent", function()
|
||||
self.frameEventFrame:UnregisterAllEvents()
|
||||
self.frameEventFrame:SetScript("OnEvent", nil)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Helper so we can forward up to nine event args (the most any vanilla
|
||||
|
||||
@@ -216,8 +216,7 @@ function ItemMixin:GetItemLink() -- requires item data to be loaded
|
||||
end
|
||||
|
||||
if self.itemID then
|
||||
local _, link = GetItemInfo(self.itemID)
|
||||
return link
|
||||
return (select(2, C_Item.GetItemInfo(self.itemID)));
|
||||
end
|
||||
|
||||
if not self:IsItemEmpty() then
|
||||
@@ -299,9 +298,7 @@ end
|
||||
|
||||
function ItemMixin:GetInventoryTypeName()
|
||||
if not self:IsItemEmpty() then
|
||||
-- GetItemInfoInstant returns: itemID, itemType, itemSubType, equipLoc, ...
|
||||
local _, _, _, equipLoc = C_Item.GetItemInfoInstant(self:GetItemID())
|
||||
return equipLoc
|
||||
return select(4, C_Item.GetItemInfoInstant(self:GetItemID()));
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -11,12 +11,19 @@ local random = math.random
|
||||
local atan2 = math.atan2
|
||||
local sqrt = math.sqrt
|
||||
|
||||
math.huge = 1 / 0
|
||||
MathUtil =
|
||||
{
|
||||
Epsilon = .000001,
|
||||
};
|
||||
|
||||
function CreateCounter(initialCount)
|
||||
MathUtil.ApproxZero = MathUtil.Epsilon;
|
||||
MathUtil.ApproxOne = 1.0 - MathUtil.Epsilon;
|
||||
|
||||
function CreateCounter(initialCount, step)
|
||||
local count = initialCount or 0
|
||||
step = step or 1
|
||||
return function()
|
||||
count = count + 1
|
||||
count = count + step
|
||||
return count
|
||||
end
|
||||
end
|
||||
@@ -130,3 +137,31 @@ end
|
||||
function CalculateAngleBetween(x1, y1, x2, y2)
|
||||
return atan2(y2 - y1, x2 - x1)
|
||||
end
|
||||
|
||||
AccumulatorMixin = {};
|
||||
|
||||
function AccumulatorMixin:Init(initialCount)
|
||||
self.count = initialCount or 0;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Add(count)
|
||||
self.count = self.count + count;
|
||||
return self.count;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Subtract(count)
|
||||
self.count = self.count - count;
|
||||
return self.count;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Count()
|
||||
return self.count;
|
||||
end
|
||||
|
||||
function AccumulatorMixin:Reset(resetCount)
|
||||
self.count = resetCount or 0;
|
||||
end
|
||||
|
||||
function CreateAccumulator(initialCount)
|
||||
return CreateAndInitFromMixin(AccumulatorMixin, initialCount);
|
||||
end
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
-- Backport of 3.3.5 FrameXML/Mixin.lua to vanilla 1.12 / Lua 5.0.
|
||||
-- `Mixin` and `CreateFromMixins` are engine (DLL) natives — see
|
||||
-- src/baselib/Mixin.cpp — so they exist whenever ClassicAPI is injected,
|
||||
-- even with this addon disabled, mirroring retail where they live in
|
||||
-- TableUtil as C functions. Only the composite helper (which calls a Lua
|
||||
-- `:Init` method) stays here, like retail's Mixin.lua.
|
||||
--
|
||||
-- Implementation differences from the 3.3.5 source:
|
||||
-- - Lua 5.0 has no select(); inside a vararg function the args are
|
||||
-- gathered into an implicit `arg` table (`arg.n` for the count).
|
||||
|
||||
function Mixin(object, ...)
|
||||
for i = 1, arg.n do
|
||||
local mixin = arg[i]
|
||||
if mixin then
|
||||
for k, v in pairs(mixin) do
|
||||
object[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
return object
|
||||
end
|
||||
|
||||
function CreateFromMixins(...)
|
||||
return Mixin({}, unpack(arg))
|
||||
end
|
||||
-- Lua 5.0 note: varargs arrive in the implicit `arg` table (`arg.n` count),
|
||||
-- so we forward them with unpack(arg) rather than 5.1's `...`.
|
||||
|
||||
function CreateAndInitFromMixin(mixin, ...)
|
||||
local object = CreateFromMixins(mixin)
|
||||
|
||||
@@ -28,7 +28,7 @@ function PlayerUtil.GetClassColor()
|
||||
end
|
||||
|
||||
function PlayerUtil.GetCurrentSpeed()
|
||||
return GetUnitSpeed("player") / 7 * 100
|
||||
return GetUnitSpeed("player") / BASE_MOVEMENT_SPEED * 100
|
||||
end
|
||||
|
||||
function GetPlayerGuid()
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
-- 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, 1, arg.n)` to forward varargs.
|
||||
-- `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`).
|
||||
-- - assertsafe / nop aren't globals in stock 1.12; defined as local
|
||||
-- fallbacks (same pattern as FunctionUtil.lua).
|
||||
|
||||
@@ -381,12 +382,12 @@ function FramePoolCollectionConverterMixin:CreatePoolKeyFromPoolArgs(args)
|
||||
end
|
||||
|
||||
function FramePoolCollectionConverterMixin:GetOrCreatePool(...)
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:GetOrCreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
function FramePoolCollectionConverterMixin:CreatePool(...)
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FramePoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:CreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
@@ -432,12 +433,12 @@ local function FontStringPoolCollection_ArgsToTable(parent, layer, subLayer, tem
|
||||
end
|
||||
|
||||
function FontStringPoolCollectionMixin:GetOrCreatePool(...)
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:GetOrCreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
function FontStringPoolCollectionMixin:CreatePool(...)
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg, 1, arg.n));
|
||||
local args = FontStringPoolCollection_ArgsToTable(unpack(arg));
|
||||
return self:CreatePoolWithArgs(args);
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
RectangleMixin = {};
|
||||
|
||||
function CreateRectangle(left, right, top, bottom)
|
||||
local rectangle = CreateFromMixins(RectangleMixin);
|
||||
rectangle:OnLoad(left, right, top, bottom);
|
||||
return rectangle;
|
||||
end
|
||||
|
||||
function RectangleMixin:OnLoad(left, right, top, bottom)
|
||||
self:SetSides(left or 0.0, right or 0.0, top or 0.0, bottom or 0.0);
|
||||
end
|
||||
|
||||
function RectangleMixin:SetSides(left, right, top, bottom)
|
||||
self.left = left;
|
||||
self.right = right;
|
||||
self.top = top;
|
||||
self.bottom = bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:Reset()
|
||||
self.left = 0.0;
|
||||
self.right = 0.0;
|
||||
self.top = 0.0;
|
||||
self.bottom = 0.0;
|
||||
end
|
||||
|
||||
function RectangleMixin:Stretch(x, y)
|
||||
self:Adjust(-x, x, -y, y);
|
||||
end
|
||||
|
||||
function RectangleMixin:Move(x, y)
|
||||
self:Adjust(x, x, y, y);
|
||||
end
|
||||
|
||||
function RectangleMixin:Adjust(left, right, top, bottom)
|
||||
self.left = self.left + left;
|
||||
self.right = self.right + right;
|
||||
self.top = self.top + top;
|
||||
self.bottom = self.bottom + bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:IsEmpty()
|
||||
return self.left == self.right or self.top == self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:IsInsideOut()
|
||||
return self.left > self.right or self.top > self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:EnclosesPoint(x, y)
|
||||
return x >= self.left and x <= self.right and y >= self.top and y <= self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:EnclosesRect(otherRect)
|
||||
return self:EnclosesPoint(otherRect:GetLeft(), otherRect:GetTop()) and self:EnclosesPoint(otherRect:GetRight(), otherRect:GetBottom());
|
||||
end
|
||||
|
||||
function RectangleMixin:IntersectsRect(otherRect)
|
||||
return not (
|
||||
self.left > otherRect.right or
|
||||
self.right < otherRect.left or
|
||||
self.top > otherRect.bottom or
|
||||
self.bottom < otherRect.top
|
||||
);
|
||||
end
|
||||
|
||||
function RectangleMixin:GetTop()
|
||||
return self.top;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetBottom()
|
||||
return self.bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetLeft()
|
||||
return self.left;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetRight()
|
||||
return self.right;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetWidth()
|
||||
return self.right - self.left;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetHeight()
|
||||
return self.bottom - self.top;
|
||||
end
|
||||
|
||||
function RectangleMixin:GetCenter()
|
||||
return Lerp(self.left, self.right, .5), Lerp(self.top, self.bottom, .5);
|
||||
end
|
||||
|
||||
function RectangleMixin:GetCenterH()
|
||||
return Lerp(self.left, self.right, .5);
|
||||
end
|
||||
|
||||
function RectangleMixin:GetCenterV()
|
||||
return Lerp(self.top, self.bottom, .5);
|
||||
end
|
||||
|
||||
function RectangleMixin:SetTop(top)
|
||||
self.top = top;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetBottom(bottom)
|
||||
self.bottom = bottom;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetLeft(left)
|
||||
self.left = left;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetRight(right)
|
||||
self.right = right;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetWidth(width)
|
||||
self.right = self.left + width;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetHeight(height)
|
||||
self.bottom = self.top + height;
|
||||
end
|
||||
|
||||
function RectangleMixin:SetSize(width, height)
|
||||
self:SetWidth(width);
|
||||
self:SetHeight(height);
|
||||
end
|
||||
|
||||
function RectangleMixin:SetCenter(x, y)
|
||||
local width = self:GetWidth();
|
||||
local height = self:GetHeight();
|
||||
|
||||
self.left = x - width * .5;
|
||||
self.right = x + width * .5;
|
||||
|
||||
self.top = y - height * .5;
|
||||
self.bottom = y + height * .5;
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Unit right-click menu for attribute-driven unit frames.
|
||||
--
|
||||
-- Backs the `menu` / `togglemenu` click verb of the Frame:SetAttribute
|
||||
-- backport (see src/frame/Attributes.cpp). When a frame with a `unit`
|
||||
-- attribute resolves a click to the `menu` verb, the DLL calls
|
||||
-- ClassicAPI_ToggleUnitMenu(unit), which pops the standard unit dropdown at
|
||||
-- the cursor -- the same UnitPopup menu Blizzard's PlayerFrame / TargetFrame /
|
||||
-- PartyMemberFrame show on right-click. Lets a unit frame express its
|
||||
-- right-click menu purely as an attribute (e.g. type2 = "menu") instead of a
|
||||
-- hand-rolled OnClick handler. pfUI's unit frames use exactly this path
|
||||
-- (api/unitframes.lua -> ClassicAPI_ToggleUnitMenu).
|
||||
--
|
||||
-- The unit -> menu-type resolution mirrors Blizzard's own generic resolver
|
||||
-- (TargetFrameDropDown_Initialize in FrameXML): self -> SELF, pet -> PET, a
|
||||
-- grouped player -> PARTY (whisper / inspect / trade / follow / promote / ...),
|
||||
-- any other player -> PLAYER (adds INVITE), anything else (NPC) ->
|
||||
-- RAID_TARGET_ICON (the raid-marker list). Vanilla's stock UI never wires a
|
||||
-- unit to the "RAID" menu (there are no clickable raid unit frames in 1.12),
|
||||
-- and PARTY carries the options players actually want on a grouped member, so
|
||||
-- grouped players (party or raid) use PARTY -- matching how pfUI drives its
|
||||
-- raid menu.
|
||||
--
|
||||
-- Focus / Clear Focus: we add one contextual entry -- "Set Focus" when the
|
||||
-- unit is not the current focus, "Clear Focus" when it is -- for every real,
|
||||
-- non-self unit, NPCs included. This is done entirely inside our own dropdown:
|
||||
-- no hooksecurefunc, and no mutation of Blizzard's shared UnitPopupMenus tables
|
||||
-- (so default frames and other addons are unaffected). Because
|
||||
-- UnitPopup_ShowMenu hardwires every button's click to UnitPopup_OnClick (which
|
||||
-- does not know our keys), we add the Focus button ourselves with our own func.
|
||||
-- To keep Cancel last, we show the standard menu MINUS its trailing Cancel,
|
||||
-- then append Focus and Cancel.
|
||||
--
|
||||
-- This also fixes NPCs opening no menu at all: an NPC resolves to the
|
||||
-- RAID_TARGET_ICON menu, whose entries UnitPopup_HideButtons hides entirely
|
||||
-- unless you are in a group and can place markers -- so solo the menu was
|
||||
-- empty and never opened. Appending Focus + Cancel ourselves guarantees the
|
||||
-- menu always opens (markers still show when they are allowed).
|
||||
--
|
||||
-- Backed by the DLL's FocusUnit / ClearFocus globals and the `focus` unit
|
||||
-- token (src/unit/Focus.cpp). Labels come from the localized SET_FOCUS /
|
||||
-- CLEAR_FOCUS globals (locales/*.lua).
|
||||
|
||||
local dropdown;
|
||||
|
||||
-- Scratch menu key we own, rebuilt each open as a copy of the resolved base
|
||||
-- menu with its trailing CANCEL removed. Kept out of Blizzard's tables so the
|
||||
-- stock menus are never mutated.
|
||||
local SCRATCH_MENU = "CLASSICAPI_UNITMENU";
|
||||
|
||||
local function EnsureDropdown()
|
||||
if not dropdown then
|
||||
dropdown = CreateFrame("Frame", "ClassicAPIUnitMenuDropDown", UIParent, "UIDropDownMenuTemplate");
|
||||
dropdown.displayMode = "MENU";
|
||||
end
|
||||
return dropdown;
|
||||
end
|
||||
|
||||
local function ResolveMenu(unit)
|
||||
if UnitIsUnit(unit, "player") then
|
||||
return "SELF";
|
||||
elseif UnitIsUnit(unit, "pet") then
|
||||
return "PET";
|
||||
elseif UnitIsPlayer(unit) then
|
||||
if UnitInParty(unit) or UnitInRaid(unit) then
|
||||
return "PARTY";
|
||||
end
|
||||
return "PLAYER";
|
||||
end
|
||||
return "RAID_TARGET_ICON";
|
||||
end
|
||||
|
||||
-- Append the contextual Focus / Clear Focus button for `unit`, with our own
|
||||
-- click func. Skipped for the player themselves (focusing yourself is a no-op).
|
||||
local function AddFocusButton(unit)
|
||||
if UnitIsUnit(unit, "player") then
|
||||
return;
|
||||
end
|
||||
local info = {};
|
||||
info.notCheckable = 1;
|
||||
if UnitExists("focus") and UnitIsUnit(unit, "focus") then
|
||||
info.text = CLEAR_FOCUS;
|
||||
info.func = function() ClearFocus(); end;
|
||||
else
|
||||
info.text = SET_FOCUS;
|
||||
info.func = function() FocusUnit(unit); end;
|
||||
end
|
||||
UIDropDownMenu_AddButton(info);
|
||||
end
|
||||
|
||||
local function AddCancelButton()
|
||||
local info = {};
|
||||
info.text = CANCEL;
|
||||
info.notCheckable = 1;
|
||||
info.func = function() end;
|
||||
UIDropDownMenu_AddButton(info);
|
||||
end
|
||||
|
||||
function ClassicAPI_ToggleUnitMenu(unit)
|
||||
if not unit or not UnitExists(unit) then
|
||||
return;
|
||||
end
|
||||
local which = ResolveMenu(unit);
|
||||
local dd = EnsureDropdown();
|
||||
dd.initialize = function(level)
|
||||
level = level or UIDROPDOWNMENU_MENU_LEVEL or 1;
|
||||
|
||||
if level > 1 then
|
||||
UnitPopup_ShowMenu(dd, which, unit);
|
||||
return;
|
||||
end
|
||||
|
||||
local base = UnitPopupMenus[which];
|
||||
local scratch = {};
|
||||
for _, value in ipairs(base) do
|
||||
if value ~= "CANCEL" then
|
||||
table.insert(scratch, value);
|
||||
end
|
||||
end
|
||||
UnitPopupMenus[SCRATCH_MENU] = scratch;
|
||||
UnitPopup_ShowMenu(dd, SCRATCH_MENU, unit);
|
||||
AddFocusButton(unit);
|
||||
AddCancelButton();
|
||||
end
|
||||
ToggleDropDownMenu(1, nil, dd, "cursor");
|
||||
end
|
||||
@@ -431,7 +431,7 @@ function SafePack(...)
|
||||
end
|
||||
|
||||
function SafeUnpack(tbl)
|
||||
return unpack(tbl, 1, tbl.n)
|
||||
return unpack(tbl)
|
||||
end
|
||||
|
||||
-- Returns the length of a table, accounting for the possibility of a table constructed using SafePack.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
TextureUtil = {};
|
||||
|
||||
function SetClampedTextureRotation(texture, rotationDegrees)
|
||||
if (rotationDegrees ~= 0 and rotationDegrees ~= 90 and rotationDegrees ~= 180 and rotationDegrees ~= 270) then
|
||||
error("SetRotation: rotationDegrees must be 0, 90, 180, or 270");
|
||||
return;
|
||||
end
|
||||
|
||||
if not (texture.rotationDegrees) then
|
||||
texture.origTexCoords = {texture:GetTexCoord()};
|
||||
texture.origWidth = texture:GetWidth();
|
||||
texture.origHeight = texture:GetHeight();
|
||||
end
|
||||
|
||||
if (texture.rotationDegrees == rotationDegrees) then
|
||||
return;
|
||||
end
|
||||
|
||||
texture.rotationDegrees = rotationDegrees;
|
||||
|
||||
if (rotationDegrees == 0 or rotationDegrees == 180) then
|
||||
texture:SetWidth(texture.origWidth);
|
||||
texture:SetHeight(texture.origHeight);
|
||||
else
|
||||
texture:SetWidth(texture.origHeight);
|
||||
texture:SetHeight(texture.origWidth);
|
||||
end
|
||||
|
||||
if (rotationDegrees == 0) then
|
||||
texture:SetTexCoord( texture.origTexCoords[1], texture.origTexCoords[2],
|
||||
texture.origTexCoords[3], texture.origTexCoords[4],
|
||||
texture.origTexCoords[5], texture.origTexCoords[6],
|
||||
texture.origTexCoords[7], texture.origTexCoords[8] );
|
||||
elseif (rotationDegrees == 90) then
|
||||
texture:SetTexCoord( texture.origTexCoords[3], texture.origTexCoords[4],
|
||||
texture.origTexCoords[7], texture.origTexCoords[8],
|
||||
texture.origTexCoords[1], texture.origTexCoords[2],
|
||||
texture.origTexCoords[5], texture.origTexCoords[6] );
|
||||
elseif (rotationDegrees == 180) then
|
||||
texture:SetTexCoord( texture.origTexCoords[7], texture.origTexCoords[8],
|
||||
texture.origTexCoords[5], texture.origTexCoords[6],
|
||||
texture.origTexCoords[3], texture.origTexCoords[4],
|
||||
texture.origTexCoords[1], texture.origTexCoords[2] );
|
||||
elseif (rotationDegrees == 270) then
|
||||
texture:SetTexCoord( texture.origTexCoords[5], texture.origTexCoords[6],
|
||||
texture.origTexCoords[1], texture.origTexCoords[2],
|
||||
texture.origTexCoords[7], texture.origTexCoords[8],
|
||||
texture.origTexCoords[3], texture.origTexCoords[4] );
|
||||
end
|
||||
end
|
||||
|
||||
function ClearClampedTextureRotation(texture)
|
||||
if (texture.rotationDegrees) then
|
||||
SetClampedTextureRotation(texture, 0);
|
||||
texture.origTexCoords = nil;
|
||||
texture.origWidth = nil;
|
||||
texture.origHeight = nil;
|
||||
texture.rotationDegrees = nil;
|
||||
end
|
||||
end
|
||||
|
||||
function GetTexCoordsByGrid(xOffset, yOffset, textureWidth, textureHeight, gridWidth, gridHeight)
|
||||
local widthPerGrid = gridWidth / textureWidth;
|
||||
local heightPerGrid = gridHeight / textureHeight;
|
||||
return (xOffset - 1) * widthPerGrid, (xOffset) * widthPerGrid, (yOffset - 1) * heightPerGrid, (yOffset) * heightPerGrid;
|
||||
end
|
||||
|
||||
-- left/right/top/bottom are normalized [0..1] coords within the file (the
|
||||
-- markup's texel fields are produced by multiplying back up by the file size).
|
||||
function CreateTextureMarkup(file, fileWidth, fileHeight, width, height, left, right, top, bottom, xOffset, yOffset)
|
||||
return string.format("|T%s:%d:%d:%d:%d:%d:%d:%d:%d:%d:%d|t",
|
||||
file
|
||||
, height
|
||||
, width
|
||||
, xOffset or 0
|
||||
, yOffset or 0
|
||||
, fileWidth
|
||||
, fileHeight
|
||||
, left * fileWidth
|
||||
, right * fileWidth
|
||||
, top * fileHeight
|
||||
, bottom * fileHeight
|
||||
);
|
||||
end
|
||||
|
||||
function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset)
|
||||
return string.format("|T%s:%d:%d:%d:%d|t",
|
||||
file
|
||||
, height or width
|
||||
, width
|
||||
, xOffset or 0
|
||||
, yOffset or 0
|
||||
);
|
||||
end
|
||||
@@ -248,7 +248,7 @@ end
|
||||
local function CreateFrame_TradeSkillLink()
|
||||
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
|
||||
f:SetSize(FRAME_W, FRAME_H);
|
||||
f:SetPoint("CENTER", 0, 0);
|
||||
f:SetPoint("CENTER");
|
||||
f:SetMovable(true);
|
||||
f:EnableMouse(true);
|
||||
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
|
||||
@@ -259,6 +259,9 @@ local function CreateFrame_TradeSkillLink()
|
||||
f:Hide();
|
||||
tinsert(UISpecialFrames, f:GetName())
|
||||
|
||||
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
|
||||
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
|
||||
|
||||
local portrait = f:CreateTexture(nil, "BACKGROUND");
|
||||
portrait:SetSize(60, 60);
|
||||
portrait:SetPoint("TOPLEFT", 7, -6);
|
||||
@@ -296,7 +299,7 @@ local function CreateFrame_TradeSkillLink()
|
||||
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
|
||||
exit:SetSize(80, 22);
|
||||
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
|
||||
exit:SetText(EXIT or "Exit");
|
||||
exit:SetText(EXIT);
|
||||
exit:SetScript("OnClick", function() f:Hide(); end);
|
||||
|
||||
local bar = CreateFrame("StatusBar", nil, f);
|
||||
@@ -393,29 +396,13 @@ local function CreateFrame_TradeSkillLink()
|
||||
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
|
||||
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
|
||||
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
|
||||
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
|
||||
f.detailScroll = detailScroll;
|
||||
-- Off until a recipe overflows the pane (toggled in RenderDetail).
|
||||
-- The template shows its slider and enables the wheel by default; on
|
||||
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
|
||||
-- wheel events and over-scrolls a hair. Start both disabled.
|
||||
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
|
||||
-- the computed overflow). `scrollBarHideable` still lets the template's
|
||||
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
|
||||
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
|
||||
detailScroll.scrollBarHideable = 1;
|
||||
detailScroll:EnableMouseWheel(false);
|
||||
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if detailSB then
|
||||
detailSB:Hide();
|
||||
end
|
||||
-- The template re-shows its slider when the frame transitions to shown
|
||||
-- (on first open, before the auto-selected recipe's RenderDetail runs),
|
||||
-- leaving a dead scrollbar until the first click. Re-assert the hidden
|
||||
-- state on show unless the current recipe genuinely overflows.
|
||||
detailScroll:SetScript("OnShow", function()
|
||||
f:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if f:IsShown() then
|
||||
f:UpdateDetailScrollbar();
|
||||
end
|
||||
end);
|
||||
end);
|
||||
f.detailScroll = detailScroll;
|
||||
|
||||
local detailChild = CreateFrame("Frame",
|
||||
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
|
||||
@@ -461,8 +448,8 @@ local function CreateFrame_TradeSkillLink()
|
||||
|
||||
prodIcon:SetScript("OnEnter", function()
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
if this.itemLink then
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
if this.itemID then
|
||||
GameTooltip:SetHyperlink("item:"..this.itemID);
|
||||
elseif this.spellID then
|
||||
GameTooltip:SetSpellByID(this.spellID);
|
||||
end
|
||||
@@ -503,29 +490,36 @@ local function CreateFrame_TradeSkillLink()
|
||||
nm:SetJustifyV("MIDDLE");
|
||||
btn.nameText = nm;
|
||||
btn:SetScript("OnEnter", function()
|
||||
if this.itemLink then
|
||||
if this.itemID then
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
|
||||
GameTooltip:SetHyperlink(this.itemLink);
|
||||
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 slider + enable the wheel only when the current
|
||||
-- recipe overflows the pane (tracked by `detailNeedScroll`).
|
||||
function f:UpdateDetailScrollbar()
|
||||
local need = self.detailNeedScroll and true or false;
|
||||
self.detailScroll:EnableMouseWheel(need);
|
||||
-- Show the detail bar/borders only when the content overflows the pane.
|
||||
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
|
||||
-- auto-hide only fires on a range CHANGE (so content that fits from the
|
||||
-- start never hides), and GetVerticalScrollRange isn't reliably readable
|
||||
-- the same tick as UpdateScrollChildRect.
|
||||
function f:SetDetailBarShown(show)
|
||||
self.detailScroll:EnableMouseWheel(show);
|
||||
local base = "ClassicAPITradeSkillLinkDetailScroll";
|
||||
local sb = getglobal(base .. "ScrollBar");
|
||||
if sb then sb:SetShown(need) end
|
||||
if sb then sb:SetShown(show) end
|
||||
local top = getglobal(base .. "Top");
|
||||
if top then top:SetShown(need) end
|
||||
if top then top:SetShown(show) end
|
||||
local bottom = getglobal(base .. "Bottom");
|
||||
if bottom then bottom:SetShown(need) end
|
||||
if bottom then bottom:SetShown(show) end
|
||||
end
|
||||
|
||||
function f:RenderDetail(entry)
|
||||
@@ -571,19 +565,22 @@ local function CreateFrame_TradeSkillLink()
|
||||
local total = table.getn(reagents);
|
||||
for i = 1, total do
|
||||
local btn = AcquireReagent(i);
|
||||
local r = reagents[i];
|
||||
local name, tex, link = itemInfo(r.itemID);
|
||||
local reagent = reagents[i];
|
||||
local name, tex, link = itemInfo(reagent.itemID);
|
||||
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
|
||||
btn.itemLink = link;
|
||||
btn.nameText:SetText(name or ("item:" .. r.itemID));
|
||||
local need = r.count or 1;
|
||||
local have = C_Item.GetItemCount(r.itemID) or 0;
|
||||
btn.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
|
||||
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
|
||||
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
|
||||
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
|
||||
btn:GetNormalTexture():SetVertexColor(r, g, b);
|
||||
btn.nameText:SetTextColor(r, g, b);
|
||||
else
|
||||
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
|
||||
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
|
||||
local r, g, b = GRAY_FONT_COLOR:GetRGB()
|
||||
btn:GetNormalTexture():SetVertexColor(r, g, b);
|
||||
btn.nameText:SetTextColor(r, g, b);
|
||||
end
|
||||
local haveText = (have >= 100) and "*" or tostring(have);
|
||||
btn.count:SetText(haveText .. " /" .. need);
|
||||
@@ -591,32 +588,36 @@ local function CreateFrame_TradeSkillLink()
|
||||
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
|
||||
|
||||
-- Grow the scroll child to the content height so the pane scrolls
|
||||
-- once the header + reagent rows exceed the visible area. Reagent
|
||||
-- row 0 sits ~67px below the child top (icon + label); add the
|
||||
-- wrapped description height for spell recipes.
|
||||
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
|
||||
local rows = math.ceil(total / 2);
|
||||
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
|
||||
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
|
||||
-- Reset to the top and manage the slider explicitly: a range that
|
||||
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
|
||||
-- range-changed event that `scrollBarHideable` relies on, so the
|
||||
-- slider would otherwise sit visible-but-useless. Show it only when
|
||||
-- the content actually overflows.
|
||||
self.detailNeedScroll = contentH > LO.detail[4];
|
||||
local function applyContentHeight()
|
||||
local contentH = LO.detail[4];
|
||||
if total > 0 then
|
||||
local childTop = self.detailChild:GetTop();
|
||||
local lastBtn = self.reagents[total];
|
||||
local lastBottom = lastBtn and lastBtn:GetBottom();
|
||||
if childTop and lastBottom then
|
||||
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
|
||||
contentH = (childTop - lastBottom) + 22;
|
||||
end
|
||||
end
|
||||
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
|
||||
-- Recompute the scroll range from the child's new height, then
|
||||
-- show the bar only if the content actually overflows the pane.
|
||||
self.detailScroll:UpdateScrollChildRect();
|
||||
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
|
||||
end
|
||||
applyContentHeight();
|
||||
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
|
||||
if sb then
|
||||
sb:SetValue(0);
|
||||
end
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
self:UpdateDetailScrollbar();
|
||||
RunNextFrame(function()
|
||||
if self:IsShown() then
|
||||
self:UpdateDetailScrollbar();
|
||||
if self:IsShown() and self.selectedSpellID == entry.spellID then
|
||||
applyContentHeight();
|
||||
end
|
||||
end)
|
||||
end
|
||||
@@ -636,11 +637,12 @@ local function CreateFrame_TradeSkillLink()
|
||||
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.detailNeedScroll = false;
|
||||
self:UpdateDetailScrollbar();
|
||||
self.detailScroll:UpdateScrollChildRect();
|
||||
self:SetDetailBarShown(false);
|
||||
self.detailScroll:SetVerticalScroll(0);
|
||||
end
|
||||
|
||||
@@ -732,7 +734,7 @@ local function ShowTradeSkillLink(link, text)
|
||||
local r = recipes[i];
|
||||
if r.isKnown then
|
||||
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
|
||||
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
|
||||
r.name = C_Spell.GetSpellName(r.spellID) or ("spell:" .. r.spellID);
|
||||
table.insert(knownList, r);
|
||||
end
|
||||
end
|
||||
@@ -752,8 +754,8 @@ local function ShowTradeSkillLink(link, text)
|
||||
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
|
||||
frame.bar:SetValue(cur or 0);
|
||||
frame.barName:SetText(prof);
|
||||
frame.barRank:SetText(string.format("%d/%d", cur or 0, max or 0));
|
||||
frame.summary:SetText(string.format("%d of %d recipes known", known, total));
|
||||
frame.barRank:SetFormattedText("%d/%d", cur or 0, max or 0);
|
||||
frame.summary:SetFormattedText("%d of %d recipes known", known, total);
|
||||
frame.recipes = knownList;
|
||||
frame.subclassFilter = nil;
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
function MouseIsOver(region, topOffset, bottomOffset, leftOffset, rightOffset)
|
||||
return region:IsMouseOver(topOffset, bottomOffset, leftOffset, rightOffset);
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
-- Verbatim backport of retail's Blizzard_SharedXML/Vector2D.lua. Pure vector
|
||||
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
|
||||
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
|
||||
-- vanilla has no taint system.)
|
||||
local cos = math.cos;
|
||||
local sin = math.sin;
|
||||
local atan2 = math.atan2;
|
||||
local sqrt = math.sqrt;
|
||||
|
||||
function Vector2D_ScaleBy(scalar, x, y)
|
||||
return x * scalar, y * scalar;
|
||||
end
|
||||
|
||||
function Vector2D_DivideBy(divisor, x, y)
|
||||
return x / divisor, y / divisor;
|
||||
end
|
||||
|
||||
function Vector2D_Add(leftX, leftY, rightX, rightY)
|
||||
return leftX + rightX, leftY + rightY;
|
||||
end
|
||||
|
||||
function Vector2D_Subtract(leftX, leftY, rightX, rightY)
|
||||
return leftX - rightX, leftY - rightY;
|
||||
end
|
||||
|
||||
function Vector2D_Cross(leftX, leftY, rightX, rightY)
|
||||
return leftX * rightY - leftY * rightX;
|
||||
end
|
||||
|
||||
function Vector2D_Dot(leftX, leftY, rightX, rightY)
|
||||
return leftX * rightX + leftY * rightY;
|
||||
end
|
||||
|
||||
function Vector2D_GetLengthSquared(x, y)
|
||||
return Vector2D_Dot(x, y, x, y);
|
||||
end
|
||||
|
||||
function Vector2D_GetLength(x, y)
|
||||
return sqrt(Vector2D_GetLengthSquared(x, y));
|
||||
end
|
||||
|
||||
function Vector2D_Normalize(x, y)
|
||||
return Vector2D_DivideBy(Vector2D_GetLength(x, y), x, y);
|
||||
end
|
||||
|
||||
function Vector2D_CalculateAngleBetween(leftX, leftY, rightX, rightY)
|
||||
return atan2(Vector2D_Cross(leftX, leftY, rightX, rightY), Vector2D_Dot(leftX, leftY, rightX, rightY));
|
||||
end
|
||||
|
||||
function Vector2D_RotateDirection(rotationRadians, x, y)
|
||||
local cosValue = cos(rotationRadians);
|
||||
local sinValue = sin(rotationRadians);
|
||||
return x * cosValue - y * sinValue, x * sinValue + y * cosValue;
|
||||
end
|
||||
|
||||
Vector2DMixin = {};
|
||||
|
||||
function CreateVector2D(x, y)
|
||||
local vector = CreateFromMixins(Vector2DMixin);
|
||||
vector:OnLoad(x, y);
|
||||
return vector;
|
||||
end
|
||||
|
||||
function AreVector2DEqual(left, right)
|
||||
if left and right then
|
||||
return left:IsEqualTo(right);
|
||||
end
|
||||
return left == right;
|
||||
end
|
||||
|
||||
function Vector2DMixin:OnLoad(x, y)
|
||||
self:SetXY(x, y);
|
||||
end
|
||||
|
||||
function Vector2DMixin:IsEqualTo(otherVector)
|
||||
return self.x == otherVector.x
|
||||
and self.y == otherVector.y;
|
||||
end
|
||||
|
||||
function Vector2DMixin:GetXY()
|
||||
return self.x, self.y;
|
||||
end
|
||||
|
||||
function Vector2DMixin:SetXY(x, y)
|
||||
self.x = x;
|
||||
self.y = y;
|
||||
end
|
||||
|
||||
function Vector2DMixin:ScaleBy(scalar)
|
||||
self:SetXY(Vector2D_ScaleBy(scalar, self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:DivideBy(scalar)
|
||||
self:SetXY(Vector2D_DivideBy(scalar, self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Add(other)
|
||||
self:SetXY(Vector2D_Add(self.x, self.y, other:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Subtract(other)
|
||||
self:SetXY(Vector2D_Subtract(self.x, self.y, other:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Cross(other)
|
||||
self:SetXY(Vector2D_Cross(self.x, self.y, other:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Dot(other)
|
||||
return Vector2D_Dot(self.x, self.y, other:GetXY());
|
||||
end
|
||||
|
||||
function Vector2DMixin:IsZero()
|
||||
return self.x == 0 and self.y == 0;
|
||||
end
|
||||
|
||||
function Vector2DMixin:GetLengthSquared()
|
||||
return Vector2D_GetLengthSquared(self:GetXY());
|
||||
end
|
||||
|
||||
function Vector2DMixin:GetLength()
|
||||
return Vector2D_GetLength(self:GetXY());
|
||||
end
|
||||
|
||||
function Vector2DMixin:Normalize()
|
||||
self:SetXY(Vector2D_Normalize(self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:RotateDirection(rotationRadians)
|
||||
self:SetXY(Vector2D_RotateDirection(rotationRadians, self:GetXY()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector2DMixin:Clone()
|
||||
return CreateVector2D(self:GetXY());
|
||||
end
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Verbatim backport of retail's Blizzard_SharedXML/Vector3D.lua. Pure vector
|
||||
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
|
||||
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
|
||||
-- vanilla has no taint system.)
|
||||
local cos = math.cos;
|
||||
local sin = math.sin;
|
||||
local atan2 = math.atan2;
|
||||
local asin = math.asin;
|
||||
local sqrt = math.sqrt;
|
||||
|
||||
function Vector3D_ScaleBy(scalar, x, y, z)
|
||||
return x * scalar, y * scalar, z * scalar;
|
||||
end
|
||||
|
||||
function Vector3D_DivideBy(divisor, x, y, z)
|
||||
return x / divisor, y / divisor, z / divisor;
|
||||
end
|
||||
|
||||
function Vector3D_Add(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftX + rightX, leftY + rightY, leftZ + rightZ;
|
||||
end
|
||||
|
||||
function Vector3D_Subtract(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftX - rightX, leftY - rightY, leftZ - rightZ;
|
||||
end
|
||||
|
||||
function Vector3D_Cross(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftY * rightZ - leftZ * rightY, leftZ * rightX - leftX * rightZ, leftX * rightY - leftY * rightX;
|
||||
end
|
||||
|
||||
function Vector3D_Dot(leftX, leftY, leftZ, rightX, rightY, rightZ)
|
||||
return leftX * rightX + leftY * rightY + leftZ * rightZ;
|
||||
end
|
||||
|
||||
function Vector3D_GetLengthSquared(x, y, z)
|
||||
return Vector3D_Dot(x, y, z, x, y, z);
|
||||
end
|
||||
|
||||
function Vector3D_GetLength(x, y, z)
|
||||
return sqrt(Vector3D_GetLengthSquared(x, y, z));
|
||||
end
|
||||
|
||||
function Vector3D_Normalize(x, y, z)
|
||||
return Vector3D_DivideBy(Vector3D_GetLength(x, y, z), x, y, z);
|
||||
end
|
||||
|
||||
function Vector3D_AddVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Add(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_SubtractVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Subtract(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_NormalizeVector(vector)
|
||||
local clone = vector:Clone();
|
||||
clone:Normalize();
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_ScaleVector(scalar, vector)
|
||||
local clone = vector:Clone();
|
||||
clone:ScaleBy(scalar);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector3D_CalculateNormalFromYawPitch(yaw, pitch)
|
||||
return cos(-pitch) * cos(yaw),
|
||||
cos(-pitch) * sin(yaw),
|
||||
sin(-pitch);
|
||||
end
|
||||
|
||||
function Vector3D_CalculateYawPitchFromNormal(x, y, z)
|
||||
if x ~= 0 or y ~= 0 then
|
||||
return atan2(y, x), asin(-z);
|
||||
end
|
||||
|
||||
return 0, asin(-z);
|
||||
end
|
||||
|
||||
function Vector3D_CalculateYawPitchFromNormalVector(vector)
|
||||
return Vector3D_CalculateYawPitchFromNormal(vector:GetXYZ());
|
||||
end
|
||||
|
||||
|
||||
function Vector3D_CreateNormalVectorFromYawPitch(yawRadians, pitchRadians)
|
||||
return CreateVector3D(Vector3D_CalculateNormalFromYawPitch(yawRadians, pitchRadians));
|
||||
end
|
||||
|
||||
Vector3DMixin = {};
|
||||
|
||||
function CreateVector3D(x, y, z)
|
||||
local vector = CreateFromMixins(Vector3DMixin);
|
||||
vector:OnLoad(x, y, z);
|
||||
return vector;
|
||||
end
|
||||
|
||||
function AreVector3DEqual(left, right)
|
||||
if left and right then
|
||||
return left:IsEqualTo(right);
|
||||
end
|
||||
return left == right;
|
||||
end
|
||||
|
||||
function Vector3DMixin:OnLoad(x, y, z)
|
||||
self:SetXYZ(x, y, z);
|
||||
end
|
||||
|
||||
function Vector3DMixin:IsEqualTo(otherVector)
|
||||
return self.x == otherVector.x
|
||||
and self.y == otherVector.y
|
||||
and self.z == otherVector.z;
|
||||
end
|
||||
|
||||
function Vector3DMixin:GetXYZ()
|
||||
return self.x, self.y, self.z;
|
||||
end
|
||||
|
||||
function Vector3DMixin:SetXYZ(x, y, z)
|
||||
self.x = x;
|
||||
self.y = y;
|
||||
self.z = z;
|
||||
end
|
||||
|
||||
function Vector3DMixin:ScaleBy(scalar)
|
||||
self:SetXYZ(Vector3D_ScaleBy(scalar, self:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:DivideBy(scalar)
|
||||
self:SetXYZ(Vector3D_DivideBy(scalar, self:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Add(other)
|
||||
self:SetXYZ(Vector3D_Add(self.x, self.y, self.z, other:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Subtract(other)
|
||||
self:SetXYZ(Vector3D_Subtract(self.x, self.y, self.z, other:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Cross(other)
|
||||
self:SetXYZ(Vector3D_Cross(self.x, self.y, self.z, other:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Dot(other)
|
||||
return Vector3D_Dot(self.x, self.y, self.z, other:GetXYZ());
|
||||
end
|
||||
|
||||
function Vector3DMixin:GetLengthSquared()
|
||||
return Vector3D_GetLengthSquared(self:GetXYZ());
|
||||
end
|
||||
|
||||
function Vector3DMixin:GetLength()
|
||||
return Vector3D_GetLength(self:GetXYZ());
|
||||
end
|
||||
|
||||
function Vector3DMixin:Normalize()
|
||||
self:SetXYZ(Vector3D_Normalize(self:GetXYZ()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector3DMixin:Clone()
|
||||
return CreateVector3D(self:GetXYZ());
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Verbatim backport of retail's Blizzard_SharedXML/Vector4D.lua. Pure vector
|
||||
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
|
||||
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
|
||||
-- vanilla has no taint system.)
|
||||
local cos = math.cos;
|
||||
local sin = math.sin;
|
||||
local atan2 = math.atan2;
|
||||
local asin = math.asin;
|
||||
local sqrt = math.sqrt;
|
||||
|
||||
function Vector4D_ScaleBy(scalar, x, y, z, w)
|
||||
return x * scalar, y * scalar, z * scalar, w * scalar;
|
||||
end
|
||||
|
||||
function Vector4D_DivideBy(divisor, x, y, z, w)
|
||||
return x / divisor, y / divisor, z / divisor, w / divisor;
|
||||
end
|
||||
|
||||
function Vector4D_Add(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
|
||||
return leftX + rightX, leftY + rightY, leftZ + rightZ, leftW + rightW;
|
||||
end
|
||||
|
||||
function Vector4D_Subtract(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
|
||||
return leftX - rightX, leftY - rightY, leftZ - rightZ, leftW - rightW;
|
||||
end
|
||||
|
||||
function Vector4D_Dot(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
|
||||
return leftX * rightX + leftY * rightY + leftZ * rightZ + leftW * rightW;
|
||||
end
|
||||
|
||||
function Vector4D_GetLengthSquared(x, y, z, w)
|
||||
return Vector4D_Dot(x, y, z, w, x, y, z, w);
|
||||
end
|
||||
|
||||
function Vector4D_GetLength(x, y, z, w)
|
||||
return sqrt(Vector4D_GetLengthSquared(x, y, z, w));
|
||||
end
|
||||
|
||||
function Vector4D_Normalize(x, y, z, w)
|
||||
return Vector4D_DivideBy(Vector4D_GetLength(x, y, z, w), x, y, z, w);
|
||||
end
|
||||
|
||||
function Vector4D_AddVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Add(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector4D_SubtractVector(left, right)
|
||||
local clone = left:Clone();
|
||||
clone:Subtract(right);
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector4D_NormalizeVector(vector)
|
||||
local clone = vector:Clone();
|
||||
clone:Normalize();
|
||||
return clone;
|
||||
end
|
||||
|
||||
function Vector4D_ScaleVector(scalar, vector)
|
||||
local clone = vector:Clone();
|
||||
clone:ScaleBy(scalar);
|
||||
return clone;
|
||||
end
|
||||
|
||||
Vector4DMixin = {};
|
||||
|
||||
function CreateVector4D(x, y, z, w)
|
||||
local vector = CreateFromMixins(Vector4DMixin);
|
||||
vector:OnLoad(x, y, z, w);
|
||||
return vector;
|
||||
end
|
||||
|
||||
function AreVector4DEqual(left, right)
|
||||
if left and right then
|
||||
return left:IsEqualTo(right);
|
||||
end
|
||||
return left == right;
|
||||
end
|
||||
|
||||
function Vector4DMixin:OnLoad(x, y, z, w)
|
||||
self:SetXYZW(x, y, z, w);
|
||||
end
|
||||
|
||||
function Vector4DMixin:IsEqualTo(otherVector)
|
||||
return self.x == otherVector.x
|
||||
and self.y == otherVector.y
|
||||
and self.z == otherVector.z
|
||||
and self.w == otherVector.w;
|
||||
end
|
||||
|
||||
function Vector4DMixin:GetXYZW()
|
||||
return self.x, self.y, self.z, self.w;
|
||||
end
|
||||
|
||||
function Vector4DMixin:SetXYZW(x, y, z, w)
|
||||
self.x = x;
|
||||
self.y = y;
|
||||
self.z = z;
|
||||
self.w = w;
|
||||
end
|
||||
|
||||
function Vector4DMixin:ScaleBy(scalar)
|
||||
self:SetXYZW(Vector4D_ScaleBy(scalar, self:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:DivideBy(scalar)
|
||||
self:SetXYZW(Vector4D_DivideBy(scalar, self:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Add(other)
|
||||
self:SetXYZW(Vector4D_Add(self.x, self.y, self.z, self.w, other:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Subtract(other)
|
||||
self:SetXYZW(Vector4D_Subtract(self.x, self.y, self.z, self.w, other:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Dot(other)
|
||||
return Vector4D_Dot(self.x, self.y, self.z, self.w, other:GetXYZW());
|
||||
end
|
||||
|
||||
function Vector4DMixin:GetLengthSquared()
|
||||
return Vector4D_GetLengthSquared(self:GetXYZW());
|
||||
end
|
||||
|
||||
function Vector4DMixin:GetLength()
|
||||
return Vector4D_GetLength(self:GetXYZW());
|
||||
end
|
||||
|
||||
function Vector4DMixin:Normalize()
|
||||
self:SetXYZW(Vector4D_Normalize(self:GetXYZW()));
|
||||
return self;
|
||||
end
|
||||
|
||||
function Vector4DMixin:Clone()
|
||||
return CreateVector4D(self:GetXYZW());
|
||||
end
|
||||
@@ -2,7 +2,33 @@ if GetLocale() ~= "deDE" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Fokus setzen";
|
||||
BINDING_NAME_TARGETFOCUS = "Fokus anvisieren";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "Seid Ihr sicher, dass Ihr das Ausrüstungsset \"%s\" löschen wollt?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "Möchtet Ihr das Ausrüstungsset \"%s\" speichern?";
|
||||
DEBUFF_SYMBOL_CURSE = "Fl";
|
||||
DEBUFF_SYMBOL_DISEASE = "Kr";
|
||||
DEBUFF_SYMBOL_MAGIC = "Ma";
|
||||
DEBUFF_SYMBOL_POISON = "Gi";
|
||||
DEBUG_FRAMESTACK = "Frame Stack";
|
||||
EQUIPMENT_MANAGER = "Ausrüstungsmanager";
|
||||
EQUIPMENT_MANAGER_IGNORE_SLOT = "Diesen Platz ignorieren";
|
||||
EQUIPMENT_MANAGER_IS_DISABLED = "Der Ausrüstungsmanager ist abgeschaltet.";
|
||||
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d fehlen)|r";
|
||||
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "In Beuteln platzieren";
|
||||
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Diesen Platz einbeziehen";
|
||||
EQUIPMENT_SETS = "Ausrüstungs-Sets: |cFFFFFFFF%s|r";
|
||||
EQUIPMENT_SETS_CANT_RENAME = "Ein Ausrüstungsset mit diesem Namen gibt es bereits.";
|
||||
EQUIPMENT_SETS_TOO_MANY = "Ihr könnt keine weiteren Ausrüstungssets erstellen.";
|
||||
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Spezialisierung zuweisen:";
|
||||
EQUIPMENT_SET_EDIT = "Name/Icon ändern";
|
||||
EQUIPMENT_SET_SETTINGS = "Einstellungen";
|
||||
EQUIPSET_EQUIP = "Anlegen";
|
||||
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Ausrüstungstausch fehlgeschlagen. Inventar ist voll.";
|
||||
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Einige Gegenstände des Sets '%s' können nicht gewechselt werden, da Ihr Euch im Kampf befindet.";
|
||||
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Mindestens ein Teil des Sets '%s' fehlt und kann nicht ausgerüstet werden.";
|
||||
CLEAR_FOCUS = "Fokus löschen";
|
||||
SET_FOCUS = "Fokus setzen";
|
||||
FOCUSTARGET = "Ziel als Fokus festlegen";
|
||||
GEARSETS_POPUP_TEXT = "Setnamen eingeben (Max. 16 Zeichen):";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4IGegenstand:Gegenstände;";
|
||||
ITEMS_EQUIPPED = "%d |4Gegenstand:Gegenstände; ausgerüstet";
|
||||
ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
|
||||
@@ -15,6 +41,10 @@ COPPER_AMOUNT_SYMBOL = "K";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "G";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "Alle Symbole";
|
||||
ICON_FILTER_ITEM = "Gegenstände";
|
||||
ICON_FILTER_SPELL = "Zauber";
|
||||
ICON_SELECTION_TITLE_CURRENT = "Aktuell ausgewählt";
|
||||
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
|
||||
@@ -42,7 +72,11 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
|
||||
LARGE_NUMBER_SEPERATOR = ".";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PAPERDOLL_EQUIPMENTMANAGER = "Ausrüstungsmanager";
|
||||
PAPERDOLL_NEWEQUIPMENTSET = "Neues Set";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Notizen für %s eintragen:";
|
||||
SET_NOTE = "Notiz eintragen";
|
||||
SILVER_AMOUNT_SYMBOL = "S";
|
||||
SLASH_CLEARFOCUS1 = "/fokusaufheben";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
BINDING_NAME_FOCUSTARGET = "Focus Target";
|
||||
BINDING_NAME_TARGETFOCUS = "Target Focus";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "Are you sure you want to delete the equipment set %s?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "Would you like to save the equipment set \"%s\"?";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
COPPER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
|
||||
DEBUFF_SYMBOL_CURSE = "Cu";
|
||||
DEBUFF_SYMBOL_DISEASE = "Di";
|
||||
DEBUFF_SYMBOL_MAGIC = "Ma";
|
||||
DEBUFF_SYMBOL_POISON = "Po";
|
||||
DEBUG_FRAMESTACK = "Frame Stack";
|
||||
EQUIPMENT_MANAGER = "Equipment Manager";
|
||||
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignore This Slot";
|
||||
EQUIPMENT_MANAGER_IS_DISABLED = "The Equipment Manager is disabled.";
|
||||
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(%d missing)|r";
|
||||
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Place In Bags";
|
||||
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Include This Slot";
|
||||
EQUIPMENT_SETS = "Equipment Sets: |cFFFFFFFF%s|r";
|
||||
EQUIPMENT_SETS_CANT_RENAME = "An equipment set with that name already exists.";
|
||||
EQUIPMENT_SETS_TOO_MANY = "You cannot create any more new equipment sets.";
|
||||
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assign to Specialization:";
|
||||
EQUIPMENT_SET_EDIT = "Change Name/Icon";
|
||||
EQUIPMENT_SET_SETTINGS = "Settings";
|
||||
EQUIPSET_EQUIP = "Equip";
|
||||
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Equipment swap failed. Inventory is full.";
|
||||
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Some items from set %s cannot be swapped because you are in combat.";
|
||||
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "One or more items from set %s were missing and could not be equipped.";
|
||||
CLEAR_FOCUS = "Clear Focus";
|
||||
SET_FOCUS = "Set Focus";
|
||||
FOCUSTARGET = "Focus Target";
|
||||
GEARSETS_POPUP_TEXT = "Enter Set Name (Max 16 Characters):";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "g";
|
||||
GOLD_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
|
||||
GOLD_AMOUNT_TEXTURE_STRING = "%s|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "All Icons";
|
||||
ICON_FILTER_ITEM = "Items";
|
||||
ICON_FILTER_SPELL = "Spells";
|
||||
ICON_SELECTION_TITLE_CURRENT = "Currently Selected";
|
||||
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Health";
|
||||
@@ -39,8 +72,13 @@ ITEM_MISSING = "%s missing";
|
||||
ITEM_SLOTS_IGNORED = "%d |4slot:slots; ignored";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PAPERDOLL_EQUIPMENTMANAGER = "Equipment Manager";
|
||||
PAPERDOLL_NEWEQUIPMENTSET = "New Set";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Set Notes for %s:";
|
||||
SET_NOTE = "Set Note";
|
||||
SILVER_AMOUNT_SYMBOL = "s";
|
||||
SILVER_AMOUNT_TEXTURE = "%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
SLASH_EQUIP_SET1 = "/equipset";
|
||||
|
||||
@@ -2,7 +2,33 @@ if GetLocale() ~= "esES" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Establecer objetivo";
|
||||
BINDING_NAME_TARGETFOCUS = "Seleccionar enfocado";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "¿Seguro que quieres eliminar el equipamiento %s?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "¿Quieres guardar el conjunto de equipamiento \"%s\"?";
|
||||
DEBUFF_SYMBOL_CURSE = "Mal";
|
||||
DEBUFF_SYMBOL_DISEASE = "En";
|
||||
DEBUFF_SYMBOL_MAGIC = "Mag";
|
||||
DEBUFF_SYMBOL_POISON = "Ve";
|
||||
DEBUG_FRAMESTACK = "Trama de pila";
|
||||
EQUIPMENT_MANAGER = "Gestor de equipamiento";
|
||||
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorar esta casilla";
|
||||
EQUIPMENT_MANAGER_IS_DISABLED = "El Gestor de equipamiento está desactivado.";
|
||||
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(falta %d)|r";
|
||||
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Colocar en bolsas";
|
||||
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Incluir esta casilla";
|
||||
EQUIPMENT_SETS = "Conjuntos de equipamiento: |cFFFFFFFF%s|r";
|
||||
EQUIPMENT_SETS_CANT_RENAME = "Ya existe un conjunto de equipamiento con ese nombre.";
|
||||
EQUIPMENT_SETS_TOO_MANY = "Ya no puedes crear más equipamientos.";
|
||||
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Asignar a especialización:";
|
||||
EQUIPMENT_SET_EDIT = "Cambiar nombre/icono";
|
||||
EQUIPMENT_SET_SETTINGS = "Config.";
|
||||
EQUIPSET_EQUIP = "Equipar";
|
||||
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "No se ha podido cambiar el equipamiento. Inventario lleno.";
|
||||
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Algunos objetos del equipo %s no se cambiarán porque estás en combate.";
|
||||
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Falta uno o más objetos del equipo %s y no se ha podido equipar.";
|
||||
CLEAR_FOCUS = "Quitar foco";
|
||||
SET_FOCUS = "Establecer foco";
|
||||
FOCUSTARGET = "Establecer objetivo";
|
||||
GEARSETS_POPUP_TEXT = "Nombra este equipamiento (Máx. 16 carac.):";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4objeto:objetos;";
|
||||
ITEMS_EQUIPPED = "Equipado: %d |4objeto:objetos;";
|
||||
ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
|
||||
@@ -15,6 +41,10 @@ COPPER_AMOUNT_SYMBOL = "c";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "o";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "Todos los iconos";
|
||||
ICON_FILTER_ITEM = "Objetos";
|
||||
ICON_FILTER_SPELL = "Hechizos";
|
||||
ICON_SELECTION_TITLE_CURRENT = "Selección actual";
|
||||
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
|
||||
ITEM_MOD_MANA_SHORT = "Maná";
|
||||
ITEM_MOD_HEALTH_SHORT = "Salud";
|
||||
@@ -42,7 +72,11 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PAPERDOLL_EQUIPMENTMANAGER = "Gestor de equipamiento";
|
||||
PAPERDOLL_NEWEQUIPMENTSET = "Nuevo conjunto";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Establecer nota para %s:";
|
||||
SET_NOTE = "Establecer nota";
|
||||
SILVER_AMOUNT_SYMBOL = "p";
|
||||
SLASH_CLEARFOCUS1 = "/quitarenfoque";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
|
||||
@@ -2,6 +2,31 @@ if GetLocale() ~= "frFR" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Définir cible";
|
||||
BINDING_NAME_TARGETFOCUS = "Reprendre cible";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "Voulez-vous vraiment supprimer l'ensemble d'équipement %s ?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "Voulez-vous sauvegarder l'ensemble d'équipement %s ?";
|
||||
DEBUFF_SYMBOL_CURSE = "Mn";
|
||||
DEBUFF_SYMBOL_DISEASE = "Md";
|
||||
DEBUFF_SYMBOL_MAGIC = "Mg";
|
||||
DEBUFF_SYMBOL_POISON = "Po";
|
||||
DEBUG_FRAMESTACK = "Empilement des cadres";
|
||||
EQUIPMENT_MANAGER = "Gestionnaire d'équipement";
|
||||
EQUIPMENT_MANAGER_IGNORE_SLOT = "Ignorer cet emplacement";
|
||||
EQUIPMENT_MANAGER_IS_DISABLED = "Le Gestionnaire d'équipement est désactivé.";
|
||||
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(objet manquant : %d)|r";
|
||||
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Mettre en sac";
|
||||
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Inclure cet emplacement";
|
||||
EQUIPMENT_SETS = "Ensembles d'équipement : |cFFFFFFFF%s|r";
|
||||
EQUIPMENT_SETS_CANT_RENAME = "Un ensemble d'équipement portant ce nom existe déjà.";
|
||||
EQUIPMENT_SETS_TOO_MANY = "Vous ne pouvez plus créer de nouveaux ensembles d'équipement.";
|
||||
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Assigner à la spécialisation :";
|
||||
EQUIPMENT_SET_EDIT = "Modifier le nom/l'icône";
|
||||
EQUIPMENT_SET_SETTINGS = "Réglages";
|
||||
EQUIPSET_EQUIP = "Equiper";
|
||||
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Échec de l'échange d'équipement. L'inventaire est plein.";
|
||||
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Certains objets de l'ensemble %s ne peuvent pas être échangés car vous êtes en combat.";
|
||||
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Une ou plusieurs pièces de l'ensemble %s manquaient et n'ont pu être équipées.";
|
||||
CLEAR_FOCUS = "Effacer la focalisation";
|
||||
SET_FOCUS = "Définir la focalisation";
|
||||
FOCUSTARGET = "Définir la cible de focalisation";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4objet:objets;";
|
||||
ITEMS_EQUIPPED = "%d |4objet:objets; équipés";
|
||||
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
|
||||
TARGETFOCUS = "Cibler l'unité focalisée";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "c";
|
||||
GEARSETS_POPUP_TEXT = "Nom de l'ensemble (16 caractères max.) :";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "o";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "Toutes les icônes";
|
||||
ICON_FILTER_ITEM = "Objets";
|
||||
ICON_FILTER_SPELL = "Sorts";
|
||||
ICON_SELECTION_TITLE_CURRENT = "Sélection actuelle";
|
||||
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
|
||||
ITEM_MOD_MANA_SHORT = "Mana";
|
||||
ITEM_MOD_HEALTH_SHORT = "Vie";
|
||||
@@ -42,7 +72,11 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PAPERDOLL_EQUIPMENTMANAGER = "Gestionnaire d'équipement";
|
||||
PAPERDOLL_NEWEQUIPMENTSET = "Nouvel ensemble";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Écrire une note pour %s :";
|
||||
SET_NOTE = "Écrire une note";
|
||||
SILVER_AMOUNT_SYMBOL = "a";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/annulerfocal";
|
||||
|
||||
@@ -2,6 +2,31 @@ if GetLocale() ~= "ruRU" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "Запомненная цель";
|
||||
BINDING_NAME_TARGETFOCUS = "Восстановить цель";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "Вы действительно хотите удалить комплект экипировки \"%s\"?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "Хотите сохранить комплект экипировки \"%s\"?";
|
||||
DEBUFF_SYMBOL_CURSE = "Пр";
|
||||
DEBUFF_SYMBOL_DISEASE = "Бз";
|
||||
DEBUFF_SYMBOL_MAGIC = "Мг";
|
||||
DEBUFF_SYMBOL_POISON = "Яд";
|
||||
DEBUG_FRAMESTACK = "Соединение рамок";
|
||||
EQUIPMENT_MANAGER = "Комплекты экипировки";
|
||||
EQUIPMENT_MANAGER_IGNORE_SLOT = "Пропустить ячейку";
|
||||
EQUIPMENT_MANAGER_IS_DISABLED = "Управление экипировкой отключено.";
|
||||
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(предмет для ячейки \"%d\" отсутствует)|r";
|
||||
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "Поместить в сумку";
|
||||
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "Учитывать ячейку";
|
||||
EQUIPMENT_SETS = "Комплекты экипировки: |cFFFFFFFF%s|r";
|
||||
EQUIPMENT_SETS_CANT_RENAME = "Это название уже используется для другого комплекта.";
|
||||
EQUIPMENT_SETS_TOO_MANY = "Вы не можете создавать новые комплекты экипировки.";
|
||||
EQUIPMENT_SET_ASSIGN_TO_SPEC = "Выбрать специализацию:";
|
||||
EQUIPMENT_SET_EDIT = "Изменить название/значок";
|
||||
EQUIPMENT_SET_SETTINGS = "Параметры";
|
||||
EQUIPSET_EQUIP = "Надеть";
|
||||
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "Не удалось заменить комплект обмундирования. Инвентарь переполнен.";
|
||||
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "Некоторые предметы из набора \"%s\" не будут экипированы, поскольку вы участвуете в бою.";
|
||||
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "Недостает одного или более предметов из набора \"%s\".";
|
||||
CLEAR_FOCUS = "Отменить запоминание цели";
|
||||
SET_FOCUS = "Запомнить цель";
|
||||
FOCUSTARGET = "Запомнить цель";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d |4предмет:предмета:предметов;";
|
||||
ITEMS_EQUIPPED = "%d |4предмет экипирован:предмета экипировано:предметов экипировано;";
|
||||
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предм
|
||||
TARGETFOCUS = "Восстановить цель";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "м";
|
||||
GEARSETS_POPUP_TEXT = "Введите название комплекта (не более 16 символов):";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "з";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "Все значки";
|
||||
ICON_FILTER_ITEM = "Предметы";
|
||||
ICON_FILTER_SPELL = "Заклинания";
|
||||
ICON_SELECTION_TITLE_CURRENT = "Текущий выбор";
|
||||
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
|
||||
ITEM_MOD_MANA_SHORT = "к мане";
|
||||
ITEM_MOD_HEALTH_SHORT = "к здоровью";
|
||||
@@ -42,7 +72,11 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
|
||||
LARGE_NUMBER_SEPERATOR = "";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PAPERDOLL_EQUIPMENTMANAGER = "Комплекты экипировки";
|
||||
PAPERDOLL_NEWEQUIPMENTSET = "Новый комплект";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "Заметка о |3-5(%s):";
|
||||
SET_NOTE = "Добавить заметку";
|
||||
SILVER_AMOUNT_SYMBOL = "с";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/сбросфокуса";
|
||||
|
||||
@@ -2,6 +2,31 @@ if GetLocale() ~= "zhCN" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "焦点目标";
|
||||
BINDING_NAME_TARGETFOCUS = "目标焦点";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "你确认要删除装备方案%s吗?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "你想要保存装备方案\"%s\"吗?";
|
||||
DEBUFF_SYMBOL_CURSE = "诅";
|
||||
DEBUFF_SYMBOL_DISEASE = "疾";
|
||||
DEBUFF_SYMBOL_MAGIC = "魔";
|
||||
DEBUFF_SYMBOL_POISON = "毒";
|
||||
DEBUG_FRAMESTACK = "框架栈";
|
||||
EQUIPMENT_MANAGER = "装备管理";
|
||||
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此栏位";
|
||||
EQUIPMENT_MANAGER_IS_DISABLED = "装备管理已禁用。";
|
||||
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d %s |cffff0000(缺少%d)|r";
|
||||
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放入背包";
|
||||
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包括此栏位";
|
||||
EQUIPMENT_SETS = "装备配置方案:|cFFFFFFFF%s|r";
|
||||
EQUIPMENT_SETS_CANT_RENAME = "一个重名的装备方案已经存在。";
|
||||
EQUIPMENT_SETS_TOO_MANY = "你不能再创建新的装备方案了。";
|
||||
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定专精:";
|
||||
EQUIPMENT_SET_EDIT = "修改名称/图标";
|
||||
EQUIPMENT_SET_SETTINGS = "设置";
|
||||
EQUIPSET_EQUIP = "装备";
|
||||
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "装备更换失败。背包已满。";
|
||||
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "装备方案%s中的某些物品不能在战斗中切换。";
|
||||
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "未找到套装方案%s中的一件或多件物品,无法装备。";
|
||||
CLEAR_FOCUS = "清除焦点";
|
||||
SET_FOCUS = "设置焦点";
|
||||
FOCUSTARGET = "设定焦点目标";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d件物品";
|
||||
ITEMS_EQUIPPED = "已装备%d件物品";
|
||||
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "选中焦点目标";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "铜";
|
||||
GEARSETS_POPUP_TEXT = "输入方案名称(最多16个字符):";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "金";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "所有图标";
|
||||
ICON_FILTER_ITEM = "物品";
|
||||
ICON_FILTER_SPELL = "法术";
|
||||
ICON_SELECTION_TITLE_CURRENT = "当前已选择";
|
||||
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
|
||||
ITEM_MOD_MANA_SHORT = "法力值";
|
||||
ITEM_MOD_HEALTH_SHORT = "生命值";
|
||||
@@ -42,7 +72,11 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PAPERDOLL_EQUIPMENTMANAGER = "装备管理";
|
||||
PAPERDOLL_NEWEQUIPMENTSET = "新的方案";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "为%s设置备注:";
|
||||
SET_NOTE = "设置备注";
|
||||
SILVER_AMOUNT_SYMBOL = "银";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/clearfocus";
|
||||
|
||||
@@ -2,6 +2,31 @@ if GetLocale() ~= "zhTW" then return end
|
||||
|
||||
BINDING_NAME_FOCUSTARGET = "鎖定目標";
|
||||
BINDING_NAME_TARGETFOCUS = "選定鎖定目標";
|
||||
CONFIRM_DELETE_EQUIPMENT_SET = "你確定要刪除裝備設定:%s嗎?";
|
||||
CONFIRM_SAVE_EQUIPMENT_SET = "你想要儲存裝備設定「%s」嗎?";
|
||||
DEBUFF_SYMBOL_CURSE = "咒";
|
||||
DEBUFF_SYMBOL_DISEASE = "疾";
|
||||
DEBUFF_SYMBOL_MAGIC = "魔";
|
||||
DEBUFF_SYMBOL_POISON = "毒";
|
||||
DEBUG_FRAMESTACK = "框架堆疊";
|
||||
EQUIPMENT_MANAGER = "裝備管理員";
|
||||
EQUIPMENT_MANAGER_IGNORE_SLOT = "忽略此欄位";
|
||||
EQUIPMENT_MANAGER_IS_DISABLED = "裝備管理員已關閉。";
|
||||
EQUIPMENT_MANAGER_ITEMS_MISSING_TOOLTIP = "%d件%s|cffff0000(缺少%d件)|r";
|
||||
EQUIPMENT_MANAGER_PLACE_IN_BAGS = "放進背包";
|
||||
EQUIPMENT_MANAGER_UNIGNORE_SLOT = "包含此欄位";
|
||||
EQUIPMENT_SETS = "裝備設定:|cFFFFFFFF%s|r";
|
||||
EQUIPMENT_SETS_CANT_RENAME = "您所選擇的名稱已經有裝備設定使用了。";
|
||||
EQUIPMENT_SETS_TOO_MANY = "你無法製造更多的新裝備設定。";
|
||||
EQUIPMENT_SET_ASSIGN_TO_SPEC = "指定專精:";
|
||||
EQUIPMENT_SET_EDIT = "改變名稱/圖示";
|
||||
EQUIPMENT_SET_SETTINGS = "設定";
|
||||
EQUIPSET_EQUIP = "裝備";
|
||||
ERR_EQUIPMENT_MANAGER_BAGS_FULL = "裝備切換失敗。物品欄已滿。";
|
||||
ERR_EQUIPMENT_MANAGER_COMBAT_SWAP_S = "%s設定中的某些物品必須等到你脫離戰鬥後才能更換。";
|
||||
ERR_EQUIPMENT_MANAGER_MISSING_ITEM_S = "%s設定中的一個或多個物品未尋獲,因此無法裝備。";
|
||||
CLEAR_FOCUS = "清除專注目標";
|
||||
SET_FOCUS = "設定專注目標";
|
||||
FOCUSTARGET = "設定鎖定目標";
|
||||
ITEMS_VARIABLE_QUANTITY = "%d件物品";
|
||||
ITEMS_EQUIPPED = "裝備%d件物品";
|
||||
@@ -12,9 +37,14 @@ ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
|
||||
TARGETFOCUS = "選定鎖定單位";
|
||||
-- BINDING_NAME_AOELOOT = "AoE Loot";
|
||||
COPPER_AMOUNT_SYMBOL = "銅";
|
||||
GEARSETS_POPUP_TEXT = "輸入設定名稱(最多16個字元):";
|
||||
GENERIC_FRACTION_STRING = "%d/%d";
|
||||
GOLD_AMOUNT_SYMBOL = "金";
|
||||
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
|
||||
ICON_FILTER_ALL = "所有圖示";
|
||||
ICON_FILTER_ITEM = "物品";
|
||||
ICON_FILTER_SPELL = "法術";
|
||||
ICON_SELECTION_TITLE_CURRENT = "目前選擇";
|
||||
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
|
||||
ITEM_MOD_MANA_SHORT = "法力";
|
||||
ITEM_MOD_HEALTH_SHORT = "生命力";
|
||||
@@ -42,7 +72,11 @@ ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
|
||||
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
|
||||
LARGE_NUMBER_SEPERATOR = ",";
|
||||
MINUTES_SECONDS = "%.2d:%.2d";
|
||||
PAPERDOLL_EQUIPMENTMANAGER = "裝備管理員";
|
||||
PAPERDOLL_NEWEQUIPMENTSET = "新設定";
|
||||
PERCENTAGE_STRING = "%d%%";
|
||||
SET_FRIENDNOTE_LABEL = "設定%s的註記:";
|
||||
SET_NOTE = "設定註記";
|
||||
SILVER_AMOUNT_SYMBOL = "銀";
|
||||
SLASH_CLEARFOCUS1 = "/clearfocus";
|
||||
SLASH_CLEARFOCUS2 = "/清除專注目標";
|
||||
|
||||
@@ -155,25 +155,6 @@ _initFrame:SetScript("OnEvent", function()
|
||||
end
|
||||
end);
|
||||
|
||||
-- Vanilla has no Frame:IsMouseOver(); do it ourselves from cursor + bounds.
|
||||
local function DebugTools_IsMouseOver(frame)
|
||||
if (not frame) then return false; end
|
||||
local visOK, visible = pcall(frame.IsVisible, frame);
|
||||
if (not visOK or not visible) then return false; end
|
||||
local sOK, scale = pcall(frame.GetEffectiveScale, frame);
|
||||
if (not sOK or not scale or scale == 0) then return false; end
|
||||
local cx, cy = GetCursorPosition();
|
||||
cx = cx / scale;
|
||||
cy = cy / scale;
|
||||
local lOK, left = pcall(frame.GetLeft, frame);
|
||||
local rOK, right = pcall(frame.GetRight, frame);
|
||||
local tOK, top = pcall(frame.GetTop, frame);
|
||||
local bOK, bottom = pcall(frame.GetBottom, frame);
|
||||
if (not (lOK and rOK and tOK and bOK)) then return false; end
|
||||
if (not (left and right and top and bottom)) then return false; end
|
||||
return cx >= left and cx <= right and cy >= bottom and cy <= top;
|
||||
end
|
||||
|
||||
local _EventTraceFrame;
|
||||
|
||||
_framesSinceLast = 0;
|
||||
@@ -566,7 +547,7 @@ function EventTraceFrame_Update()
|
||||
button:GetHighlightTexture():SetVertexColor(.8, .8, 1, .15);
|
||||
button:UnlockHighlight();
|
||||
end
|
||||
if (DebugTools_IsMouseOver(button)) then
|
||||
if MouseIsOver(button) then
|
||||
EventTraceFrameEvent_OnEnter(button);
|
||||
end
|
||||
end
|
||||
@@ -1016,8 +997,8 @@ local function _CollectFramesUnderMouse(showHidden)
|
||||
local frame = EnumerateFrames(nil);
|
||||
while (frame) do
|
||||
local visOK, visible = pcall(frame.IsVisible, frame);
|
||||
local mouseOver = DebugTools_IsMouseOver(frame);
|
||||
if (mouseOver and (showHidden or (visOK and visible))) then
|
||||
local overOK, mouseOver = pcall(frame.IsMouseOver, frame);
|
||||
if (overOK and mouseOver and (showHidden or (visOK and visible))) then
|
||||
local nameOK, name = pcall(frame.GetName, frame);
|
||||
if (not nameOK or not name) then name = "<anonymous>"; end
|
||||
table.insert(matches, { name = name, frame = frame });
|
||||
@@ -1037,7 +1018,7 @@ end
|
||||
|
||||
local function _UpdateFrameStackTooltip(tooltip, showHidden)
|
||||
tooltip:ClearLines();
|
||||
tooltip:AddLine("Frame Stack", 1, 1, 1);
|
||||
tooltip:AddLine(DEBUG_FRAMESTACK or "Frame Stack", 1, 1, 1);
|
||||
local matches = _CollectFramesUnderMouse(showHidden);
|
||||
local count = table.getn(matches);
|
||||
if (count == 0) then
|
||||
|
||||
@@ -456,14 +456,11 @@
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<!-- Breadcrumb showing the active navigation path,
|
||||
e.g. "_G > MyFrame > backdrop". Updated each
|
||||
`TableInspectorFrame_Update` tick. -->
|
||||
<FontString name="$parentBreadcrumb" inherits="GameFontHighlightSmall" justifyH="LEFT">
|
||||
<Size x="380" y="16"/>
|
||||
<Size x="300" y="16"/>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT">
|
||||
<Offset x="80" y="-34"/>
|
||||
<Offset x="156" y="-34"/>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
@@ -510,6 +507,17 @@
|
||||
<OnClick>TableInspector_Back();</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="$parentRefreshButton" inherits="UIPanelButtonTemplate" text="Refresh">
|
||||
<Size x="70" y="20"/>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="$parentBackButton" relativePoint="RIGHT">
|
||||
<Offset x="4" y="0"/>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>TableInspector_Refresh();</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Slider name="$parentScroll" orientation="VERTICAL">
|
||||
<Size x="16" y="0"/>
|
||||
<Anchors>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
-- TableInspector_Show(tbl, label) -- open with `tbl` as root, `label` shown in breadcrumb
|
||||
-- TableInspector_Push(tbl, label) -- drill in (called by row click)
|
||||
-- TableInspector_Back() -- pop one level
|
||||
-- TableInspector_Refresh() -- re-read the current level's live table (scroll kept)
|
||||
-- TableInspector_Close() -- hide the window
|
||||
-- /tinspect <luaexpr> -- evaluate `<luaexpr>` as Lua and open if result is a table
|
||||
------------------------------------------------------------------------------
|
||||
@@ -184,6 +185,13 @@ function TableInspector_Back()
|
||||
TableInspectorFrame_Update();
|
||||
end
|
||||
|
||||
function TableInspector_Refresh()
|
||||
local top = _navStack[table.getn(_navStack)];
|
||||
if (not top or type(top.tbl) ~= "table") then return; end
|
||||
_RebuildEntries();
|
||||
TableInspectorFrame_Update();
|
||||
end
|
||||
|
||||
function TableInspector_Close()
|
||||
if (_TableInspectorFrame) then _TableInspectorFrame:Hide(); end
|
||||
_navStack = {};
|
||||
|
||||
+49
-29
@@ -25,6 +25,39 @@ set(TINYCBOR_SOURCES
|
||||
external/tinycbor/src/cborparser_float.c
|
||||
external/tinycbor/src/cborerrorstrings.c)
|
||||
|
||||
# Version derivation. Dev builds use a sentinel "newer than every
|
||||
# plausible release" so addon-side `CLASSIC_API_VERSION >= X` gates
|
||||
# always pass locally. CI release builds pass `-DCLASSICAPI_TAG=vX.Y.Z`,
|
||||
# which drives BOTH the compiled `CLASSIC_API_VERSION` int AND the
|
||||
# embedded addon's `## Version:` toc line (substituted in
|
||||
# embed_addon.cmake). The on-disk source toc stays `## Version: DEV`;
|
||||
# only the embedded copy is stamped, so a release build needs nothing
|
||||
# but the flag — no source edit.
|
||||
set(CLASSICAPI_VERSION_VALUE 99999999)
|
||||
set(CLASSICAPI_VER_MAJOR 9999)
|
||||
set(CLASSICAPI_VER_MINOR 99)
|
||||
set(CLASSICAPI_VER_PATCH 99)
|
||||
set(CLASSICAPI_TOC_VERSION "DEV")
|
||||
|
||||
set(CLASSICAPI_TAG
|
||||
""
|
||||
CACHE STRING "Release tag like v1.2.3")
|
||||
|
||||
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
|
||||
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
|
||||
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
|
||||
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
|
||||
math(EXPR CLASSICAPI_VERSION_VALUE
|
||||
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
|
||||
set(CLASSICAPI_TOC_VERSION
|
||||
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}")
|
||||
elseif(CLASSICAPI_TAG)
|
||||
message(
|
||||
FATAL_ERROR
|
||||
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
|
||||
)
|
||||
endif()
|
||||
|
||||
# Bundle `AddOns/!!!ClassicAPI/` into a generated header so the DLL can
|
||||
# surface the addon to the engine even when the user doesn't have it
|
||||
# installed on disk. The file-read hook in `src/addons/Embedded.cpp`
|
||||
@@ -34,12 +67,16 @@ set(EMBEDDED_CLASSICAPI_SRC "${CMAKE_CURRENT_SOURCE_DIR}/AddOns/!!!ClassicAPI")
|
||||
set(EMBEDDED_CLASSICAPI_HEADER "${CMAKE_BINARY_DIR}/generated/embedded_classicapi.h")
|
||||
file(GLOB_RECURSE EMBEDDED_CLASSICAPI_FILES CONFIGURE_DEPENDS
|
||||
"${EMBEDDED_CLASSICAPI_SRC}/*")
|
||||
# The `.classicapi-dev` marker is a disk-only developer signal — keep it
|
||||
# out of the embed inputs (embed_addon.cmake filters it too).
|
||||
list(FILTER EMBEDDED_CLASSICAPI_FILES EXCLUDE REGEX "/\\.classicapi-dev$")
|
||||
add_custom_command(
|
||||
OUTPUT "${EMBEDDED_CLASSICAPI_HEADER}"
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DSRC_DIR=${EMBEDDED_CLASSICAPI_SRC}
|
||||
-DOUT_HEADER=${EMBEDDED_CLASSICAPI_HEADER}
|
||||
-DADDON_NAME=ClassicAPI
|
||||
-DADDON_VERSION=${CLASSICAPI_TOC_VERSION}
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed_addon.cmake"
|
||||
DEPENDS
|
||||
${EMBEDDED_CLASSICAPI_FILES}
|
||||
@@ -71,33 +108,6 @@ add_library(${PROJECT_NAME} SHARED
|
||||
${EMBEDDED_CLASSICAPI_HEADER}
|
||||
${EMBEDDED_BINDINGS_HEADER})
|
||||
|
||||
# Dev-build sentinel — represents "newer than every plausible
|
||||
# release". Encoded as v9999.99.99 = 9999 * 10000 + 99 * 100 + 99 =
|
||||
# 99,999,999. Picked so addon-side feature gates of the form
|
||||
# `CLASSIC_API_VERSION >= X` always pass during local development;
|
||||
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
|
||||
set(CLASSICAPI_VERSION_VALUE 99999999)
|
||||
set(CLASSICAPI_VER_MAJOR 9999)
|
||||
set(CLASSICAPI_VER_MINOR 99)
|
||||
set(CLASSICAPI_VER_PATCH 99)
|
||||
|
||||
set(CLASSICAPI_TAG
|
||||
""
|
||||
CACHE STRING "Release tag like v1.2.3")
|
||||
|
||||
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
|
||||
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
|
||||
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
|
||||
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
|
||||
math(EXPR CLASSICAPI_VERSION_VALUE
|
||||
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
|
||||
elseif(CLASSICAPI_TAG)
|
||||
message(
|
||||
FATAL_ERROR
|
||||
"Invalid CLASSICAPI_TAG: ${CLASSICAPI_TAG} (expected vMAJOR.MINOR.PATCH)"
|
||||
)
|
||||
endif()
|
||||
|
||||
target_compile_definitions(
|
||||
${PROJECT_NAME}
|
||||
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
|
||||
@@ -118,7 +128,9 @@ target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
|
||||
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
|
||||
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
|
||||
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
|
||||
# user32: Win32 clipboard API (OpenClipboard / SetClipboardData) for
|
||||
# CopyToClipboard in src/clipboard/Copy.cpp.
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32 user32)
|
||||
|
||||
target_include_directories(
|
||||
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
@@ -132,5 +144,13 @@ set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "ClassicAPI")
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /MT")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT /O2 /Ob2 /DNDEBUG")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS
|
||||
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT"
|
||||
# /MAP emits build/Release/ClassicAPI.map — a linker-only output (no effect
|
||||
# on code layout) that maps `0001:offset` addresses from WoW's ERROR #132
|
||||
# crash dumps back to functions. Published as a build/release artifact so
|
||||
# any crash from a shipped DLL can be triaged exactly. (/OPT:ICF folds
|
||||
# identical functions, so a few map symbols share an address — harmless.)
|
||||
# /DEF exports `Load` (VanillaFixes' post-injection entry point) undecorated
|
||||
# so its GetProcAddress(module, "Load") resolves. See src/ClassicAPI.def and
|
||||
# the loader-lock rationale in src/DllMain.cpp.
|
||||
"/OPT:REF /OPT:ICF /MERGE:.rdata=.text /MERGE:.pdata=.text /SECTION:.text,EWR /NODEFAULTLIB:MSVCRT /MAP /DEF:${CMAKE_CURRENT_SOURCE_DIR}/src/ClassicAPI.def"
|
||||
)
|
||||
|
||||
@@ -20,33 +20,38 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [Action](docs/API.md#action) | `GetActionInfo` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
|
||||
| [Bindings](docs/API.md#bindings) | `SetBindingSpell`, `SetBindingItem`, `SetBindingMacro`, `SetBindingClick`, `SetOverrideBinding`, `SetOverrideBindingSpell`, `SetOverrideBindingItem`, `SetOverrideBindingMacro`, `SetOverrideBindingClick`, `ClearOverrideBindings` |
|
||||
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
|
||||
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
|
||||
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
|
||||
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
|
||||
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
|
||||
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
|
||||
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
|
||||
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
|
||||
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
|
||||
| [Currency](docs/API.md#currency) | `GetCoinTextureString`, `C_CurrencyInfo.GetCoinTextureString` |
|
||||
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `fontstring:GetStringHeight`, `fontstring:GetUnboundedStringWidth`, `fontstring:GetWrappedWidth`, `fontstring:GetNumLines`, `fontstring:GetLineHeight`, `fontstring:IsTruncated`, `fontstring:SetMaxLines`, `fontstring:GetMaxLines`, `fontstring:SetFormattedText`, `texture:SetRotation`, `texture:GetRotation`, `texture:SetVertexOffset`, `texture:GetVertexOffset`, `texture:SetColorTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs` |
|
||||
| [FriendList](docs/API.md#friendlist) | `C_FriendList.GetFriendInfo`, `C_FriendList.GetFriendInfoByIndex`, `C_FriendList.GetNumFriends`, `C_FriendList.GetNumOnlineFriends`, `C_FriendList.GetNumWhoResults`, `C_FriendList.GetWhoInfo`, `C_FriendList.IsFriend`, `C_FriendList.IsIgnored`, `C_FriendList.IsIgnoredByGuid`, `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName`, `C_FriendList.SetFriendNotes`, `C_FriendList.SetFriendNotesByIndex` |
|
||||
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
|
||||
| [Glue](docs/API.md#glue) | `C_Glue.IsOnGlueScreen` |
|
||||
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
|
||||
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
|
||||
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
|
||||
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
|
||||
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
|
||||
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfo`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
|
||||
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.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` |
|
||||
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `math.fmod`, `select`, `string.gmatch`, `string.match`, `table.wipe` |
|
||||
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
|
||||
| [Macros](docs/API.md#macros) | `C_Macro.CreateMacro`, `C_Macro.EditMacro`, `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
|
||||
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
|
||||
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
|
||||
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
|
||||
@@ -56,17 +61,19 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
|
||||
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
|
||||
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
|
||||
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
|
||||
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
|
||||
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellDispelType`, `C_Spell.GetSpellEffectMechanics`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsNextMeleeSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.ResetsMeleeSwing`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
|
||||
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellBookItemInfo`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
|
||||
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsIndoors`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsOutdoors`, `IsStealthed`, `IsSwimming` |
|
||||
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
|
||||
| [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` |
|
||||
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
|
||||
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
|
||||
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime`, `GetTimeCached` |
|
||||
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPosition`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `UnitSpellTargetName`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
|
||||
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration` |
|
||||
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
|
||||
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
|
||||
|
||||
@@ -86,6 +93,7 @@ the glue state because GlueXML had no way to reach them otherwise.
|
||||
| [Account](docs/API.md#account) | `SaveAccount`, `DeleteAccount`, `GetSavedAccounts`, `LoginWithSavedAccount` (passwords encrypted in Windows Credential Manager, scoped per realmlist; plaintext never returned to Lua) |
|
||||
| [CharacterList](docs/API.md#characterlist) | `GetSavedCharacterOrder`, `SetSavedCharacterOrder` (persist to `WTF\Account\...\ClassicAPI.txt`) |
|
||||
| CVar | `GetCVar`, `SetCVar`, `RegisterCVar`, `GetCVarDefault`, `C_CVar.GetCVarBool` (storage is process-global — writes from glue are visible in-world and vice versa) |
|
||||
| [Glue](docs/API.md#glue) | `C_Glue.IsFirstLoadThisSession`, `C_Glue.IsOnGlueScreen` |
|
||||
| Script | `RunScript` (compile and run a Lua chunk in the glue state's globals — useful for slash-command-style helpers in GlueXML) |
|
||||
| State | `IsLoggedIn` |
|
||||
|
||||
@@ -144,10 +152,24 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `PLAYER_STOPPED_MOVING` | *(none)* |
|
||||
| `PLAYER_STARTED_TURNING` | *(none)* |
|
||||
| `PLAYER_STOPPED_TURNING` | *(none)* |
|
||||
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
|
||||
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
|
||||
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
|
||||
| `QUEST_REMOVED` | `questID` |
|
||||
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
|
||||
| `UNIT_SPELLCAST_SENT` | `"player", target, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_START` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_STOP` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_DELAYED` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_SUCCEEDED` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_INTERRUPTED` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_FAILED` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_FAILED_QUIET` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_START` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_UPDATE` | `"player", castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_CHANNEL_STOP` | `unit, castGUID, spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_RETICLE_TARGET` | `"player", "", spellID, spellName, rank` |
|
||||
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
|
||||
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
@@ -166,6 +188,10 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
|
||||
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
|
||||
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
|
||||
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
|
||||
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
|
||||
| Spellbook bank | `Enum.SpellBookSpellBank.{Player,Pet}` (0–1) |
|
||||
| Spellbook item type | `Enum.SpellBookItemType.{None,Spell,FutureSpell,PetAction,Flyout}` (0–4; 1.12 only yields `Spell`/`PetAction`) |
|
||||
|
||||
### Unit tokens
|
||||
|
||||
@@ -173,9 +199,21 @@ when launching with `-console`), not as Lua functions. See the
|
||||
|-------|-------------|
|
||||
| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
|
||||
| `focus` / `focustarget` | Sticky target set via [`FocusUnit`](docs/API.md#focusunitunit), cleared via [`ClearFocus`](docs/API.md#clearfocus). Same `UnitX` coverage as `nameplateN`. Fires [`PLAYER_FOCUS_CHANGED`](docs/API.md#player_focus_changed-event) on transition. See [Focus](docs/API.md#focus). |
|
||||
| `mark1`..`mark8` | Unit currently wearing the Nth raid-target marker (`mark1` = star … `mark8` = skull). Same `UnitX` coverage as `nameplateN`, and fires unit events (`UNIT_HEALTH`, `UNIT_AURA`, …) with `arg1 == "markN"`. `UnitExists("markN")` is `false` when the marker is unset, out of range, or on a non-unit. Suffix chains (`mark1target`) compose. See [Unit tokens (`markN`)](docs/API.md#unit-tokens-markn). |
|
||||
|
||||
### Bindings
|
||||
|
||||
ClassicAPI backports the later-client direct-action and temporary override
|
||||
binding families. Permanent bindings use the standard `SPELL`, `ITEM`,
|
||||
`MACRO`, and `CLICK` command strings and can be saved normally. Overrides are
|
||||
session-only, frame-owned, and support the usual priority flag.
|
||||
|
||||
See the [Bindings API reference](docs/API.md#bindings) for signatures,
|
||||
precedence, execution behavior, macro-text usage, and 1.12 compatibility
|
||||
notes.
|
||||
|
||||
#### Predefined focus bindings
|
||||
|
||||
Injected into the engine's **Targeting Functions** group at FrameXML
|
||||
Bindings.xml load time, so they appear in the keybind UI alongside
|
||||
native targeting bindings instead of orphaned at the bottom.
|
||||
@@ -185,7 +223,8 @@ native targeting bindings instead of orphaned at the bottom.
|
||||
| `FOCUSTARGET` | `FocusUnit("target")` — pin current target as focus |
|
||||
| `TARGETFOCUS` | `TargetUnit("focus")` — switch target to the focus |
|
||||
|
||||
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
|
||||
See [Predefined focus bindings](docs/API.md#predefined-focus-bindings-focustarget--targetfocus)
|
||||
for the implementation note.
|
||||
|
||||
### Behaviors
|
||||
|
||||
@@ -194,6 +233,7 @@ Transparent engine tweaks — no API to call, they just fix a vanilla limitation
|
||||
| Tweak | Effect |
|
||||
|-------|--------|
|
||||
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips, the way 4.3.4+ clients do. Vanilla 1.12 shows the raw escape as literal text instead. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -216,36 +256,38 @@ The Lua-side companion library lives in
|
||||
[`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/). It's a 1.12.1 /
|
||||
Lua 5.0 backport of the modern Blizzard helpers that aren't engine
|
||||
functions but that consumer code still expects to find as globals —
|
||||
`Mixin` / `CreateFromMixins`, `CallbackRegistryMixin`, `EventRegistry`,
|
||||
`ColorMixin` + `CreateColor`, `Item` / `ItemLocation`, `MathUtil`
|
||||
(`Lerp` / `Clamp` / `CreateCounter`), `TableUtil` (`tCompare`,
|
||||
`MergeTable`, `SafePack`, etc.), and `EventUtil`
|
||||
`CallbackRegistryMixin`, `EventRegistry`, `ColorMixin` + `CreateColor`,
|
||||
`Item` / `ItemLocation`, `MathUtil`(`Lerp` / `Clamp` / `CreateCounter`),
|
||||
`TableUtil` (`tCompare`, `MergeTable`, `SafePack`, etc.), and `EventUtil`
|
||||
(`ContinueOnAddOnLoaded` etc.).
|
||||
|
||||
Some of the addon's surface depends on engine functions ClassicAPI
|
||||
provides (`C_UIColor.GetColors` populates the modern color globals,
|
||||
`C_Item.GetItemInfoInstant` powers `ItemMixin`, the
|
||||
`ITEM_DATA_LOAD_RESULT` event drives `ContinueOnItemLoad`); the rest
|
||||
is pure Lua and would work standalone. Without the DLL the addon
|
||||
still loads, but those features no-op.
|
||||
|
||||
The triple-`!` prefix is **load-order-significant** — WoW loads
|
||||
addons alphabetically, so `!!!ClassicAPI` is guaranteed to run before
|
||||
any consumer that depends on its globals. Don't rename the folder.
|
||||
|
||||
**You don't have to install the addon manually** — the DLL embeds
|
||||
the contents of [`AddOns/!!!ClassicAPI/`](AddOns/!!!ClassicAPI/) and
|
||||
registers them with the engine as a synthetic addon on startup, so
|
||||
the library is always available when the DLL is loaded. The engine
|
||||
sees it as a normal addon: it appears in `GetAddOnInfo`, fires
|
||||
`ADDON_LOADED`, supports `SavedVariables`, the works.
|
||||
the library is always available when the DLL is loaded. It fires
|
||||
`ADDON_LOADED`, supports `SavedVariables`, and is resolvable by name —
|
||||
`IsAddOnLoaded("!!!ClassicAPI")`, `GetAddOnInfo("!!!ClassicAPI")`, and
|
||||
`## Dependencies: !!!ClassicAPI` all work as usual.
|
||||
|
||||
If you do drop the folder into your `Interface/AddOns/` directory,
|
||||
**that copy wins** — the DLL's embedded version is only registered
|
||||
when the engine's normal scan doesn't already have an entry under
|
||||
that name. Useful for editing the Lua locally without rebuilding the
|
||||
DLL. The dispatch is transparent: addons consuming `Mixin`,
|
||||
`ColorMixin`, `TableUtil`, etc. behave identically in both cases.
|
||||
**It is intentionally always-on and cannot be disabled.** The library
|
||||
provides FrameXML-compat fixes that other addons and the DLL's own
|
||||
features rely on, so leaving it toggleable would let a user (or a
|
||||
one-off character-select uncheck) silently break them. The DLL
|
||||
therefore hides it from the character-select AddOns list and
|
||||
force-enables it every login — so it never appears as a checkbox and
|
||||
can't be switched off. Being hidden means it's omitted from the
|
||||
index-based `GetNumAddOns()` / `GetAddOnInfo(i)` enumeration, but every
|
||||
by-name lookup and dependency reference still resolves normally.
|
||||
|
||||
If you drop the folder into your `Interface/AddOns/` directory, **that
|
||||
copy's files win** — the DLL's embedded version only serves files when
|
||||
the engine's normal scan doesn't already have a newer entry under that
|
||||
name. Useful for editing the Lua locally without rebuilding the DLL.
|
||||
The hidden + force-enabled treatment applies either way (it's keyed to
|
||||
the addon name, not to which copy serves the files), so the local copy
|
||||
is also absent from the AddOns list. The dispatch is transparent:
|
||||
addons consuming `Mixin`, `ColorMixin`, `TableUtil`, etc. behave
|
||||
identically in both cases.
|
||||
|
||||
## Bundled addon: DebugTools
|
||||
|
||||
|
||||
+24
-1
@@ -34,6 +34,10 @@ if(NOT ADDON_NAME)
|
||||
endif()
|
||||
|
||||
file(GLOB_RECURSE INPUT_FILES RELATIVE "${SRC_DIR}" "${SRC_DIR}/*")
|
||||
# Never embed the developer marker — it must reflect on-disk presence
|
||||
# only (see src/addons/Embedded.cpp DiskHasDevMarker), and it is
|
||||
# gitignored so it only exists in a developer working tree anyway.
|
||||
list(FILTER INPUT_FILES EXCLUDE REGEX "(^|/)\\.classicapi-dev$")
|
||||
list(SORT INPUT_FILES) # deterministic codegen
|
||||
|
||||
set(BODY "")
|
||||
@@ -41,7 +45,26 @@ set(MANIFEST "")
|
||||
set(INDEX 0)
|
||||
foreach(REL_PATH ${INPUT_FILES})
|
||||
set(ABS_PATH "${SRC_DIR}/${REL_PATH}")
|
||||
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
|
||||
|
||||
# Stamp the addon's `## Version:` line from the build flag rather
|
||||
# than the committed source (which stays `DEV`). This is what makes
|
||||
# a release build a pure function of `-DCLASSICAPI_TAG` — the source
|
||||
# toc is never edited. Only the `.toc` file is rewritten, in-memory,
|
||||
# before hex-encoding; every other file is embedded verbatim.
|
||||
if(ADDON_VERSION AND REL_PATH MATCHES "\\.toc$")
|
||||
file(READ "${ABS_PATH}" TOC_TEXT)
|
||||
string(REGEX REPLACE "## Version:[^\n\r]*"
|
||||
"## Version: ${ADDON_VERSION}" TOC_TEXT "${TOC_TEXT}")
|
||||
# Round-trip through a temp file to hex-encode the modified text
|
||||
# (portable to CMake 3.10; avoids requiring string(HEX), 3.18+).
|
||||
set(TOC_TMP "${OUT_HEADER}.toc.tmp")
|
||||
file(WRITE "${TOC_TMP}" "${TOC_TEXT}")
|
||||
file(READ "${TOC_TMP}" CONTENT_HEX HEX)
|
||||
file(REMOVE "${TOC_TMP}")
|
||||
else()
|
||||
file(READ "${ABS_PATH}" CONTENT_HEX HEX)
|
||||
endif()
|
||||
|
||||
string(LENGTH "${CONTENT_HEX}" HEX_LEN)
|
||||
math(EXPR FILE_SIZE "${HEX_LEN} / 2")
|
||||
|
||||
|
||||
+3018
-163
File diff suppressed because it is too large
Load Diff
@@ -435,6 +435,17 @@ and other DLLs claim for custom events.
|
||||
|
||||
## Notes on specific entries
|
||||
|
||||
- **`[336] UPDATE_MOUSEOVER_UNIT` now fires on loss too (ClassicAPI).**
|
||||
Stock 1.12 fires this only when a mouseover unit is *gained*; moving
|
||||
the cursor off a unit clears the mouseover and signals nothing.
|
||||
ClassicAPI co-hooks the engine's mouseover set/clear chokepoint
|
||||
(`0x00492890`, the sole writer of the mouseover GUID) and fires
|
||||
`UPDATE_MOUSEOVER_UNIT` on the unit→non-unit transition, matching
|
||||
retail. Gated on the *old* mouseover being a unit and the *new* one
|
||||
not, so moving off a gameobject / item (which never fired a gain)
|
||||
doesn't spuriously fire. No payload either way — handlers read
|
||||
`UnitExists("mouseover")`, false at fire time on loss. See
|
||||
[API.md](API.md#update_mouseover_unit-event-loss-fire-fix).
|
||||
- **Repeated `UNIT_*` entries at low indices** (e.g. four slots named
|
||||
`UNIT_DAMAGE` at 128–131, five `UNIT_STATS` at 144–148, seven
|
||||
`UNIT_RESISTANCES` at 149–155) — these are the per-stat /
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
# Backporting inline `|T…|t` texture escape sequences to 1.12
|
||||
|
||||
The 1.12 text engine shows `|Tpath:height:width:…|t` markup as literal text.
|
||||
This project makes it draw the icon inline in FontStrings, chat, and tooltips —
|
||||
the way 4.3.4 and later clients do. The feature is complete and shipped
|
||||
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp),
|
||||
[src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)).
|
||||
|
||||
This file is the reverse-engineering map. **Current design** below states what
|
||||
ships today. Everything from **Goal & spec** down is the historical RE trail —
|
||||
the 4.3.4 study, the 1.12 injection map, and the bring-up plan. It is kept for
|
||||
the addresses and the reasoning, not as a description of the current code.
|
||||
|
||||
## Current design (shipped)
|
||||
|
||||
Each icon renders as an **engine-managed region** (a pooled `CSimpleTexture`)
|
||||
anchored to its owning FontString. The engine draws the region every frame and
|
||||
moves it with its line, so texture residency and scroll tracking are correct by
|
||||
construction. (A raw-GxU-quad renderer existed during bring-up and was removed
|
||||
in `56c2670`; its RE is the "REMOVED" section below.)
|
||||
|
||||
The DLL hooks the engine's own text pipeline — no companion addon:
|
||||
|
||||
- **Positioning** — a co-hook on the glyph emitter `FUN_005ccbe0` records each
|
||||
icon at its pen position and delegates the plain text runs to the engine
|
||||
(segmented delegation, below). The pen read-back needed a fix: the engine's
|
||||
pen is lazy and omits the last glyph's own advance, so a coin sat on its
|
||||
digits; the emitter now adds the true terminal advance from the engine's own
|
||||
glyph-advance helpers (`550cf5e`).
|
||||
- **Rendering** — the paint-pass co-hook `FUN_005c8fe0` walks the icon records
|
||||
and queues each to `Text::InlineTexturePool`, which places the region on the
|
||||
next frame tick (never mid-render).
|
||||
- **Placement coordinates** — text pen space IS render-target pixels. The
|
||||
engine stores node positions NORMALIZED (fs-local anchor ÷
|
||||
`[0x832A44]`/`[0x832A48]` per axis, `FUN_0041ade0`), and the origin finalize
|
||||
`FUN_005cdf70` multiplies by the integer raster dimensions
|
||||
`[0xC2B9A4]`/`[0xC2B9A0]` (rounded, with the justify shift and vertical
|
||||
align folded in). So the flush converts pen → fs-relative anchor with
|
||||
`K = raster ÷ anchorExtent`, **per axis**, from those four live globals — no
|
||||
calibration, no cache, correct at every resolution and UI scale (`968335d`).
|
||||
Two earlier forms both failed: an empirical K (`origin ÷ rect edge`) that
|
||||
chat could never self-derive (its rect edge sits near screen x = 0 and
|
||||
failed the divide guard) and so rode a global cache seeded by whichever
|
||||
fontstring derived first — right only at the layout it was calibrated on;
|
||||
then a closed form built from the SetPoint px factor, which used the wrong
|
||||
global pair. Full derivation chain at `VAR_TEXT_RASTER_X/Y` in `Offsets.h`.
|
||||
- **Fade / parent alpha** — icons fade with their line (the pool mirrors the
|
||||
fs's folded colour alpha per frame, `3ca3b7e`), and segmented GLYPHS do too:
|
||||
a bit-3 (standalone FontString) node's baked per-glyph colours never re-bake
|
||||
on an alpha change (the engine's colour-change invalidate `FUN_005ccb40`
|
||||
skips bit-3 nodes), so the flush mirrors the node's live alpha byte
|
||||
(node+0x2c — SetTextColor alpha × frame-chain alpha, kept folded by the
|
||||
engine) into every baked colour, change-detected per page (`bd14a93`). An
|
||||
earlier `0xFF` alpha force at emit was a misdiagnosis of a bubble that built
|
||||
while faded out; it froze icon-line text fully opaque while everything
|
||||
around it faded.
|
||||
- **Outlined fonts** — outline ink extends past the glyph advances; the
|
||||
engine itself grows line height by exactly 4.0 / 2.0 pen px for thick / thin
|
||||
outline (`face+0x180` bits 3 / 0, `.rdata` constants). `OutlineInkPen` reads
|
||||
the same flags and constants, and half the ink leads each icon, half trails
|
||||
— threaded through the shared advance helper so the emitter advance, the
|
||||
justify pre-shift, and the wrap-stepper probes all agree. Without it, coins
|
||||
clipped into outlined digits the moment the exact K removed the old
|
||||
miscalibration's accidental slack (`968335d`).
|
||||
- **Measure** — three cold FontString-level co-hooks make measure match the
|
||||
drawn result: width (`FUN_00772890`), height (`FUN_007729B0`), and wrap
|
||||
(`FUN_005C7260`). `GetStringWidth`, `fontstring:GetStringHeight`, wrap
|
||||
breaks, and line height all count the icon.
|
||||
- **Tall icons** — an icon taller than the font grows its line height, so it
|
||||
does not overlap its neighbors, and it centers in the grown line. A hyperlink
|
||||
that contains a tall icon is hoverable across the whole icon
|
||||
(`49934b0`, `b9d0705`).
|
||||
- **Chat fade** — icons fade in lockstep with their chat line (`3ca3b7e`).
|
||||
- **Editboxes** — an editbox keeps the raw markup, so the caret stays aligned
|
||||
(two complementary signals, below).
|
||||
- **Payload** — the full
|
||||
`|Tpath:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b|t`:
|
||||
size, pen offset, sprite-sheet crop, and vertex tint. BLP and uncompressed
|
||||
TGA.
|
||||
- **Anti-spoof** — chat strips player-injected `|T` icons.
|
||||
|
||||
Every fs-level measure path now counts icons: width (`FUN_00772890`), height
|
||||
(`FUN_007729B0`), wrap (`FUN_005C7260`), and the substring measure
|
||||
(`FUN_00772AE0` — an earlier note claimed nothing consumed it; its xrefs
|
||||
refute that: the GameTooltip auto-size measures each WRAPPED SEGMENT of a
|
||||
wrap-enabled line through it, so icon-bearing wrapped tooltip lines
|
||||
undersized their tooltip until it was hooked too). The Lua control surface is
|
||||
one kill switch, `_classicapi_InlineTexEnable` (the SEH latch trips it on a
|
||||
flush fault); the bring-up tune / stat / probe functions were removed
|
||||
(`5e5677b`, `6800a9a`).
|
||||
|
||||
## REMOVED (bring-up record) — the raw-GxU-quad rendering primitive
|
||||
|
||||
This was the FIRST renderer: each icon drawn as a raw textured quad through the
|
||||
text VB path. It shipped, then was removed in `56c2670` for the engine-region
|
||||
model (regions are resident by construction; the quad path needed the texture-
|
||||
residency machinery below and still flickered on VRAM pressure — see
|
||||
`docs/InlineTextureResidency.md`). This section is the address record for the
|
||||
removed code, not a description of the current renderer. `LoadTextureByPath`,
|
||||
`DrawTexturedQuad`, and the GxU device offsets it names are gone from
|
||||
`Offsets.h`; the values live here and in git history.
|
||||
|
||||
An arbitrary texture draws through the text VB path as a coloured, alpha-blended
|
||||
quad. The end-to-end recipe:
|
||||
|
||||
1. **Load by path** — `LoadTextureByPath` replicates `FUN_00770200`'s load: build
|
||||
flags via `FUN_0058a980(&flags, DAT_00878cf0, 0,0, 0,0,0, 1, 0)`, then
|
||||
`FUN_00449d90(path, &desc, flags, 0, 1)` with a byte-identical 5-dword desc
|
||||
(`{PTR_FUN_007ffa10, 8, &self8, (&self8)|1, 0}`). Returns the **HTEXTURE**
|
||||
(0x15c-byte struct; path@+0xc, flags@+0x110, refcount@+4).
|
||||
2. **THE KEY — residency + correct bind via `FUN_0044acf0(HTEXTURE, 1, 0)`.**
|
||||
This is the engine's own "get renderable texture" call (what the widget
|
||||
render uses every frame). It returns the bindable **CGxTexture** at
|
||||
`[HTEXTURE+0x140]` and, with force=1, drives the streaming load
|
||||
(`FUN_0044ad50` submits/prioritises it via `[HTEXTURE+0x138]`). **Calling it
|
||||
every frame is the live reference that makes the texture resident** — no
|
||||
hidden widget, no manual BLP decode. This was the entire multi-session
|
||||
blocker: WoW streams textures and only loads ones a live frame references; a
|
||||
standalone `FUN_00449d90` texture never loads on its own. Binding the raw
|
||||
HTEXTURE is `SetTexture([HTEXTURE+0x48]=0)` = a **white** quad; the D3D
|
||||
handle lives on the CGxTexture (`[CGxTexture+0x48]`), not the HTEXTURE.
|
||||
Dead ends that wasted time: manually invoking the decode callback
|
||||
`FUN_0044a260` (fills `[+0x120]` but not the bindable `+0x140`, and faults);
|
||||
binding `[HTEXTURE+0x48]` or `[HTEXTURE+0x120]` (both wrong offsets); forcing
|
||||
the streamable flag `tex[0x3c]|=0x80` (crashes in d3d9 — the bind's stream-in
|
||||
vfunc corrupts the resource).
|
||||
3. **Bind** — `FUN_00589e80(0x17, cgxTex)` (selector 0x17 = text texture stage).
|
||||
4. **Colour** — the font stage's default stage-0 COLOROP is already MODULATE, so
|
||||
`texture.rgb × diffuse(white)` = the icon's true colours; texture alpha works
|
||||
too. No colorop override is needed for a plain white vertex. (If a tint is
|
||||
wanted later: COLOROP is D3DTSS internal state `0x42` via `FUN_005a2570`; the
|
||||
engine leaves COLORARG1 at the D3D default = TEXTURE. Do NOT poke it directly
|
||||
mid-draw — it leaks `MODULATE` to the whole UI when a fault skips the restore.
|
||||
Use the engine's selector `0x1f` (value→COLOROP/ALPHAOP preset table at
|
||||
`DAT_0080a25c`/`DAT_0080a274`; value 1 = MODULATE/MODULATE).)
|
||||
5. **Quad** — lock the shared dynamic VB `FUN_0058a140(0, 0x18, 0x800)`, get the
|
||||
write ptr `FUN_0058a080`, write **4 verts** of the format
|
||||
`{float x,y,z; u32 colorBGRA; float u,v}` (stride 0x18) in corner order
|
||||
**TL, TR, BL, BR** (matches the shared quad index buffer `{0,1,2, 2,1,3}` and
|
||||
the device cull winding), submit `FUN_005c8f40(&handle, 4)`, unlock
|
||||
`FUN_0058a0a0(handle, 0)`.
|
||||
6. **Where** — co-hook the text paint pass `FUN_005c8fe0` and draw after the
|
||||
original glyph flush (device is in the correct textured-UI state there).
|
||||
Coordinate space is **per-layout pixels, y-down**; glyph verts live at small
|
||||
local coords (a probed glyph was at ~(9,33)–(17,50)). Draw coords must be
|
||||
within the layout's rendered area (a fixed (100,100) test lands in chat).
|
||||
|
||||
The build-side gotcha: the D3D device (default backend, `FUN_0058dd70`) is
|
||||
actually **OpenGL** (`glMaterialfv`/`glFogi`) — the D3D backend is `FUN_0058ba70`
|
||||
(`FUN_00598ce0`, vtable `PTR_FUN_00809ef8`); its state applier is `vtable[1]` =
|
||||
`FUN_005a2f00`, which maps engine selectors to D3D via `FUN_005a2570`
|
||||
(SetRenderState `+0xe4`, SetTextureStageState `+0x10c`, SetSamplerState `+0x114`,
|
||||
SetTexture `+0x104`). Selector map (shared across GL/D3D backends): 7=blend,
|
||||
0x10=depthtest, 0x14=cullface, 0x17-0x1e=texture bind (8 units),
|
||||
0x1f-0x26=colorop/alphaop preset (unit 0-7).
|
||||
|
||||
## SOLVED — positioning, measure/wrap, editbox exclusion, texcoords
|
||||
|
||||
The primitive draws at arbitrary coords; the rest is knowing WHERE and WHEN.
|
||||
Everything is in [src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp);
|
||||
offsets under the "Inline texture escape" + positioning blocks in
|
||||
[Offsets.h](../src/Offsets.h).
|
||||
|
||||
### The text render hierarchy (verified)
|
||||
|
||||
```
|
||||
frame → renderNode [frame+0x118] → group [renderNode+0x18]
|
||||
→ node [group+0x18] → line [node+0x24] ← the emitter's `this`
|
||||
```
|
||||
|
||||
- Main UI render `FUN_007657d0(DAT_00cf0bd8)` walks strata → frames, calls
|
||||
`FUN_0076fb00(renderNode)` per frame's render node.
|
||||
- `FUN_0076fb00` draws the frame's Texture regions then `FUN_005c1ef0(group)` →
|
||||
`FUN_005c8b70(group)` → `FUN_005c8fe0(node)` per node.
|
||||
- `FUN_005c8fe0(node)` = the **paint pass** we co-hook: ensures each line built
|
||||
(`FUN_005cd6a0` → `FUN_005cdc20` → the emitter), then binds each font page and
|
||||
copies the line's per-page glyph batches into the dynamic VB, translated by the
|
||||
line origin `[line+0x70]/[+0x74]`.
|
||||
- The **emitter** `FUN_005ccbe0(line, text, len, colorState, penXYZ, pageMask,
|
||||
linkState)` builds one wrapped line's glyph quads. `this` (the "line"/node the
|
||||
icon list keys on) owns batches `[+0xa0+page*4]` and origin `[+0x70]`. **Note
|
||||
the render node the emitter sees has NO back-pointer up to the frame** — the
|
||||
editbox discriminator had to be found another way (see below).
|
||||
|
||||
### Positioning — segmented delegation (co-hook the emitter)
|
||||
|
||||
We do NOT reimplement the emitter's intricate glyph vertex math. Instead, in the
|
||||
`FUN_005ccbe0` co-hook, for a line containing an inline texture:
|
||||
|
||||
1. Split the line at icon boundaries. Render each **plain run** by calling the
|
||||
ORIGINAL emitter on just that substring — the engine still draws every glyph.
|
||||
2. Thread the pen across runs: on return the emitter leaves the final node-local
|
||||
pen x in **`linkState[4]`**, stored with `FSTP dword` — a **FLOAT bit
|
||||
pattern**, not an int. Read it as a float (`*(float*)&linkState[4]`); an
|
||||
int-cast yields garbage (this bug hid the icon off-screen and dropped the
|
||||
trailing text).
|
||||
3. Record an `IconRecord` at the pen (node-local `x`,`y`) into `g_nodeIcons[line]`
|
||||
and advance the pen by the icon width. No draw runs during the build — icons
|
||||
are recorded and **flushed** in the `FUN_005c8fe0` paint co-hook, which
|
||||
computes each icon's pen-space position (line origin `[line+0x70]/[+0x74]`
|
||||
maps node-local → pen, the same translate the glyph copy `FUN_005c8710`
|
||||
applies), converts pen → anchor with the per-axis K (see **Placement
|
||||
coordinates** in Current design), and queues it to `Text::InlineTexturePool`
|
||||
as a placement RELATIVE TO THE OWNING FONTSTRING. The pool applies
|
||||
placements on the next frame tick.
|
||||
4. Restore `penXYZ[0]` on exit (the original never writes it; the draw builder
|
||||
does NOT reset it between left-justified lines, so leaving it mutated
|
||||
cascade-shifts every following line).
|
||||
|
||||
### FontString pipe-doubling — the escape arrives as `||T…||t`
|
||||
|
||||
The FontString text sanitizer **doubles any pipe that doesn't begin a recognized
|
||||
escape** so it renders as a literal `|`. 1.12 doesn't know `|T`, so a caller's
|
||||
`|Tpath:h|t` reaches the emitter as `||Tpath:h||t` (verified: emitter text length
|
||||
+2, pipes doubled). The detector therefore matches BOTH the doubled form (the
|
||||
real-world case) and a clean `|T` (should one ever arrive undoubled). Normal
|
||||
doubled pipes (`|| `) never match because the next char must be `T`.
|
||||
|
||||
### bit-3 batch-clear mode
|
||||
|
||||
The emitter clears the line's page batches at its top only when node flag
|
||||
`[line+0x5c] & 0x08` is set (the FontString/static case). Calling the original
|
||||
per segment would then wipe all but the last run. Handled by doing the clear
|
||||
ONCE per build (a `len==0` original call with bit 3 still set), then clearing
|
||||
bit 3 so the per-segment calls APPEND, restoring flags on exit. Bit 5 (shadow)
|
||||
isn't set for these nodes, so the only added work is a harmless colour append.
|
||||
|
||||
Clearing bit 3 has one more consequence: it switches the engine from the
|
||||
UNIFORM colour path (node+0x2c, consulted live at every paint) to baked
|
||||
PER-GLYPH colours the paint uses verbatim. The build-time alpha in colorState
|
||||
is already correct (the engine folds SetTextColor alpha × frame-chain alpha
|
||||
into node+0x2c on every alpha change), but the baked copies never refresh —
|
||||
the flush's alpha mirror keeps them tracking later fades. See **Fade / parent
|
||||
alpha** in Current design.
|
||||
|
||||
### Multi-line / multi-icon accumulation
|
||||
|
||||
The draw builder walks a node's wrapped lines by calling the emitter repeatedly
|
||||
on the SAME node, advancing the text pointer. So the icon list is cleared ONCE
|
||||
per build — on the first wrapped line, detected by `text == [node+0x48]` (the
|
||||
node's text start) — and icons ACCUMULATE across lines. Multiple icons per line
|
||||
fall out of the segmentation loop naturally.
|
||||
|
||||
### Vertical centring — font-relative
|
||||
|
||||
`penXYZ[1]` sits near the text TOP, so the icon centre = `penY + fontHeight *
|
||||
g_centerFrac` (0.6, calibrated in-game across chat + pfUI's bubble). Font height
|
||||
comes from the engine's own `FUN_005c6fa0(flag, [node+0x1c])` (the emitter's
|
||||
glyph-sizing call), so centring holds across font sizes without a fixed pixel
|
||||
nudge. A TALL icon (height > font) centres in its GROWN line (see the tall-icon
|
||||
sections in Current design) — the string-height co-hook adds the overflow, so
|
||||
the extra room sits half above and half below the text.
|
||||
|
||||
### Measure / wrap — co-hook the tokenizer
|
||||
|
||||
Line wrap is decided by the measure/fit functions (`FUN_005c6940`, `FUN_005c7470`
|
||||
via `FUN_005c7260`), which all share the `|`-tokenizer `FUN_005c2810`. Left
|
||||
alone, they count the hidden path text and wrap early. Fix: co-hook the
|
||||
tokenizer — when it's at an inline-texture span, consume the whole span as ONE
|
||||
near-zero-width token so every measure/fit caller ignores the path characters.
|
||||
The emitter detects icons independently (it scans the raw bytes), so the draw
|
||||
path is unaffected.
|
||||
|
||||
### Measure width: FIXED at the fs-level choke (`GetStringWidth` counts icons)
|
||||
|
||||
The tokenizer reports an icon span as a **near-zero-width** token, so at the
|
||||
gxu loop level every measure caller counts the icon as ~0 pixels wide. (The
|
||||
emitter still reserves the render width, so text after an icon positions
|
||||
correctly.) An earlier version of this doc concluded that the gap could not
|
||||
be closed cleanly, because the gxu engine has **no single cold choke**:
|
||||
|
||||
- `FUN_005c6940` (width), `FUN_005c7470`/`FUN_005c7260` (wrap), and the
|
||||
hit-test loop each tokenize and accrue width independently.
|
||||
- The token contract has no width field.
|
||||
- The per-glyph helpers (`FUN_005cabd0`/`FUN_005c6b70`) are the hottest text
|
||||
functions in the engine.
|
||||
|
||||
That analysis was right about the gxu level. The choke sits ONE LEVEL UP:
|
||||
**`FUN_00772890` = `CSimpleFontString::GetStringWidthInternal`**
|
||||
(`Offsets::FUN_FONTSTRING_STRING_WIDTH`) is cold, MinHook-safe, and the single
|
||||
funnel for exactly 4 callers:
|
||||
|
||||
- `Script_GetStringWidth` (0x0079E510)
|
||||
- the GameTooltip auto-size (0x00530640)
|
||||
- the editbox caret positioner (0x0077DE70)
|
||||
- the layout effective-width vmethod (`FUN_00772930` = fs+0x24 vtbl +0x1C,
|
||||
which feeds auto-width fontstring layout)
|
||||
|
||||
It lazily computes into the `fs+0xFC` cache (anchor units, 0.0 sentinel) via
|
||||
the measure core `FUN_0044D670`, then divides the pen-space result by
|
||||
`fs+0x7C`.
|
||||
|
||||
The co-hook lives in `src/text/InlineTexture.cpp`. It calls the original,
|
||||
then adds `SumIconAdvances(text) / K` when two gates pass:
|
||||
|
||||
- `InlineInterceptActive(text)` is true — the **same predicate** the tokenizer
|
||||
hook stands down on, factored into one function so measure and render can
|
||||
never disagree.
|
||||
- The fs is not an editbox: `fs+0x120 & 0x1000` is clear (the fs-level bit
|
||||
`FUN_0044D670` translates into the gxu editbox flag 0x40).
|
||||
|
||||
The icon sum is computed in TRUE PEN PX — the exact numbers the emitter
|
||||
reserves — and bridged into the getter's return space by the font height
|
||||
known in both spaces: `sumPen × (fontHInt / fontHPen)`. `ResolveFsPenFont`
|
||||
replicates the render chain: `fontHPen = round((fontHInt × fs+0x7C, floored
|
||||
at [0x801628]/rasterY) × rasterY)` (the rebuild's node fontSize + the node
|
||||
ctor's min clamp + the emitter's own height helper), and the outline ink
|
||||
resolves through `face = [[fs+0xE0]+0x20]` — the same object the node stores
|
||||
at +0x44. fs-built nodes are always in snap mode (no fs-flag maps to node
|
||||
bit 7), so the snap-truncated advances are exact. The per-icon advance comes
|
||||
from `IconAdvancePen`, the **same helper** the emitter reserves with — so the
|
||||
measured width matches the rendered width by construction, outline ink and
|
||||
pixel snapping included. (An earlier bridge used the SetPoint push factor
|
||||
`div×1024/mul ≈ 1468` as the intermediate — the units cancelled for `:0`
|
||||
icons, but it couldn't express the truncation or the ink.)
|
||||
|
||||
Unit trap (hit on first flight): escape sizes are UI PIXELS. Do NOT divide
|
||||
the icon sum by `fs+0x7C` — that field is the layout UI *scale* (~0.68–1.0).
|
||||
The original's own `out / fs+0x7C` applies to a value the measure core
|
||||
already ran through the `FUN_0041AD80` gxu→internal converter, not to raw
|
||||
pixels. The `/0x7C` version measured a 16px icon as +44k px.
|
||||
|
||||
The hook NEVER writes the `fs+0xFC` cache: the original can serve the cached
|
||||
value, so the icon sum is re-derived and re-added per call (idempotent).
|
||||
|
||||
The same hook also fixes two layout consumers for free: tooltips auto-size
|
||||
wide enough for icon-bearing lines, and auto-width fontstrings lay out at
|
||||
their true rendered width.
|
||||
|
||||
### Wrap: FIXED at the shared stepper (`FUN_TEXT_WRAP_STEPPER` = 0x005C7260)
|
||||
|
||||
Wrap decisions also measured icons as ~0, so a chat line with prefix icons
|
||||
broke as if the icons were not there and the emitter's real advances ran past
|
||||
the right edge. The choke is the wrap-stepper dispatcher `FUN_005C7260`: it
|
||||
lays out one wrapped line per call, and its exactly 4 callers are EVERY wrap
|
||||
consumer — the draw builder `FUN_005CDC20` (render breaks), the height
|
||||
measure `FUN_005C2070`, the chars-that-fit counter `FUN_005C21C0` (ellipsis),
|
||||
and the break-array computer `FUN_005C2430` (behind `FUN_00772B60`).
|
||||
|
||||
The co-hook shrinks the `wrapWidth` argument by a **minimal feasible
|
||||
shrink**, gated on the same `InlineInterceptActive` predicate plus the flags
|
||||
editbox bit 0x40 the dispatcher already carries. The stepper reports each
|
||||
probe's measured (icons-at-zero) line width in `outWidth`, which gives a
|
||||
direct feasibility check: the line renders inside the frame iff its icons
|
||||
fit in the shrink plus the spare the break left (`iconSum ≤ s + (probeW −
|
||||
outWidth)`). The hook probes sandboxed (local out-params plus a COPY of the
|
||||
`p10` in/out state byte, so the caller's first-line state is not consumed):
|
||||
s = 0 first (feasible → no shrink at all — the common short-message case),
|
||||
escalate by the measured deficit when infeasible, bisect down after the
|
||||
first feasible hit; ≤8 probes, and with no feasible hit the last escalation
|
||||
target wins (wraps early rather than overflowing). The feasibility check
|
||||
carries a HALF-GLYPH tolerance: an auto-width fontstring (single anchor, no
|
||||
width — e.g. pfUI's addon-list labels) wraps at the icon-inclusive string
|
||||
width our GetStringWidth hook feeds the effective-width vmethod, so its line
|
||||
fits with exactly zero slack, and cross-computation drift between the two
|
||||
hooks' unit chains (plus OUTLINE extras and the trailing-glyph quirk) read
|
||||
"fits exactly" as "1px over" — shrinking a zero-slack line forces a wrap
|
||||
that had no business existing. Genuine icon overflow is ≥ a full icon, so
|
||||
half a glyph separates drift from real overflow. `outBreak` and
|
||||
`outNext − text` are byte counts; `outBreak == len` means the whole text
|
||||
fit. Two rejected earlier schemes, for the record: a naive whole-remaining-
|
||||
text sum shredded an 8-icon line to one word per line, and a fixed-point
|
||||
iteration on the icon count oscillated (a ~55-byte zero-width escape makes
|
||||
the break↔icon-count coupling discontinuous — no fixed point exists).
|
||||
`text` always points at the REMAINING text, so a continuation line whose
|
||||
icons are behind it gets sum = 0. Because all four consumers share the hook,
|
||||
render breaks, `GetStringHeight`, truncation, and break arrays shift
|
||||
together. (A `_classicapi_InlineTexWrap(n)` ring dumped the last 8
|
||||
icon-bearing calls during bring-up; removed with the other diagnostics in
|
||||
`5e5677b`.)
|
||||
|
||||
Unit trap #2 (hit TWICE): the stepper's inputs are gxu-normalized, PER AXIS —
|
||||
fontH is normalized-Y (× rasterY = pen px) and wrapWidth/outWidth are
|
||||
normalized-X (× rasterX = pen px). Both caller flavors land there: the draw
|
||||
builder passes node+0x1C/+0x3C (normalized at block creation), and the
|
||||
fs-level callers (the path chat wraps through) x-normalize their widths
|
||||
inside `FUN_0044d670` before gxu. First flight subtracted raw pixels and
|
||||
annihilated the small normalized widths (2-glyph chat fragments). The second
|
||||
converted with `fontH / fontHPx` — a Y-AXIS factor applied to an X-axis
|
||||
width — overshrinking the budget by the render aspect (×1.33 at 4:3, ×2.37
|
||||
at 2560×1080): icon-bearing chat lines wrapped a word or two early, worse per
|
||||
icon and per aspect. The correct pen-x → width-units factor is `1/rasterX`,
|
||||
and the glyph-relative tolerances use `fontHPx/rasterX` so "half a glyph"
|
||||
means an actual glyph width.
|
||||
|
||||
**Chat hyperlink hover over inline icons works (verified).** An emote wrapped in a
|
||||
hyperlink — `|Htel:name|h|T…|t|h`, the TwitchEmotes pattern — pops its tooltip when
|
||||
you hover the *icon*, not just adjacent text. The hover region is NOT re-measured
|
||||
on hover: the engine records each hyperlink's screen rect at layout time into a
|
||||
`GXUFONTHYPERLINKINFO` array (32-byte entries: rect + link data) via `FUN_005cd310`,
|
||||
called from the draw builder `FUN_005cdc20` — the same layout pass that runs the
|
||||
line through our co-hooked tokenizer (`FUN_005c2810`) and wrap-stepper
|
||||
(`FUN_005c7260`). So the recorded rect rides the same icon-aware position
|
||||
accounting the wrap fix installs; the icon lands inside the hover region for free,
|
||||
and hovering it fires the frame's `OnHyperlinkEnter`. This is a consequence of the
|
||||
wrap fix, not a separate feature. (Ghidra caveat: the decompile of the
|
||||
`FUN_005cd310` call args is mangled — a pointer-as-float + an uninitialized temp —
|
||||
so this is confirmed by the call path plus in-game test, not the byte-level
|
||||
formula.)
|
||||
|
||||
**Still icon-blind (accepted residuals):**
|
||||
|
||||
- Sub-pixel only for a string-final icon: the width hooks report its INK edge
|
||||
(advance minus the trail half-pad/ink — `TrailingIconTrimPen`), matching the
|
||||
engine's own last-glyph convention (the gxu width loop ends on ink, not
|
||||
advance — the source of the old −0.8px measurement, whose previous-glyph
|
||||
advance treatment is semantically CORRECT once an icon renders after it).
|
||||
What remains is the icon region's ±0.5px placement rounding and the base
|
||||
loop's ~0 contribution for the payload-0 icon token.
|
||||
- An fs whose `fs+0xFC` cache was filled while the feature was toggled
|
||||
differently (`_classicapi_InlineTexEnable`) keeps its old base width until
|
||||
the next `SetText`/font change re-dirties it. Debug-toggle-only.
|
||||
|
||||
**Editbox measure stays raw (by design).** The caret/measure path reads the
|
||||
focused editbox's INPUT buffer in place, and the tokenizer stands down on it
|
||||
per-editbox (`TextInFocusedEditbox` pointer-range test — not a global focus
|
||||
check), so caret math measures the raw `|T…|t` literal the editbox renders.
|
||||
The width hook mirrors that with the `fs+0x120 & 0x1000` editable gate. One
|
||||
marginal wobble: the focused single-line chat editbox's display fontstring
|
||||
carries editable=0 (only multi-line editors set the bit), so the caret
|
||||
positioner's line-boundary branch (multi-line-only in practice) can see an
|
||||
icon-adjusted `FUN_00772890` on it. Closing that costs a per-token content
|
||||
compare, which is not worth it.
|
||||
|
||||
### Editbox exclusion — two COMPLEMENTARY signals (bit 6 + focus global)
|
||||
|
||||
Editable text must show raw, editable `|T…|t` markup, not rendered icons — you
|
||||
edit macros, tweak icon fields, copy/paste texture strings. **This is
|
||||
retail-correct, verified in-game on a modern client:** typing
|
||||
`|TInterface\Icons\INV_Misc_Coin_01:16|t` into the chat entry box OR a macro body
|
||||
shows it **raw** (no icon) — Blizzard neutralizes player-*typed* escape sequences
|
||||
as anti-abuse — while addon/system text (`AddMessage`, `GetCoinTextureString`)
|
||||
renders. Retail's discriminator is the text's SOURCE (trusted vs player-typed),
|
||||
not editbox-vs-display; a shift-clicked item link renders in the chat box because
|
||||
it's inserted through the trusted path, but a typed `|T`/`|c` does not. Our
|
||||
editbox-raw + display-renders split reproduces the same observable outcome. (One
|
||||
mechanism-level divergence we accept: our editbox-raw uses the GLOBAL focus signal
|
||||
below, so while an editbox is focused OTHER display text that rebuilds also
|
||||
briefly shows raw; retail suppresses per-source. Cosmetic, and a per-editbox fix
|
||||
would need hot-path text-buffer matching — not worth it.) 1.12's tokenizer,
|
||||
unlike 4.3.4's, has no per-render texture-disable bit (4.3.4 uses tokenizer flag
|
||||
**0x1000** to kill only `|T`, **0x800** to kill all escapes), and editbox text
|
||||
nodes are NOT distinguishable by class from the render node — the emitter can't
|
||||
reach the owning frame. It takes **two signals together**, not one (an earlier
|
||||
version of this note wrongly called the focus global a redundant "belt-and-
|
||||
suspenders fallback" — removing it renders the chat/copy boxes' `|T` as icons):
|
||||
|
||||
- **Node flag `[node+0x5c]` bit 6 (0x40) = EDITABLE** — catches the **macro
|
||||
editor** (`MacroFrameText`, flags `0x4D`), which builds its layout once,
|
||||
**un-focused**, so the focus global never sees it. But single-line inputs
|
||||
(chat entry box, `pfChatCopyBox`) have bit 6 **CLEAR** — their nodes are
|
||||
`0x0D`/`0x20D`, identical to display FontStrings — so bit 6 can't catch them.
|
||||
- **Focused-editbox global `DAT_00cf4dc8`** (`InputFocused()`; holds the editbox
|
||||
with keyboard focus, cleared by `ClearFocus` = `FUN_0077e410`) — catches those
|
||||
single-line inputs. It's GLOBAL (true UI-wide, not per-node), which is why,
|
||||
while an editbox is focused, other display text that *rebuilds* also briefly
|
||||
renders raw. That's an accepted tradeoff: there is no per-node signal for
|
||||
single-line inputs, and when you're in an editbox seeing raw markup is what you
|
||||
want anyway.
|
||||
|
||||
**Why the tokenizer's bit-6 gate is load-bearing for RENDER, not just measure:**
|
||||
when the emitter suppresses an editbox it delegates to the ORIGINAL emitter,
|
||||
which **re-enters the tokenizer** to lay out glyphs (`FUN_005ccbe0` →
|
||||
`FUN_005c2810`). On that render path `flags` is the node's own flags, so bit
|
||||
0x40 makes the tokenizer stand down and the `|T` draws as literal text. Drop the
|
||||
bit-6 gate and the (un-focused) macro editor draws **BLANK** — the tokenizer eats
|
||||
the span as a zero-width token and the original emitter renders nothing (verified
|
||||
in-game). Focused single-line inputs get the same "don't intercept" via
|
||||
`Suppressed()` → `InputFocused()`. So both the emitter and the tokenizer consult
|
||||
`Suppressed() || bit-6`; the two must agree or you get blank-vs-raw mismatches.
|
||||
|
||||
(Dead end: **monochrome render flag `DAT_00c2b9dc`** (`FUN_005c8b70` sets it per
|
||||
group) — un-set at MacroFrameText's build time and flickered as a flush gate.
|
||||
Dropped. The editbox class vtable is `0x0081c8c0`, its script-slot resolver
|
||||
`FUN_0077a310` with focus slots at `+0x438`/`+0x440` — useful if a frame-level
|
||||
hook is ever needed.)
|
||||
|
||||
### Descriptor fields + the vertical flip
|
||||
|
||||
`ParseIcon` handles the full positional payload
|
||||
`path:height:width:offsetX:offsetY:texW:texH:left:right:top:bottom:r:g:b`.
|
||||
Texcoords crop a sprite sheet to one cell: `u = left/texW, right/texW`,
|
||||
`v = top/texH, bottom/texH` — e.g. `UI-RaidTargetingIcons` is a 256×256 sheet,
|
||||
4×2 grid of 64×64, so marker N's cell is `256:256:<col*64>:<+64>:<row*64>:<+64>`.
|
||||
|
||||
**Vertex colour (`r:g:b`, 0-255):** the last three positional fields tint the
|
||||
icon. They pack to `0xFFrrggbb` — the same `0xAARRGGBB` order the tokenizer's
|
||||
`|cAARRGGBB` colour path builds (verified from `FUN_005c2810`'s colour branch),
|
||||
so the value flows straight into the quad's vertex colour and the font stage's
|
||||
default MODULATE COLOROP multiplies `texture.rgb × tint`. White (`255:255:255`,
|
||||
the default) = untinted. Because parsing is positional, a tint needs all the
|
||||
preceding fields present; the `TintIcon` helper passes `0`s for width..bottom
|
||||
(texW/texH `0` disables the texcoord crop → full texture) so the colour lands in
|
||||
the trailing slots.
|
||||
|
||||
**Texcoord order:** the region's 4-float texcoord setter
|
||||
(`FUN_SIMPLETEXTURE_SET_TEXCOORD`) takes the rect **Y-FIRST INTERLEAVED**
|
||||
(`{v0, u0, v1, u1}`), matching the engine's `{yA, left, yB, right}` rect
|
||||
convention — diagnosed in-game when `{u0,v0,u1,v1}` rendered a raid-mark crop as
|
||||
the wrong cell. v is top-down, same as the `|T` payload's top/bottom fields.
|
||||
(The removed quad path had the inverse concern: the OpenGL backend put v=0 at
|
||||
the bottom, so the quad mapped the TOP screen corners to v1 — kept here as the
|
||||
backend note.)
|
||||
|
||||
### Build / deploy / test cadence
|
||||
|
||||
- `set -o pipefail; cmake --build build --config Release 2>&1 | tail -5 && cp build/Release/ClassicAPI.dll "C:/WoW/Octo/dll/ClassicAPI.dll"`
|
||||
— `pipefail` stops a failed build from deploying a stale DLL.
|
||||
- **The client must be fully EXITED to relink** (it loads a symlink to the
|
||||
linker's output). DLL changes need a full restart, not `/reload`.
|
||||
- **The embedded `!!!ClassicAPI` addon is symlinked**, so Lua-only changes (the
|
||||
`TextureTest()` harness in `Util/AddOnCompat.lua`) need only `/reload`.
|
||||
|
||||
### Remaining / optional
|
||||
|
||||
- **Measure width, height, wrap** — all DONE (the three fs-level co-hooks in
|
||||
Current design). Vertex-colour tint (`:r:g:b`) — DONE. The bring-up
|
||||
diagnostics (`_classicapi_InlineTexSuppress/Tune/Stats/ProbeFS/RegionCal/Wrap`
|
||||
and the capture globals) were REMOVED (`5e5677b`); only
|
||||
`_classicapi_InlineTexEnable` remains.
|
||||
- **Animated emotes** — no DLL work is needed: a TwitchEmotes-style animator
|
||||
re-`SetText`s each line ~30fps and rewrites the `|T` texcoords to crop the
|
||||
current film-strip frame. That is addon Lua. RLE-compressed TGAs do not
|
||||
decode in 1.12 (uncompressed only) — convert with
|
||||
`magick in.tga -compress none in.tga` (no `-orient`/`-flip`: the region
|
||||
renderer's texcoord order shows the frame right-side-up).
|
||||
- **Still icon-blind** (accepted): the hyperlink hit-test past a tall icon
|
||||
OUTSIDE the link on the same line. The substring measure `FUN_00772AE0` was
|
||||
hooked once its real consumer surfaced (the tooltip auto-size measures
|
||||
wrapped SEGMENTS with it), and the trailing-icon width residual is trimmed
|
||||
to sub-pixel (`TrailingIconTrimPen` — a string-final icon reports its ink
|
||||
edge, not its advance, per the engine's own last-glyph convention).
|
||||
|
||||
## Goal & spec
|
||||
|
||||
Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequences#Textures):
|
||||
|
||||
```
|
||||
|Tpath:height[:width[:offsetX:offsetY[:texWidth:texHeight:left:right:top:bottom[:rV:gV:bV]]]]|t
|
||||
```
|
||||
|
||||
Atlas markup (`|A:atlas:…|a`) is Legion+ (needs an atlas DB 1.12 lacks) — out
|
||||
of scope. FileDataID paths are Legion+ — 1.12 resolves texture PATH strings
|
||||
only.
|
||||
|
||||
## Confirmed: 1.12 has ZERO inline-texture support
|
||||
|
||||
In-game test on the target client — all of these render as literal text, no
|
||||
icon:
|
||||
|
||||
```
|
||||
|TInterface\Icons\INV_Misc_Coin_01:16|t
|
||||
|TInterface\Icons\INV_Misc_Coin_01:16:16|t
|
||||
|TInterface\Icons\INV_Misc_Coin_01:16:16:0:0|t
|
||||
```
|
||||
|
||||
Root cause (see 1.12 map below): the shared escape tokenizer has no `T`/`t`
|
||||
case, so `|T` falls through to "literal `|` + ordinary glyphs".
|
||||
|
||||
## Reference: 4.3.4 (`C:\WoW\Proudmoore\apwow.exe`, build 15595)
|
||||
|
||||
4.3.4's text pipeline is a shared `|`-tokenizer feeding per-purpose loops that
|
||||
`switch` on the returned token type. It DOES support `|T`.
|
||||
|
||||
- **Tokenizer `FUN_00613980`** — reads one token, returns a type code, bytes
|
||||
consumed (`*param2`), payload char (`*param5`). `|`-switch token types:
|
||||
`1`=color(`|c`+8 hex), `2`=reset(`|r`), `3`=break(`|n`/newline), `4`=`||`,
|
||||
`5`=`|H`, `6`=`|h`, **`7`=texture(`|T`…`|t`)**, `8`=`|t`, `9`/`10`=`|K`/`|k`
|
||||
callback, `0xb`/`0xc`=`|W`/`|w`, `0`=ordinary glyph. The `T` case scans
|
||||
forward to the closing `|t` and returns 7 with the whole span's byte length.
|
||||
- **Field parser `FUN_00617ba0(tokenStart, &desc, size)`** — splits the `|T`
|
||||
payload on `:` (helpers: `FUN_0040fa50`=strchr, `FUN_004b3d00`=atof,
|
||||
`FUN_004b3480`=atoi) into a **0x40-byte descriptor**:
|
||||
|
||||
| off | field |
|
||||
|----|-------|
|
||||
| `+0x10`/`+0x14` | path ptr / length (up to first `:`) |
|
||||
| `+0x18` | height (font-scaled) — required |
|
||||
| `+0x1C` | width (defaults to height) |
|
||||
| `+0x20`/`+0x24` | offsetX / offsetY |
|
||||
| `+0x28`–`+0x34` | 4 normalized texcoords (texel ÷ texW/texH) |
|
||||
| `+0x3C` | hasTexCoords flag |
|
||||
| `+0x38`–`+0x3B` | vertex B/G/R/A |
|
||||
| `+0x3D` | hasColor flag |
|
||||
|
||||
- **Measure loop `FUN_00618430`** — walks tokens; `case 7` calls
|
||||
`FUN_00617ba0` and adds the texture width to the pen.
|
||||
- **Draw emitter `FUN_0061ea10`** (analog of 1.12 `FUN_005ccbe0`) — its
|
||||
`case 7` is the texture draw:
|
||||
1. `FUN_00617ba0(tokenStart, &desc)` — parse fields into the descriptor
|
||||
(stored inline on the line-state struct at `param7+0xb`, 0x10 dwords).
|
||||
2. Compute pen x, width (`desc.width[+0x18 dword = param7[0x12]] * scale`),
|
||||
and y from the justify mode `[obj+0x58]` (0=baseline, 1=center, 2=top).
|
||||
3. Copy the descriptor to a stack struct and call **`FUN_0061e820`** — the
|
||||
inline-texture quad emit. (`FUN_0061dd50` handles a region-state 1→2
|
||||
transition when a texture interrupts a hyperlink capture.)
|
||||
- **`FUN_0061e820`** — normalizes the 4 quad corners (÷ screen w/h from
|
||||
`DAT_00dd9ea0`/`DAT_00dd9ea4`) and calls `FUN_0061e410(1, &verts)`.
|
||||
- **`FUN_0061e410(count=1, &verts)`** (`__thiscall`, ECX = a vertex-buffer
|
||||
object) — appends `count` quads of **0x40 bytes** (one quad = pos + texcoords)
|
||||
to a **dedicated inline-texture vertex buffer**, growing it as needed
|
||||
(`FUN_0061db40`). So inline textures accumulate in their OWN batch, separate
|
||||
from the per-font-page glyph batches, each quad carrying its own texture
|
||||
(bound at flush). This is the concrete confirmation of the "separate quad
|
||||
path" — the backport must add such a batch + flush on the 1.12 side.
|
||||
|
||||
## Target: 1.12 (`C:\WoW\Octo\WoW.exe`, build 5875) injection map
|
||||
|
||||
Same architecture as 4.3.4, different addresses and token numbering. Reached
|
||||
from `Script_GetStringWidth` (`FUN_0079e510`) → `FUN_00772890` →
|
||||
`FUN_0044d670` → `FUN_005c2050` → the loops.
|
||||
|
||||
- **Shared tokenizer `FUN_005c2810`** — `~11 callers`. `|`-switch token types:
|
||||
`0`=color(`|c`), `1`=reset(`|r`), `2`=break(`|n`/newline), `3`=`||`,
|
||||
`4`=`|H`, `5`=`|h`, `6`=ordinary glyph. **No `T`/`t` case** → `|T` hits the
|
||||
fall-through (`*param5=0x7c; return 6`) = literal `|` then glyphs. Adding a
|
||||
texture token means picking an unused code (e.g. `7`).
|
||||
- **Measure loop `FUN_005c6940`** — `switch`: `0/1/4/5` zero-width, `2` break,
|
||||
default glyph (advance via `FUN_005c6b70`; kerning `FUN_005ca2d0`/
|
||||
`FUN_005ca4b0`). No texture case.
|
||||
- **Fit/width loops** `FUN_005c6c50` (chars-into-width + per-char x, for
|
||||
cursor/wrap) and `FUN_005c7470` (truncation/ellipsis). Same switch shape.
|
||||
- **Draw builder `FUN_005cdc20`** — reads the layout object's text at `[+0x48]`,
|
||||
handles line-wrap / justify (`[+0x54]`) / color state, per line calls the
|
||||
glyph emitter and `FUN_005cd310` (hyperlink rect registration).
|
||||
- **Glyph emitter `FUN_005ccbe0`** — the real quad emitter. Per glyph:
|
||||
`FUN_005cabd0(fontFace,char)` → glyph record (atlas **page** at `rec[10]`,
|
||||
advance `rec[0x12]`, texcoords `rec[0x18..0x1b]`), then appends 4 verts to a
|
||||
**per-atlas-page** batch at `[obj + page*4 + 0xa0]`. `case 0/1` set/reset
|
||||
color, `4/5` open/close hyperlink region, default = glyph.
|
||||
|
||||
### The architectural wrinkle
|
||||
|
||||
Glyph quads batch **per font-atlas page**. An inline `|T` texture is an
|
||||
arbitrary texture, not a font page, so it cannot join that batch — it needs a
|
||||
**separate textured-quad draw** (its own draw call, correctly ordered against
|
||||
the glyph batches so it appears inline). This is the substantive work, not the
|
||||
tokenizing. 4.3.4 solves it exactly this way — captured below: its draw `case 7`
|
||||
appends the quad to a dedicated inline-texture vertex buffer at
|
||||
`renderObject+0x9c`, separate from the per-font-page glyph batches (see the
|
||||
`FUN_0061ea10` / `FUN_0061e820` items under Open items).
|
||||
|
||||
## Strategy
|
||||
|
||||
The tokenizer is shared by ~11 callers, so DON'T just make it return a new
|
||||
type — the other callers' `switch`es would hit `default` and mis-handle it.
|
||||
Instead:
|
||||
|
||||
1. **Reimplement (co-hook) the measure loop `FUN_005c6940` and the draw
|
||||
builder `FUN_005cdc20`/emitter `FUN_005ccbe0`** — copy existing logic
|
||||
(fully decompiled), delegate every non-texture token to the original
|
||||
handling, and add an inline `|T…|t` case: measure → advance pen by texture
|
||||
width; draw → emit the arbitrary-texture quad.
|
||||
2. **Port `FUN_00617ba0`** (field parse → 0x40 descriptor). Standalone, safe to
|
||||
write now from the 4.3.4 decompilation; needs 1.12 equivalents of strchr/
|
||||
atof/atoi (trivial or already in `Offsets.h`).
|
||||
3. **Leave the shared tokenizer `FUN_005c2810` and the other ~9 callers
|
||||
untouched** — they keep rendering `|T` literally (wrap/cursor math slightly
|
||||
off inside a texture in v1 — cosmetic, extend later).
|
||||
4. **Texture load by path** — reuse the engine's `Texture:SetTexture` loader;
|
||||
**quad emit** — the primitive 4.3.4's draw case uses (to pin).
|
||||
|
||||
Risk: the hot text path (every string every frame). But these are
|
||||
deterministic C functions, NOT the contended nampower/SuperWoW targets, so the
|
||||
MinHook-collision concern is low. Reimplementing two complex loops faithfully
|
||||
is the real cost.
|
||||
|
||||
## Incremental slices
|
||||
|
||||
1. Fixed-size `|Tpath:height|t` — render + width only (ignore width/offset/
|
||||
texcoord/color). Proves the end-to-end quad path.
|
||||
2. width, offsetX/Y.
|
||||
3. texcoords (`texW:texH:l:r:t:b`).
|
||||
4. vertex color.
|
||||
5. correct wrap/cursor (extend `FUN_005c6c50`/`FUN_005c7470`).
|
||||
|
||||
## Open items / next steps
|
||||
|
||||
- [x] **4.3.4 draw-side `case 7`** — captured: `FUN_0061ea10` case 7 →
|
||||
`FUN_00617ba0` (parse) + `FUN_0061e820`→`FUN_0061e410` (append quad to a
|
||||
dedicated inline-texture vertex buffer). Separate batch, per-quad texture.
|
||||
- [x] **4.3.4 inline-texture buffer located** — `FUN_0061e820` (`RET 0x40`,
|
||||
i.e. a 0x40-byte quad struct by value) does `ADD ECX,0x9c` before
|
||||
`FUN_0061e410`, so the dedicated inline-texture vertex buffer is at
|
||||
**renderObject+0x9c** (glyph batches are the `+0xa0 + page*4` array — the
|
||||
two are adjacent but distinct). Quad struct: positions at `+0x8..+0x14`,
|
||||
texcoords at `+0x20..+0x2c` (rest = z / texture handle / color). Appended
|
||||
one quad at a time via `FUN_0061e410(this=buf@+0x9c, count, &quad)`.
|
||||
- [x] **Flush + path→texture load** — NOT traced on the 4.3.4 side by design;
|
||||
its purpose (the flush + per-quad texture bind + path→load mechanism) is
|
||||
fully answered on the **1.12 side** below: paint/flush `FUN_005c8fe0`,
|
||||
bind `FUN_00589e80(0x17,tex)` + draw `FUN_005c8f40`, load `FUN_00449d90`.
|
||||
4.3.4's own `+0x9c`-buffer flush is not needed — we implement against 1.12.
|
||||
- [x] **1.12 path→texture loader** — `FUN_00770200(textureObj, path, flag,
|
||||
blend=DAT_00878cf0, flag)`. It's the string-path branch of
|
||||
`Script_Texture_SetTexture` (`FUN_0079bb40`, method-table entry at
|
||||
`0x0087c1a0`). This is how a `|T` path becomes a bound texture.
|
||||
- [x] **1.12 batch render + textured-quad GPU primitive — FOUND.** The paint
|
||||
pass is `FUN_005c8fe0(layoutObj)`: it ensures each line built
|
||||
(`FUN_005cd6a0`), then per font page (stride 0x18, indices 0..7 over
|
||||
`[obj+0x18c+page*0x18]` = the page's texture object) does:
|
||||
- **bind texture:** `FUN_00589e80(0x17, texturePage)` (0x17 = the texture
|
||||
render-state selector),
|
||||
- **lock dynamic VB:** `FUN_0058a140(0, stride=0x18, 0x800)` → handle;
|
||||
`FUN_0058a080(handle)` → vertex data ptr,
|
||||
- copy the page's built verts from the per-page batch at
|
||||
`[lineObj + 0xa0 + page*4]` (`FUN_005ce090`=count, `FUN_005ce0c0`=copy),
|
||||
- **draw:** `FUN_005c8f40(&handle, vertCount)` (flush/submit),
|
||||
- **unlock:** `FUN_0058a0a0(handle, 0)`.
|
||||
|
||||
**Vertex stride = 0x18** (24 bytes = pos + uv + packed color). So an inline
|
||||
`|T` quad = 4 (or 6) such verts, bound to its own texture. Reuse path for
|
||||
the `|T` draw: `FUN_00589e80(0x17, inlineTex)` → write quad verts →
|
||||
`FUN_005c8f40`. **`FUN_005c8fe0` is the co-hook site for strategy B** —
|
||||
after the glyph batches are drawn, draw the frame's collected `|T` quads.
|
||||
- [x] **Path → bindable texture handle — RESOLVED.** `FUN_00770200`
|
||||
(SetTexture-by-path) internally calls **`FUN_00449d90(path, &desc, flags,
|
||||
0, 1)`** — the general path→CGxTexture loader (normalizes path, by-name
|
||||
cache, creates on miss via `FUN_0044a140`/`FUN_0044a310`, returns a
|
||||
**fallback texture on failure so never null**) — and stores the handle at
|
||||
`textureObj+0xcc`. That handle is the SAME CGxTexture type
|
||||
`FUN_00589e80(0x17, tex)` binds (the `tex[0x3c]` streamable flag +
|
||||
non-null `*tex` that `FUN_00593840` checks are CGxTexture fields; font
|
||||
pages and Texture widgets bind identically). So a `|T` draws as:
|
||||
`tex = FUN_00449d90(path,&desc,flags,0,1)` → `FUN_00589e80(0x17,tex)` →
|
||||
write 4 verts (stride 0x18) → `FUN_005c8f40(&vb,n)`. No scratch widget
|
||||
needed if we replicate `FUN_00770200`'s descriptor setup
|
||||
(`FUN_0058a980(&desc, blend, …)` then `FUN_00449d90`); or, simplest, keep
|
||||
one scratch Texture object, `FUN_00770200(scratch,path,…)`, read
|
||||
`[scratch+0xcc]`. The internal by-name cache makes repeats cheap.
|
||||
|
||||
Two implementation strategies once the primitive is pinned:
|
||||
- **A (mirror 4.3.4):** own a separate inline-texture vertex buffer, flush it
|
||||
in the paint pass alongside the glyph batches. Faithful, more integration.
|
||||
- **B (immediate):** at paint time, draw each collected `|T` quad immediately
|
||||
via the GxU primitive (bind `FUN_00770200`'d texture, draw one quad). Simpler;
|
||||
inline textures are few, so per-quad draw calls are fine. Likely the better
|
||||
first cut.
|
||||
- [ ] Confirm 1.12's tokenizer flag bits (`param4 & 0x100/0x200/0x400/0x800`)
|
||||
that gate color/escape handling, so the reimplemented loops pass them
|
||||
through unchanged.
|
||||
|
||||
## Reality check on scope — how it actually landed
|
||||
|
||||
The plan above feared reimplementing 1.12's glyph emitter `FUN_005ccbe0` (a
|
||||
long, intricate function) and the measure loop. It did not come to that. The
|
||||
shipped design avoids both:
|
||||
|
||||
- **Positioning** does NOT reimplement the emitter — it SEGMENTS the line at
|
||||
icon boundaries and delegates each plain run to the ORIGINAL emitter, so the
|
||||
engine still lays out every glyph. We only track where the icons go.
|
||||
- **Rendering** does NOT stand up a separate quad batch — icons are
|
||||
engine-managed regions anchored to their FontString (the removed quad batch
|
||||
was strategy B; regions replaced it).
|
||||
- **Measure** does NOT reimplement the loops — three cold co-hooks on the
|
||||
fs-level width / height / wrap functions add the icons' contribution and call
|
||||
the originals.
|
||||
|
||||
The result is the largest feature in the project, but built from co-hooks that
|
||||
delegate to the engine rather than from reimplemented layout code. It was
|
||||
verified by in-game iteration over many build/test rounds, as expected.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Why inline icons render as engine regions
|
||||
|
||||
Inline `|T…|t` icons render as pooled `CSimpleTexture` regions
|
||||
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) — one per
|
||||
visible icon, anchored to the icon's owning FontString. The engine draws each
|
||||
region every frame like any UI texture, so the icon is resident by construction
|
||||
and moves with its line for free. This is 4.3.4's `CSimpleEmbeddedTexture`
|
||||
model, built from the parts 1.12 ships.
|
||||
|
||||
This file records why that design was chosen and the RE behind it. A raw-GxU-
|
||||
quad renderer came first and was removed (`56c2670`).
|
||||
|
||||
## History — a residency misdiagnosis, corrected
|
||||
|
||||
The quad renderer flickered: a random subset of chat icons blanked on scroll,
|
||||
and a second client showed no icons at all. The first diagnosis was **texture
|
||||
residency** — the theory that a raw quad bound a texture the VRAM manager had
|
||||
evicted, because a raw quad is owned by no frame object.
|
||||
|
||||
That diagnosis was wrong. The real cause was the Large-Address-Aware pointer
|
||||
bound (`dc61f77`): a 2GB cap in the flush's `LooksReadable` check rejected valid
|
||||
heap pointers above `0x80000000`, and the flush's node walk BREAKS on a rejected
|
||||
node. So one high node truncated the walk, and every node after it lost its
|
||||
icon. That is the "random subset blanks" pattern, and a second client shows none
|
||||
because it grows the heap past 2GB sooner. With the bound fixed, the raw quads
|
||||
did not flicker either.
|
||||
|
||||
So engine regions are kept for **4.3.4 parity**, not necessity. They are
|
||||
resident by construction, which closes the residency question for good — but the
|
||||
question was never the real bug. Do not re-open the residency investigation; the
|
||||
D3D notes at the end are the record of what was ruled out.
|
||||
|
||||
## The region the pool builds
|
||||
|
||||
1.12 ships `CSimpleTexture` — the ordinary `Texture` widget
|
||||
(`frame:CreateTexture()`). It is a managed, reference-counted, `CGxTexCache`-
|
||||
backed, engine-drawn texture. The only thing 3.3.5's `CSimpleEmbeddedTexture`
|
||||
adds is automatic inline layout inside the FontString, and the emitter already
|
||||
computes the icon positions — so the pool supplies them. (`CSimpleEmbeddedTexture`
|
||||
is compiled into 3.3.5 only; RTTI confirms it is absent from 1.12.)
|
||||
|
||||
Per icon, the pool takes a pooled region and sets the texture, texcoords,
|
||||
colour, and two anchor points relative to the owning FontString's bottom-left.
|
||||
The engine then owns load, residency, managed-pool reload, clipping, and draw.
|
||||
|
||||
### 1.12 entry points (used by the pool)
|
||||
|
||||
RTTI name strings: `.?AVCSimpleTexture@@` @ `0x00846588`,
|
||||
`.?AVCSimpleFontString@@` @ `0x00846544`.
|
||||
|
||||
Create a `CSimpleTexture` from C (mirrors `Tooltip::LinePool`'s FontString path):
|
||||
|
||||
```
|
||||
void* mem = FUN_00760450(&DAT_00cf4ce0, 0, /*rtti*/0x00846588, 0xFFFFFFFE); // pool alloc
|
||||
void* tex = FUN_0076fc40(mem, parentFrame, /*drawLayer*/2, /*sublayer*/1); // ctor
|
||||
```
|
||||
|
||||
Set the texture path (loads through the engine's own loader, owns the handle):
|
||||
|
||||
```
|
||||
FUN_00770200(tex, path, 0, DAT_00878cf0, 0); // CSimpleTexture::SetTexture(path)
|
||||
```
|
||||
|
||||
`FUN_00770200` builds GxTexFlags (`FUN_0058a980`), loads via `FUN_00449d90`,
|
||||
stores the HTEXTURE at `tex+0xCC` (owned ref), releases the old one
|
||||
(`FUN_0041aed0`), and marks the region dirty. This ownership is the whole
|
||||
residency story — the engine holds the texture and reloads it on eviction.
|
||||
|
||||
Region methods the pool calls directly:
|
||||
|
||||
| method | C++ target |
|
||||
|---|---|
|
||||
| SetTexCoord | `FUN_00770410(tex, &{v0,u0,v1,u1})` — Y-FIRST interleaved |
|
||||
| SetColor | `FUN_0077F750(tex, &BGRA)` — also the chat-fade alpha mirror |
|
||||
| SetPoint | `FUN_00767C70` — internal units (see LinePool `PixelToInternal`) |
|
||||
| Show / Hide | `FUN_0077FCB0` / `FUN_0077FC60` (desired-shown `+0xC4`, realized `+0xC8`) |
|
||||
|
||||
`CSimpleTexture` layout (from ctor `FUN_0076fc40`): vtable `0x0081c718`;
|
||||
region-render vtable at `+0x24`; HTEXTURE `+0xCC`; blend `+0xD0`; per-corner
|
||||
quad position/texcoord arrays `+0xD4..+0x100` (`OFF_SIMPLETEXTURE_CORNERS`);
|
||||
`+0x128` a flag. The region-render vtable slot 0 (`0x00770670`) computes the
|
||||
screen rect and calls `FUN_007705b0` to STORE the quad corners; the GPU draw is
|
||||
deferred to the engine's batched region paint.
|
||||
|
||||
## The pen↔anchor bridge
|
||||
|
||||
The emitter computes icon positions in PEN units; the pool stores region
|
||||
`SetPoint` offsets in ANCHOR units, relative to the FontString's rect. The
|
||||
bridge is one scale `K` (pen units per anchor unit) plus the FontString's own
|
||||
rect: the flush reads the FontString rect and derives `K` per node, converts
|
||||
each icon's screen rect to FontString-relative anchor offsets, and queues that.
|
||||
The pool applies the queued placement on the next frame tick — never mid-render.
|
||||
`K` derivation and the FontString-relative store are in `FlushLayout`
|
||||
([src/text/InlineTexture.cpp](../src/text/InlineTexture.cpp)); the
|
||||
convergence pass in `Maintain`
|
||||
([src/text/InlineTexturePool.cpp](../src/text/InlineTexturePool.cpp)) self-heals
|
||||
any residual scale from the region's own frame-scale chain.
|
||||
|
||||
## What was ruled out during the residency chase (do not re-investigate)
|
||||
|
||||
The blank icons had zero CPU-side signature — records present, node chain
|
||||
contiguous, geometry sane, index buffer ready at submit, batched draw, z-buffer
|
||||
excluded twice. The full D3D9 render state at the true draw point was benign:
|
||||
`zFunc=8` always, `aBlend=1`, `colorW=0xF`, scissor and stencil off, texture
|
||||
stage MODULATE with a valid bound texture every draw. All of this was consistent
|
||||
with the LAA-bound truncation (the node never reached the draw), not with a lost
|
||||
texture surface.
|
||||
|
||||
D3D9 device access, if it is ever needed again:
|
||||
|
||||
- `IDirect3DDevice9* = *(void**)(*(void**)0x00C0ED38 + 0x38a8)` (CGxDeviceD3d).
|
||||
- `GetRenderState` = device vtable `+0xE8`; `SetRenderState` `+0xE4`.
|
||||
- The GxU re-applies its cached render states before every draw (`FUN_005a1b20`),
|
||||
so a raw `SetRenderState` for a GxU-managed state is overwritten; `ZENABLE` is
|
||||
not in that path, so a direct set survives.
|
||||
@@ -0,0 +1,9 @@
|
||||
; Exports for ClassicAPI.dll.
|
||||
;
|
||||
; `Load` is VanillaFixes' post-injection entry point: after the launcher
|
||||
; injects each dlls.txt DLL, the VanillaFixes runtime calls the DLL's `Load`
|
||||
; export on the game's MAIN thread (GetProcAddress(module, "Load")), outside
|
||||
; the loader lock and with no timeout. Listing it here exports it undecorated
|
||||
; as "Load" so GetProcAddress finds it regardless of calling convention.
|
||||
EXPORTS
|
||||
Load
|
||||
@@ -13,26 +13,6 @@
|
||||
|
||||
namespace Common {
|
||||
|
||||
#define HOOK_FUNCTION(offset, hook, original) \
|
||||
{ \
|
||||
auto *target = reinterpret_cast<LPVOID>(offset); \
|
||||
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(hook), \
|
||||
reinterpret_cast<LPVOID *>(&original)) != MH_OK) \
|
||||
return FALSE; \
|
||||
if (MH_EnableHook(target) != MH_OK) \
|
||||
return FALSE; \
|
||||
}
|
||||
|
||||
#define UNHOOK_FUNCTION(offset, original) \
|
||||
{ \
|
||||
auto *target = reinterpret_cast<LPVOID>(offset); \
|
||||
if (MH_DisableHook(target) != MH_OK) \
|
||||
return FALSE; \
|
||||
if (MH_RemoveHook(target) != MH_OK) \
|
||||
return FALSE; \
|
||||
(original) = nullptr; \
|
||||
}
|
||||
|
||||
bool PatchBytes(void *dst, const void *src, size_t len);
|
||||
|
||||
} // namespace Common
|
||||
|
||||
+167
-36
@@ -16,8 +16,12 @@
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "model/DisplayInfo.h"
|
||||
#include "nameplate/Walk.h"
|
||||
#include "player/NameCache.h"
|
||||
#include "text/InlineTexture.h"
|
||||
#include "text/InlineTexturePool.h"
|
||||
#include "texture/Transform.h"
|
||||
|
||||
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
|
||||
static Game::LoadScriptFunctions_t LoadScriptFunctions_o = nullptr;
|
||||
@@ -47,6 +51,20 @@ static bool __fastcall FrameScript_Initialize_h() {
|
||||
// cache to clear here.
|
||||
NamePlate::Events::PrepareForReload();
|
||||
|
||||
// The reload teardown destroys every icon pool texture, fontstring,
|
||||
// and text node — forget all inline-icon pointers now so nothing
|
||||
// touches them (see InlineTexture.h / InlineTexturePool.h).
|
||||
Text::InlineTexture::PrepareForReload();
|
||||
Text::InlineTexturePool::PrepareForReload();
|
||||
|
||||
// Drop per-region corner transforms (SetRotation / SetVertexOffset) — the
|
||||
// reload destroys every addon texture and the region pool reuses their pointers.
|
||||
Texture::Transform::PrepareForReload();
|
||||
|
||||
// Drop pending character-dress jobs and their engine compositors — the
|
||||
// reload destroys every Model frame the jobs point at.
|
||||
Model::DisplayInfo::PrepareForReload();
|
||||
|
||||
// Persist the name cache before the engine starts tearing down.
|
||||
// This hook fires on both `/reload` and `/logout` (the engine
|
||||
// re-initializes Lua state in both cases), giving us a clean
|
||||
@@ -71,6 +89,13 @@ static void __fastcall LoadScriptFunctions_h() {
|
||||
static void __stdcall LoadGlueScriptFunctions_h() {
|
||||
LoadGlueScriptFunctions_o();
|
||||
Game::RunGlueModuleRegistrations();
|
||||
// World→glue teardown destroyed every world fontstring and text node the
|
||||
// inline-icon maps reference; forget them now that the glue UI is booting.
|
||||
// (Glue→world is covered by FrameScript_Initialize_h; the node-free hook
|
||||
// handles individual deaths, but bulk teardown paths may bypass it.)
|
||||
Text::InlineTexture::PrepareForReload();
|
||||
Text::InlineTexturePool::PrepareForReload();
|
||||
Texture::Transform::PrepareForReload();
|
||||
}
|
||||
|
||||
// Every Lua-side `frame:RegisterEvent(...)` is a chance to claim a slot
|
||||
@@ -86,47 +111,153 @@ static void __fastcall FrameRegisterEvent_h(void *frame, void *edx,
|
||||
FrameRegisterEvent_o(frame, edx, eventName);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook install — kept OFF the Windows loader lock.
|
||||
//
|
||||
// Every MH_EnableHook freezes all process threads (CreateToolhelp32Snapshot +
|
||||
// SuspendThread/GetThreadContext each). Doing ~90 of those from DllMain, under
|
||||
// the loader lock, is the pattern MinHook documents as unsafe: on machines
|
||||
// whose security stack intercepts thread suspension it stalled the remote
|
||||
// LoadLibrary thread past VanillaFixes' 10-second injection deadline, so VF
|
||||
// read the still-running thread as STILL_ACTIVE (259) and showed its generic
|
||||
// "compatible client" error — a false diagnosis of a slow load.
|
||||
//
|
||||
// So DllMain installs nothing. The install runs later, on a thread that does
|
||||
// NOT hold the loader lock, via one of two triggers that both funnel through
|
||||
// the latched EnsureInitialized():
|
||||
// * VanillaFixes' `Load` export, called on the game's main thread after
|
||||
// injection (the sanctioned VF extension point), or
|
||||
// * a fallback worker thread we spawn from DllMain, ONLY when VanillaFixes
|
||||
// is not the loader (no VfPatcher.dll in the process), for injectors that
|
||||
// never call `Load`.
|
||||
//
|
||||
// The worker MUST stand down under VanillaFixes. VF creates the process
|
||||
// suspended and injects every dlls.txt DLL sequentially via remote threads;
|
||||
// a worker spawned from our DllMain starts the moment our own LoadLibrary
|
||||
// releases the loader lock — i.e. while VF is still injecting the NEXT
|
||||
// dlls.txt DLL. Its ~90-prologue install (plus the MH_ApplyQueued
|
||||
// thread-freeze) then races that DLL's DllMain patching the same engine
|
||||
// functions, which made SuperWoWhook.dll fail to load whenever ClassicAPI
|
||||
// preceded it in dlls.txt. `Load` has no such race: VfPatcher calls it on the
|
||||
// game's main thread after the process resumes, when every DllMain in the
|
||||
// injection chain has already completed, serialized in dlls.txt order.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static volatile LONG g_initClaimed = 0; // 0 until a thread takes the installer role
|
||||
static volatile LONG g_initDone = 0; // 0 until the install has finished
|
||||
static volatile LONG g_initResult = 1; // 0 = success, 1 = failure (until proven)
|
||||
static volatile LONG g_mhInitialized = 0; // MH_Initialize succeeded (gates detach teardown)
|
||||
|
||||
static bool CreateAndQueue(uintptr_t offset, void *hook, void **original) {
|
||||
auto *target = reinterpret_cast<LPVOID>(offset);
|
||||
if (MH_CreateHook(target, hook, original) != MH_OK)
|
||||
return false;
|
||||
// Queue only — a single MH_ApplyQueued below applies the whole batch in
|
||||
// one thread-freeze.
|
||||
if (MH_QueueEnableHook(target) != MH_OK)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool InstallHooks() {
|
||||
if (MH_Initialize() != MH_OK)
|
||||
return false;
|
||||
InterlockedExchange(&g_mhInitialized, 1);
|
||||
|
||||
if (!CreateAndQueue(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK,
|
||||
reinterpret_cast<void *>(InvalidFunctionPtrCheck_h), nullptr))
|
||||
return false;
|
||||
|
||||
// Four core init hooks — each runs glue logic (PrepareForReload,
|
||||
// RunModuleRegistrations, RunGlueModuleRegistrations, EnableWrites,
|
||||
// RetryClaims) tightly coupled to DllMain state, so a declarative
|
||||
// HookAutoRegister wouldn't simplify them.
|
||||
if (!CreateAndQueue(Offsets::FUN_FRAME_SCRIPT_INITIALIZE,
|
||||
reinterpret_cast<void *>(FrameScript_Initialize_h),
|
||||
reinterpret_cast<void **>(&FrameScript_Initialize_o)))
|
||||
return false;
|
||||
if (!CreateAndQueue(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS,
|
||||
reinterpret_cast<void *>(LoadScriptFunctions_h),
|
||||
reinterpret_cast<void **>(&LoadScriptFunctions_o)))
|
||||
return false;
|
||||
if (!CreateAndQueue(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
|
||||
reinterpret_cast<void *>(LoadGlueScriptFunctions_h),
|
||||
reinterpret_cast<void **>(&LoadGlueScriptFunctions_o)))
|
||||
return false;
|
||||
if (!CreateAndQueue(Offsets::FUN_FRAME_REGISTER_EVENT,
|
||||
reinterpret_cast<void *>(FrameRegisterEvent_h),
|
||||
reinterpret_cast<void **>(&FrameRegisterEvent_o)))
|
||||
return false;
|
||||
|
||||
// All feature hooks declared via `Game::HookAutoRegister` at file scope
|
||||
// in their respective modules (create + queue-enable, no apply yet).
|
||||
if (!Game::RunHookRegistrations())
|
||||
return false;
|
||||
|
||||
// One thread-freeze that activates every queued hook at once.
|
||||
return MH_ApplyQueued() == MH_OK;
|
||||
}
|
||||
|
||||
// Runs InstallHooks exactly once. Returns 0 on success, 1 on failure. If a
|
||||
// second caller arrives while the install is in flight, it blocks briefly so
|
||||
// both callers observe the real result.
|
||||
static DWORD EnsureInitialized() {
|
||||
if (InterlockedCompareExchange(&g_initClaimed, 1, 0) == 0) {
|
||||
g_initResult = InstallHooks() ? 0 : 1;
|
||||
InterlockedExchange(&g_initDone, 1);
|
||||
} else {
|
||||
while (InterlockedCompareExchange(&g_initDone, 0, 0) == 0)
|
||||
Sleep(1);
|
||||
}
|
||||
return static_cast<DWORD>(g_initResult);
|
||||
}
|
||||
|
||||
static DWORD WINAPI InitWorker(LPVOID) {
|
||||
EnsureInitialized();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// VanillaFixes calls this on the game's MAIN thread after injection
|
||||
// (InitAdditionalDLLs -> GetProcAddress(module, "Load")), outside the loader
|
||||
// lock and with no timeout — the sanctioned VF extension point. Returns 0 on
|
||||
// success; VF reports any non-zero result to the user. Exported undecorated
|
||||
// as "Load" via src/ClassicAPI.def.
|
||||
extern "C" DWORD Load() { return EnsureInitialized(); }
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
|
||||
if (MH_Initialize() != MH_OK)
|
||||
return FALSE;
|
||||
|
||||
auto *target = reinterpret_cast<LPVOID>(Offsets::FUN_INVALID_FUNCTION_PTR_CHECK);
|
||||
if (MH_CreateHook(target, reinterpret_cast<LPVOID>(InvalidFunctionPtrCheck_h), nullptr) != MH_OK)
|
||||
return FALSE;
|
||||
if (MH_EnableHook(target) != MH_OK)
|
||||
return FALSE;
|
||||
|
||||
// Four core init hooks stay here — each runs glue logic
|
||||
// (PrepareForReload, RunModuleRegistrations,
|
||||
// RunGlueModuleRegistrations, EnableWrites, RetryClaims)
|
||||
// that's tightly coupled to DllMain state, so a declarative
|
||||
// HookAutoRegister wouldn't simplify them.
|
||||
HOOK_FUNCTION(Offsets::FUN_FRAME_SCRIPT_INITIALIZE, FrameScript_Initialize_h,
|
||||
FrameScript_Initialize_o);
|
||||
HOOK_FUNCTION(Offsets::FUN_LOAD_SCRIPT_FUNCTIONS, LoadScriptFunctions_h,
|
||||
LoadScriptFunctions_o);
|
||||
HOOK_FUNCTION(Offsets::FUN_LOAD_GLUE_SCRIPT_FUNCTIONS,
|
||||
LoadGlueScriptFunctions_h, LoadGlueScriptFunctions_o);
|
||||
HOOK_FUNCTION(Offsets::FUN_FRAME_REGISTER_EVENT, FrameRegisterEvent_h,
|
||||
FrameRegisterEvent_o);
|
||||
|
||||
// All feature hooks declared via `Game::HookAutoRegister` at
|
||||
// file scope in their respective modules.
|
||||
if (!Game::RunHookRegistrations())
|
||||
return FALSE;
|
||||
// Install nothing here (loader lock — see the block comment above).
|
||||
//
|
||||
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
|
||||
// is injected before any dlls.txt DLL, so its presence identifies VF as
|
||||
// the loader; and the `Load` mechanism shipped in the same VF release
|
||||
// (v1.4) as dlls.txt support itself, so any VF that loaded us will call
|
||||
// it. Spawning the worker here would race the DllMains of the dlls.txt
|
||||
// DLLs VF injects after us (see the block comment above).
|
||||
if (GetModuleHandleW(L"VfPatcher.dll") == nullptr) {
|
||||
// Any other injector: no `Load` caller exists, so install from a
|
||||
// worker off the lock. The new thread cannot run its body until
|
||||
// DllMain returns and the loader lock releases, and we never wait
|
||||
// on it. DisableThreadLibraryCalls suppressed its THREAD_ATTACH.
|
||||
HANDLE worker = CreateThread(nullptr, 0, InitWorker, nullptr, 0, nullptr);
|
||||
if (worker != nullptr)
|
||||
CloseHandle(worker);
|
||||
}
|
||||
} else if (reason == DLL_PROCESS_DETACH) {
|
||||
// Clean /quit path. Flush before MH_Uninitialize — the cache's
|
||||
// file I/O uses Win32 directly (no MinHook involvement), so
|
||||
// either order works in practice, but flushing first lets us
|
||||
// bail early if the hook teardown ever grows side effects.
|
||||
// Hard process termination (task manager kill) bypasses this
|
||||
// path; that's an inherent OS limitation, and the 5-minute
|
||||
// backstop flush in Remember() covers the worst case there.
|
||||
Player::NameCache::Flush();
|
||||
MH_Uninitialize();
|
||||
// Clean /quit path — only tear down if we actually initialized.
|
||||
// Flush before MH_Uninitialize — the cache's file I/O uses Win32
|
||||
// directly (no MinHook involvement), so either order works in
|
||||
// practice, but flushing first lets us bail early if the hook
|
||||
// teardown ever grows side effects. Hard process termination (task
|
||||
// manager kill) bypasses this path; that's an inherent OS
|
||||
// limitation, and the 5-minute backstop flush in Remember() covers
|
||||
// the worst case there.
|
||||
if (g_mhInitialized) {
|
||||
Player::NameCache::Flush();
|
||||
MH_Uninitialize();
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
+36
-3
@@ -57,7 +57,8 @@ namespace Lua {
|
||||
F(Resume, lua_resume, LUA_RESUME) \
|
||||
F(Yield, lua_yield, LUA_YIELD) \
|
||||
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
|
||||
F(SetN, luaL_setn, LUAL_SETN)
|
||||
F(SetN, luaL_setn, LUAL_SETN) \
|
||||
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
|
||||
|
||||
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
|
||||
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
|
||||
@@ -72,7 +73,7 @@ using RegisterFrameMethods_t = void(__fastcall *)(const FrameMethodEntry *table,
|
||||
} // namespace
|
||||
|
||||
void *State() {
|
||||
return *reinterpret_cast<void **>(static_cast<uintptr_t>(Offsets::VAR_LUA_STATE));
|
||||
return Game::Read<void *>(Offsets::VAR_LUA_STATE);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -184,6 +185,33 @@ void SetGlobalNumber(void *L, const char *name, double value) {
|
||||
RawSet(L, GLOBALS_INDEX);
|
||||
}
|
||||
|
||||
bool PushGlobalFunction(void *L, const char *name) {
|
||||
PushString(L, name);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
if (Type(L, -1) == TYPE_FUNCTION)
|
||||
return true;
|
||||
SetTop(L, -2); // pop the non-function
|
||||
return false;
|
||||
}
|
||||
|
||||
void CallGlobal(void *L, const char *name) {
|
||||
const int top = GetTop(L);
|
||||
if (PushGlobalFunction(L, name))
|
||||
Call(L, 0, 0);
|
||||
SetTop(L, top);
|
||||
}
|
||||
|
||||
bool CallGlobalString(void *L, const char *name, const char *arg) {
|
||||
const int top = GetTop(L);
|
||||
const bool called = PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
PushString(L, arg);
|
||||
Call(L, 1, 0);
|
||||
}
|
||||
SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
void PushLocalizedString(void *L, const char *globalName, const char *fallback) {
|
||||
PushString(L, globalName);
|
||||
GetTable(L, GLOBALS_INDEX);
|
||||
@@ -292,7 +320,12 @@ bool RunHookRegistrations() {
|
||||
if (MH_CreateHook(targetPtr, node->hook,
|
||||
reinterpret_cast<LPVOID *>(node->original)) != MH_OK)
|
||||
return false;
|
||||
if (MH_EnableHook(targetPtr) != MH_OK)
|
||||
// Queue, don't enable. The caller (InstallHooks) applies every
|
||||
// queued hook with a single MH_ApplyQueued, so all ~90 hooks share
|
||||
// ONE thread-freeze instead of one freeze per hook. This is the
|
||||
// difference that keeps install cheap on machines whose security
|
||||
// stack intercepts SuspendThread / VirtualProtect per call.
|
||||
if (MH_QueueEnableHook(targetPtr) != MH_OK)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
+72
-8
@@ -17,6 +17,46 @@
|
||||
|
||||
namespace Game {
|
||||
|
||||
// --- Typed engine-memory access ---------------------------------------------
|
||||
//
|
||||
// The canonical way to read/write an engine object field or a fixed-VA global,
|
||||
// replacing the `*reinterpret_cast<const T *>(base + Offsets::OFF_X)` idiom
|
||||
// (and its double-cast variant when `base` isn't a byte pointer). Offsets stay
|
||||
// in Offsets.h per the single-source-of-truth rule — these only centralize the
|
||||
// cast noise, they add no checking.
|
||||
//
|
||||
// Game::Read<int>(obj, Offsets::OFF_FIELD) field read
|
||||
// Game::Ref<uint32_t>(obj, Offsets::OFF_FIELD) writable field lvalue
|
||||
// Game::Ptr<float>(obj, Offsets::OFF_FIELD) pointer TO a field (arrays,
|
||||
// out-params for engine calls)
|
||||
// Game::Read<float>(Offsets::VAR_GLOBAL) fixed-VA global read
|
||||
// Game::Ref<int>(Offsets::VAR_GLOBAL) fixed-VA global lvalue
|
||||
|
||||
template <typename T>
|
||||
inline const T *Ptr(const void *base, uintptr_t offset) {
|
||||
return reinterpret_cast<const T *>(reinterpret_cast<const uint8_t *>(base) + offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T *Ptr(void *base, uintptr_t offset) {
|
||||
return reinterpret_cast<T *>(reinterpret_cast<uint8_t *>(base) + offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T Read(const void *base, uintptr_t offset) {
|
||||
return *Ptr<T>(base, offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T &Ref(void *base, uintptr_t offset) {
|
||||
return *Ptr<T>(base, offset);
|
||||
}
|
||||
template <typename T>
|
||||
inline T Read(uintptr_t address) {
|
||||
return *reinterpret_cast<const T *>(address);
|
||||
}
|
||||
template <typename T>
|
||||
inline T &Ref(uintptr_t address) {
|
||||
return *reinterpret_cast<T *>(address);
|
||||
}
|
||||
|
||||
using FrameScript_Initialize_t = bool(__fastcall *)();
|
||||
using LoadScriptFunctions_t = void(__fastcall *)();
|
||||
|
||||
@@ -100,6 +140,7 @@ using lua_resume_t = int(__fastcall *)(void *L, int nargs);
|
||||
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
|
||||
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
|
||||
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
|
||||
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
|
||||
|
||||
extern const lua_isnumber_t IsNumber;
|
||||
extern const lua_isstring_t IsString;
|
||||
@@ -142,6 +183,7 @@ extern const lua_resume_t Resume;
|
||||
extern const lua_yield_t Yield;
|
||||
extern const luaL_argerror_t ArgError;
|
||||
extern const luaL_setn_t SetN;
|
||||
extern const lua_checkstack_t CheckStack;
|
||||
|
||||
// Returns the global `lua_State *` (read on demand from the engine's global).
|
||||
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
|
||||
@@ -242,6 +284,22 @@ void PushLocalizedString(void *L, const char *globalName, const char *fallback);
|
||||
// using `PushLocalizedString` + manual `string.format` invocation.
|
||||
void PushLocalizedFormatInt(void *L, const char *globalName,
|
||||
const char *fallback, int n);
|
||||
|
||||
// Pushes `_G[name]` and returns true only if it resolved to a function
|
||||
// (leaving it on the stack, ready to be called); otherwise pops it and
|
||||
// returns false. The building block for the CallGlobal* helpers below and for
|
||||
// any C module that dispatches to a Lua/FrameXML global by name (a pattern
|
||||
// several modules otherwise open-code).
|
||||
bool PushGlobalFunction(void *L, const char *name);
|
||||
|
||||
// `_G[name]()` — no args, no results; a no-op if the global isn't a function.
|
||||
// Stack-neutral.
|
||||
void CallGlobal(void *L, const char *name);
|
||||
|
||||
// `_G[name](arg)` — one string arg (NULL → nil), no results. Returns true iff
|
||||
// the global was a function and got called, so callers can fall back to a
|
||||
// native path when the FrameXML/addon global is absent. Stack-neutral.
|
||||
bool CallGlobalString(void *L, const char *name, const char *arg);
|
||||
} // namespace Lua
|
||||
|
||||
// Developer-console command system — the `~` console available when the
|
||||
@@ -323,19 +381,24 @@ void RunGlueModuleRegistrations();
|
||||
|
||||
// Declarative MinHook registration. Each feature module declares a
|
||||
// file-scope `static const Game::HookAutoRegister _hookreg{target,
|
||||
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and DllMain's
|
||||
// `RunHookRegistrations` walks the list once after `MH_Initialize`,
|
||||
// installing each hook with `MH_CreateHook` + `MH_EnableHook`.
|
||||
// &hook_fn, reinterpret_cast<void**>(&original_fn)};` and the installer
|
||||
// (`InstallHooks` in DllMain.cpp) walks the list once after
|
||||
// `MH_Initialize`, creating each hook and QUEUE-enabling it; a single
|
||||
// `MH_ApplyQueued` then activates them all in one thread-freeze.
|
||||
//
|
||||
// The install runs OFF the loader lock (via the `Load` export or the
|
||||
// fallback worker thread — see DllMain.cpp), never inside DllMain, so
|
||||
// the thread-freeze can't deadlock or stall the loader.
|
||||
//
|
||||
// Same lifetime rules as ModuleAutoRegister: constructors chain onto
|
||||
// a static-init list before DllMain runs, the linker keeps the OBJ
|
||||
// because the constructor has side effects, and order across TUs is
|
||||
// undefined but doesn't matter here (hooks are independent).
|
||||
//
|
||||
// Use only for feature hooks. The three core engine-init hooks in
|
||||
// Use only for feature hooks. The four core engine-init hooks in
|
||||
// DllMain (FrameScript_Initialize / LoadScriptFunctions /
|
||||
// Frame::RegisterEvent) have inline logic that interleaves with the
|
||||
// hook chain and stays in DllMain.
|
||||
// LoadGlueScriptFunctions / Frame::RegisterEvent) have inline logic that
|
||||
// interleaves with the hook chain and stays with the installer.
|
||||
struct HookAutoRegister {
|
||||
HookAutoRegister(uintptr_t target, void *hook, void **original);
|
||||
uintptr_t target;
|
||||
@@ -344,8 +407,9 @@ struct HookAutoRegister {
|
||||
HookAutoRegister *next;
|
||||
};
|
||||
|
||||
// Installs every registered hook. Returns `false` and stops on first
|
||||
// failure — caller (DllMain) should propagate by returning FALSE.
|
||||
// Creates and QUEUE-enables every registered hook (no `MH_ApplyQueued`
|
||||
// here — the caller applies the whole batch at once). Returns `false`
|
||||
// and stops on first failure.
|
||||
bool RunHookRegistrations();
|
||||
|
||||
} // namespace Game
|
||||
|
||||
+1581
-40
File diff suppressed because it is too large
Load Diff
+106
-12
@@ -60,6 +60,13 @@ namespace {
|
||||
constexpr const char *kAddonName = "!!!ClassicAPI";
|
||||
constexpr const char *kAddonTocFile = "!!!ClassicAPI.toc";
|
||||
|
||||
// Developer marker. When this file exists in the on-disk addon folder,
|
||||
// the disk copy wins unconditionally (see `DiskHasDevMarker` /
|
||||
// `DecideSource`). It is gitignored, excluded from the embed, and the
|
||||
// release ships only the DLL — so it can only exist where a developer
|
||||
// deliberately created it, never in a normal user's install.
|
||||
constexpr const char *kDevMarkerFile = ".classicapi-dev";
|
||||
|
||||
// `Interface\AddOns\!!!ClassicAPI\` — the prefix the engine
|
||||
// constructs for any of our addon's files. Comparing case-
|
||||
// insensitively because the TOC parser uses `Interface\AddOns\%s\…`
|
||||
@@ -156,17 +163,20 @@ bool ExtractTocVersion(const char *content, size_t size,
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is a
|
||||
// sentinel meaning "newer than any real release" — local dev builds
|
||||
// always win against installed-on-disk versions. Otherwise the two
|
||||
// strings are walked as dot-separated numeric semver components
|
||||
// Returns -1/0/+1 for `a < b` / `a == b` / `a > b`. "DEV" is the
|
||||
// local-build sentinel: two DEV builds are equal, but a DEV build sorts
|
||||
// BELOW any real release. Dev-over-release precedence is signalled
|
||||
// explicitly by the `.classicapi-dev` marker now — NOT by the toc
|
||||
// version — so a stray `## Version: DEV` disk copy (repo clone / old
|
||||
// bundle) never silently shadows a newer embedded release. Otherwise
|
||||
// the two strings are walked as dot-separated numeric semver components
|
||||
// (`1.2` < `1.10`).
|
||||
int CompareVersions(const char *a, const char *b) {
|
||||
const bool aDev = std::strcmp(a, "DEV") == 0;
|
||||
const bool bDev = std::strcmp(b, "DEV") == 0;
|
||||
if (aDev && bDev) return 0;
|
||||
if (aDev) return 1;
|
||||
if (bDev) return -1;
|
||||
if (aDev) return -1;
|
||||
if (bDev) return 1;
|
||||
while (*a || *b) {
|
||||
int va = 0, vb = 0;
|
||||
while (*a >= '0' && *a <= '9') { va = va * 10 + (*a - '0'); ++a; }
|
||||
@@ -203,16 +213,48 @@ void EnsureEmbeddedVersionExtracted() {
|
||||
}
|
||||
}
|
||||
|
||||
// True iff the on-disk addon folder contains the `.classicapi-dev`
|
||||
// marker. Read via the ORIGINAL `FUN_FILE_READ` (bypassing our embed
|
||||
// hook), so it reflects a real file on disk / in an MPQ — never an
|
||||
// embedded copy. That makes it a clean "is this a developer working
|
||||
// tree" signal: the marker is gitignored + excluded from the embed and
|
||||
// the release ships only the DLL, so a normal user's install can't
|
||||
// carry it.
|
||||
bool DiskHasDevMarker() {
|
||||
char fullPath[256];
|
||||
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
|
||||
kAddonPathPrefix, kDevMarkerFile);
|
||||
void *buf = nullptr;
|
||||
size_t size = 0;
|
||||
const int ok = FileRead_o(0, fullPath, &buf, &size, 1, 1, 0);
|
||||
if (ok == 0 || buf == nullptr)
|
||||
return false;
|
||||
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
|
||||
SMemFree(buf, __FILE__, __LINE__, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decide whether disk or embedded should serve all reads for this
|
||||
// addon. Called on the first FileRead_h call with a matching prefix.
|
||||
// Reads the on-disk TOC via the original `FUN_FILE_READ` (bypassing
|
||||
// our hook), parses its version, compares to the embedded version,
|
||||
// and caches the winner. If disk has no TOC at all, embedded wins by
|
||||
// default.
|
||||
// The `.classicapi-dev` marker forces disk unconditionally (explicit
|
||||
// developer override). Otherwise reads the on-disk TOC via the original
|
||||
// `FUN_FILE_READ` (bypassing our hook), parses its version, compares to
|
||||
// the embedded version, and caches the newer. If disk has no TOC at
|
||||
// all, embedded wins by default.
|
||||
void DecideSource() {
|
||||
if (g_source != Source::Undecided) return;
|
||||
EnsureEmbeddedVersionExtracted();
|
||||
|
||||
// Explicit developer override: disk wins regardless of version. This
|
||||
// is how a dev keeps disk precedence when running local Lua against a
|
||||
// *released* DLL (where the embedded version would otherwise be newer
|
||||
// and win). Dev intent lives in this marker, decoupled from the toc
|
||||
// version — so users never get silently shadowed by a stray copy.
|
||||
if (DiskHasDevMarker()) {
|
||||
g_source = Source::Disk;
|
||||
return;
|
||||
}
|
||||
|
||||
char fullPath[256];
|
||||
std::snprintf(fullPath, sizeof(fullPath), "%s%s",
|
||||
kAddonPathPrefix, kAddonTocFile);
|
||||
@@ -336,7 +378,32 @@ using ListInsert_t = void(__thiscall *)(void *listCtrl, void *entry,
|
||||
constexpr uintptr_t VAR_ADDON_LIST_HEAD = 0x00BE1B6C;
|
||||
constexpr int OFF_ADDON_ENTRY_NAME_PTR = 0x14;
|
||||
|
||||
void MoveEmbeddedToListHead() {
|
||||
// `entry+0x29` — the "filter-out" byte. The flat display-array builder
|
||||
// `FUN_0051da70` walks the linked list and copies an entry into
|
||||
// `GetNumAddOns`/`GetAddOnInfo`'s array ONLY when this byte is 0, so
|
||||
// setting it hides `!!!ClassicAPI` from the character-select AddOns
|
||||
// list — there's no checkbox, so it can't be toggled off. The builder
|
||||
// only ever *writes* this byte on the secure/SMSG path (`entry+0x28 !=
|
||||
// 0`); our entry is non-secure, so a manual `1` here is stable across
|
||||
// rebuilds. Hiding does NOT stop loading (the load pass `FUN_0051f600`
|
||||
// never reads `+0x29`) and does NOT break dependency resolution or
|
||||
// by-name queries (those use the addon hash table, which keeps the
|
||||
// entry regardless).
|
||||
constexpr int OFF_ADDON_ENTRY_FILTER_OUT = 0x29;
|
||||
|
||||
// `entry+0x2b` — DefaultState (`## DefaultState: enabled/disabled`).
|
||||
// The enable resolver `FUN_ADDON_ENABLE_RESOLVE` falls back to this
|
||||
// when the player has no per-character override. Belt-and-suspenders
|
||||
// alongside the resolver co-hook below (which forces enabled anyway).
|
||||
constexpr int OFF_ADDON_ENTRY_DEFAULT_STATE = 0x2b;
|
||||
|
||||
// Post-registration finalize for the embedded entry: move it to the
|
||||
// head of the load-order list (so consumers that read our globals from
|
||||
// their main chunk see them — see the block comment above the offsets),
|
||||
// hide it from the addon list, and mark it default-enabled. Combined
|
||||
// with the `FUN_ADDON_ENABLE_RESOLVE` co-hook, `!!!ClassicAPI` always
|
||||
// loads and never appears as a toggleable entry.
|
||||
void FinalizeEmbeddedEntry() {
|
||||
uintptr_t entry = *reinterpret_cast<uintptr_t *>(VAR_ADDON_LIST_HEAD);
|
||||
const int linkOffset = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ADDON_LIST_CTRL));
|
||||
@@ -344,6 +411,11 @@ void MoveEmbeddedToListHead() {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
entry + OFF_ADDON_ENTRY_NAME_PTR);
|
||||
if (name != nullptr && std::strcmp(name, kAddonName) == 0) {
|
||||
// Hide from the character-select AddOns list and default it
|
||||
// to enabled.
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_FILTER_OUT) = 1;
|
||||
*reinterpret_cast<uint8_t *>(entry + OFF_ADDON_ENTRY_DEFAULT_STATE) = 1;
|
||||
|
||||
auto fn = reinterpret_cast<ListInsert_t>(
|
||||
Offsets::FUN_INTRUSIVE_LIST_INSERT);
|
||||
fn(reinterpret_cast<void *>(
|
||||
@@ -361,7 +433,24 @@ void __fastcall AddonInit_h(const char *accountName) {
|
||||
AddonInit_o(accountName);
|
||||
auto TocParser = reinterpret_cast<TocParser_t>(Offsets::FUN_TOC_PARSER);
|
||||
TocParser(kAddonName);
|
||||
MoveEmbeddedToListHead();
|
||||
FinalizeEmbeddedEntry();
|
||||
}
|
||||
|
||||
// `FUN_ADDON_ENABLE_RESOLVE` — per-character enable resolver. We
|
||||
// co-hook it to force-enable `!!!ClassicAPI` regardless of DefaultState
|
||||
// or any stale WTF disable-override, so hiding it from the addon list
|
||||
// can never leave it stuck off. Returns 2 ("enabled for all"); the
|
||||
// load-pass gate only tests `!= 0`. Every other addon falls through to
|
||||
// the original untouched. See the Offsets.h note for the ABI.
|
||||
using AddonEnableResolve_t =
|
||||
uint32_t(__fastcall *)(const char *name, const char *account, char resolveAll);
|
||||
AddonEnableResolve_t AddonEnableResolve_o = nullptr;
|
||||
|
||||
uint32_t __fastcall AddonEnableResolve_h(const char *name, const char *account,
|
||||
char resolveAll) {
|
||||
if (name != nullptr && PathEqualsCI(name, kAddonName))
|
||||
return 2;
|
||||
return AddonEnableResolve_o(name, account, resolveAll);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookFileRead{
|
||||
@@ -374,6 +463,11 @@ const Game::HookAutoRegister _hookAddonInit{
|
||||
reinterpret_cast<void *>(&AddonInit_h),
|
||||
reinterpret_cast<void **>(&AddonInit_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookEnableResolve{
|
||||
Offsets::FUN_ADDON_ENABLE_RESOLVE,
|
||||
reinterpret_cast<void *>(&AddonEnableResolve_h),
|
||||
reinterpret_cast<void **>(&AddonEnableResolve_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Addons::Embedded
|
||||
|
||||
@@ -55,8 +55,10 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "item/CGItem.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Record.h"
|
||||
#include "item/Swap.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
@@ -97,30 +99,18 @@ Job g_job{};
|
||||
|
||||
// --- small CGItem / cache readers ---------------------------------------
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
int CGItemCount(const uint8_t *item) {
|
||||
auto *desc = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
auto *desc = Item::ObjectFields(item);
|
||||
if (desc == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
return Game::Read<int>(desc, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
}
|
||||
|
||||
uint64_t CGItemGuid(const uint8_t *item) {
|
||||
auto *inst = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
|
||||
auto *inst = Item::InstanceBlock(item);
|
||||
if (inst == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
return Game::Read<uint64_t>(inst, Offsets::OFF_INSTANCE_BLOCK_GUID);
|
||||
}
|
||||
|
||||
struct SlotInfo {
|
||||
@@ -145,18 +135,18 @@ SlotInfo ReadSlot(void *L, int bag, int slot) {
|
||||
// --- auction-house globals ----------------------------------------------
|
||||
|
||||
uint64_t AuctioneerGuid() {
|
||||
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
|
||||
const uint32_t lo = Game::Read<uint32_t>(
|
||||
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
|
||||
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
|
||||
const uint32_t hi = Game::Read<uint32_t>(
|
||||
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
|
||||
return (static_cast<uint64_t>(hi) << 32) | lo;
|
||||
}
|
||||
bool AHOpen() { return AuctioneerGuid() != 0; }
|
||||
|
||||
void SetSellGuid(uint64_t guid) {
|
||||
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
|
||||
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
|
||||
static_cast<uint32_t>(guid);
|
||||
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
|
||||
Game::Ref<uint32_t>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
|
||||
static_cast<uint32_t>(guid >> 32);
|
||||
}
|
||||
|
||||
@@ -188,11 +178,11 @@ bool BagIsGeneral(int bag) {
|
||||
const int id = Item::ID::FromCGItem(bagItem);
|
||||
if (id == 0)
|
||||
return false;
|
||||
const uint8_t *rec = PeekItemRecord(static_cast<uint32_t>(id));
|
||||
const uint8_t *rec = Item::PeekRecord(static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
return *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
|
||||
return Game::Read<uint32_t>(
|
||||
rec, Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
|
||||
}
|
||||
|
||||
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
|
||||
|
||||
+161
-69
@@ -17,18 +17,27 @@
|
||||
// nameplate flags, etc.) are populated with vanilla-truthful
|
||||
// defaults so consumers reading those keys get sensible values.
|
||||
//
|
||||
// Filter parsing here mirrors what most modern addons actually pass:
|
||||
// "HELPFUL" / "HARMFUL" are honored, every other modern filter
|
||||
// (`PLAYER` / `RAID` / `CANCELABLE` / `INCLUDE_NAME_PLATE_ONLY`) is
|
||||
// accepted but no-ops — they'd require either source-GUID tracking
|
||||
// (`PLAYER`), class-dispel-matrix infra (`RAID`), or systems
|
||||
// vanilla doesn't have at all.
|
||||
// Filter parsing (`ParseFilters`) tokenizes the modern AuraFilters string:
|
||||
// tokens separated by `|` and/or whitespace, each optionally negated with a
|
||||
// leading `!`. Whole-token matching, so `RAID_PLAYER_DISPELLABLE` is not
|
||||
// mistaken for `PLAYER` and `!PLAYER` is a negation rather than a match.
|
||||
// Honored: `HELPFUL` / `HARMFUL` (descriptor slot ranges), `PLAYER` /
|
||||
// `!PLAYER` (caster == / != the local player, from the Aura::Source cache),
|
||||
// `DISPELLABLE` / `!DISPELLABLE` (dispel type is / isn't one a
|
||||
// dispel/purge/steal can remove — Spell.dbc Dispel ∈ Magic/Curse/Disease/
|
||||
// Poison, matching the server's DISPEL_ALL_MASK), and `CROWD_CONTROL` /
|
||||
// `!CROWD_CONTROL` (is / isn't a hard control effect, via the shared
|
||||
// `Spell::CrowdControl` classifier that also backs C_LossOfControl). Every
|
||||
// other modern token (`RAID`, `CANCELABLE`, `INCLUDE_NAME_PLATE_ONLY`, `MAW`,
|
||||
// …) is accepted and ignored — they need a class-dispel matrix or systems
|
||||
// vanilla has no data for.
|
||||
|
||||
#include "Data.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "ui/ColorData.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
@@ -47,24 +56,84 @@ const uint8_t *ResolveUnit(const char *token) {
|
||||
return static_cast<const uint8_t *>(fn(token));
|
||||
}
|
||||
|
||||
// Parses the filter string. Returns Helpful by default; Harmful if
|
||||
// the string contains the substring "HARMFUL". Case-sensitive — the
|
||||
// modern API documents the filter tokens as upper-case constants
|
||||
// (`"HELPFUL"` etc.) and addons that pass them through case-changing
|
||||
// transforms are already broken on modern too.
|
||||
Data::Filter ParseFilter(const char *filter) {
|
||||
if (filter == nullptr)
|
||||
return Data::Filter::Helpful;
|
||||
if (strstr(filter, "HARMFUL") != nullptr)
|
||||
return Data::Filter::Harmful;
|
||||
return Data::Filter::Helpful;
|
||||
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
|
||||
// Only valid to call AFTER `ResolveUnit(token)` returned null *without raising*:
|
||||
// `ResolveUnit` runs the engine's token→GUID resolver first, so reaching a null
|
||||
// return means the token was well-formed (the resolver never raised). This runs
|
||||
// the same non-raising resolver and returns 0 for a token that maps to no
|
||||
// rostered member — which the group-array push paths treat as "no auras".
|
||||
uint64_t GuidForOutOfRange(const char *token) {
|
||||
if (token == nullptr)
|
||||
return 0;
|
||||
return Unit::Identity::GuidForToken(token);
|
||||
}
|
||||
|
||||
// The `PLAYER` filter token restricts results to auras the local player
|
||||
// cast. No other modern token contains the substring "PLAYER", so a plain
|
||||
// substring test is safe.
|
||||
bool HasPlayerFilter(const char *filter) {
|
||||
return filter != nullptr && strstr(filter, "PLAYER") != nullptr;
|
||||
// Parsed AuraFilters string. `helpful`/`harmful` record which range tokens
|
||||
// were present (neither → both, matching modern's default); `caster` carries
|
||||
// the PLAYER / !PLAYER restriction.
|
||||
struct ParsedFilter {
|
||||
bool helpful = false;
|
||||
bool harmful = false;
|
||||
Data::CasterMode caster = Data::CasterMode::Any;
|
||||
Data::DispelMode dispel = Data::DispelMode::Any;
|
||||
Data::CcMode cc = Data::CcMode::Any;
|
||||
|
||||
Data::Match ToMatch() const { return Data::Match{caster, dispel, cc}; }
|
||||
};
|
||||
|
||||
// Reduces the parsed range tokens to the single `Data::Filter` the indexed /
|
||||
// by-id / by-name getters take: Harmful when HARMFUL was given, else Helpful
|
||||
// (the modern default). HELPFUL wins ties are irrelevant here — an indexed
|
||||
// getter reads one range.
|
||||
Data::Filter RangeFilter(const ParsedFilter &pf) {
|
||||
return pf.harmful && !pf.helpful ? Data::Filter::Harmful
|
||||
: Data::Filter::Helpful;
|
||||
}
|
||||
|
||||
// Tokenizes the filter string. Case-sensitive (modern documents the tokens as
|
||||
// upper-case constants). `HELPFUL`/`HARMFUL` are positive range selectors;
|
||||
// negating them is meaningless for our slot-based ranges, so a leading `!` is
|
||||
// ignored on those. `PLAYER` honors negation (`!PLAYER` → NotPlayer). Any
|
||||
// unrecognized token is accepted and skipped.
|
||||
ParsedFilter ParseFilters(const char *filter) {
|
||||
ParsedFilter out;
|
||||
if (filter == nullptr)
|
||||
return out;
|
||||
for (const char *p = filter; *p != '\0';) {
|
||||
while (*p == '|' || *p == ' ' || *p == '\t')
|
||||
++p;
|
||||
if (*p == '\0')
|
||||
break;
|
||||
bool negate = false;
|
||||
if (*p == '!') {
|
||||
negate = true;
|
||||
++p;
|
||||
}
|
||||
char tok[64];
|
||||
size_t n = 0;
|
||||
while (*p != '\0' && *p != '|' && *p != ' ' && *p != '\t') {
|
||||
if (n + 1 < sizeof(tok))
|
||||
tok[n++] = *p;
|
||||
++p;
|
||||
}
|
||||
tok[n] = '\0';
|
||||
|
||||
if (strcmp(tok, "HELPFUL") == 0)
|
||||
out.helpful = true;
|
||||
else if (strcmp(tok, "HARMFUL") == 0)
|
||||
out.harmful = true;
|
||||
else if (strcmp(tok, "PLAYER") == 0)
|
||||
out.caster = negate ? Data::CasterMode::NotPlayer
|
||||
: Data::CasterMode::PlayerOnly;
|
||||
else if (strcmp(tok, "DISPELLABLE") == 0)
|
||||
out.dispel = negate ? Data::DispelMode::NotDispellable
|
||||
: Data::DispelMode::DispellableOnly;
|
||||
else if (strcmp(tok, "CROWD_CONTROL") == 0)
|
||||
out.cc = negate ? Data::CcMode::NotCrowdControl
|
||||
: Data::CcMode::CrowdControlOnly;
|
||||
// else: accepted and ignored (retail-only / unimplemented token).
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const char *ArgUnit(void *L, int idx) {
|
||||
@@ -89,18 +158,27 @@ const char *ArgOptString(void *L, int idx) {
|
||||
// or nil if no such aura. Used by `GetAuraDataByIndex` and the
|
||||
// filter-locked aliases.
|
||||
int PushAuraByIndex(void *L, const char *unitToken, int index,
|
||||
Data::Filter filter, bool playerOnly = false) {
|
||||
Data::Filter filter, Data::Match match = {}) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindNthSlot(unit, index, filter, match);
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
}
|
||||
// Descriptor exhausted — an aura the engine dropped from the slot array
|
||||
// (rogue stealth, nearby party range fluctuation) may still be live in
|
||||
// the Aura::Source cache. Surface it after the descriptor entries.
|
||||
if (Data::PushNthCacheFallback(L, unit, index, filter, match))
|
||||
return 1;
|
||||
} else {
|
||||
// No live CGUnit — an out-of-range / cross-map groupmate. The server
|
||||
// still sends their aura spell IDs via SMSG_PARTY_MEMBER_STATS; read
|
||||
// them the same way the built-in UnitBuff/UnitDebuff do.
|
||||
if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
|
||||
filter, match))
|
||||
return 1;
|
||||
}
|
||||
// Descriptor exhausted — an aura the engine dropped from the slot array
|
||||
// (rogue stealth, party range fluctuation) may still be live in the
|
||||
// Aura::Source cache. Surface it after the descriptor entries.
|
||||
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
|
||||
return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
@@ -113,8 +191,8 @@ int __fastcall Script_GetAuraDataByIndex(void *L) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
return PushAuraByIndex(L, unit, index, ParseFilter(filterStr),
|
||||
HasPlayerFilter(filterStr));
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
return PushAuraByIndex(L, unit, index, RangeFilter(pf), pf.ToMatch());
|
||||
}
|
||||
|
||||
int __fastcall Script_GetBuffDataByIndex(void *L) {
|
||||
@@ -146,19 +224,23 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
|
||||
return 1;
|
||||
}
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
Data::Filter f;
|
||||
const Data::Filter *fp = nullptr;
|
||||
if (filterStr != nullptr) {
|
||||
f = ParseFilter(filterStr);
|
||||
fp = &f;
|
||||
}
|
||||
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
|
||||
fp, HasPlayerFilter(filterStr));
|
||||
if (slot < 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
Data::Filter f = RangeFilter(pf);
|
||||
const Data::Filter *fp = (pf.helpful || pf.harmful) ? &f : nullptr;
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindSlotBySpellID(
|
||||
unit, static_cast<uint32_t>(spellID), fp, pf.ToMatch());
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
}
|
||||
} else if (Data::PushGroupAuraBySpellID(L, GuidForOutOfRange(unitToken),
|
||||
static_cast<uint32_t>(spellID), fp,
|
||||
pf.ToMatch())) {
|
||||
// Out-of-range groupmate — read from the group-member aura array.
|
||||
return 1;
|
||||
}
|
||||
Data::Push(L, unit, slot);
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -171,19 +253,21 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
|
||||
return 1;
|
||||
}
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
Data::Filter f;
|
||||
const Data::Filter *fp = nullptr;
|
||||
if (filterStr != nullptr) {
|
||||
f = ParseFilter(filterStr);
|
||||
fp = &f;
|
||||
}
|
||||
const int slot = Data::FindSlotBySpellName(unit, spellName, fp,
|
||||
HasPlayerFilter(filterStr));
|
||||
if (slot < 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
Data::Filter f = RangeFilter(pf);
|
||||
const Data::Filter *fp = (pf.helpful || pf.harmful) ? &f : nullptr;
|
||||
if (unit != nullptr) {
|
||||
const int slot = Data::FindSlotBySpellName(unit, spellName, fp, pf.ToMatch());
|
||||
if (slot >= 0) {
|
||||
Data::Push(L, unit, slot);
|
||||
return 1;
|
||||
}
|
||||
} else if (Data::PushGroupAuraBySpellName(L, GuidForOutOfRange(unitToken),
|
||||
spellName, fp, pf.ToMatch())) {
|
||||
// Out-of-range groupmate — read from the group-member aura array.
|
||||
return 1;
|
||||
}
|
||||
Data::Push(L, unit, slot);
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -207,7 +291,7 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
|
||||
// sequential keys starting from `nextKey`. Updates `nextKey` so a
|
||||
// follow-up call can append to the same outer table.
|
||||
void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
|
||||
Data::Filter filter, int &nextKey, bool playerOnly) {
|
||||
Data::Filter filter, int &nextKey, Data::Match match) {
|
||||
const int start = (filter == Data::Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
@@ -217,14 +301,15 @@ void AppendRangeToArray(void *L, const uint8_t *unit, int outerIdx,
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!Data::IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (playerOnly && !Data::IsPlayerCast(unit, slot))
|
||||
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
|
||||
Data::ReadSpellID(unit, slot)))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
Data::Push(L, unit, slot);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
// Append auras the descriptor dropped but Aura::Source still has live.
|
||||
Data::AppendCacheFallbacks(L, unit, filter, playerOnly, outerIdx, nextKey);
|
||||
Data::AppendCacheFallbacks(L, unit, filter, match, outerIdx, nextKey);
|
||||
}
|
||||
|
||||
int __fastcall Script_GetUnitAuras(void *L) {
|
||||
@@ -234,22 +319,29 @@ int __fastcall Script_GetUnitAuras(void *L) {
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (unit == nullptr)
|
||||
return 1;
|
||||
|
||||
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
|
||||
// selects that range, neither selects both. So `"PLAYER"` alone returns
|
||||
// both ranges restricted to player-cast auras (matching retail).
|
||||
const bool playerOnly = HasPlayerFilter(filterStr);
|
||||
const bool hasHelpful = filterStr != nullptr && strstr(filterStr, "HELPFUL") != nullptr;
|
||||
const bool hasHarmful = filterStr != nullptr && strstr(filterStr, "HARMFUL") != nullptr;
|
||||
const bool both = !hasHelpful && !hasHarmful;
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
const bool both = !pf.helpful && !pf.harmful;
|
||||
|
||||
int nextKey = 1;
|
||||
if (hasHelpful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
|
||||
if (hasHarmful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
|
||||
if (unit != nullptr) {
|
||||
if (pf.helpful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, pf.ToMatch());
|
||||
if (pf.harmful || both)
|
||||
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, pf.ToMatch());
|
||||
} else {
|
||||
// No live CGUnit — out-of-range / cross-map groupmate. Enumerate the
|
||||
// group-member aura array (spell IDs the server still transmits), the
|
||||
// same source the built-in UnitBuff/UnitDebuff read out of range.
|
||||
const uint64_t guid = GuidForOutOfRange(unitToken);
|
||||
if (pf.helpful || both)
|
||||
Data::AppendGroupAuras(L, guid, Data::Filter::Helpful, pf.ToMatch(), 1, nextKey);
|
||||
if (pf.harmful || both)
|
||||
Data::AppendGroupAuras(L, guid, Data::Filter::Harmful, pf.ToMatch(), 1, nextKey);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
+68
-65
@@ -21,7 +21,9 @@
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "net/PacketReader.h"
|
||||
#include "net/SendObserver.h"
|
||||
#include "spell/Mod.h"
|
||||
#include "time/Clock.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -31,12 +33,7 @@ namespace Aura::ComboDuration {
|
||||
namespace {
|
||||
|
||||
using Net::CDataStore;
|
||||
|
||||
uint32_t NowMs() {
|
||||
using TickCount_t = uint32_t(__fastcall *)();
|
||||
return reinterpret_cast<TickCount_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
|
||||
}
|
||||
using Time::Clock::NowMs;
|
||||
|
||||
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
|
||||
|
||||
@@ -56,28 +53,52 @@ uint8_t CurrentComboPoints() {
|
||||
return *(info + Offsets::OFF_CGPLAYER_COMBO_POINTS);
|
||||
}
|
||||
|
||||
// One cast in flight at a time is the practical case (a second finisher
|
||||
// can't be cast until the server consumes the first one's combo points),
|
||||
// so a single slot with a spellID match and a short TTL suffices.
|
||||
// Keyed by spellID, because a finisher's own SMSG_SPELL_GO is not reliably
|
||||
// the next one to arrive after its cast. A talent that procs an aura on the
|
||||
// finisher (Turtle's rogue Taste for Blood casts spell 52529 on the player)
|
||||
// lands its SPELL_GO first, and every player-cast aura spell reaches
|
||||
// TryComboScaledMs. A single slot -- or a lookup that cleared the slot on a
|
||||
// spellID mismatch -- therefore lost the snapshot to the proc, and the
|
||||
// finisher fell back to its 0-CP base duration. Only the matching entry is
|
||||
// ever consumed; a miss leaves every other capture intact.
|
||||
struct Capture {
|
||||
uint32_t spellId;
|
||||
uint8_t comboPoints;
|
||||
uint32_t capturedMs;
|
||||
};
|
||||
Capture g_capture{0, 0, 0};
|
||||
constexpr int kCaptureCount = 8;
|
||||
Capture g_captures[kCaptureCount];
|
||||
int g_captureCursor = 0;
|
||||
|
||||
// Worst-case send -> SMSG_SPELL_GO roundtrip. Past this the snapshot is
|
||||
// stale (the cast failed / was rejected) and must not scale a later cast.
|
||||
constexpr uint32_t kCaptureTtlMs = 2000;
|
||||
|
||||
void RememberCapture(uint32_t spellId, uint8_t comboPoints, uint32_t nowMs) {
|
||||
for (auto &c : g_captures) {
|
||||
if (c.spellId == spellId) { // re-cast before the first resolved
|
||||
c.comboPoints = comboPoints;
|
||||
c.capturedMs = nowMs;
|
||||
return;
|
||||
}
|
||||
}
|
||||
g_captures[g_captureCursor] = {spellId, comboPoints, nowMs};
|
||||
g_captureCursor = (g_captureCursor + 1) % kCaptureCount;
|
||||
}
|
||||
|
||||
int ConsumeCapturedCP(uint32_t spellId) {
|
||||
const Capture c = g_capture;
|
||||
g_capture = {0, 0, 0};
|
||||
if (c.spellId != spellId || c.comboPoints == 0)
|
||||
return 0;
|
||||
if (NowMs() - c.capturedMs >= kCaptureTtlMs)
|
||||
return 0;
|
||||
return c.comboPoints;
|
||||
for (auto &c : g_captures) {
|
||||
if (c.spellId != spellId)
|
||||
continue;
|
||||
const Capture got = c;
|
||||
c = {0, 0, 0};
|
||||
if (got.comboPoints == 0)
|
||||
return 0;
|
||||
if (NowMs() - got.capturedMs >= kCaptureTtlMs)
|
||||
return 0;
|
||||
return got.comboPoints;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Co-hook on the NetClient send — the one funnel every outgoing CMSG
|
||||
@@ -86,28 +107,17 @@ int ConsumeCapturedCP(uint32_t spellId) {
|
||||
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
|
||||
// Snapshot the combo points at the moment the cast request leaves: the
|
||||
// server consumes them before SMSG_SPELL_GO arrives, so reading at
|
||||
// SpellGo time is too late.
|
||||
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
|
||||
CDataStore *packet);
|
||||
NetSend_t g_origNetSend = nullptr;
|
||||
|
||||
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
|
||||
if (packet != nullptr) {
|
||||
const uint32_t saved = packet->m_read;
|
||||
const uint32_t opcode = Net::Read<uint32_t>(packet);
|
||||
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
if (spellId != 0)
|
||||
g_capture = {spellId, CurrentComboPoints(), NowMs()};
|
||||
}
|
||||
packet->m_read = saved;
|
||||
}
|
||||
g_origNetSend(conn, edx, packet);
|
||||
// SpellGo time is too late. We watch sends through the shared
|
||||
// `Net::SendObserver` rather than owning the funnel hook ourselves.
|
||||
void OnSend(uint32_t opcode, Net::CDataStore *packet) {
|
||||
if (opcode != Offsets::OP_CMSG_CAST_SPELL)
|
||||
return;
|
||||
const uint32_t spellId = Net::Read<uint32_t>(packet);
|
||||
if (spellId != 0)
|
||||
RememberCapture(spellId, CurrentComboPoints(), NowMs());
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _sendHook{
|
||||
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
|
||||
reinterpret_cast<void **>(&g_origNetSend)};
|
||||
const Net::SendObserver::AutoSubscribe _sendSub{&OnSend};
|
||||
|
||||
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
|
||||
|
||||
@@ -132,30 +142,19 @@ const Override *FindOverride(uint32_t spellId) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Known values for spells whose duration row DANGLES — a non-zero
|
||||
// DurationIndex pointing at a SpellDuration row the client doesn't have.
|
||||
// Consulted ONLY when that exact condition holds, so the gate is the bug
|
||||
// itself, not a server fingerprint: on a stock client every one of these
|
||||
// resolves to a valid row and this table is never reached, and if the
|
||||
// broken client data is ever fixed the DBC row takes over automatically.
|
||||
//
|
||||
// Turtle's reworked Rip is the known case: all six ranks point at
|
||||
// SpellDuration row 87, which Turtle added on the SERVER only
|
||||
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
|
||||
// i.e. 8s + 2s per combo point); their client patch never shipped the
|
||||
// row (the client table jumps 86 -> 105). Verified the only dangling
|
||||
// duration index in the entire client Spell.dbc.
|
||||
constexpr Override kDanglingRowKnown[] = {
|
||||
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
|
||||
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
|
||||
};
|
||||
// Resolver chain for dangling-duration-row spells (client DBC missing the
|
||||
// row). The KNOWN VALUES themselves are server-custom data and live in
|
||||
// their own module (e.g. `src/turtle/ComboDuration.cpp` for Turtle's
|
||||
// reworked Rip); this stock module only owns the mechanism and the
|
||||
// dangling-condition gate. Chained at static-init by `AutoDanglingResolver`.
|
||||
AutoDanglingResolver *g_danglingResolvers = nullptr;
|
||||
|
||||
const Override *FindDanglingRowKnown(uint32_t spellId) {
|
||||
for (const auto &k : kDanglingRowKnown) {
|
||||
if (k.spellId == spellId)
|
||||
return &k;
|
||||
}
|
||||
return nullptr;
|
||||
bool ResolveDangling(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
|
||||
for (AutoDanglingResolver *r = g_danglingResolvers; r != nullptr;
|
||||
r = r->next)
|
||||
if (r->fn(spellId, baseMs, maxMs))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
|
||||
@@ -203,6 +202,11 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoDanglingResolver::AutoDanglingResolver(DanglingResolver f)
|
||||
: fn(f), next(g_danglingResolvers) {
|
||||
g_danglingResolvers = this;
|
||||
}
|
||||
|
||||
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
|
||||
if (spellRecord == nullptr || spellId == 0)
|
||||
return 0;
|
||||
@@ -227,12 +231,11 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
|
||||
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
|
||||
} else if (idx != 0) {
|
||||
// Dangling duration index — the client data is broken for this
|
||||
// spell. Fall back to the built-in known values (Turtle Rip).
|
||||
const Override *k = FindDanglingRowKnown(spellId);
|
||||
if (k == nullptr)
|
||||
// spell. Defer to a registered resolver (the known values are
|
||||
// server-custom data owned by a platform module, e.g.
|
||||
// src/turtle for Turtle's reworked Rip).
|
||||
if (!ResolveDangling(spellId, &baseMs, &maxMs))
|
||||
return 0;
|
||||
baseMs = k->baseMs;
|
||||
maxMs = k->maxMs;
|
||||
} else {
|
||||
return 0; // no duration row at all
|
||||
}
|
||||
|
||||
@@ -47,4 +47,22 @@ namespace Aura::ComboDuration {
|
||||
// duration path".
|
||||
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
|
||||
|
||||
// Extension point for finishers whose SpellDuration row DANGLES — a
|
||||
// non-zero DurationIndex pointing at a row the client DBC doesn't have.
|
||||
// A resolver returns true and fills *baseMs/*maxMs if it knows the spell,
|
||||
// false otherwise. Consulted by `TryComboScaledMs` ONLY in that dangling
|
||||
// condition (after any Lua-registered override), so the activation gate is
|
||||
// the client-data bug itself, not a server fingerprint — the deliberate
|
||||
// design choice (a realm-sniffing approach was rejected). Server-custom
|
||||
// data (e.g. Turtle's reworked Rip) registers its values here instead of
|
||||
// living in this stock module: declare a file-scope
|
||||
// `static const Aura::ComboDuration::AutoDanglingResolver`.
|
||||
using DanglingResolver = bool (*)(uint32_t spellId, int32_t *baseMs,
|
||||
int32_t *maxMs);
|
||||
struct AutoDanglingResolver {
|
||||
explicit AutoDanglingResolver(DanglingResolver fn);
|
||||
DanglingResolver fn;
|
||||
AutoDanglingResolver *next;
|
||||
};
|
||||
|
||||
} // namespace Aura::ComboDuration
|
||||
|
||||
+483
-92
@@ -17,7 +17,11 @@
|
||||
#include "Offsets.h"
|
||||
#include "aura/Source.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "group/MemberStats.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "spell/CrowdControl.h"
|
||||
#include "spell/IsSelfBuff.h"
|
||||
#include "time/Clock.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -27,13 +31,6 @@ namespace Aura::Data {
|
||||
|
||||
namespace {
|
||||
|
||||
// Spell.dbc record field offsets used locally. `spell/Info.cpp` has
|
||||
// the same set of locals for `Script_GetSpellInfo`; promoting them
|
||||
// to `Offsets.h` is a future refactor — for now we mirror its
|
||||
// convention so both files stay in sync.
|
||||
constexpr int OFF_SPELL_ICON_ID = 0x1D4;
|
||||
constexpr int OFF_SPELLICON_PATH = 0x04;
|
||||
|
||||
const uint8_t *Descriptor(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return nullptr;
|
||||
@@ -71,13 +68,13 @@ const char *SpellIconPath(const uint8_t *spellRecord) {
|
||||
if (spellRecord == nullptr)
|
||||
return nullptr;
|
||||
const int iconID = *reinterpret_cast<const int *>(
|
||||
spellRecord + OFF_SPELL_ICON_ID);
|
||||
spellRecord + Offsets::OFF_SPELL_RECORD_ICON_ID);
|
||||
if (iconID <= 0)
|
||||
return nullptr;
|
||||
return DBC::StringField(Offsets::VAR_SPELL_ICON_RECORDS,
|
||||
Offsets::VAR_SPELL_ICON_COUNT,
|
||||
static_cast<uint32_t>(iconID),
|
||||
OFF_SPELLICON_PATH);
|
||||
Offsets::OFF_SPELLICON_PATH);
|
||||
}
|
||||
|
||||
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
|
||||
@@ -139,6 +136,20 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
|
||||
const uint32_t expirationMs = expirationTable[slotCode];
|
||||
if (expirationMs == 0)
|
||||
return 0.0;
|
||||
// Report "no timer" once the stored expiration has elapsed — this is not a
|
||||
// heuristic, it's exactly what the engine's own GetPlayerBuffTimeLeft does.
|
||||
// Script_GetPlayerBuffTimeLeft (0x004e4930) → FUN_004e4450 reads this same
|
||||
// expiration table and returns:
|
||||
// if ((int)(now - expiration[slot]) >= 0) return 0; // elapsed -> 0
|
||||
// return expiration[slot] - now; // else remaining
|
||||
// Permanent auras (paladin auras, passives, racials) carry an infinite
|
||||
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
|
||||
// cast-time in this slot for them, so it's always "already elapsed" and the
|
||||
// engine shows no countdown. `Time::Clock::Reached` takes the signed diff, so
|
||||
// it also absorbs the ~24.86-day GetTickCount wrap (both operands wrap
|
||||
// together).
|
||||
if (Time::Clock::Reached(expirationMs))
|
||||
return 0.0; // expiration reached/passed → permanent or no timer
|
||||
return static_cast<double>(expirationMs) * 0.001;
|
||||
}
|
||||
|
||||
@@ -192,6 +203,13 @@ double SpellBaseDurationSeconds(uint32_t spellID, int unitLevel) {
|
||||
// Reads the player's level from UNIT_FIELD_LEVEL. Returns 0 when
|
||||
// unresolved (pre-login, no descriptor) — caller should treat that
|
||||
// as "skip level scaling".
|
||||
// True if the engine-tick expiration `expMs` has already reached/passed now.
|
||||
// Same signed-wrap-safe compare `PlayerBuffExpirationSeconds` uses (absorbs
|
||||
// the ~24.86-day GetTickCount wrap). An already-elapsed cached expiration is
|
||||
// not meaningful — the caster is kept regardless, but the time is reported as
|
||||
// unknown rather than as a negative remaining.
|
||||
bool ExpirationElapsed(uint32_t expMs) { return Time::Clock::Reached(expMs); }
|
||||
|
||||
int PlayerLevel(const uint8_t *player) {
|
||||
auto *desc = Descriptor(player);
|
||||
if (desc == nullptr)
|
||||
@@ -200,6 +218,35 @@ int PlayerLevel(const uint8_t *player) {
|
||||
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
|
||||
}
|
||||
|
||||
// Caster + timing attributed to ONE aura instance — the aura `spellID` sitting
|
||||
// in absolute descriptor `slot` on `guid` (`Aura::Source::SLOT_UNBOUND` when
|
||||
// the caller has no slot: the out-of-range group array, the cache fallback).
|
||||
// The slot is what tells two casters' copies of one spell apart; without it
|
||||
// they are indistinguishable from a spell ID alone.
|
||||
//
|
||||
// The caster is the observed one from the Aura::Source cache, else — when we
|
||||
// never saw the cast — the unit itself if the spell is a self-only-target buff
|
||||
// (self-buffs can't be cross-cast), else 0. Single source of truth for both
|
||||
// display (sourceGUID/sourceUnit) and the PLAYER caster filter, so a self-buff
|
||||
// shows a source AND matches HELPFUL|PLAYER instead of the two paths
|
||||
// disagreeing.
|
||||
struct Attribution {
|
||||
uint64_t caster;
|
||||
uint32_t expirationMs;
|
||||
uint32_t durationMs;
|
||||
};
|
||||
|
||||
Attribution Attribute(uint64_t guid, uint32_t spellID, int slot) {
|
||||
Attribution a = {0, 0, 0};
|
||||
if (guid == 0 || spellID == 0)
|
||||
return a;
|
||||
Aura::Source::Get(guid, spellID, slot, &a.caster, &a.expirationMs,
|
||||
&a.durationMs);
|
||||
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
|
||||
a.caster = guid; // self-only-target aura → cast by the unit itself
|
||||
return a;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
uint32_t ReadSpellID(const uint8_t *unit, int slot) {
|
||||
@@ -233,16 +280,68 @@ bool IsPlayerCast(const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
if (spellID == 0)
|
||||
return false;
|
||||
uint64_t casterGuid = 0;
|
||||
uint32_t expMs = 0;
|
||||
uint32_t durMs = 0;
|
||||
if (!Aura::Source::Get(UnitGuid(unit), spellID, &casterGuid, &expMs, &durMs))
|
||||
const uint64_t player = Unit::Identity::PlayerGuid();
|
||||
return player != 0 && Attribute(UnitGuid(unit), spellID, slot).caster == player;
|
||||
}
|
||||
|
||||
// Applies the PLAYER / !PLAYER caster restriction. `isPlayerCast` is the
|
||||
// per-aura "was this cast by the local player" answer (false on a cache miss).
|
||||
bool CasterMatches(CasterMode caster, bool isPlayerCast) {
|
||||
switch (caster) {
|
||||
case CasterMode::PlayerOnly: return isPlayerCast;
|
||||
case CasterMode::NotPlayer: return !isPlayerCast;
|
||||
default: return true; // Any
|
||||
}
|
||||
}
|
||||
|
||||
bool IsDispellable(uint32_t spellID) {
|
||||
const uint8_t *rec = SpellRecord(spellID);
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
return casterGuid != 0 && casterGuid == Unit::Identity::PlayerGuid();
|
||||
const uint32_t dispelType = *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_SPELL_DISPEL_TYPE);
|
||||
// Magic(1) / Curse(2) / Disease(3) / Poison(4) — the server's
|
||||
// DISPEL_ALL_MASK. None(0) / Stealth(5) / Invisibility(6) / Enrage(9)
|
||||
// etc. are not dispellable by any mechanism.
|
||||
return dispelType >= 1 && dispelType <= 4;
|
||||
}
|
||||
|
||||
bool DispelMatches(DispelMode dispel, uint32_t spellID) {
|
||||
switch (dispel) {
|
||||
case DispelMode::DispellableOnly: return IsDispellable(spellID);
|
||||
case DispelMode::NotDispellable: return !IsDispellable(spellID);
|
||||
default: return true; // Any
|
||||
}
|
||||
}
|
||||
|
||||
bool CcMatches(CcMode cc, uint32_t spellID) {
|
||||
switch (cc) {
|
||||
case CcMode::CrowdControlOnly:
|
||||
return Spell::CrowdControl::IsCrowdControl(spellID);
|
||||
case CcMode::NotCrowdControl:
|
||||
return !Spell::CrowdControl::IsCrowdControl(spellID);
|
||||
default:
|
||||
return true; // Any
|
||||
}
|
||||
}
|
||||
|
||||
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
|
||||
return CasterMatches(match.caster, isPlayerCast) &&
|
||||
DispelMatches(match.dispel, spellID) &&
|
||||
CcMatches(match.cc, spellID);
|
||||
}
|
||||
|
||||
// Group-array analog of IsPlayerCast: the member has no descriptor, so there is
|
||||
// no slot to attribute by and the cache is consulted by (guid, spellID) alone.
|
||||
// A miss counts as "not the player" (same as IsPlayerCast).
|
||||
bool GroupIsPlayerCast(uint64_t guid, uint32_t spellID) {
|
||||
const uint64_t player = Unit::Identity::PlayerGuid();
|
||||
return player != 0 &&
|
||||
Attribute(guid, spellID, Aura::Source::SLOT_UNBOUND).caster == player;
|
||||
}
|
||||
|
||||
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
bool playerOnly) {
|
||||
Match match) {
|
||||
if (unit == nullptr || oneBasedIndex < 1)
|
||||
return -1;
|
||||
const int start = (filter == Filter::Harmful)
|
||||
@@ -255,7 +354,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (playerOnly && !IsPlayerCast(unit, slot))
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex)
|
||||
return slot;
|
||||
@@ -264,7 +363,7 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
}
|
||||
|
||||
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
|
||||
const Filter *filter, bool playerOnly) {
|
||||
const Filter *filter, Match match) {
|
||||
if (unit == nullptr || spellID == 0)
|
||||
return -1;
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
@@ -278,7 +377,7 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
|
||||
continue;
|
||||
if (ReadSpellID(unit, slot) != spellID)
|
||||
continue;
|
||||
if (playerOnly && !IsPlayerCast(unit, slot))
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -286,7 +385,7 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
|
||||
}
|
||||
|
||||
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
|
||||
const Filter *filter, bool playerOnly) {
|
||||
const Filter *filter, Match match) {
|
||||
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
|
||||
return -1;
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
@@ -301,7 +400,7 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
|
||||
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
|
||||
if (name == nullptr || std::strcmp(name, spellName) != 0)
|
||||
continue;
|
||||
if (playerOnly && !IsPlayerCast(unit, slot))
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -419,56 +518,82 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
|
||||
// yields nil, so the table doesn't need an explicit entry.
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
|
||||
// unit's auras except the local player's — for everyone else (target,
|
||||
// party, raid, mouseover) `expirationTime` stays 0 unless the
|
||||
// `Aura::Source` cache observed the cast. For the player we read the
|
||||
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
|
||||
// returns), which gives a real expiration timestamp.
|
||||
//
|
||||
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
|
||||
// (the *base* value); talent / glyph duration-extension modifiers are
|
||||
// already baked into the expiration timestamp on the caster's side, so
|
||||
// `expirationTime - GetTime()` reflects true remaining time even when the
|
||||
// base `duration` doesn't include the talent boost.
|
||||
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
|
||||
// core behind every push path (descriptor, cache fallback, group-array). No
|
||||
// dependence on a live descriptor: the descriptor path passes what it read
|
||||
// (stacks, level, is-this-the-player), the others pass their known-or-default
|
||||
// values.
|
||||
//
|
||||
// Timing rules (identical across paths): `duration` is the Spell.dbc →
|
||||
// SpellDuration.dbc base scaled by `unitLevel` (0 skips scaling); talent/glyph
|
||||
// duration modifiers are baked into the caster-side expiration, so
|
||||
// `expirationTime - GetTime()` is the true remaining time even when `duration`
|
||||
// omits the boost. `expirationTime` for the local player comes from the engine
|
||||
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
|
||||
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
|
||||
// cache when it observed the cast — a miss leaves the modern-truthful defaults
|
||||
// (expiration 0, no sourceUnit/GUID). `slot` is the absolute descriptor slot
|
||||
// the aura occupies, which is what attributes it to the right caster when two
|
||||
// of them hold the same spell on the unit; `Aura::Source::SLOT_UNBOUND` for the
|
||||
// paths with no descriptor to read one from.
|
||||
//
|
||||
// `known` short-circuits the by-(guid,spell,slot) resolve: the cache-fallback
|
||||
// paths already hold the exact entry's attribution (Enumerate copied it out),
|
||||
// so they pass it straight through. Re-resolving there would call `Get` a second
|
||||
// time and, for an unbound entry sharing its spell with another caster's entry,
|
||||
// return a miss — dropping a caster Enumerate already knew. Null for the
|
||||
// descriptor / group-array paths, which have no per-entry attribution in hand.
|
||||
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
|
||||
bool isHelpful, int applications, int unitLevel,
|
||||
bool isPlayer, int slot,
|
||||
const Attribution *known = nullptr) {
|
||||
double duration = 0.0;
|
||||
double expirationTime = 0.0;
|
||||
uint64_t casterGuid = 0;
|
||||
const uint8_t *player = LocalPlayer();
|
||||
if (spellID != 0) {
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
if (spellID != 0)
|
||||
duration = SpellBaseDurationSeconds(spellID, unitLevel);
|
||||
}
|
||||
if (unit != nullptr && unit == player) {
|
||||
if (isPlayer && spellID != 0) {
|
||||
const uint8_t *entry = FindPlayerBuffEntry(spellID);
|
||||
if (entry != nullptr)
|
||||
expirationTime = PlayerBuffExpirationSeconds(entry);
|
||||
}
|
||||
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
|
||||
// cast time (the descriptor has neither). The player-buff table above is
|
||||
// authoritative for the player's own expiration; the cache fills it for
|
||||
// everyone else. Prefer the applied (caster-modified) duration so it stays
|
||||
// consistent with `expirationTime` — otherwise a talented DoT (Improved
|
||||
// Shadow Word: Pain) would show remaining time exceeding the base.
|
||||
if (spellID != 0) {
|
||||
uint64_t c = 0;
|
||||
uint32_t expMs = 0;
|
||||
uint32_t durMs = 0;
|
||||
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
|
||||
if (expirationTime == 0.0 && expMs != 0)
|
||||
expirationTime = static_cast<double>(expMs) * 0.001;
|
||||
if (durMs != 0)
|
||||
duration = static_cast<double>(durMs) * 0.001;
|
||||
casterGuid = c;
|
||||
}
|
||||
if (spellID != 0 && guid != 0) {
|
||||
// Caster (sourceGUID/sourceUnit) and timing resolved together, so a
|
||||
// self-buff both shows a source and matches HELPFUL|PLAYER instead of
|
||||
// the two paths disagreeing.
|
||||
Attribution a = (known != nullptr) ? *known : Attribute(guid, spellID, slot);
|
||||
// Self-buff inference for a pre-resolved entry whose caster the cache
|
||||
// never learned (idempotent on the Attribute() path, which already
|
||||
// applied it — the caster is nonzero there when it matched).
|
||||
if (a.caster == 0 && Spell::IsSelfBuff::IsSelfBuff(spellID))
|
||||
a.caster = guid;
|
||||
// A cached expiration that already elapsed while the aura is still
|
||||
// present (non-player casters get an underestimated base duration)
|
||||
// is not meaningful — report unknown (0) rather than a negative
|
||||
// remaining time.
|
||||
if (expirationTime == 0.0 && a.expirationMs != 0 &&
|
||||
!ExpirationElapsed(a.expirationMs))
|
||||
expirationTime = static_cast<double>(a.expirationMs) * 0.001;
|
||||
if (a.durationMs != 0)
|
||||
duration = static_cast<double>(a.durationMs) * 0.001;
|
||||
casterGuid = a.caster;
|
||||
}
|
||||
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
|
||||
casterGuid);
|
||||
}
|
||||
|
||||
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
|
||||
expirationTime, casterGuid);
|
||||
// Attribution held directly by an `Aura::Source` cache entry (from `Enumerate`),
|
||||
// for the cache-fallback push paths to hand to `PushEnriched` as `known`.
|
||||
static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
|
||||
return {c.casterGuid, c.expirationMs, c.durationMs};
|
||||
}
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot);
|
||||
}
|
||||
|
||||
namespace {
|
||||
@@ -479,17 +604,15 @@ namespace {
|
||||
// applied (caster-modified) ms when known, else the Spell.dbc base.
|
||||
void PushFromCache(void *L, const uint8_t *unit,
|
||||
const Aura::Source::CachedAura &c, bool isHelpful) {
|
||||
double duration = 0.0;
|
||||
if (c.durationMs != 0) {
|
||||
duration = static_cast<double>(c.durationMs) * 0.001;
|
||||
} else {
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
|
||||
}
|
||||
const double expirationTime =
|
||||
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
|
||||
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
|
||||
c.casterGuid);
|
||||
// `c` already carries this exact instance's caster + timing (Enumerate
|
||||
// copied it out), so hand it to `PushEnriched` as `known` rather than
|
||||
// re-resolving by (guid, spell, slot) — an unbound entry sharing its spell
|
||||
// with another caster's would otherwise resolve to a miss and drop the
|
||||
// caster. Stacks aren't in SMSG_SPELL_GO, so `applications` is 1.
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
const Attribution a = CachedAttribution(c);
|
||||
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false,
|
||||
c.slot, &a);
|
||||
}
|
||||
|
||||
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
|
||||
@@ -506,15 +629,9 @@ void ReconcileCache(const uint8_t *unit) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
uint32_t present[Offsets::UNIT_AURA_TOTAL];
|
||||
int n = 0;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = ReadSpellID(unit, slot);
|
||||
if (id != 0)
|
||||
present[n++] = id;
|
||||
}
|
||||
if (n == 0)
|
||||
return;
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
|
||||
present[slot] = ReadSpellID(unit, slot);
|
||||
Aura::Source::EvictAbsent(UnitGuid(unit), present);
|
||||
}
|
||||
|
||||
// True if `unit`'s descriptor currently exposes any visible aura in either
|
||||
@@ -536,8 +653,8 @@ bool DescriptorHasVisibleAura(const uint8_t *unit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
|
||||
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
|
||||
// Counts populated descriptor slots matching `filter` (and `caster`).
|
||||
int CountSlots(const uint8_t *unit, Filter filter, Match match) {
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
const int start = (filter == Filter::Harmful)
|
||||
@@ -549,7 +666,7 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (playerOnly && !IsPlayerCast(unit, slot))
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
continue;
|
||||
++n;
|
||||
}
|
||||
@@ -559,21 +676,21 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
|
||||
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
|
||||
// `filter`/`playerOnly`: the spell must be a user-visible aura, it must not
|
||||
// `filter`/`caster`: the spell must be a user-visible aura, it must not
|
||||
// already be in a populated descriptor slot (no double-listing the same live
|
||||
// aura) and, when player-filtered, must have been cast by us. Superseded /
|
||||
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
|
||||
// eviction, not filtered here.
|
||||
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
Filter filter, bool playerOnly) {
|
||||
Filter filter, Match match) {
|
||||
if (!IsVisible(SpellRecord(c.spellId)))
|
||||
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
|
||||
// the descriptor path drops these via IsSlotPopulated, so
|
||||
// the cache path must apply the same engine predicate or
|
||||
// they leak through as tooltip-less phantom buffs.
|
||||
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
|
||||
if (FindSlotBySpellID(unit, c.spellId, &filter, Match{}) >= 0)
|
||||
return false; // still in the descriptor — returned via the slot path
|
||||
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
|
||||
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
@@ -581,7 +698,7 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
} // namespace
|
||||
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, bool playerOnly) {
|
||||
Filter filter, Match match) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
@@ -590,7 +707,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
// The descriptor held `descCount` matches; the caller already found fewer
|
||||
// than `oneBasedIndex` there, so the cache supplies index
|
||||
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
|
||||
const int target = oneBasedIndex - CountSlots(unit, filter, playerOnly);
|
||||
const int target = oneBasedIndex - CountSlots(unit, filter, match);
|
||||
if (target < 1)
|
||||
return false;
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
@@ -601,7 +718,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
continue;
|
||||
if (++seen == target) {
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
@@ -612,7 +729,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
}
|
||||
|
||||
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey) {
|
||||
Match match, int outerIdx, int &nextKey) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
@@ -625,7 +742,7 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, playerOnly))
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
@@ -633,4 +750,278 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Out-of-range groupmate path ────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
// Absolute slot range [start, end) for a filter, matching the descriptor
|
||||
// convention (helpful 0..31, harmful 32..47).
|
||||
void GroupRange(Filter filter, int &start, int &end) {
|
||||
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
|
||||
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
}
|
||||
|
||||
// Spell ID at group-array slot (0..47), 0 if `arr` null / OOB / empty.
|
||||
uint32_t GroupSpellID(const uint16_t *arr, int slot) {
|
||||
if (arr == nullptr || slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
|
||||
return 0;
|
||||
return arr[slot];
|
||||
}
|
||||
|
||||
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
|
||||
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
|
||||
// ID with a real Spell.dbc record that passes the visibility predicate (the
|
||||
// server only transmits *applied* auras, so there is no flag nibble to check).
|
||||
// `caster` additionally requires the `Aura::Source` cache to attribute the
|
||||
// cast to the local player (a miss excludes it — we can't confirm the caster
|
||||
// from the group array alone).
|
||||
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
|
||||
Match match) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id == 0 || !IsVisible(SpellRecord(id)))
|
||||
return false;
|
||||
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
|
||||
// roster lookup; only hit on the out-of-range path.
|
||||
int GroupMemberLevel(uint64_t guid) {
|
||||
return Group::MemberStats::Lookup(guid).level;
|
||||
}
|
||||
|
||||
// True if the group array exposes ANY visible aura (either kind). When it
|
||||
// does, the server's out-of-range aura data is present and authoritative, so
|
||||
// we do NOT supplement from the Aura::Source cache — mirroring how the
|
||||
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
|
||||
// Only when the array is entirely empty do we fall back to the cache: the
|
||||
// server resends out-of-range stats as deltas only, so a member seen in range
|
||||
// and then moved away again leaves an empty party-stats aura array (the array
|
||||
// is cleared when they become visible and never re-sent), yet their auras
|
||||
// still sit in the cache from when we observed them in range.
|
||||
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id != 0 && IsVisible(SpellRecord(id)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether cache entry `c` may supplement the (empty) group array: a visible
|
||||
// aura, not already present in the array, and — when player-filtered — cast by
|
||||
// us. See GroupArrayHasVisibleAura for when this path runs.
|
||||
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
|
||||
Match match) {
|
||||
if (!IsVisible(SpellRecord(c.spellId)))
|
||||
return false;
|
||||
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
|
||||
if (GroupSpellID(arr, slot) == c.spellId)
|
||||
return false;
|
||||
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
|
||||
// member whose group array is empty. Pushes the AuraData table on a hit.
|
||||
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
int oneBasedIndex, Filter filter, Match match) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
continue;
|
||||
if (++seen == oneBasedIndex) {
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
|
||||
GroupMemberLevel(guid), false, buf[i].slot, &a);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Append every cache-fallback aura of `filter`'s kind into the array table.
|
||||
void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
|
||||
Filter filter, Match match, int outerIdx,
|
||||
int &nextKey) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
|
||||
false, buf[i].slot, &a);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
}
|
||||
|
||||
// Single cache-fallback aura matching `spellID` (when nonzero) or `spellName`
|
||||
// (when non-null), of the given kind. Backs the by-ID / by-name group queries.
|
||||
bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
bool harmful, uint32_t spellID,
|
||||
const char *spellName, Match match) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (spellID != 0 && buf[i].spellId != spellID)
|
||||
continue;
|
||||
if (spellName != nullptr) {
|
||||
const char *nm = LocalizedSpellName(SpellRecord(buf[i].spellId));
|
||||
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
|
||||
continue;
|
||||
}
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
continue;
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
|
||||
false, buf[i].slot, &a);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
Match match) {
|
||||
if (guid == 0 || oneBasedIndex < 1)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
int start, end;
|
||||
GroupRange(filter, start, end);
|
||||
int matches = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex) {
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
|
||||
false, Aura::Source::SLOT_UNBOUND);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
|
||||
match);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
const Filter *filter, Match match) {
|
||||
if (guid == 0 || spellID == 0)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter != nullptr && *filter == Filter::Helpful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: Offsets::UNIT_AURA_TOTAL;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (GroupSpellID(arr, slot) != spellID)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
continue;
|
||||
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
|
||||
match))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
|
||||
match))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
const Filter *filter, Match match) {
|
||||
if (guid == 0 || spellName == nullptr || *spellName == '\0')
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
const int start = (filter != nullptr && *filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter != nullptr && *filter == Filter::Helpful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: Offsets::UNIT_AURA_TOTAL;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id == 0)
|
||||
continue;
|
||||
const char *name = LocalizedSpellName(SpellRecord(id));
|
||||
if (name == nullptr || std::strcmp(name, spellName) != 0)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
continue;
|
||||
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
|
||||
GroupMemberLevel(guid), false, Aura::Source::SLOT_UNBOUND);
|
||||
return true;
|
||||
}
|
||||
if (!GroupArrayHasVisibleAura(arr)) {
|
||||
const bool both = (filter == nullptr);
|
||||
if ((both || *filter == Filter::Helpful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
|
||||
match))
|
||||
return true;
|
||||
if ((both || *filter == Filter::Harmful) &&
|
||||
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
|
||||
match))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
int outerIdx, int &nextKey) {
|
||||
if (guid == 0)
|
||||
return;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
int start, end;
|
||||
GroupRange(filter, start, end);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
filter == Filter::Helpful, 1, level, false,
|
||||
Aura::Source::SLOT_UNBOUND);
|
||||
Game::Lua::SetTable(L, outerIdx);
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx,
|
||||
nextKey);
|
||||
}
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
+91
-5
@@ -33,6 +33,37 @@ namespace Aura::Data {
|
||||
// to helpful when no filter is specified.
|
||||
enum class Filter { Helpful, Harmful };
|
||||
|
||||
// Caster restriction from the `PLAYER` / `!PLAYER` aura filter tokens.
|
||||
// `Any` = no restriction; `PlayerOnly` = only auras the local player cast
|
||||
// (`PLAYER`); `NotPlayer` = only auras NOT cast by the local player
|
||||
// (`!PLAYER`). Caster attribution comes from the `Aura::Source` cache; a
|
||||
// cache miss counts as "not the player" (so `PlayerOnly` excludes it and
|
||||
// `NotPlayer` includes it — matching `IsPlayerCast`'s miss semantics).
|
||||
enum class CasterMode { Any, PlayerOnly, NotPlayer };
|
||||
|
||||
// Restriction from the `DISPELLABLE` / `!DISPELLABLE` aura filter tokens.
|
||||
// `Any` = no restriction; `DispellableOnly` = only auras that can be
|
||||
// dispelled / purged / stolen by SOME mechanism (dispel type Magic, Curse,
|
||||
// Disease, or Poison — the server's DISPEL_ALL_MASK); `NotDispellable` = the
|
||||
// complement. "Regardless of whether the local player can dispel it" —
|
||||
// matching the modern AuraFilters semantics.
|
||||
enum class DispelMode { Any, DispellableOnly, NotDispellable };
|
||||
|
||||
// Restriction from the `CROWD_CONTROL` / `!CROWD_CONTROL` aura filter tokens.
|
||||
// `Any` = no restriction; `CrowdControlOnly` = only auras that are a crowd-
|
||||
// control effect (stun / fear / silence / root / etc., per
|
||||
// `Spell::CrowdControl`); `NotCrowdControl` = the complement.
|
||||
enum class CcMode { Any, CrowdControlOnly, NotCrowdControl };
|
||||
|
||||
// A parsed aura-filter's per-aura predicates (beyond the helpful/harmful
|
||||
// range, which is the separate `Filter`). Extensible: new orthogonal filter
|
||||
// dimensions add a field here rather than another function parameter.
|
||||
struct Match {
|
||||
CasterMode caster = CasterMode::Any;
|
||||
DispelMode dispel = DispelMode::Any;
|
||||
CcMode cc = CcMode::Any;
|
||||
};
|
||||
|
||||
// Reads the spell ID at the given absolute slot (0..47). Returns 0
|
||||
// if the slot is empty or the unit pointer is null.
|
||||
uint32_t ReadSpellID(const uint8_t *unit, int slot);
|
||||
@@ -50,27 +81,41 @@ bool IsSlotPopulated(const uint8_t *unit, int slot);
|
||||
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
|
||||
// the local player cast (per `IsPlayerCast`) are counted/returned.
|
||||
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
bool playerOnly = false);
|
||||
Match match = {});
|
||||
|
||||
// Finds the absolute slot of a populated aura with the given spell
|
||||
// ID, optionally restricted to one filter range. `filter` of
|
||||
// nullptr searches both ranges (helpful first, then harmful).
|
||||
// `playerOnly` restricts to player-cast auras. Returns -1 if not found.
|
||||
int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
|
||||
const Filter *filter, bool playerOnly = false);
|
||||
const Filter *filter, Match match = {});
|
||||
|
||||
// Finds the absolute slot of a populated aura whose locale-resolved
|
||||
// spell name exactly matches `spellName`. Case-sensitive. Otherwise
|
||||
// same semantics as `FindSlotBySpellID`. Returns -1 if not found
|
||||
// or if `spellName` is null/empty.
|
||||
int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
|
||||
const Filter *filter, bool playerOnly = false);
|
||||
const Filter *filter, Match match = {});
|
||||
|
||||
// True iff the aura at `slot` was cast by the local player, per the
|
||||
// `Aura::Source` cache. False on a cache miss (caster unknown) — so a
|
||||
// PLAYER-filtered query excludes auras whose cast we didn't observe.
|
||||
bool IsPlayerCast(const uint8_t *unit, int slot);
|
||||
|
||||
// Applies a `CasterMode` to a per-aura "was cast by the local player" answer.
|
||||
// `Any` → always true; `PlayerOnly` → the answer; `NotPlayer` → its negation.
|
||||
bool CasterMatches(CasterMode caster, bool isPlayerCast);
|
||||
|
||||
// True iff the spell's dispel type is one a dispel/purge/steal can remove —
|
||||
// Magic, Curse, Disease, or Poison (`Spell.dbc` Dispel ∈ 1..4). None,
|
||||
// Stealth, Invisibility, Enrage, etc. are not dispellable. Verified against
|
||||
// the server's DISPEL_ALL_MASK.
|
||||
bool IsDispellable(uint32_t spellID);
|
||||
|
||||
// Applies a full `Match` (caster + dispel predicates) to one aura, given the
|
||||
// per-aura "was cast by the local player" answer and its spell ID.
|
||||
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
|
||||
|
||||
// Display stack count for the given slot (engine stores stacks-1,
|
||||
// we add 1). Returns 0 if the unit is null.
|
||||
int ReadStacks(const uint8_t *unit, int slot);
|
||||
@@ -122,7 +167,7 @@ void Push(void *L, const uint8_t *unit, int slot);
|
||||
// helpful/harmful classification matches `filter` and that aren't already in a
|
||||
// populated descriptor slot are considered.
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, bool playerOnly = false);
|
||||
Filter filter, Match match = {});
|
||||
|
||||
// Appends every eligible `Aura::Source` cache fallback for `unit` matching
|
||||
// `filter` into the array table at `outerIdx`, continuing from `nextKey`
|
||||
@@ -130,6 +175,47 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
// for `GetUnitAuras`. Skips entries already present in a populated descriptor
|
||||
// slot so it never double-lists.
|
||||
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
bool playerOnly, int outerIdx, int &nextKey);
|
||||
Match match, int outerIdx, int &nextKey);
|
||||
|
||||
// ── Out-of-range groupmate path ────────────────────────────────────────────
|
||||
//
|
||||
// When a party/raid member has no live CGUnit at all (different map, far out
|
||||
// of range) the token resolves to a GUID but no object, so there is no
|
||||
// descriptor to read. The server still transmits that member's current aura
|
||||
// spell IDs via SMSG_PARTY_MEMBER_STATS, which the client keeps in the
|
||||
// group-member stats structs (`Group::MemberStats::AuraArray`). These functions
|
||||
// enumerate that array — exactly what the built-in `UnitBuff`/`UnitDebuff` do
|
||||
// out of range — and build the same AuraData table.
|
||||
//
|
||||
// Spell IDs only come off the wire: `applications` is 1 (stacks aren't sent)
|
||||
// and IDs are u16-truncated (custom IDs > 65535 are wrong). Any caster / real
|
||||
// expirationTime we observed for the member via SMSG_SPELL_GO is still merged
|
||||
// in from the `Aura::Source` cache. `guid` of 0 (not a rostered member) yields
|
||||
// no results. These are only meaningful when the unit has NO descriptor —
|
||||
// callers use them in the `unit == nullptr` branch, so there is no descriptor
|
||||
// to dedup against.
|
||||
|
||||
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
|
||||
// matching `filter`. On a hit pushes the table and returns true; otherwise
|
||||
// pushes nothing and returns false (caller pushes nil).
|
||||
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
Match match = {});
|
||||
|
||||
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
|
||||
// restricted to one filter range (nullptr = both, helpful first). Returns true
|
||||
// on a hit (table pushed), false otherwise (nothing pushed).
|
||||
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
const Filter *filter, Match match = {});
|
||||
|
||||
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
|
||||
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
|
||||
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
const Filter *filter, Match match = {});
|
||||
|
||||
// Appends every group-array aura on `guid` matching `filter` into the array
|
||||
// table at `outerIdx`, continuing from `nextKey` (updated in place). The
|
||||
// bulk-enumeration analog of `PushNthGroupAura`, for `GetUnitAuras`.
|
||||
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
int outerIdx, int &nextKey);
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
+899
-132
File diff suppressed because it is too large
Load Diff
+90
-21
@@ -19,7 +19,15 @@
|
||||
// the client ever sees the caster + a server-authoritative duration is the
|
||||
// `SMSG_SPELL_GO` packet at cast time. We parse it (the same packet
|
||||
// nampower parses for its `AURA_CAST_ON_*` events) and remember, per
|
||||
// `(targetGuid, spellId)`, who cast it and when it should expire.
|
||||
// `(targetGuid, spellId, casterGuid)`, who cast it and when it should expire.
|
||||
//
|
||||
// The caster belongs in that key because it belongs in the server's: two
|
||||
// same-class raiders' Corruption on one boss are two auras in two descriptor
|
||||
// slots, and a `(target, spell)` cache holds one entry for both — the second
|
||||
// cast overwrites the first caster's timing, and either copy falling off
|
||||
// evicts the survivor's entry too. The descriptor stores only spell IDs, so
|
||||
// each entry additionally records the slot its aura was seated in
|
||||
// (`OnAuraAdded`), which is what lets a per-slot query name the right instance.
|
||||
//
|
||||
// `Aura::Data::Push` consults this to fill `sourceUnit` (caster, resolved
|
||||
// to a unit token) and `expirationTime` for non-player units. The cache is
|
||||
@@ -31,36 +39,82 @@
|
||||
|
||||
namespace Aura::Source {
|
||||
|
||||
// Looks up the cached caster + timing for the aura `spellId` currently on
|
||||
// the unit identified by `unitGuid`. Returns true and fills the out params
|
||||
// on a hit. `*outExpirationMs` is an absolute `GetTickCount`-epoch timestamp
|
||||
// (0 = unknown / infinite-duration aura); `*outDurationMs` is the applied
|
||||
// duration including the caster's modifiers (talents etc.; 0 = none) — use
|
||||
// it for the `duration` field so it stays consistent with `expirationTime`;
|
||||
// `*outCaster` is the caster's 64-bit GUID (never 0 on a hit). Returns false
|
||||
// on a miss or for zero inputs.
|
||||
bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
|
||||
// `slot` value for a query with no descriptor slot to go on (out-of-range
|
||||
// group array, cache fallback) and for an entry not yet seated in one.
|
||||
constexpr int SLOT_UNBOUND = -1;
|
||||
|
||||
// Looks up the cached caster + timing for the aura `spellId` occupying
|
||||
// absolute descriptor `slot` on the unit identified by `unitGuid`. Returns
|
||||
// true and fills the out params on a hit. `*outExpirationMs` is an absolute
|
||||
// `GetTickCount`-epoch timestamp (0 = unknown / infinite-duration aura);
|
||||
// `*outDurationMs` is the applied duration including the caster's modifiers
|
||||
// (talents etc.; 0 = none) — use it for the `duration` field so it stays
|
||||
// consistent with `expirationTime`; `*outCaster` is the caster's 64-bit GUID
|
||||
// (0 when the aura was seen applied but its cast never observed). Returns
|
||||
// false on a miss or for zero inputs.
|
||||
//
|
||||
// Pass `SLOT_UNBOUND` when the caller has no slot. Resolution is by slot
|
||||
// first, then by spell ID when the unit carries only one entry for it; two
|
||||
// casters' entries with no slot binding resolve to a miss rather than to a
|
||||
// coin flip between them.
|
||||
bool Get(uint64_t unitGuid, uint32_t spellId, int slot, uint64_t *outCaster,
|
||||
uint32_t *outExpirationMs, uint32_t *outDurationMs);
|
||||
|
||||
// Refreshes `casterGuid`'s aura on `unitGuid` matching the same selector the
|
||||
// duration rules use — SpellFamilyName + family-flag overlap + optional icon
|
||||
// (0 = any) — to a full duration from now. Returns the spellID refreshed, or 0
|
||||
// if nothing matched. For a mechanic whose duration edit reaches the client
|
||||
// too late to be attributed to a cast packet, so no rule can express it.
|
||||
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
|
||||
uint64_t mask, uint32_t icon,
|
||||
uint64_t casterGuid);
|
||||
|
||||
// Op codes for AddDurationMod (mirror the Lua op strings).
|
||||
enum DurationModOp {
|
||||
DURATION_MOD_REFRESH = 0,
|
||||
DURATION_MOD_REDUCE = 1,
|
||||
DURATION_MOD_SET = 2,
|
||||
DURATION_MOD_REMOVE = 3,
|
||||
};
|
||||
|
||||
// Register a server duration-modifier rule from C++. The trigger is matched by
|
||||
// exact `triggerSpellId`; the affected aura by SpellFamilyName + a family-flag
|
||||
// overlap (+ optional `affectedIcon`, 0 = any). `valueMs` is the reduce/set
|
||||
// amount in milliseconds (ignored by refresh/remove). Used by src/turtle
|
||||
// modules for the server's built-in mods (the family/school-matched variant is
|
||||
// still Lua-registerable via C_UnitAuras.RegisterAuraDurationModifierByTrigger).
|
||||
// Returns false on bad input or a full table.
|
||||
bool AddDurationMod(uint32_t triggerSpellId, uint32_t affectedFamily,
|
||||
uint64_t affectedMask, uint32_t affectedIcon, int op,
|
||||
int32_t valueMs);
|
||||
|
||||
// One cached aura, as returned by `Enumerate`.
|
||||
struct CachedAura {
|
||||
uint32_t spellId;
|
||||
uint64_t casterGuid; // 0 = caster unknown (application hook, no SpellGo)
|
||||
uint32_t expirationMs; // 0 = infinite / unknown
|
||||
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
|
||||
int slot; // descriptor slot the aura was seated in; feed it
|
||||
// back to `Get` to reach this exact entry again
|
||||
};
|
||||
|
||||
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
|
||||
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
|
||||
// authoritative descriptor when it is back in view (a populated aura array
|
||||
// means the unit is fully synced): an entry the descriptor no longer lists
|
||||
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
|
||||
// while out of our range, whose `OnAuraRemoved` we never received — so drop
|
||||
// it before the descriptor-drop fallback resurfaces it as a phantom. The
|
||||
// caller must only invoke this when the descriptor is populated (count > 0);
|
||||
// an empty array can't distinguish "out of range" from "genuinely buffless",
|
||||
// so reconciling then would wrongly wipe still-valid out-of-range entries.
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
|
||||
// Evicts every cached entry for `unitGuid` the unit's descriptor contradicts.
|
||||
// `slotSpellIds` is the raw `UNIT_FIELD_AURA` array — exactly
|
||||
// `Offsets::UNIT_AURA_TOTAL` spell IDs indexed by absolute slot, 0 for empty.
|
||||
// Used to reconcile the cache against a unit's authoritative descriptor when
|
||||
// it is back in view (a populated aura array means the unit is fully synced):
|
||||
// an entry the descriptor no longer backs was removed while we couldn't
|
||||
// observe it — e.g. a buff the owner cancelled while out of our range, whose
|
||||
// `OnAuraRemoved` we never received — so drop it before the descriptor-drop
|
||||
// fallback resurfaces it as a phantom.
|
||||
//
|
||||
// An entry seated in a slot is checked against THAT slot, so one caster's
|
||||
// copy going away retires its own entry even while another caster keeps the
|
||||
// spell ID present on the unit. Entries never seated are checked against the
|
||||
// whole array. An all-empty array is ignored: it can't distinguish "out of
|
||||
// range" from "genuinely buffless", and reconciling then would wrongly wipe
|
||||
// still-valid out-of-range entries.
|
||||
void EvictAbsent(uint64_t unitGuid, const uint32_t *slotSpellIds);
|
||||
|
||||
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
|
||||
// whose helpful/harmful classification matches `harmful`. Returns the count
|
||||
@@ -74,4 +128,19 @@ void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
|
||||
// the aura is still active server-side: the cache survives descriptor clears.
|
||||
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
|
||||
|
||||
// Feeds the cache from an out-of-range group member's aura spell-ID array
|
||||
// (`Group::MemberStats::AuraArray` — 48 slots, buffs 0..BUFF_COUNT-1, debuffs
|
||||
// after). Such a member has no CGUnit and never yields SMSG_SPELL_GO, so the
|
||||
// SpellGo path never learns their auras' timing — but SMSG_PARTY_MEMBER_STATS
|
||||
// carries the spell IDs and the server sends it promptly when an aura is
|
||||
// added/removed. So a spell ID that newly *appears* here did so ~one server
|
||||
// tick + latency after the real application: we stamp a best-effort
|
||||
// `now + base duration` expiration (base only — no remote caster mods, no
|
||||
// caster recorded). Auras already present the first time we see a member have
|
||||
// unknown age and are NOT stamped; only genuine absent->present transitions
|
||||
// are. Idempotent within a frame; call it before enumerating the array so a
|
||||
// just-appeared aura carries its guess on the same poll. The Store guard keeps
|
||||
// any real SpellGo timing we already hold, so this never downgrades better data.
|
||||
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
|
||||
|
||||
} // namespace Aura::Source
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
#include "event/Custom.h"
|
||||
#include "tick/WorldTick.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Bag::UpdateDelayed {
|
||||
|
||||
namespace {
|
||||
@@ -127,6 +129,19 @@ unsigned __fastcall KeyringDescChange_h(void *bagDesc, void *edx,
|
||||
void OnWorldTick() {
|
||||
if (!g_pending)
|
||||
return;
|
||||
// Bag changes aren't meaningful until the player is actually in the world.
|
||||
// During any loading screen (initial login, zone/instance transition) the
|
||||
// engine re-populates bag slots as objects stream in (setting g_pending)
|
||||
// while still behind the screen; the per-frame world tick runs there too,
|
||||
// so firing BAG_UPDATE_DELAYED would make addons rescan bags repeatedly
|
||||
// mid-load for no real change. Hold the pending flag — do NOT clear it —
|
||||
// until the engine's in-world flag is set; the first in-world frame then
|
||||
// emits a single DELAYED covering the settled inventory (matching retail's
|
||||
// one BAG_UPDATE_DELAYED per load). VAR_IN_WORLD clears on every transition,
|
||||
// so this re-suppresses on later zone changes too, not just initial login.
|
||||
if (*reinterpret_cast<const volatile uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_IN_WORLD)) == 0)
|
||||
return;
|
||||
g_pending = false;
|
||||
const int slot = Event::Custom::Lookup(kEventName);
|
||||
if (slot >= 0)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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
|
||||
|
||||
// Tiny ASCII string helpers. Header-only — no locale, no allocation, safe from
|
||||
// any context (hook callbacks, DBC walks). Use these instead of hand-rolling
|
||||
// yet another case-insensitive compare.
|
||||
|
||||
namespace Ascii {
|
||||
|
||||
// Fold a single ASCII char to lower case (non-letters unchanged).
|
||||
inline char ToLower(char c) {
|
||||
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
|
||||
}
|
||||
|
||||
// Case-insensitive full-string equality over ASCII. Two null pointers compare
|
||||
// equal; a null vs non-null does not.
|
||||
inline bool EqualCI(const char *a, const char *b) {
|
||||
if (a == nullptr || b == nullptr)
|
||||
return a == b;
|
||||
for (; *a && *b; ++a, ++b)
|
||||
if (ToLower(*a) != ToLower(*b))
|
||||
return false;
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
} // namespace Ascii
|
||||
+40
-5
@@ -14,16 +14,21 @@
|
||||
// Lua 5.1 math-library additions that 1.12's Lua 5.0 is missing:
|
||||
//
|
||||
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
|
||||
// - `math.modf(x)` — split into integral + fractional parts.
|
||||
// - `math.huge` — positive infinity constant.
|
||||
//
|
||||
// This is a pure rename: Lua 5.0 ships the exact same C function under the
|
||||
// name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
|
||||
// register `math.fmod` as a direct alias of the engine's `math.mod` C
|
||||
// function (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too
|
||||
// (we don't touch it), so code written for either name works.
|
||||
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
|
||||
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
|
||||
// register it as a direct alias of the engine's `math.mod` C function
|
||||
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
|
||||
// written for either name works. `modf` and `huge` are genuinely new in 5.1
|
||||
// (not in 1.12's mathlib table), so we implement/define them here.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace BaseLib::MathLib {
|
||||
|
||||
namespace {
|
||||
@@ -33,8 +38,38 @@ namespace {
|
||||
const auto Script_math_fmod =
|
||||
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_MATH_FMOD);
|
||||
|
||||
// `math.modf(x)` — returns `(integral, fractional)`, the integral part
|
||||
// truncated toward zero and the fractional part carrying `x`'s sign
|
||||
// (`modf(3.7) → 3, 0.7`; `modf(-3.7) → -3, -0.7`). Matches C `modf` / Lua 5.1.
|
||||
int __fastcall Script_math_modf(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: math.modf(x)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
double integral = 0.0;
|
||||
const double fractional = std::modf(Game::Lua::ToNumber(L, 1), &integral);
|
||||
Game::Lua::PushNumber(L, integral);
|
||||
Game::Lua::PushNumber(L, fractional);
|
||||
return 2;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
|
||||
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
|
||||
|
||||
// `math.huge` — a numeric constant, not a function. Fetch the (now
|
||||
// guaranteed-present) `math` table and set the field directly.
|
||||
void *L = Game::Lua::State();
|
||||
if (L != nullptr) {
|
||||
Game::Lua::PushString(L, "math");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [math]
|
||||
if (Game::Lua::Type(L, -1) == 5 /* LUA_TTABLE */) {
|
||||
Game::Lua::SetFieldNumber(L, "huge", HUGE_VAL); // math.huge = inf; [math]
|
||||
Game::Lua::SetTop(L, -2); // pop [math]
|
||||
} else {
|
||||
Game::Lua::SetTop(L, -2); // pop whatever GetTable returned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both states are Lua 5.0 and equally missing this; RegisterTableFunction
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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/>.
|
||||
|
||||
// `Mixin(object, ...)` and `CreateFromMixins(...)` — the FrameXML table-mixin
|
||||
// primitives. Modern WoW provides these as engine C functions (in TableUtil);
|
||||
// we do the same rather than leaving them in the !!!ClassicAPI addon so they
|
||||
// exist whenever the DLL is injected, even if the addon is disabled. The
|
||||
// composite `CreateAndInitFromMixin` (which calls a Lua `:Init` method) stays
|
||||
// Lua-side in the addon, mirroring retail's Mixin.lua.
|
||||
//
|
||||
// Behaviour matches the canonical Lua implementation exactly:
|
||||
// Mixin(object, ...) -- shallow-copy every key/value from each table
|
||||
// argument onto `object`; returns `object`.
|
||||
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
|
||||
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
namespace BaseLib::Mixin {
|
||||
|
||||
namespace {
|
||||
|
||||
// Shallow-copy every pair from the table at `srcIdx` onto the table at
|
||||
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
|
||||
void MixinInto(void *L, int dstIdx, int srcIdx) {
|
||||
Game::Lua::PushNil(L);
|
||||
while (Game::Lua::Next(L, srcIdx) != 0) {
|
||||
// stack top: ..., key (-2), value (-1). Copy both so the originals
|
||||
// survive for the next Next() iteration.
|
||||
Game::Lua::PushValue(L, -2); // key copy
|
||||
Game::Lua::PushValue(L, -2); // value copy
|
||||
Game::Lua::SetTable(L, dstIdx); // dst[key] = value; pops the two copies
|
||||
Game::Lua::SetTop(L, -2); // pop value, leave key for Next()
|
||||
}
|
||||
}
|
||||
|
||||
int __fastcall Script_Mixin(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: Mixin(object, ...)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
for (int i = 2; i <= top; ++i)
|
||||
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
|
||||
MixinInto(L, 1, i);
|
||||
Game::Lua::PushValue(L, 1); // return object
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_CreateFromMixins(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::NewTable(L); // new object at index top + 1
|
||||
const int objIdx = top + 1;
|
||||
for (int i = 1; i <= top; ++i)
|
||||
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
|
||||
MixinInto(L, objIdx, i);
|
||||
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterGlobalFunction("Mixin", &Script_Mixin);
|
||||
Game::Lua::RegisterGlobalFunction("CreateFromMixins", &Script_CreateFromMixins);
|
||||
}
|
||||
|
||||
// Pure table helpers — register on both the in-game and glue states so they're
|
||||
// always present (matching the "always available" intent).
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace BaseLib::Mixin
|
||||
+189
-4
@@ -11,16 +11,22 @@
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Lua 5.1 string-library additions that 1.12's Lua 5.0 is missing:
|
||||
// String helpers that 1.12's Lua 5.0 is missing:
|
||||
//
|
||||
// - `string.match(s, pattern [, init])` — first-match extraction.
|
||||
// - `string.gmatch(s, pattern)` — match iterator.
|
||||
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
|
||||
// - `string.gmatch(s, pattern)` — match iterator (5.1).
|
||||
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
|
||||
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
|
||||
// - `strtrim(str [, chars])` — WoW global; trim a char set.
|
||||
// - `strreplace(str, find, replace)` — plain-text replace (see below).
|
||||
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
|
||||
//
|
||||
// Both are the same underlying pattern engine as functions 5.0 already ships:
|
||||
// The two pattern `string.*` ones reuse machinery 5.0 already ships:
|
||||
// - `match` is `find` returning captures / the whole match instead of the
|
||||
// start/end indices — so we call the engine's `str_find` and transform.
|
||||
// - `gmatch` is exactly 5.0's `string.gfind` (renamed in 5.1); we register
|
||||
// it as a direct alias of the engine's `gfind` C function.
|
||||
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
|
||||
//
|
||||
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
|
||||
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
|
||||
@@ -33,6 +39,8 @@
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace BaseLib::StringLib {
|
||||
|
||||
@@ -83,9 +91,186 @@ int __fastcall Script_string_match(void *L) {
|
||||
const auto Script_string_gmatch =
|
||||
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
|
||||
|
||||
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
|
||||
// `string.split`), split `str` on ANY character in `sep` and return the
|
||||
// pieces as multiple values. `pieces > 0` caps the result count, with the
|
||||
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
|
||||
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
|
||||
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
|
||||
// string unreferenced, so a GC step triggered by a `pushlstring` could free
|
||||
// the very buffer we're still scanning. Keeping the args on the stack keeps
|
||||
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
|
||||
// of the args sitting below them. Each push is guarded by `lua_checkstack`
|
||||
// (this pushes an unbounded number of results), erroring like 3.3.5 on
|
||||
// genuine stack exhaustion.
|
||||
int __fastcall Script_strsplit(void *L) {
|
||||
const char *sep = Game::Lua::ToString(L, 1);
|
||||
const char *str = Game::Lua::ToString(L, 2);
|
||||
if (sep == nullptr || str == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const int pieces =
|
||||
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
|
||||
|
||||
const char *segStart = str;
|
||||
int count = 0;
|
||||
// Only scan for separators while unlimited (pieces == 0) or the cap
|
||||
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
|
||||
// yields the whole string as a single piece — matches 3.3.5.
|
||||
if (pieces == 0 || pieces > 1) {
|
||||
for (const char *p = str; *p != '\0'; ++p) {
|
||||
bool isSep = false;
|
||||
for (const char *s = sep; *s != '\0'; ++s) {
|
||||
if (*p == *s) {
|
||||
isSep = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isSep)
|
||||
continue;
|
||||
++count;
|
||||
if (Game::Lua::CheckStack(L, count) == 0) {
|
||||
Game::Lua::Error(L, "strsplit(): Stack overflow");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::PushLString(L, segStart,
|
||||
static_cast<unsigned int>(p - segStart));
|
||||
segStart = p + 1;
|
||||
if (count == pieces - 1)
|
||||
break; // cap hit; remainder becomes the final piece
|
||||
}
|
||||
}
|
||||
if (Game::Lua::CheckStack(L, count + 1) == 0) {
|
||||
Game::Lua::Error(L, "strsplit(): Stack overflow");
|
||||
return 0; // unreachable
|
||||
}
|
||||
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
|
||||
return count + 1;
|
||||
}
|
||||
|
||||
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
|
||||
// vararg pieces with `delimiter` between each. Standard WoW global (FrameXML
|
||||
// defines it in Lua; there's no C original to mirror). A nil / non-coercible
|
||||
// piece is treated as an empty string rather than erroring, so the delimiter
|
||||
// placement stays predictable. `strjoin(",")` with no pieces returns "".
|
||||
int __fastcall Script_strjoin(void *L) {
|
||||
const char *delim = Game::Lua::ToString(L, 1);
|
||||
if (delim == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strjoin(delimiter, ...)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned delimLen = Game::Lua::StrLen(L, 1);
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
std::string out; // no Error() past this point — a longjmp would leak it
|
||||
for (int i = 2; i <= top; ++i) {
|
||||
if (i > 2)
|
||||
out.append(delim, delimLen);
|
||||
const char *piece = Game::Lua::ToString(L, i);
|
||||
if (piece != nullptr)
|
||||
out.append(piece, Game::Lua::StrLen(L, i));
|
||||
}
|
||||
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `strtrim(str [, chars])` — remove any of the characters in `chars` from
|
||||
// both ends of `str` (default: whitespace). `chars` is a literal set of
|
||||
// characters, not a pattern. Standard WoW global. Two-ended scan — no
|
||||
// allocation, embedded NULs preserved (bounds come from the Lua length).
|
||||
int __fastcall Script_strtrim(void *L) {
|
||||
const char *str = Game::Lua::ToString(L, 1);
|
||||
if (str == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strtrim(str [, chars])");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned len = Game::Lua::StrLen(L, 1);
|
||||
const char *chars = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2)
|
||||
: " \t\n\v\f\r";
|
||||
|
||||
bool trim[256] = {false};
|
||||
for (const char *c = chars; *c != '\0'; ++c)
|
||||
trim[static_cast<unsigned char>(*c)] = true;
|
||||
|
||||
unsigned start = 0, end = len;
|
||||
while (start < end && trim[static_cast<unsigned char>(str[start])])
|
||||
++start;
|
||||
while (end > start && trim[static_cast<unsigned char>(str[end - 1])])
|
||||
--end;
|
||||
Game::Lua::PushLString(L, str + start, end - start);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `strreplace(str, find, replace)` — replace every occurrence of the literal
|
||||
// substring `find` with `replace`; returns `(result, count)`. NOT a stock WoW
|
||||
// global and NOT pattern-based (that's `gsub`) — a plain-text replace that
|
||||
// needs no magic-character escaping. Empty `find` returns `str` unchanged
|
||||
// (count 0) rather than looping forever.
|
||||
int __fastcall Script_strreplace(void *L) {
|
||||
const char *str = Game::Lua::ToString(L, 1);
|
||||
const char *find = Game::Lua::ToString(L, 2);
|
||||
const char *repl = Game::Lua::ToString(L, 3);
|
||||
if (str == nullptr || find == nullptr || repl == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: strreplace(str, find, replace)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned strLen = Game::Lua::StrLen(L, 1);
|
||||
const unsigned findLen = Game::Lua::StrLen(L, 2);
|
||||
const unsigned replLen = Game::Lua::StrLen(L, 3);
|
||||
|
||||
if (findLen == 0) { // nothing to match — return input unchanged
|
||||
Game::Lua::PushLString(L, str, strLen);
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 2;
|
||||
}
|
||||
|
||||
std::string out; // no Error() past this point
|
||||
int count = 0;
|
||||
unsigned i = 0;
|
||||
while (i < strLen) {
|
||||
if (i + findLen <= strLen && std::memcmp(str + i, find, findLen) == 0) {
|
||||
out.append(repl, replLen);
|
||||
i += findLen;
|
||||
++count;
|
||||
} else {
|
||||
out.push_back(str[i]);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(count));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `string.reverse(s)` / `strrev(s)` — return `s` with its bytes reversed.
|
||||
// Lua 5.1 added `string.reverse`; 5.0's strlib lacks it. Byte-wise (not
|
||||
// UTF-8 aware), matching stock Lua; embedded NULs preserved (bounds from the
|
||||
// Lua length).
|
||||
int __fastcall Script_string_reverse(void *L) {
|
||||
const char *s = Game::Lua::ToString(L, 1);
|
||||
if (s == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: string.reverse(s)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const unsigned len = Game::Lua::StrLen(L, 1);
|
||||
std::string out(len, '\0'); // no Error() past this point
|
||||
for (unsigned i = 0; i < len; ++i)
|
||||
out[i] = s[len - 1 - i];
|
||||
Game::Lua::PushLString(L, out.data(), len);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
|
||||
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
|
||||
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
|
||||
Game::Lua::RegisterTableFunction("string", "split", &Script_strsplit);
|
||||
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
|
||||
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
|
||||
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
|
||||
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
|
||||
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
|
||||
}
|
||||
|
||||
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under
|
||||
// the terms of the GNU General Public License as published by the Free Software
|
||||
// Foundation, either version 3 of the License, or (at your option) any later
|
||||
// version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
// A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Backports the direct-action and override binding APIs introduced in 2.0.
|
||||
// Normal bindings are stored by vanilla's SetBinding implementation using the
|
||||
// later-client SPELL/ITEM/MACRO/CLICK command strings. The resolved-command
|
||||
// hook executes those four command families; every native binding command is
|
||||
// passed through unchanged.
|
||||
//
|
||||
// Override bindings live in a separate owner-scoped layer in front of the
|
||||
// native key lookup. They are deliberately never written to the saved binding
|
||||
// table, so clearing an owner immediately reveals the next override or the
|
||||
// unchanged native binding underneath it.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "macro/Execute.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace Bindings::Api {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char kSpellPrefix[] = "SPELL ";
|
||||
constexpr const char kItemPrefix[] = "ITEM ";
|
||||
constexpr const char kMacroPrefix[] = "MACRO ";
|
||||
constexpr const char kClickPrefix[] = "CLICK ";
|
||||
constexpr const char kDefaultMouseButton[] = "LeftButton";
|
||||
|
||||
using ScriptSetBinding_t = int(__fastcall *)(void *L);
|
||||
using KeyDispatch_t = int(__thiscall *)(void *mgr, const char *key, int isDown);
|
||||
using CommandExecute_t = int(__thiscall *)(void *mgr, const char *cmd, int isDown);
|
||||
|
||||
KeyDispatch_t KeyDispatch_o = nullptr;
|
||||
CommandExecute_t CommandExecute_o = nullptr;
|
||||
|
||||
enum class CommandType {
|
||||
None,
|
||||
Spell,
|
||||
Item,
|
||||
Macro,
|
||||
Click,
|
||||
};
|
||||
|
||||
struct ParsedCommand {
|
||||
CommandType type = CommandType::None;
|
||||
const char *argument = nullptr;
|
||||
};
|
||||
|
||||
struct OverrideEntry {
|
||||
const void *owner = nullptr;
|
||||
std::string key;
|
||||
std::string command;
|
||||
bool priority = false;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
std::vector<OverrideEntry> g_overrides;
|
||||
std::unordered_map<std::string, std::string> g_activeOverrides;
|
||||
uint64_t g_nextSequence = 0;
|
||||
|
||||
// Normalizes a binding key to the engine's own canonical form so a stored
|
||||
// override matches the string FUN_BINDING_KEY_DISPATCH builds at keypress. The
|
||||
// engine's key-string builder (FUN_004b6630) always emits modifiers in the
|
||||
// fixed order ALT-CTRL-SHIFT- ahead of the base key, regardless of the order
|
||||
// the caller passed them; vanilla SetBinding never reorders, so the override
|
||||
// layer does it here. Both the store and the lookup run through this, so they
|
||||
// stay consistent while also matching the engine's keypress form.
|
||||
std::string CanonicalKey(const char *key) {
|
||||
std::string result = key ? key : "";
|
||||
for (char &character : result) {
|
||||
if (character >= 'a' && character <= 'z')
|
||||
character = static_cast<char>(character - 'a' + 'A');
|
||||
}
|
||||
|
||||
// Strip the three modifier prefixes in whatever order/repetition they
|
||||
// appear, then re-emit them in the engine's fixed order. Prefix-stripping
|
||||
// (rather than splitting on '-') keeps the minus base key correct, e.g.
|
||||
// "SHIFT--" -> modifier SHIFT + base "-".
|
||||
bool alt = false, ctrl = false, shift = false;
|
||||
size_t base = 0;
|
||||
for (bool matched = true; matched;) {
|
||||
matched = false;
|
||||
if (result.compare(base, 4, "ALT-") == 0) {
|
||||
alt = true;
|
||||
base += 4;
|
||||
matched = true;
|
||||
} else if (result.compare(base, 5, "CTRL-") == 0) {
|
||||
ctrl = true;
|
||||
base += 5;
|
||||
matched = true;
|
||||
} else if (result.compare(base, 6, "SHIFT-") == 0) {
|
||||
shift = true;
|
||||
base += 6;
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alt && !ctrl && !shift)
|
||||
return result;
|
||||
|
||||
std::string canonical;
|
||||
if (alt)
|
||||
canonical += "ALT-";
|
||||
if (ctrl)
|
||||
canonical += "CTRL-";
|
||||
if (shift)
|
||||
canonical += "SHIFT-";
|
||||
canonical += result.substr(base);
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
std::string MakeCommand(const char *prefix, const char *arg) {
|
||||
std::string cmd = prefix;
|
||||
cmd += arg;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
std::string MakeClickCommand(const char *btnName, const char *mouseBtn) {
|
||||
std::string cmd = MakeCommand(kClickPrefix, btnName);
|
||||
if (mouseBtn != nullptr) {
|
||||
cmd += ':';
|
||||
cmd += mouseBtn;
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
ParsedCommand ParseCommand(const char *cmd) {
|
||||
if (cmd == nullptr)
|
||||
return {};
|
||||
|
||||
struct PrefixEntry {
|
||||
const char *prefix;
|
||||
size_t length;
|
||||
CommandType type;
|
||||
};
|
||||
|
||||
static constexpr PrefixEntry prefixes[] = {
|
||||
{kSpellPrefix, sizeof(kSpellPrefix) - 1, CommandType::Spell},
|
||||
{kItemPrefix, sizeof(kItemPrefix) - 1, CommandType::Item},
|
||||
{kMacroPrefix, sizeof(kMacroPrefix) - 1, CommandType::Macro},
|
||||
{kClickPrefix, sizeof(kClickPrefix) - 1, CommandType::Click},
|
||||
};
|
||||
|
||||
for (const PrefixEntry &entry : prefixes) {
|
||||
if (std::strncmp(cmd, entry.prefix, entry.length) == 0)
|
||||
return {entry.type, cmd + entry.length};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool CallGlobalString(void *L, const char *func, const char *arg) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (!Game::Lua::PushGlobalFunction(L, func)) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, arg);
|
||||
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
|
||||
Game::Lua::SetTop(L, top);
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool CallTableString(void *L, const char *table, const char *func, const char *arg) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushString(L, table);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
const int tableIndex = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushString(L, func);
|
||||
Game::Lua::GetTable(L, tableIndex);
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, arg);
|
||||
const bool ok = Game::Lua::PCall(L, 1, 0, 0) == 0;
|
||||
Game::Lua::SetTop(L, top);
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
void InvokeButtonClick(void *L, const char *btnName, size_t btnNameLength, const char *mouseBtn) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
|
||||
Game::Lua::PushLString(L, btnName, static_cast<unsigned int>(btnNameLength));
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::SetTop(L, top);
|
||||
return;
|
||||
}
|
||||
|
||||
const int frameIndex = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "Click");
|
||||
Game::Lua::GetTable(L, frameIndex);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, frameIndex);
|
||||
Game::Lua::PushString(L, mouseBtn);
|
||||
Game::Lua::PCall(L, 2, 0, 0);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, top);
|
||||
}
|
||||
|
||||
void ExecuteClick(void *L, const char *arg) {
|
||||
const char *separator = std::strrchr(arg, ':');
|
||||
if (separator == arg || (separator != nullptr && separator[1] == '\0'))
|
||||
return;
|
||||
|
||||
const char *mouseBtn = kDefaultMouseButton;
|
||||
size_t btnNameLength = std::strlen(arg);
|
||||
if (separator != nullptr) {
|
||||
btnNameLength = static_cast<size_t>(separator - arg);
|
||||
mouseBtn = separator + 1;
|
||||
}
|
||||
|
||||
if (btnNameLength == 0)
|
||||
return;
|
||||
|
||||
InvokeButtonClick(L, arg, btnNameLength, mouseBtn);
|
||||
}
|
||||
|
||||
// Returns true for every recognized direct-action command, including malformed
|
||||
// or stale commands. Vanilla cannot execute these namespaces, so a bad command
|
||||
// is consumed as a no-op instead of falling through to an unrelated handler.
|
||||
bool ExecuteSpecialCommand(const char *cmd, int isDown) {
|
||||
const ParsedCommand parsed = ParseCommand(cmd);
|
||||
if (parsed.type == CommandType::None)
|
||||
return false;
|
||||
|
||||
if (!isDown || parsed.argument == nullptr || *parsed.argument == '\0')
|
||||
return true;
|
||||
|
||||
void *L = Game::Lua::State();
|
||||
if (L == nullptr)
|
||||
return true;
|
||||
|
||||
switch (parsed.type) {
|
||||
case CommandType::Spell:
|
||||
CallGlobalString(L, "CastSpellByName", parsed.argument);
|
||||
break;
|
||||
case CommandType::Item:
|
||||
CallTableString(L, "C_Item", "UseItemByName", parsed.argument);
|
||||
break;
|
||||
case CommandType::Macro:
|
||||
Macro::Execute::Saved(L, parsed.argument);
|
||||
break;
|
||||
case CommandType::Click:
|
||||
ExecuteClick(L, parsed.argument);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const OverrideEntry *FindEffectiveOverride(const std::string &key) {
|
||||
const OverrideEntry *best = nullptr;
|
||||
for (const OverrideEntry &entry : g_overrides) {
|
||||
if (entry.key != key)
|
||||
continue;
|
||||
|
||||
if (best == nullptr || entry.priority > best->priority ||
|
||||
(entry.priority == best->priority && entry.sequence > best->sequence))
|
||||
best = &entry;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
void SetOverride(const void *owner, bool priority, const std::string &key, const std::string &cmd) {
|
||||
auto existing =
|
||||
std::find_if(g_overrides.begin(), g_overrides.end(),
|
||||
[&](const OverrideEntry &entry) {
|
||||
return entry.owner == owner && entry.key == key;
|
||||
});
|
||||
|
||||
if (existing == g_overrides.end()) {
|
||||
g_overrides.push_back(
|
||||
{owner, key, cmd, priority, ++g_nextSequence});
|
||||
return;
|
||||
}
|
||||
|
||||
existing->command = cmd;
|
||||
existing->priority = priority;
|
||||
existing->sequence = ++g_nextSequence;
|
||||
}
|
||||
|
||||
void RemoveOverride(const void *owner, const std::string &key) {
|
||||
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
|
||||
[&](const OverrideEntry &entry) {
|
||||
return entry.owner == owner &&
|
||||
entry.key == key;
|
||||
}),
|
||||
g_overrides.end());
|
||||
}
|
||||
|
||||
int ExecuteCommand(void *mgr, const char *cmd, int isDown) {
|
||||
if (ExecuteSpecialCommand(cmd, isDown))
|
||||
return 1;
|
||||
|
||||
return CommandExecute_o(mgr, cmd, isDown);
|
||||
}
|
||||
|
||||
int __fastcall CommandExecute_h(void *mgr, void *, const char *cmd, int isDown) {
|
||||
return ExecuteCommand(mgr, cmd, isDown);
|
||||
}
|
||||
|
||||
// Runs an override-resolved command with the same execution context the engine
|
||||
// wraps around a native keypress. FUN_BINDING_KEY_DISPATCH saves the frame-
|
||||
// script exec-context, zeroes it for the nested command, marks the manager as
|
||||
// mid-command (`+0xD8`), then restores the context and floors the nesting
|
||||
// depth. Native-table bindings already run inside this (the engine sets it up
|
||||
// before calling FUN_BINDING_COMMAND_EXECUTE, which we co-hook); overrides
|
||||
// bypass that path, so we mirror it here. Without it, a native Bindings.xml
|
||||
// command dispatched through an override would run without the context its
|
||||
// handler expects. Special SPELL/ITEM/MACRO/CLICK commands run harmlessly
|
||||
// inside the same wrapper.
|
||||
int DispatchOverrideCommand(void *mgr, const char *cmd, int isDown) {
|
||||
volatile uint32_t *context =
|
||||
reinterpret_cast<volatile uint32_t *>(Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT);
|
||||
volatile uint32_t *depth = reinterpret_cast<volatile uint32_t *>(
|
||||
Offsets::VAR_FRAMESCRIPT_EXEC_CONTEXT_DEPTH);
|
||||
volatile uint32_t *executing =
|
||||
Game::Ptr<volatile uint32_t>(mgr, Offsets::OFF_BINDING_MANAGER_EXECUTING);
|
||||
|
||||
const uint32_t savedContext = *context;
|
||||
*depth = *depth + 1;
|
||||
if (*depth != 0)
|
||||
*context = 0;
|
||||
|
||||
*executing = 1;
|
||||
const int result = ExecuteCommand(mgr, cmd, isDown);
|
||||
*executing = 0;
|
||||
|
||||
if (*depth != 0)
|
||||
*context = savedContext;
|
||||
if (static_cast<int32_t>(*depth) - 1 > 0)
|
||||
*depth = *depth - 1;
|
||||
else
|
||||
*depth = 0;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int __fastcall KeyDispatch_h(void *mgr, void *, const char *key, int isDown) {
|
||||
const std::string canonicalKey = CanonicalKey(key);
|
||||
|
||||
// Keep a key-down paired with its override command even if that override is
|
||||
// cleared while the command executes. The newly revealed native binding
|
||||
// must not receive only the key-up half of the same physical press.
|
||||
if (!isDown) {
|
||||
auto active = g_activeOverrides.find(canonicalKey);
|
||||
if (active != g_activeOverrides.end()) {
|
||||
DispatchOverrideCommand(mgr, active->second.c_str(), 0);
|
||||
g_activeOverrides.erase(active);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const OverrideEntry *entry = FindEffectiveOverride(canonicalKey);
|
||||
if (entry == nullptr)
|
||||
return KeyDispatch_o(mgr, key, isDown);
|
||||
|
||||
if (isDown)
|
||||
g_activeOverrides[canonicalKey] = entry->command;
|
||||
|
||||
return DispatchOverrideCommand(mgr, entry->command.c_str(), isDown);
|
||||
}
|
||||
|
||||
int SetBindingCommand(void *L, const char *prefix, const char *usage) {
|
||||
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const std::string key = Game::Lua::ToString(L, 1);
|
||||
const std::string command = MakeCommand(prefix, Game::Lua::ToString(L, 2));
|
||||
|
||||
// Preserve the stock SetBinding return value, binding-set selection,
|
||||
// key normalization, UPDATE_BINDINGS event, and SaveBindings behavior.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushString(L, key.c_str());
|
||||
Game::Lua::PushString(L, command.c_str());
|
||||
auto setBinding =
|
||||
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
|
||||
|
||||
return setBinding(L);
|
||||
}
|
||||
|
||||
const char *OptionalMouseButton(void *L, int index, const char *usage) {
|
||||
if (Game::Lua::GetTop(L) < index || Game::Lua::Type(L, index) == Game::Lua::TYPE_NIL)
|
||||
return nullptr;
|
||||
|
||||
if (!Game::Lua::IsString(L, index)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return Game::Lua::ToString(L, index);
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingSpell(void *L) {
|
||||
return SetBindingCommand(L, kSpellPrefix, "Usage: SetBindingSpell(key, spell)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingItem(void *L) {
|
||||
return SetBindingCommand(L, kItemPrefix, "Usage: SetBindingItem(key, item)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingMacro(void *L) {
|
||||
return SetBindingCommand(L, kMacroPrefix, "Usage: SetBindingMacro(key, macro)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetBindingClick(void *L) {
|
||||
constexpr const char *usage = "Usage: SetBindingClick(key, buttonName [, mouseButton])";
|
||||
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const std::string key = Game::Lua::ToString(L, 1);
|
||||
const char *buttonName = Game::Lua::ToString(L, 2);
|
||||
const char *mouseButton = OptionalMouseButton(L, 3, usage);
|
||||
const std::string command = MakeClickCommand(buttonName, mouseButton);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushString(L, key.c_str());
|
||||
Game::Lua::PushString(L, command.c_str());
|
||||
auto setBinding =
|
||||
reinterpret_cast<ScriptSetBinding_t>(Offsets::FUN_SCRIPT_SET_BINDING);
|
||||
|
||||
return setBinding(L);
|
||||
}
|
||||
|
||||
bool ReadOverrideHeader(void *L, const char *usage, const void **owner,
|
||||
bool *priority, std::string *key) {
|
||||
*owner = Game::Lua::ResolveObject(L, 1);
|
||||
if (*owner == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_BOOLEAN ||
|
||||
!Game::Lua::IsString(L, 3)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return false;
|
||||
}
|
||||
|
||||
*priority = Game::Lua::ToBoolean(L, 2) != 0;
|
||||
*key = CanonicalKey(Game::Lua::ToString(L, 3));
|
||||
return true;
|
||||
}
|
||||
|
||||
int SetOverrideBindingCommand(void *L, const char *prefix, const char *usage) {
|
||||
const void *owner = nullptr;
|
||||
bool priority = false;
|
||||
|
||||
std::string key;
|
||||
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
|
||||
return 0;
|
||||
|
||||
if (!Game::Lua::IsString(L, 4)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
SetOverride(owner, priority, key,
|
||||
MakeCommand(prefix, Game::Lua::ToString(L, 4)));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBinding(void *L) {
|
||||
constexpr const char *usage = "Usage: SetOverrideBinding(owner, isPriority, key [, command])";
|
||||
const void *owner = nullptr;
|
||||
bool priority = false;
|
||||
|
||||
std::string key;
|
||||
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
|
||||
return 0;
|
||||
|
||||
if (Game::Lua::GetTop(L) < 4 ||
|
||||
Game::Lua::Type(L, 4) == Game::Lua::TYPE_NIL) {
|
||||
RemoveOverride(owner, key);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!Game::Lua::IsString(L, 4)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
SetOverride(owner, priority, key, Game::Lua::ToString(L, 4));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingSpell(void *L) {
|
||||
return SetOverrideBindingCommand(
|
||||
L, kSpellPrefix,
|
||||
"Usage: SetOverrideBindingSpell(owner, isPriority, key, spell)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingItem(void *L) {
|
||||
return SetOverrideBindingCommand(
|
||||
L, kItemPrefix,
|
||||
"Usage: SetOverrideBindingItem(owner, isPriority, key, item)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingMacro(void *L) {
|
||||
return SetOverrideBindingCommand(
|
||||
L, kMacroPrefix,
|
||||
"Usage: SetOverrideBindingMacro(owner, isPriority, key, macro)");
|
||||
}
|
||||
|
||||
int __fastcall Script_SetOverrideBindingClick(void *L) {
|
||||
constexpr const char *usage =
|
||||
"Usage: SetOverrideBindingClick(owner, "
|
||||
"isPriority, key, buttonName [, mouseButton])";
|
||||
|
||||
const void *owner = nullptr;
|
||||
bool priority = false;
|
||||
|
||||
std::string key;
|
||||
if (!ReadOverrideHeader(L, usage, &owner, &priority, &key))
|
||||
return 0;
|
||||
|
||||
if (!Game::Lua::IsString(L, 4)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *buttonName = Game::Lua::ToString(L, 4);
|
||||
const char *mouseButton = OptionalMouseButton(L, 5, usage);
|
||||
SetOverride(owner, priority, key,
|
||||
MakeClickCommand(buttonName, mouseButton));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_ClearOverrideBindings(void *L) {
|
||||
const void *owner = Game::Lua::ResolveObject(L, 1);
|
||||
if (owner == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: ClearOverrideBindings(owner)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
g_overrides.erase(std::remove_if(g_overrides.begin(), g_overrides.end(),
|
||||
[&](const OverrideEntry &entry) {
|
||||
return entry.owner == owner;
|
||||
}),
|
||||
g_overrides.end());
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
// A fresh FrameScript state makes every owner pointer from the previous
|
||||
// state stale. Permanent bindings remain in vanilla's binding table.
|
||||
g_overrides.clear();
|
||||
g_activeOverrides.clear();
|
||||
g_nextSequence = 0;
|
||||
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingSpell", &Script_SetBindingSpell);
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingItem", &Script_SetBindingItem);
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingMacro", &Script_SetBindingMacro);
|
||||
Game::Lua::RegisterGlobalFunction("SetBindingClick", &Script_SetBindingClick);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBinding",
|
||||
&Script_SetOverrideBinding);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingSpell",
|
||||
&Script_SetOverrideBindingSpell);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingItem",
|
||||
&Script_SetOverrideBindingItem);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingMacro",
|
||||
&Script_SetOverrideBindingMacro);
|
||||
Game::Lua::RegisterGlobalFunction("SetOverrideBindingClick",
|
||||
&Script_SetOverrideBindingClick);
|
||||
Game::Lua::RegisterGlobalFunction("ClearOverrideBindings",
|
||||
&Script_ClearOverrideBindings);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
const Game::HookAutoRegister _hookKeyDispatch{
|
||||
Offsets::FUN_BINDING_KEY_DISPATCH, reinterpret_cast<void *>(&KeyDispatch_h),
|
||||
reinterpret_cast<void **>(&KeyDispatch_o)};
|
||||
|
||||
const Game::HookAutoRegister _hookCommandExecute{
|
||||
Offsets::FUN_BINDING_COMMAND_EXECUTE,
|
||||
reinterpret_cast<void *>(&CommandExecute_h),
|
||||
reinterpret_cast<void **>(&CommandExecute_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Bindings::Api
|
||||
@@ -13,8 +13,6 @@
|
||||
TargetUnit("focus");
|
||||
</Binding>
|
||||
<Binding name="AOELOOT">
|
||||
if not C_Loot.IsScanInProgress() then
|
||||
C_Loot.ScanNearbyLoot()
|
||||
end
|
||||
C_Loot.LootAllCorpses()
|
||||
</Binding>
|
||||
</Bindings>
|
||||
|
||||
+17
-49
@@ -18,29 +18,19 @@
|
||||
// against an internal cache to identify the player. With this we
|
||||
// just read the GUID directly.
|
||||
//
|
||||
// Implementation: the SMSG_MESSAGECHAT handler at FUN_0049D560 reads
|
||||
// the senderGUID from the packet (offsets `[ebp-0x10]` / `[ebp-0xC]`
|
||||
// in that function's stack frame) and forwards it as args 9-10 to
|
||||
// the chat dispatcher at FUN_0049A870. The dispatcher in turn fires
|
||||
// the appropriate CHAT_MSG_* event during its execution. We hook the
|
||||
// dispatcher's entry, stash the GUID into a file-scope global, call
|
||||
// the original (which fires the event synchronously), then clear the
|
||||
// global on return. Inside the addon's OnEvent, `GetCurrentChatGUID`
|
||||
// reads the global.
|
||||
//
|
||||
// Why not hook the SMSG handler directly: the GUID isn't in argument
|
||||
// registers/stack at the handler's entry — it's read mid-function
|
||||
// into locals. Hooking the dispatcher (which receives the GUID as
|
||||
// args) is structurally simpler. The trade-off: the dispatcher is
|
||||
// also called for synthetic chat (system notifications, arena team
|
||||
// events, etc.) with GUID = 0; for those, `GetCurrentChatGUID()`
|
||||
// returns nil — matches the modern API's behavior of nil GUID for
|
||||
// non-player-sourced chat.
|
||||
// The GUID is published for the duration of a chat dispatch by a
|
||||
// `DispatchScope` (see the header), which Chat::Dispatch — the single
|
||||
// owner of the FUN_0049A870 chat-dispatch hook — constructs around the
|
||||
// original call. The engine fires the CHAT_MSG_* event synchronously
|
||||
// inside that call, so `GetCurrentChatGUID` reads the right GUID from
|
||||
// the addon's OnEvent. The dispatcher is also called for synthetic chat
|
||||
// (system notifications, arena team events, etc.) with GUID = 0; for
|
||||
// those `GetCurrentChatGUID()` returns nil — matching the modern API's
|
||||
// behavior of a nil GUID for non-player-sourced chat.
|
||||
|
||||
#include "CurrentGUID.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "guid/Guid.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -69,39 +59,17 @@ void RegisterLuaFunctions() {
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatDispatch_t ChatDispatch_o = nullptr;
|
||||
DispatchScope::DispatchScope(uint32_t guidLo, uint32_t guidHi)
|
||||
: prevLo_(g_currentGuidLo), prevHi_(g_currentGuidHi) {
|
||||
g_currentGuidLo = guidLo;
|
||||
g_currentGuidHi = guidHi;
|
||||
}
|
||||
|
||||
void __fastcall ChatDispatch_h(
|
||||
const char *sender, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi)
|
||||
{
|
||||
// Save before original — the engine's chat event fires DURING
|
||||
// origCall, and the addon's OnEvent runs synchronously inside
|
||||
// that window. The GUID must be readable at that point.
|
||||
const uint32_t prevLo = g_currentGuidLo;
|
||||
const uint32_t prevHi = g_currentGuidHi;
|
||||
g_currentGuidLo = static_cast<uint32_t>(senderGuidLo);
|
||||
g_currentGuidHi = static_cast<uint32_t>(senderGuidHi);
|
||||
|
||||
ChatDispatch_o(sender, chatType, arg3, arg4, arg5, arg6, arg7,
|
||||
targetGuidLo, targetGuidHi, arg10,
|
||||
senderGuidLo, senderGuidHi);
|
||||
|
||||
// Restore prior value rather than clearing to 0, so nested
|
||||
// dispatch (one chat event firing during another's OnEvent —
|
||||
// e.g. an addon calling SendChatMessage from its handler) leaves
|
||||
// the outer context's GUID intact when the inner returns.
|
||||
g_currentGuidLo = prevLo;
|
||||
g_currentGuidHi = prevHi;
|
||||
DispatchScope::~DispatchScope() {
|
||||
g_currentGuidLo = prevLo_;
|
||||
g_currentGuidHi = prevHi_;
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
static const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_CHAT_DISPATCH,
|
||||
reinterpret_cast<void *>(&ChatDispatch_h),
|
||||
reinterpret_cast<void **>(&ChatDispatch_o)};
|
||||
|
||||
} // namespace Chat::CurrentGUID
|
||||
|
||||
+21
-48
@@ -13,55 +13,28 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Chat::CurrentGUID {
|
||||
|
||||
// Hook for FUN_CHAT_DISPATCH (0x0049A870). Saves the senderGUID into
|
||||
// a file-scope global for the duration of the original call, then
|
||||
// clears it on return. Inside that window the engine fires the
|
||||
// appropriate CHAT_MSG_* event, so `GetCurrentChatGUID()` called from
|
||||
// the addon's OnEvent sees the GUID for the message that triggered it.
|
||||
//
|
||||
// 12-arg __fastcall signature. The function has a stack-probe
|
||||
// prologue (`mov eax, 0x1F0C; call _chkstk`) — its frame is ~8KB.
|
||||
// Verified by disassembling 0x0049A870 in 1.12.1: stack-arg access
|
||||
// reaches `[ebp+0x2C]` (= param 12) and the function exits with
|
||||
// `ret 0x28` (cleans 40 bytes = 10 stack args + 2 register args).
|
||||
//
|
||||
// ECX = arg1 (sender name string)
|
||||
// EDX = arg2 (chat type byte)
|
||||
// [esp+ 4] = arg3
|
||||
// [esp+ 8] = arg4
|
||||
// [esp+ c] = arg5
|
||||
// [esp+10] = arg6
|
||||
// [esp+14] = arg7
|
||||
// [esp+18] = arg8 — target GUID lo (for whisper/channel target)
|
||||
// [esp+1c] = arg9 — target GUID hi
|
||||
// [esp+20] = arg10
|
||||
// [esp+24] = arg11 — sender GUID lo ← captured
|
||||
// [esp+28] = arg12 — sender GUID hi ← captured
|
||||
//
|
||||
// Identified args 11/12 as senderGUID by tracing the AFK auto-reply
|
||||
// path at 0x0049AB39: reads params 11/12, OR's them to check for
|
||||
// non-zero, pushes them onto the packet stream via FUN_00418370
|
||||
// (the "write u64" helper). That's the GUID the AFK whisper-back is
|
||||
// addressed to — i.e. the original message sender.
|
||||
//
|
||||
// An earlier 10-arg signature derived from Ghidra (on the wrong
|
||||
// binary) had `ret 0x20` cleanup. The 8-byte cleanup mismatch with
|
||||
// the engine's `ret 0x28`-shaped call left 8 bytes of stack
|
||||
// uncleaned per chat-dispatch invocation, corrupting the engine's
|
||||
// stack frame and crashing on a later indirect call. See the
|
||||
// commit message for the full diagnosis.
|
||||
using ChatDispatch_t = void(__fastcall *)(
|
||||
const char *sender, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi);
|
||||
extern ChatDispatch_t ChatDispatch_o;
|
||||
void __fastcall ChatDispatch_h(
|
||||
const char *sender, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi);
|
||||
// RAII scope that publishes the sender GUID of the chat message currently being
|
||||
// dispatched, for `GetCurrentChatGUID()` to read. Chat::Dispatch constructs one
|
||||
// around the original chat-dispatch call: the engine fires the CHAT_MSG_* event
|
||||
// synchronously inside that call, so an addon's OnEvent sees the GUID of the
|
||||
// message that triggered it. Restores the prior value on destruction rather than
|
||||
// clearing to 0, so nested dispatch (a chat event firing during another's
|
||||
// OnEvent — e.g. an addon calling SendChatMessage from its handler) leaves the
|
||||
// outer context's GUID intact when the inner returns.
|
||||
class DispatchScope {
|
||||
public:
|
||||
DispatchScope(uint32_t guidLo, uint32_t guidHi);
|
||||
~DispatchScope();
|
||||
DispatchScope(const DispatchScope &) = delete;
|
||||
DispatchScope &operator=(const DispatchScope &) = delete;
|
||||
|
||||
private:
|
||||
uint32_t prevLo_;
|
||||
uint32_t prevHi_;
|
||||
};
|
||||
|
||||
} // namespace Chat::CurrentGUID
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Single owner of the chat-dispatch hook (FUN_0049A870).
|
||||
//
|
||||
// The engine routes every server-delivered chat message — say/yell/whisper/
|
||||
// channel/emote and synthetic system notifications — through this one function.
|
||||
// (Addon `print`/`AddMessage` does NOT pass through it.) Several independent
|
||||
// concerns need to observe or transform a dispatched message, but MinHook allows
|
||||
// only ONE hook per target, so this module owns the hook and orchestrates them,
|
||||
// mirroring Net::PacketDispatch (one hook, fanned out to independent concerns):
|
||||
//
|
||||
// - Chat::IconFilter::Sanitize — strip player-injected `|T` icon spoofs from
|
||||
// the message before the engine builds the chat line AND the speech bubble
|
||||
// (both come from the same buffer, so one sanitize covers both).
|
||||
// - Chat::RaidMarkers::Substitute — expand `{rt1}`/`{skull}`/… raid-target
|
||||
// tokens into inline marker icons. Runs AFTER Sanitize so a player gets the
|
||||
// fixed marker icons but still can't inject an arbitrary `|T` path.
|
||||
// - Chat::CurrentGUID::DispatchScope — publish the sender GUID for the
|
||||
// synchronous CHAT_MSG_* event so `GetCurrentChatGUID()` can read it.
|
||||
//
|
||||
// Signature — `__fastcall`, `RET 0x28`:
|
||||
// ECX = arg1 — the MESSAGE text (verified from the call site in FUN_0049D560:
|
||||
// `MOV ECX,[EBP-0x10]` = the raw/processed message. An older
|
||||
// note mislabeled this "sender name".) Copied into one buffer
|
||||
// fed to both the chat frame and the bubble spawn FUN_00608AC0.
|
||||
// EDX = arg2 — chat type byte
|
||||
// [esp+04] = arg3 [esp+08] = arg4 [esp+0c] = arg5
|
||||
// [esp+10] = arg6 [esp+14] = arg7
|
||||
// [esp+18] = arg8 — target GUID lo (whisper/channel target)
|
||||
// [esp+1c] = arg9 — target GUID hi
|
||||
// [esp+20] = arg10
|
||||
// [esp+24] = arg11 — sender GUID lo ← published
|
||||
// [esp+28] = arg12 — sender GUID hi ← published
|
||||
//
|
||||
// The `RET 0x28` cleanup (40 bytes = 10 stack args) MUST match the declared
|
||||
// signature exactly; an earlier 10-arg / `ret 0x20` version left 8 bytes
|
||||
// uncleaned per call and crashed the engine on a later indirect call.
|
||||
|
||||
#include "CurrentGUID.h"
|
||||
#include "IconFilter.h"
|
||||
#include "RaidMarkers.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Chat::Dispatch {
|
||||
|
||||
namespace {
|
||||
|
||||
using ChatDispatch_t = void(__fastcall *)(
|
||||
const char *message, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi);
|
||||
|
||||
ChatDispatch_t g_original = nullptr;
|
||||
|
||||
void __fastcall ChatDispatch_h(
|
||||
const char *message, int chatType,
|
||||
int arg3, int arg4, int arg5, int arg6, int arg7,
|
||||
int targetGuidLo, int targetGuidHi, int arg10,
|
||||
int senderGuidLo, int senderGuidHi)
|
||||
{
|
||||
// Strip player-injected `|T` icon escapes (covers chat line + speech bubble;
|
||||
// both build from this message). The 0x800 buffer matches the engine's own
|
||||
// message-buffer cap.
|
||||
char iconBuf[0x800];
|
||||
const char *msg = Chat::IconFilter::Sanitize(message, iconBuf, sizeof iconBuf);
|
||||
|
||||
// Expand raid-target tokens ({rt1}/{skull}/…) into inline marker icons —
|
||||
// after the sanitize, so only these fixed, safe escapes are added.
|
||||
char markerBuf[0x800];
|
||||
msg = Chat::RaidMarkers::Substitute(msg, markerBuf, sizeof markerBuf);
|
||||
|
||||
// Publish the sender GUID for the CHAT_MSG_* event the original fires
|
||||
// synchronously; restored when this scope ends (after the original returns).
|
||||
Chat::CurrentGUID::DispatchScope guid(static_cast<uint32_t>(senderGuidLo),
|
||||
static_cast<uint32_t>(senderGuidHi));
|
||||
|
||||
g_original(msg, chatType, arg3, arg4, arg5, arg6, arg7,
|
||||
targetGuidLo, targetGuidHi, arg10,
|
||||
senderGuidLo, senderGuidHi);
|
||||
}
|
||||
|
||||
static const Game::HookAutoRegister _hookreg{
|
||||
Offsets::FUN_CHAT_DISPATCH,
|
||||
reinterpret_cast<void *>(&ChatDispatch_h),
|
||||
reinterpret_cast<void **>(&g_original)};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Chat::Dispatch
|
||||
@@ -0,0 +1,85 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Inline-texture (`|T`) chat anti-spoof.
|
||||
//
|
||||
// Vanilla neutralizes player-*typed* escapes in chat (a typed `|cff…|r`,
|
||||
// `|Hitem…|h` or `|T…|t` shows as raw text) — only *trusted* text renders them
|
||||
// (addon `AddMessage`, shift-clicked links). Our Text::InlineTexture backport
|
||||
// breaks that parity for `|T` alone: it detects the neutralized `||T` form and
|
||||
// draws the icon anyway, so a player could `/say` `|Tpath|t` and have a real
|
||||
// icon appear in everyone's chat AND in the speech bubble over their head.
|
||||
//
|
||||
// This restores vanilla's behavior for `|T` by defanging it in the chat path,
|
||||
// while leaving `|c`/`|r`/`|H`/`|h` exactly as vanilla (already raw for typed
|
||||
// input). It CANNOT be done in the text emitter: addon `|T` and player `|T`
|
||||
// reach the emitter as byte-identical `||T` (vanilla doubles the unrecognized
|
||||
// `|T` for both), so the only place they're distinguishable is the source —
|
||||
// server-delivered chat flows through the chat dispatcher, addon `print` does
|
||||
// not. Chat::Dispatch owns that hook and calls this on the message.
|
||||
//
|
||||
// Defang = replace the `|` of each `|T` with a space, breaking the pipe→T
|
||||
// adjacency the inline-texture detector keys on (`|T` and `||T` both contain the
|
||||
// `|T` substring, so this catches the doubled form too). The path text stays
|
||||
// readable, the closer `|t` is inert without an opener, and every other escape
|
||||
// is untouched.
|
||||
|
||||
#include "IconFilter.h"
|
||||
|
||||
namespace Chat::IconFilter {
|
||||
|
||||
namespace {
|
||||
|
||||
// True if `s` contains an inline-texture opener (`|T`) anywhere.
|
||||
bool HasIconEscape(const char *s) {
|
||||
for (; *s != '\0'; ++s)
|
||||
if (s[0] == '|' && s[1] == 'T')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
|
||||
if (msg == nullptr || !HasIconEscape(msg))
|
||||
return msg; // common case: forward untouched, no copy
|
||||
|
||||
// NEVER write `msg` in place: several of the chat handler's ~50 callers pass
|
||||
// read-only `.rdata` literals (system notifications), so an in-place edit
|
||||
// would fault. We copy only when a `|T` is actually present.
|
||||
//
|
||||
// Defanging must not leave a LONE `|` followed by a non-escape char — that's
|
||||
// an invalid escape, which the chat frame tolerates but the speech-bubble
|
||||
// text setter chokes on, producing an empty bubble (verified in-game). By the
|
||||
// time chat reaches us the escape is usually the doubled `||T` (vanilla's own
|
||||
// neutralization of typed escapes), where `||` is a VALID literal-pipe escape.
|
||||
// So: keep the pipe(s) and blank the `T` when the pipe completes a `||`; blank
|
||||
// BOTH chars for a lone `|T`. Either way no dangling `|` remains, and no `|T`
|
||||
// opener survives for the inline-texture detector to fire on.
|
||||
size_t j = 0;
|
||||
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
|
||||
if (msg[i] == '|' && msg[i + 1] == 'T') {
|
||||
const bool doubledPipe = (i > 0 && msg[i - 1] == '|');
|
||||
buf[j++] = doubledPipe ? '|' : ' '; // keep pipe only if it completes `||`
|
||||
if (j + 1 < bufSize)
|
||||
buf[j++] = ' '; // blank the `T`
|
||||
i += 2; // consumed `|T`
|
||||
} else {
|
||||
buf[j++] = msg[i++];
|
||||
}
|
||||
}
|
||||
buf[j] = '\0';
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace Chat::IconFilter
|
||||
@@ -0,0 +1,28 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace Chat::IconFilter {
|
||||
|
||||
// Strips player-injected inline-texture (`|T…|t`) escapes from a chat message.
|
||||
// Returns `msg` unchanged when it contains no `|T` (the common case — no copy),
|
||||
// otherwise a sanitized copy written into `buf`. Called by Chat::Dispatch on the
|
||||
// message the engine's chat handler is about to display. See IconFilter.cpp for
|
||||
// the anti-spoof rationale and why sanitizing here (not in the text emitter) is
|
||||
// the only place player-typed and addon `|T` are distinguishable.
|
||||
const char *Sanitize(const char *msg, char *buf, size_t bufSize);
|
||||
|
||||
} // namespace Chat::IconFilter
|
||||
@@ -0,0 +1,154 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Raid-target marker chat substitutions ({rt1}..{rt8} / {star} / {skull} / ...).
|
||||
//
|
||||
// Modern WoW lets players type `{skull}` (etc.) in chat and everyone sees the
|
||||
// raid-target icon. Vanilla 1.12 has no such substitution. We backport it onto
|
||||
// the inline-texture (`|T…|t`) renderer: each recognized token is rewritten into
|
||||
// a `|T` escape that crops the shared raid-target sprite sheet
|
||||
// `Interface\TargetingFrame\UI-RaidTargetingIcons` to the matching 64×64 cell.
|
||||
//
|
||||
// Runs in Chat::Dispatch AFTER Chat::IconFilter::Sanitize. Order is deliberate:
|
||||
// sanitize first defangs any player-typed raw `|T` spoof, then we add ONLY the
|
||||
// fixed, known-safe marker escapes — so players get the markers without a path to
|
||||
// inject arbitrary textures.
|
||||
//
|
||||
// Icon→texcoord mapping verified against the client's own UnitPopup.lua
|
||||
// (RAID_TARGET_1..8): the sheet is 256×256 with 64-pixel cells, marker N at
|
||||
// column (N-1)%4, row (N-1)/4. So RT1(star) is the top-left cell and RT8(skull)
|
||||
// is row 1, column 3. The inline renderer's OpenGL v-flip already draws this sheet
|
||||
// right-side-up (it's the sheet that flip was verified against), so top<bottom
|
||||
// pixel coords render correctly.
|
||||
|
||||
#include "RaidMarkers.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Chat::RaidMarkers {
|
||||
|
||||
namespace {
|
||||
|
||||
// On-screen pixel size of an inline marker. The default chat font is ~14px, so 14
|
||||
// keeps a marker about one line tall. Fixed (the substitution is a static string
|
||||
// rewrite and can't see the target font); tunable here if it reads too small/large.
|
||||
constexpr int kMarkerSizePx = 14;
|
||||
|
||||
struct Token {
|
||||
const char *name; // lowercase alias, no braces
|
||||
int marker; // raid-target index 1..8
|
||||
};
|
||||
|
||||
// Aliases per the modern client, all case-insensitive. `{coin}` is a colloquial
|
||||
// alias for `{circle}` (RT2) and `{x}` for `{cross}` (RT7) — both shown in the
|
||||
// in-game substitution help.
|
||||
constexpr Token kTokens[] = {
|
||||
{"rt1", 1}, {"star", 1},
|
||||
{"rt2", 2}, {"circle", 2}, {"coin", 2},
|
||||
{"rt3", 3}, {"diamond", 3},
|
||||
{"rt4", 4}, {"triangle", 4},
|
||||
{"rt5", 5}, {"moon", 5},
|
||||
{"rt6", 6}, {"square", 6},
|
||||
{"rt7", 7}, {"cross", 7}, {"x", 7},
|
||||
{"rt8", 8}, {"skull", 8},
|
||||
};
|
||||
|
||||
char LowerAscii(char c) { return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c; }
|
||||
|
||||
// Case-insensitive match of the n-char token body [s, s+n) against a lowercase
|
||||
// literal — true only when `lit` is exactly n chars long.
|
||||
bool EqCI(const char *s, size_t n, const char *lit) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
if (lit[i] == '\0' || LowerAscii(s[i]) != lit[i])
|
||||
return false;
|
||||
return lit[n] == '\0';
|
||||
}
|
||||
|
||||
// Raid-target index (1..8) for the token body [name, name+n), or 0 if unknown.
|
||||
int MatchMarker(const char *name, size_t n) {
|
||||
for (const Token &t : kTokens)
|
||||
if (EqCI(name, n, t.name))
|
||||
return t.marker;
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool HasBrace(const char *s) {
|
||||
for (; *s != '\0'; ++s)
|
||||
if (*s == '{')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *Substitute(const char *msg, char *buf, size_t bufSize) {
|
||||
if (msg == nullptr || !HasBrace(msg))
|
||||
return msg; // common case: forward untouched, no copy
|
||||
|
||||
// A token inflates ~28x (a 3-char `{rtN}` -> ~85-byte `|T` markup), so a
|
||||
// marker-dense message can exceed `buf`. Never DROP message content to fit:
|
||||
// only expand a token when the expansion AND the whole remaining input (worst
|
||||
// case copied 1:1 as literal text) still fit. Past that point later tokens
|
||||
// stay literal `{rtN}`, so the full message text is always preserved — a DLL
|
||||
// user never sees a shorter line than a non-DLL user, just fewer icons.
|
||||
const size_t msgLen = std::strlen(msg);
|
||||
|
||||
size_t j = 0;
|
||||
for (size_t i = 0; msg[i] != '\0' && j + 1 < bufSize;) {
|
||||
if (msg[i] == '{') {
|
||||
// Scan the token body to the closing brace (bounded — longest alias
|
||||
// is 8 chars, so a `{` with no nearby `}` is just literal text).
|
||||
size_t k = i + 1;
|
||||
while (msg[k] != '\0' && msg[k] != '}' && k - i <= 12)
|
||||
++k;
|
||||
if (msg[k] == '}') {
|
||||
const int marker = MatchMarker(msg + i + 1, k - (i + 1));
|
||||
if (marker != 0) {
|
||||
const int col = (marker - 1) % 4;
|
||||
const int row = (marker - 1) / 4;
|
||||
const int l = col * 64, r = l + 64, t = row * 64, b = t + 64;
|
||||
// Emit the pipe-DOUBLED form `||T…||t`. The inline emitter
|
||||
// renders both `|T` and `||T`, but the vanilla speech-bubble
|
||||
// text builder (FUN_004b1600) TRUNCATES at a lone `|T` while
|
||||
// PRESERVING `||` — so the doubled form is what survives into
|
||||
// the bubble's FontString (which pfUI's bubble reskin copies
|
||||
// and renders). Anti-spoof Sanitize runs before this, so these
|
||||
// are the only `||T` openers a player can produce.
|
||||
char markup[128];
|
||||
const int m = std::snprintf(
|
||||
markup, sizeof markup,
|
||||
"||TInterface\\TargetingFrame\\UI-RaidTargetingIcons:%d:%d:0:0:256:256:"
|
||||
"%d:%d:%d:%d||t",
|
||||
kMarkerSizePx, kMarkerSizePx, l, r, t, b);
|
||||
// Reserve room for the literal tail after this token
|
||||
// (msg[k+1..]) so the rest of the message always fits.
|
||||
const size_t tail = msgLen - (k + 1);
|
||||
if (m > 0 && j + static_cast<size_t>(m) + tail + 1 <= bufSize) {
|
||||
std::memcpy(buf + j, markup, static_cast<size_t>(m));
|
||||
j += static_cast<size_t>(m);
|
||||
i = k + 1; // consume through the closing `}`
|
||||
continue;
|
||||
}
|
||||
// No room for the expansion — fall through and copy the
|
||||
// literal `{` so the message is never left truncated mid-token.
|
||||
}
|
||||
}
|
||||
}
|
||||
buf[j++] = msg[i++];
|
||||
}
|
||||
buf[j] = '\0';
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace Chat::RaidMarkers
|
||||
@@ -0,0 +1,30 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace Chat::RaidMarkers {
|
||||
|
||||
// Expands raid-target marker tokens in a chat message into inline raid-target
|
||||
// icon textures — the modern `{rt1}`..`{rt8}` / `{star}` / `{circle}` / `{skull}`
|
||||
// (etc.) chat substitutions, backported onto the inline-texture renderer. Returns
|
||||
// `msg` unchanged when it contains no `{` (the common case — no copy), otherwise a
|
||||
// rewritten copy in `buf`. Called by Chat::Dispatch AFTER the `|T` anti-spoof
|
||||
// sanitize, so a player can use the fixed, safe marker icons but still can't
|
||||
// inject an arbitrary texture path. See RaidMarkers.cpp for the token table and
|
||||
// the icon→texcoord mapping.
|
||||
const char *Substitute(const char *msg, char *buf, size_t bufSize);
|
||||
|
||||
} // namespace Chat::RaidMarkers
|
||||
@@ -0,0 +1,82 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_ChatBubbles.GetAllChatBubbles([includeForbidden])` — returns an
|
||||
// array of the currently-active chat-bubble Frame objects, matching
|
||||
// modern WoW's 7.2.5 signature. Addons use it to recolor / translate /
|
||||
// reposition speech bubbles by reading the bubble's FontString region.
|
||||
//
|
||||
// Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full
|
||||
// `CSimpleFrame`s created purely in C++ (never via `CreateFrame`), so
|
||||
// they have no name and no Lua wrapper until something pushes them. This
|
||||
// is exactly the default-nameplate case: we push each via the shared
|
||||
// `UI::FrameObject::Push`, which delegates to the engine's own
|
||||
// `FrameScript_Object::ScriptRegister` to lazily build the canonical
|
||||
// wrapper. The returned frames respond to real frame methods — the
|
||||
// FontString holding the spoken text (bubble + 0x31c, created with the
|
||||
// bubble as its parent) shows up in `bubble:GetRegions()`, so the modern
|
||||
// "iterate GetRegions, find the FontString, read GetText()" idiom works
|
||||
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
|
||||
// wiki describes, but handed the exact set instead of the whole world.
|
||||
//
|
||||
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
|
||||
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
|
||||
// link sits at `+0x318` and the list terminator carries the low-bit
|
||||
// sentinel. We mirror the engine's own walk in the per-frame update
|
||||
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
|
||||
// unit has despawned are pruned on the engine's next update tick, so a
|
||||
// just-orphaned bubble can linger one frame — harmless, and addons that
|
||||
// care filter on `bubble:IsShown()` (a real method here) anyway.
|
||||
//
|
||||
// `includeForbidden` is accepted and ignored: vanilla has no forbidden
|
||||
// frames, so every bubble is returned regardless.
|
||||
|
||||
#include "Game.h"
|
||||
#include "ui/FrameObject.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace ChatBubble::Info {
|
||||
|
||||
namespace {
|
||||
|
||||
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
|
||||
// forward link, and the end-of-list low-bit sentinel — see the engine
|
||||
// walks at 0x004b1060 / 0x004b0f80.
|
||||
constexpr uintptr_t kListHead = 0x00B6F038;
|
||||
constexpr int kOffNextLink = 0x318;
|
||||
|
||||
} // namespace
|
||||
|
||||
static int __fastcall Script_GetAllChatBubbles(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
|
||||
int nextIndex = 1;
|
||||
while (node != 0 && (node & 1) == 0) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
|
||||
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
|
||||
Game::Lua::SetTable(L, -3);
|
||||
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_ChatBubbles", "GetAllChatBubbles",
|
||||
&Script_GetAllChatBubbles);
|
||||
}
|
||||
|
||||
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace ChatBubble::Info
|
||||
+8
-30
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Names.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -22,21 +23,11 @@ namespace Classes::Info {
|
||||
|
||||
namespace {
|
||||
|
||||
const uint8_t *const *Records() {
|
||||
return *reinterpret_cast<const uint8_t *const *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
|
||||
}
|
||||
|
||||
int Count() {
|
||||
return *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
|
||||
}
|
||||
|
||||
int LocaleIndex() {
|
||||
return *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
|
||||
}
|
||||
|
||||
// `FillLocalizedClassList(table [, isFemale]) → table` — fills the
|
||||
// passed-in table with `[classToken] = localizedName` pairs for every
|
||||
// class in `ChrClasses.dbc`, then returns the same table for chaining.
|
||||
@@ -58,15 +49,7 @@ int __fastcall Script_FillLocalizedClassList(void *L) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto *records = Records();
|
||||
if (records == nullptr) {
|
||||
// DBC not loaded yet — return the table unchanged. Matches
|
||||
// modern behavior of "fill what's available, don't crash".
|
||||
Game::Lua::SetTop(L, 1);
|
||||
return 1;
|
||||
}
|
||||
const int count = Count();
|
||||
const int locale = LocaleIndex();
|
||||
|
||||
// Drop the optional `isFemale` arg (and any further junk) so the
|
||||
// table sits alone at idx 1. Each loop iteration pushes key+value
|
||||
@@ -74,19 +57,14 @@ int __fastcall Script_FillLocalizedClassList(void *L) {
|
||||
// [table] before the next iteration.
|
||||
Game::Lua::SetTop(L, 1);
|
||||
for (int i = 1; i <= count; i++) {
|
||||
const uint8_t *record = records[i];
|
||||
if (record == nullptr)
|
||||
// DBC::* are null-safe (empty result if ChrClasses.dbc isn't loaded)
|
||||
// and skip empty strings, so a sparse/absent class id just falls
|
||||
// through — same "fill what's available, don't crash" behavior.
|
||||
const char *token = DBC::ClassToken(static_cast<uint32_t>(i));
|
||||
if (token == nullptr)
|
||||
continue;
|
||||
|
||||
const char *token = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_CHRCLASSES_FILENAME);
|
||||
if (token == nullptr || *token == '\0')
|
||||
continue;
|
||||
|
||||
const char *const *names = reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_CHRCLASSES_NAMES);
|
||||
const char *name = names[locale];
|
||||
if (name == nullptr || *name == '\0')
|
||||
const char *name = DBC::ClassName(static_cast<uint32_t>(i));
|
||||
if (name == nullptr)
|
||||
continue;
|
||||
|
||||
Game::Lua::PushString(L, token);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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/>.
|
||||
|
||||
// `length = CopyToClipboard(text [, removeMarkup])` — copy `text` to the
|
||||
// Windows clipboard and return the number of bytes copied. Vanilla has no
|
||||
// clipboard access at all (Blizzard never exposed one), so this is a pure
|
||||
// ClassicAPI addition backed by the Win32 clipboard API.
|
||||
//
|
||||
// - `text` the string to copy (numbers are coerced, like Lua does).
|
||||
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
|
||||
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
|
||||
// visible text, |T..|t textures, |n newline, || → |) before
|
||||
// copying, so the clipboard gets plain text.
|
||||
// - returns byte length of the copied (possibly stripped) string —
|
||||
// consistent with Lua's `#s`. 0 on an empty string or if
|
||||
// the clipboard couldn't be opened.
|
||||
//
|
||||
// WoW's in-memory strings are UTF-8 here (same assumption the credential and
|
||||
// TTS modules make), so we convert to UTF-16 and set CF_UNICODETEXT; Windows
|
||||
// synthesizes CF_TEXT for legacy consumers.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace Clipboard::Copy {
|
||||
|
||||
namespace {
|
||||
|
||||
// Strip WoW UI escape sequences, leaving readable text. See header comment for
|
||||
// the set handled; unknown `|x` sequences are left verbatim so real text that
|
||||
// merely contains a pipe survives.
|
||||
std::string StripMarkup(const char *s, unsigned len) {
|
||||
std::string out;
|
||||
out.reserve(len);
|
||||
unsigned i = 0;
|
||||
while (i < len) {
|
||||
const char c = s[i];
|
||||
if (c != '|') {
|
||||
out.push_back(c);
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
const char n = (i + 1 < len) ? s[i + 1] : '\0';
|
||||
switch (n) {
|
||||
case '|': // escaped pipe
|
||||
out.push_back('|');
|
||||
i += 2;
|
||||
break;
|
||||
case 'n': // newline token
|
||||
out.push_back('\n');
|
||||
i += 2;
|
||||
break;
|
||||
case 'r': // color reset
|
||||
i += 2;
|
||||
break;
|
||||
case 'c': // color open: |c + up to 8 hex digits
|
||||
i += 2;
|
||||
for (int k = 0; k < 8 && i < len; ++k) {
|
||||
const char h = s[i];
|
||||
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
|
||||
(h >= 'A' && h <= 'F');
|
||||
if (!isHex)
|
||||
break;
|
||||
++i;
|
||||
}
|
||||
break;
|
||||
case 'h': // hyperlink boundary (text separator or closer) — drop
|
||||
i += 2;
|
||||
break;
|
||||
case 'H': // hyperlink data opener — skip through to the next |h
|
||||
i += 2;
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
|
||||
++i;
|
||||
break; // the |h is dropped by the 'h' case on the next iteration
|
||||
case 't': // stray texture closer — drop
|
||||
i += 2;
|
||||
break;
|
||||
case 'T': // texture opener — skip through to the next |t
|
||||
i += 2;
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
|
||||
++i;
|
||||
break; // the |t is dropped by the 't' case on the next iteration
|
||||
default: // unknown sequence — keep the pipe literally
|
||||
out.push_back('|');
|
||||
++i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Place a UTF-8 byte range on the clipboard as CF_UNICODETEXT. Returns true on
|
||||
// success. Does not free the allocation on success — ownership passes to the
|
||||
// clipboard.
|
||||
bool SetClipboardUtf8(const char *data, int byteLen) {
|
||||
if (!OpenClipboard(nullptr))
|
||||
return false;
|
||||
|
||||
bool ok = false;
|
||||
if (EmptyClipboard()) {
|
||||
// Empty string still writes a valid (empty) clipboard entry.
|
||||
const int wlen =
|
||||
byteLen > 0 ? MultiByteToWideChar(CP_UTF8, 0, data, byteLen, nullptr, 0) : 0;
|
||||
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast<SIZE_T>(wlen) + 1) * sizeof(wchar_t));
|
||||
if (h != nullptr) {
|
||||
auto *dst = static_cast<wchar_t *>(GlobalLock(h));
|
||||
if (dst != nullptr) {
|
||||
if (wlen > 0)
|
||||
MultiByteToWideChar(CP_UTF8, 0, data, byteLen, dst, wlen);
|
||||
dst[wlen] = L'\0';
|
||||
GlobalUnlock(h);
|
||||
if (SetClipboardData(CF_UNICODETEXT, h) != nullptr)
|
||||
ok = true; // clipboard owns h now
|
||||
}
|
||||
if (!ok)
|
||||
GlobalFree(h);
|
||||
}
|
||||
}
|
||||
|
||||
CloseClipboard();
|
||||
return ok;
|
||||
}
|
||||
|
||||
int __fastcall Script_CopyToClipboard(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1) && !Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: CopyToClipboard(text [, removeMarkup])");
|
||||
return 0; // unreachable
|
||||
}
|
||||
|
||||
const char *text = Game::Lua::ToString(L, 1);
|
||||
const unsigned len = Game::Lua::StrLen(L, 1);
|
||||
const bool removeMarkup = Game::Lua::ToBoolean(L, 2) != 0;
|
||||
|
||||
int copiedLen;
|
||||
if (removeMarkup) {
|
||||
const std::string stripped = StripMarkup(text, len);
|
||||
copiedLen = static_cast<int>(stripped.size());
|
||||
SetClipboardUtf8(stripped.data(), copiedLen);
|
||||
} else {
|
||||
copiedLen = static_cast<int>(len);
|
||||
SetClipboardUtf8(text, copiedLen);
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(copiedLen));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterGlobalFunction("CopyToClipboard", &Script_CopyToClipboard);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Clipboard::Copy
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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_ColorUtil` — the modern color-space + text-color-code helpers, backported
|
||||
// to 1.12. Every one is pure math / string formatting (no engine state), so
|
||||
// this is a plain reimplementation rather than a hook.
|
||||
//
|
||||
// Conventions verified in-game against a retail-family client that ships
|
||||
// C_ColorUtil natively:
|
||||
// - HSV/HSL hue is in DEGREES, [0, 360); saturation / value / lightness are
|
||||
// [0, 1]. Achromatic (gray) inputs yield hue = -1 (a sentinel, not 0).
|
||||
// - GenerateTextColorCode(color) returns the BARE 8-hex string "AARRGGBB"
|
||||
// (e.g. red -> "ffff0000"), alpha forced to ff (the arg type is colorRGB,
|
||||
// no alpha channel).
|
||||
// - WrapTextInColorCode(text, code) returns "|c" .. code .. text .. "|r".
|
||||
// - WrapTextInColor(text, color) = WrapTextInColorCode(text,
|
||||
// GenerateTextColorCode(color)) -> "|cffff0000Hi|r".
|
||||
//
|
||||
// The color arg is a table read by raw r/g/b fields (ColorMixin-shaped); an
|
||||
// optional a field is honored (default 1.0) though retail's colorRGB type
|
||||
// omits it — for the documented no-alpha case both give ff.
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace Color::Util {
|
||||
|
||||
namespace {
|
||||
|
||||
double Clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
|
||||
|
||||
int ToByte(double v) {
|
||||
const double c = Clamp01(v);
|
||||
return static_cast<int>(c * 255.0 + 0.5); // round to nearest
|
||||
}
|
||||
|
||||
double Max3(double a, double b, double c) {
|
||||
double m = a > b ? a : b;
|
||||
return m > c ? m : c;
|
||||
}
|
||||
double Min3(double a, double b, double c) {
|
||||
double m = a < b ? a : b;
|
||||
return m < c ? m : c;
|
||||
}
|
||||
double MinLuma(double l) { return l < 1.0 - l ? l : 1.0 - l; }
|
||||
|
||||
// --- Pure conversions --------------------------------------------------
|
||||
|
||||
void RgbToHsv(double r, double g, double b, double &h, double &s, double &v) {
|
||||
const double mx = Max3(r, g, b), mn = Min3(r, g, b), d = mx - mn;
|
||||
v = mx;
|
||||
s = (mx <= 0.0) ? 0.0 : d / mx;
|
||||
if (d <= 0.0) { // achromatic
|
||||
h = -1.0;
|
||||
return;
|
||||
}
|
||||
if (mx == r)
|
||||
h = 60.0 * std::fmod((g - b) / d, 6.0);
|
||||
else if (mx == g)
|
||||
h = 60.0 * ((b - r) / d + 2.0);
|
||||
else
|
||||
h = 60.0 * ((r - g) / d + 4.0);
|
||||
if (h < 0.0)
|
||||
h += 360.0;
|
||||
}
|
||||
|
||||
void HsvToRgb(double h, double s, double v, double &r, double &g, double &b) {
|
||||
if (s <= 0.0 || h < 0.0) { // achromatic (incl. the -1 sentinel)
|
||||
r = g = b = v;
|
||||
return;
|
||||
}
|
||||
h = std::fmod(h, 360.0);
|
||||
if (h < 0.0)
|
||||
h += 360.0;
|
||||
const double hh = h / 60.0;
|
||||
const int i = static_cast<int>(std::floor(hh));
|
||||
const double f = hh - i;
|
||||
const double p = v * (1.0 - s);
|
||||
const double q = v * (1.0 - s * f);
|
||||
const double t = v * (1.0 - s * (1.0 - f));
|
||||
switch (i % 6) {
|
||||
case 0: r = v; g = t; b = p; break;
|
||||
case 1: r = q; g = v; b = p; break;
|
||||
case 2: r = p; g = v; b = t; break;
|
||||
case 3: r = p; g = q; b = v; break;
|
||||
case 4: r = t; g = p; b = v; break;
|
||||
default: r = v; g = p; b = q; break;
|
||||
}
|
||||
}
|
||||
|
||||
void HsvToHsl(double h, double sv, double v, double &ho, double &sl, double &l) {
|
||||
ho = h; // hue (incl. -1) passes through unchanged
|
||||
l = v * (1.0 - sv / 2.0);
|
||||
sl = (l <= 0.0 || l >= 1.0) ? 0.0 : (v - l) / MinLuma(l);
|
||||
}
|
||||
|
||||
void HslToHsv(double h, double sl, double l, double &ho, double &sv, double &v) {
|
||||
ho = h;
|
||||
v = l + sl * MinLuma(l);
|
||||
sv = (v <= 0.0) ? 0.0 : 2.0 * (1.0 - l / v);
|
||||
}
|
||||
|
||||
void HslToRgb(double h, double s, double l, double &r, double &g, double &b) {
|
||||
if (s <= 0.0 || h < 0.0) { // achromatic
|
||||
r = g = b = l;
|
||||
return;
|
||||
}
|
||||
h = std::fmod(h, 360.0);
|
||||
if (h < 0.0)
|
||||
h += 360.0;
|
||||
const double c = (1.0 - std::fabs(2.0 * l - 1.0)) * s;
|
||||
const double hh = h / 60.0;
|
||||
const double x = c * (1.0 - std::fabs(std::fmod(hh, 2.0) - 1.0));
|
||||
const double m = l - c / 2.0;
|
||||
double r1, g1, b1;
|
||||
switch (static_cast<int>(std::floor(hh)) % 6) {
|
||||
case 0: r1 = c; g1 = x; b1 = 0.0; break;
|
||||
case 1: r1 = x; g1 = c; b1 = 0.0; break;
|
||||
case 2: r1 = 0.0; g1 = c; b1 = x; break;
|
||||
case 3: r1 = 0.0; g1 = x; b1 = c; break;
|
||||
case 4: r1 = x; g1 = 0.0; b1 = c; break;
|
||||
default: r1 = c; g1 = 0.0; b1 = x; break;
|
||||
}
|
||||
r = r1 + m;
|
||||
g = g1 + m;
|
||||
b = b1 + m;
|
||||
}
|
||||
|
||||
// --- Lua glue ----------------------------------------------------------
|
||||
|
||||
// Read arg `n` as a number, or raise the usage error.
|
||||
double Arg(void *L, int n, const char *usage) {
|
||||
if (!Game::Lua::IsNumber(L, n)) {
|
||||
Game::Lua::Error(L, "%s", usage);
|
||||
return 0.0; // unreachable
|
||||
}
|
||||
return Game::Lua::ToNumber(L, n);
|
||||
}
|
||||
|
||||
// Read a numeric field from the color table at (absolute, positive) index
|
||||
// `idx`. Raw access — ColorMixin stores r/g/b/a as direct keys.
|
||||
double ColorField(void *L, int idx, const char *key, double dflt) {
|
||||
Game::Lua::PushString(L, key);
|
||||
Game::Lua::RawGet(L, idx);
|
||||
const double v = Game::Lua::IsNumber(L, -1) ? Game::Lua::ToNumber(L, -1) : dflt;
|
||||
Game::Lua::SetTop(L, -2); // pop the value
|
||||
return v;
|
||||
}
|
||||
|
||||
// "AARRGGBB" from a color table at `idx`, alpha default ff.
|
||||
std::string HexFromColor(void *L, int idx) {
|
||||
const int a = ToByte(ColorField(L, idx, "a", 1.0));
|
||||
const int r = ToByte(ColorField(L, idx, "r", 0.0));
|
||||
const int g = ToByte(ColorField(L, idx, "g", 0.0));
|
||||
const int b = ToByte(ColorField(L, idx, "b", 0.0));
|
||||
char buf[9];
|
||||
std::snprintf(buf, sizeof(buf), "%02x%02x%02x%02x", a, r, g, b);
|
||||
return std::string(buf, 8);
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertRGBToHSV(void *L) {
|
||||
const double r = Arg(L, 1, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
|
||||
const double g = Arg(L, 2, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
|
||||
const double b = Arg(L, 3, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
|
||||
double h, s, v;
|
||||
RgbToHsv(r, g, b, h, s, v);
|
||||
Game::Lua::PushNumber(L, h);
|
||||
Game::Lua::PushNumber(L, s);
|
||||
Game::Lua::PushNumber(L, v);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSVToRGB(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
|
||||
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
|
||||
double r, g, b;
|
||||
HsvToRgb(h, s, v, r, g, b);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSVToHSL(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
|
||||
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
|
||||
double ho, sl, l;
|
||||
HsvToHsl(h, s, v, ho, sl, l);
|
||||
Game::Lua::PushNumber(L, ho);
|
||||
Game::Lua::PushNumber(L, sl);
|
||||
Game::Lua::PushNumber(L, l);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSLToHSV(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
|
||||
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
|
||||
double ho, sv, v;
|
||||
HslToHsv(h, s, l, ho, sv, v);
|
||||
Game::Lua::PushNumber(L, ho);
|
||||
Game::Lua::PushNumber(L, sv);
|
||||
Game::Lua::PushNumber(L, v);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_ConvertHSLToRGB(void *L) {
|
||||
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
|
||||
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
|
||||
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
|
||||
double r, g, b;
|
||||
HslToRgb(h, s, l, r, g, b);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int __fastcall Script_GenerateTextColorCode(void *L) {
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: C_ColorUtil.GenerateTextColorCode(color)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
const std::string hex = HexFromColor(L, 1);
|
||||
Game::Lua::PushString(L, hex.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_WrapTextInColor(void *L) {
|
||||
const char *text = Game::Lua::ToString(L, 1);
|
||||
if (text == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_TABLE) {
|
||||
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColor(text, color)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
std::string out = "|c" + HexFromColor(L, 2) + text + "|r";
|
||||
Game::Lua::PushString(L, out.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_WrapTextInColorCode(void *L) {
|
||||
const char *text = Game::Lua::ToString(L, 1);
|
||||
const char *code = Game::Lua::ToString(L, 2);
|
||||
if (text == nullptr || code == nullptr) {
|
||||
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColorCode(text, colorCode)");
|
||||
return 0; // unreachable
|
||||
}
|
||||
std::string out = std::string("|c") + code + text + "|r";
|
||||
Game::Lua::PushString(L, out.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterFns() {
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertRGBToHSV",
|
||||
&Script_ConvertRGBToHSV);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToRGB",
|
||||
&Script_ConvertHSVToRGB);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToHSL",
|
||||
&Script_ConvertHSVToHSL);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToHSV",
|
||||
&Script_ConvertHSLToHSV);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToRGB",
|
||||
&Script_ConvertHSLToRGB);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "GenerateTextColorCode",
|
||||
&Script_GenerateTextColorCode);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColor",
|
||||
&Script_WrapTextInColor);
|
||||
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColorCode",
|
||||
&Script_WrapTextInColorCode);
|
||||
}
|
||||
|
||||
// Pure helpers — useful on both the in-game and glue states.
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
|
||||
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Color::Util
|
||||
+23
-38
@@ -13,8 +13,10 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "item/BagFamily.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Record.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -22,21 +24,6 @@ namespace Container::FreeSlots {
|
||||
|
||||
namespace {
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
// Same `Peek the item cache without firing a query` shape as
|
||||
// `Item::Info::FetchItemRecord`. We don't share that helper because it's
|
||||
// file-static there; copying the four-line lookup is simpler than
|
||||
// promoting it to a header for one extra caller.
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
// Resolves the bag's metadata: total slot count + BagFamily bitfield.
|
||||
//
|
||||
// - bagID 0 (backpack) → (16, 0). Vanilla 1.12 backpack is fixed at 16
|
||||
@@ -67,21 +54,20 @@ BagInfo ResolveBagInfo(int bagID) {
|
||||
if (itemID == 0)
|
||||
return {0, 0};
|
||||
|
||||
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
|
||||
auto *record = Item::PeekRecord(static_cast<uint32_t>(itemID));
|
||||
if (record == nullptr)
|
||||
return {0, 0};
|
||||
|
||||
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
|
||||
// Convert 1.12's raw BagFamily ID to the modern bitmask. See
|
||||
// `Offsets::OFF_ITEMSTATS_BAG_FAMILY` for the encoding-shift story
|
||||
// and `Item::Info::BagFamilyIdToBitmask` for the canonical helper —
|
||||
// we inline the one-liner here to avoid a header dance for a
|
||||
// single-instruction conversion.
|
||||
const uint32_t rawFamily = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
|
||||
// BagFamily bitmask: the item's field if populated, else derived from
|
||||
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
|
||||
// Soul Bag would otherwise report family 0 and get miscounted as a
|
||||
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
|
||||
// with C_Item.GetItemFamily so a bag and its contents agree. See
|
||||
// `Item::BagFamily`.
|
||||
const int family =
|
||||
(rawFamily == 0) ? 0 : static_cast<int>(1u << (rawFamily - 1));
|
||||
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
|
||||
return {slots, family};
|
||||
}
|
||||
|
||||
@@ -106,14 +92,13 @@ int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
|
||||
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
|
||||
// rather than nil/nothing.
|
||||
//
|
||||
// `bagType` is the `BagFamily` bitmask in modern encoding (raw 1.12
|
||||
// ID converted via `1 << (ID-1)`). Empirically on Turtle WoW, only
|
||||
// Light Quiver actually carries a non-zero family for bags themselves
|
||||
// — Soul Pouch, Enchanting Bag, etc. all return 0 because the
|
||||
// vanilla-era server data doesn't populate the field. **Individual
|
||||
// items** (arrows, bullets, shards, herbs) reliably return their
|
||||
// family, so addons that auto-route loot work correctly via
|
||||
// `C_Item.GetItemFamily(itemID)` even though `bagType` is sparse.
|
||||
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
|
||||
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
|
||||
// all read 0), so it's derived from the bag's class + subclass via
|
||||
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
|
||||
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
|
||||
// bag and the items it holds. See that helper for the Turtle custom-family
|
||||
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
|
||||
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
@@ -139,12 +124,12 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
|
||||
// free, family = C_Container.GetContainerNumFreeSlots(bag)
|
||||
// if family == 0 then total = total + free end
|
||||
//
|
||||
// Takes no arguments. Caveat inherited from `GetContainerNumFreeSlots`:
|
||||
// vanilla-era server data leaves most bags' BagFamily at 0, so specialty
|
||||
// bags that retail *would* exclude (Soul Pouch, Enchanting Bag, …) get
|
||||
// counted here — the client just doesn't carry the family bit for them. The
|
||||
// result stays consistent with what `GetContainerNumFreeSlots` reports for
|
||||
// those same bags.
|
||||
// Takes no arguments. Specialty bags are correctly excluded: their family
|
||||
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
|
||||
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
|
||||
// left out of the general-purpose total — matching retail's FrameXML
|
||||
// behavior. (An earlier version miscounted them because Turtle leaves the
|
||||
// raw family field at 0 for bags.)
|
||||
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
|
||||
int total = 0;
|
||||
for (int bagID = 0; bagID <= 4; bagID++) {
|
||||
|
||||
+24
-24
@@ -50,9 +50,11 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "item/CGItem.h"
|
||||
#include "item/ID.h"
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Record.h"
|
||||
#include "item/StatAccum.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -78,13 +80,12 @@ bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
|
||||
// readable-text id is non-zero (letters / server-authored notes).
|
||||
bool ItemReadable(const uint8_t *item, const uint8_t *record) {
|
||||
if (record != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
|
||||
Game::Read<uint32_t>(record, Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
|
||||
return true;
|
||||
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
const uint8_t *descriptor = Item::ObjectFields(item);
|
||||
return descriptor != nullptr &&
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
|
||||
Game::Read<uint32_t>(
|
||||
descriptor, Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
@@ -107,23 +108,22 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
// them off the half-built table avoids any stack interleaving. ---
|
||||
const int itemID = Item::ID::FromCGItem(item);
|
||||
const uint8_t *record = (itemID > 0)
|
||||
? Item::StatAccum::FetchRecord(static_cast<uint32_t>(itemID))
|
||||
? Item::PeekRecord(static_cast<uint32_t>(itemID))
|
||||
: nullptr;
|
||||
|
||||
// Live per-instance descriptor fields.
|
||||
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
|
||||
item + Offsets::OFF_ITEM_DESCRIPTOR);
|
||||
const uint8_t *descriptor = Item::ObjectFields(item);
|
||||
int stackCount = 0;
|
||||
bool isBound = false;
|
||||
if (descriptor != nullptr) {
|
||||
stackCount = *reinterpret_cast<const int *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
isBound = (*reinterpret_cast<const uint32_t *>(
|
||||
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS) &
|
||||
stackCount = Game::Read<int>(
|
||||
descriptor, Offsets::OFF_DESCRIPTOR_STACK_COUNT);
|
||||
isBound = (Game::Read<uint32_t>(
|
||||
descriptor, Offsets::OFF_DESCRIPTOR_FLAGS) &
|
||||
Offsets::ITEM_FLAG_SOULBOUND) != 0;
|
||||
}
|
||||
const bool isLocked = (*reinterpret_cast<const uint32_t *>(
|
||||
item + Offsets::OFF_ITEM_CLIENT_LOCK) &
|
||||
const bool isLocked = (Game::Read<uint32_t>(
|
||||
item, Offsets::OFF_ITEM_CLIENT_LOCK) &
|
||||
Offsets::ITEM_CLIENT_LOCK_BIT) != 0;
|
||||
const bool isReadable = ItemReadable(item, record);
|
||||
|
||||
@@ -135,17 +135,17 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
bool hasLoot = false;
|
||||
bool hasNoValue = false;
|
||||
if (record != nullptr) {
|
||||
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
const uint32_t displayInfoID = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
|
||||
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
|
||||
quality = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
quality = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_QUALITY);
|
||||
haveQuality = true;
|
||||
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_FLAGS);
|
||||
const uint32_t flags = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_FLAGS);
|
||||
hasLoot = (flags & Offsets::ITEM_FLAG_LOOTABLE) != 0;
|
||||
hasNoValue = *reinterpret_cast<const uint32_t *>(
|
||||
record + Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
|
||||
hasNoValue = Game::Read<uint32_t>(
|
||||
record, Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
|
||||
}
|
||||
|
||||
const char *hyperlink = Item::Link::FromCGItem(item);
|
||||
@@ -155,8 +155,8 @@ int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
|
||||
if (Item::Link::NameFromCGItem(item, nameBuf, sizeof(nameBuf))) {
|
||||
itemName = nameBuf;
|
||||
} else if (record != nullptr) {
|
||||
const char *baseName = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
const char *baseName = Game::Read<const char *>(
|
||||
record, Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (baseName != nullptr && baseName[0] != '\0')
|
||||
itemName = baseName;
|
||||
}
|
||||
|
||||
@@ -107,11 +107,19 @@ static int AuxResume(void *L, void *co, int nargs) {
|
||||
: "cannot resume non-suspended coroutine");
|
||||
return -1;
|
||||
}
|
||||
if (Game::Lua::CheckStack(co, nargs) == 0) {
|
||||
Game::Lua::PushString(L, "too many arguments to resume coroutine");
|
||||
return -1;
|
||||
}
|
||||
Game::Lua::XMove(L, co, nargs);
|
||||
const int s = Game::Lua::Resume(co, nargs);
|
||||
const bool yielded = LuaCI(co) > LuaBaseCI(co);
|
||||
if (s == LUA_OK || yielded) {
|
||||
const int nres = Game::Lua::GetTop(co);
|
||||
if (Game::Lua::CheckStack(L, nres) == 0) {
|
||||
Game::Lua::PushString(L, "too many results to resume coroutine");
|
||||
return -1;
|
||||
}
|
||||
Game::Lua::XMove(co, L, nres);
|
||||
return nres;
|
||||
}
|
||||
|
||||
+38
-39
@@ -53,7 +53,9 @@
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "cache/QueryLoad.h"
|
||||
#include "creature/Info.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "dbc/Names.h"
|
||||
#include "event/Custom.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -93,14 +95,14 @@ int __fastcall Script_GetCreatureInfoByID(void *L) {
|
||||
if (rec == nullptr)
|
||||
return 0; // not cached -> nil
|
||||
|
||||
const char *name = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_NAME);
|
||||
const char *name = Game::Read<const char *>(rec, Offsets::OFF_CREATURE_NAME);
|
||||
const char *subName =
|
||||
*reinterpret_cast<const char *const *>(rec + Offsets::OFF_CREATURE_SUBNAME);
|
||||
const uint32_t type = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_TYPE);
|
||||
const uint32_t family = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_FAMILY);
|
||||
const uint32_t rank = *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_RANK);
|
||||
Game::Read<const char *>(rec, Offsets::OFF_CREATURE_SUBNAME);
|
||||
const uint32_t type = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_TYPE);
|
||||
const uint32_t family = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_FAMILY);
|
||||
const uint32_t rank = Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_RANK);
|
||||
const uint32_t displayID =
|
||||
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_CREATURE_DISPLAYID);
|
||||
Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_DISPLAYID);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "creatureID", creatureID);
|
||||
@@ -143,17 +145,10 @@ int __fastcall Script_GetRaceInfo(void *L) {
|
||||
return 0; // nil
|
||||
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *file =
|
||||
(raceID > 0) ? DBC::StringField(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT,
|
||||
static_cast<uint32_t>(raceID),
|
||||
Offsets::OFF_CHRRACES_FILENAME)
|
||||
: nullptr;
|
||||
(raceID > 0) ? DBC::RaceToken(static_cast<uint32_t>(raceID)) : nullptr;
|
||||
if (file == nullptr)
|
||||
return 0; // unknown id -> nil
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT,
|
||||
static_cast<uint32_t>(raceID),
|
||||
Offsets::OFF_CHRRACES_NAMES);
|
||||
const char *name = DBC::RaceName(static_cast<uint32_t>(raceID));
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "raceName", name); // NULL coerced to ""
|
||||
@@ -169,17 +164,10 @@ int __fastcall Script_GetClassInfo(void *L) {
|
||||
return 0; // nil
|
||||
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
const char *file =
|
||||
(classID > 0) ? DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT,
|
||||
static_cast<uint32_t>(classID),
|
||||
Offsets::OFF_CHRCLASSES_FILENAME)
|
||||
: nullptr;
|
||||
(classID > 0) ? DBC::ClassToken(static_cast<uint32_t>(classID)) : nullptr;
|
||||
if (file == nullptr)
|
||||
return 0; // unknown id -> nil
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT,
|
||||
static_cast<uint32_t>(classID),
|
||||
Offsets::OFF_CHRCLASSES_NAMES);
|
||||
const char *name = DBC::ClassName(static_cast<uint32_t>(classID));
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "className", name); // NULL coerced to ""
|
||||
@@ -225,7 +213,7 @@ int __fastcall Script_GetCreatureFamilyIDs(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CREATUREFAMILY_COUNT);
|
||||
Game::Read<int>(Offsets::VAR_CREATUREFAMILY_COUNT);
|
||||
int n = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
|
||||
@@ -262,8 +250,8 @@ int __fastcall Script_GetFactionInfo(void *L) {
|
||||
static_cast<uint32_t>(raceID));
|
||||
if (crRec == nullptr)
|
||||
return 0; // unknown race
|
||||
const int ftId = *reinterpret_cast<const int *>(
|
||||
crRec + Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
|
||||
const int ftId = Game::Read<int>(
|
||||
crRec, Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
|
||||
if (ftId <= 0)
|
||||
return 0;
|
||||
|
||||
@@ -272,31 +260,31 @@ int __fastcall Script_GetFactionInfo(void *L) {
|
||||
static_cast<uint32_t>(ftId));
|
||||
if (ftRec == nullptr)
|
||||
return 0;
|
||||
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
|
||||
ftRec + Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
|
||||
const uint32_t mask = Game::Read<uint32_t>(
|
||||
ftRec, Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
|
||||
|
||||
// FactionGroup is stored contiguously (records-base + i*stride), not as a
|
||||
// pointer array — iterate by hand rather than via DBC::Record.
|
||||
auto *fgBase = *reinterpret_cast<const uint8_t *const *>(
|
||||
auto *fgBase = Game::Read<const uint8_t *>(
|
||||
Offsets::VAR_FACTIONGROUP_RECORDS);
|
||||
const int fgCount =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_FACTIONGROUP_COUNT);
|
||||
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
|
||||
Game::Read<int>(Offsets::VAR_FACTIONGROUP_COUNT);
|
||||
const int locale = Game::Read<int>(Offsets::VAR_LOCALE_INDEX);
|
||||
if (fgBase == nullptr)
|
||||
return 0;
|
||||
|
||||
for (int i = 0; i < fgCount; ++i) {
|
||||
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
|
||||
const int bit = *reinterpret_cast<const int *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_BIT);
|
||||
const int bit = Game::Read<int>(
|
||||
row, Offsets::OFF_FACTIONGROUP_BIT);
|
||||
if ((mask & (1u << (bit & 0x1F))) == 0)
|
||||
continue;
|
||||
const char *loc = *reinterpret_cast<const char *const *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
|
||||
const char *loc = Game::Read<const char *>(
|
||||
row, Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
|
||||
if (loc == nullptr || *loc == '\0')
|
||||
continue; // "Player" / "Monster" have empty localized names
|
||||
const char *eng = *reinterpret_cast<const char *const *>(
|
||||
row + Offsets::OFF_FACTIONGROUP_ENGLISH);
|
||||
const char *eng = Game::Read<const char *>(
|
||||
row, Offsets::OFF_FACTIONGROUP_ENGLISH);
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "name", loc);
|
||||
@@ -336,7 +324,7 @@ int __fastcall Script_GetCreatureTypeIDs(void *L) {
|
||||
Game::Lua::NewTable(L);
|
||||
|
||||
const int count =
|
||||
*reinterpret_cast<const int *>(Offsets::VAR_CREATURETYPE_COUNT);
|
||||
Game::Read<int>(Offsets::VAR_CREATURETYPE_COUNT);
|
||||
int n = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const char *name = DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
|
||||
@@ -380,4 +368,15 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Public accessor for Model::DisplayInfo's SetCreature — the same peek
|
||||
// GetCreatureInfoByID uses, reduced to the display-ID field. 0 = uncached.
|
||||
uint32_t DisplayID(uint32_t creatureID) {
|
||||
if (creatureID == 0)
|
||||
return 0;
|
||||
const uint8_t *rec = PeekCreature(creatureID);
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
return Game::Read<uint32_t>(rec, Offsets::OFF_CREATURE_DISPLAYID);
|
||||
}
|
||||
|
||||
} // namespace Creature::Info
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Creature::Info {
|
||||
|
||||
// The cached model display ID for a creature/NPC entry, or 0 when the creature
|
||||
// is not in the client creature cache (creaturecache.wdb, or queried this
|
||||
// session). A synchronous peek — no network query. Backs `Model:SetCreature`:
|
||||
// the cache being keyed by entry ID and carrying a display ID is exactly the
|
||||
// entry-ID → model mapping vanilla has no DBC for.
|
||||
uint32_t DisplayID(uint32_t creatureID);
|
||||
|
||||
} // namespace Creature::Info
|
||||
@@ -0,0 +1,144 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `GetCoinTextureString` / `C_CurrencyInfo.GetCoinTextureString` — the coin-icon
|
||||
// money string (e.g. "12<gold> 34<silver> 56<copper>"), a C++ backport of the
|
||||
// retail C function.
|
||||
//
|
||||
// Behaviour derived from 3.3.5's `Script_GetCoinTextureString` (FUN_00510d00 →
|
||||
// formatter FUN_007e7e10): split the copper amount into gold / silver / copper,
|
||||
// emit only the non-zero denominations in that order, space-separated; a zero
|
||||
// amount shows "0" copper. Each denomination is built from a localized
|
||||
// GlobalString — `GOLD_AMOUNT_TEXTURE` / `SILVER_AMOUNT_TEXTURE` /
|
||||
// `COPPER_AMOUNT_TEXTURE` — exactly as the engine does (its formatter
|
||||
// `FUN_0076f070` is a C-format over the same GlobalString). Those strings don't
|
||||
// exist in stock 1.12, so they're backported in `locales/enUS.lua`
|
||||
// (language-independent, so the enUS base covers every locale); the embedded
|
||||
// `!!!ClassicAPI` addon guarantees they're loaded.
|
||||
//
|
||||
// 1.12 has no separate UI-GoldIcon / UI-SilverIcon / UI-CopperIcon textures
|
||||
// (those are a later-expansion split); vanilla uses ONE sprite sheet
|
||||
// `Interface\MoneyFrame\UI-MoneyIcons` cropped per coin (gold 0-0.25, silver
|
||||
// 0.25-0.5, copper 0.5-0.75 across a 4-cell strip — from CoinPickupFrame.xml).
|
||||
// So the format carries the inline-texture texcoord fields (texW=4 texH=1, then
|
||||
// the per-coin left:right cell). The `|T` markup renders as real coin icons via
|
||||
// ClassicAPI's inline-texture backport (`Text::InlineTexture`).
|
||||
|
||||
#include "Game.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace Currency::CoinText {
|
||||
|
||||
namespace {
|
||||
|
||||
// Icon size. Retail defaults fontHeight to 0 = "use the display font's height",
|
||||
// and the inline-texture renderer now honours 0 (it resolves the coin to the
|
||||
// line's font height at draw time), so coins match the surrounding text instead
|
||||
// of a fixed size. Callers may still pass an explicit height.
|
||||
constexpr int kDefaultHeight = 0;
|
||||
|
||||
// Fallbacks byte-identical to the backported GlobalStrings, used only if the
|
||||
// locale layer somehow isn't loaded when this is first called.
|
||||
// Format: count |T sheet : height=0 : width=0 : offsetX=0 : offsetY=0 : texW=4 :
|
||||
// texH=1 : left : right : top=0 : bottom=1 |t (left:right selects the coin cell).
|
||||
// height/width 0 = size to the font. offsetX stays 0: the inline-texture
|
||||
// renderer's font-relative pad spaces the coin from its digits (the historical
|
||||
// "coin sits on the digit" bug was the emitter's lazy pen read-back, fixed in
|
||||
// the DLL). Never grow a per-coin offset here.
|
||||
constexpr const char *kGoldFallback =
|
||||
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:0:1:0:1|t";
|
||||
constexpr const char *kSilverFallback =
|
||||
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:1:2:0:1|t";
|
||||
// The three coin arts occupy IDENTICAL pixels within their sheet cells (x 0-14,
|
||||
// y 1-14 of 16 — confirmed by decoding the DXT3 alpha of the exported
|
||||
// UI-MoneyIcons.blp); only the left:right cell select differs (gold 0:1,
|
||||
// silver 1:2, copper 2:3).
|
||||
constexpr const char *kCopperFallback =
|
||||
"%d|TInterface\\MoneyFrame\\UI-MoneyIcons:%d:%d:0:0:4:1:2:3:0:1|t";
|
||||
|
||||
// Formats one denomination from its localized GlobalString and appends it to
|
||||
// `out`. Reads `_G[globalName]` the way retail reads the coin GlobalStrings; the
|
||||
// format consumes (count, iconHeight, iconWidth). No separator between
|
||||
// denominations — the renderer's symmetric per-icon pad already spaces each coin
|
||||
// from the surrounding digits, so a space here would double the gap.
|
||||
void AppendDenom(void *L, std::string &out, const char *globalName, const char *fallback,
|
||||
int count, int height) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushLocalizedString(L, globalName, fallback); // [.., fmt]
|
||||
const char *fmt = Game::Lua::ToString(L, -1);
|
||||
char piece[256];
|
||||
piece[0] = '\0';
|
||||
if (fmt != nullptr)
|
||||
std::snprintf(piece, sizeof piece, fmt, count, height, height);
|
||||
Game::Lua::SetTop(L, top); // pop the format string
|
||||
out += piece;
|
||||
}
|
||||
|
||||
// GetCoinTextureString(amount [, fontHeight]) -> string
|
||||
int __fastcall Script_GetCoinTextureString(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: GetCoinTextureString(amount [, fontHeight])");
|
||||
return 0; // unreachable — Error long-jumps
|
||||
}
|
||||
const double amt = Game::Lua::ToNumber(L, 1);
|
||||
long long amount = static_cast<long long>(amt < 0.0 ? amt - 0.5 : amt + 0.5); // round
|
||||
if (amount < 0)
|
||||
amount = 0;
|
||||
// Vanilla money is a uint32 copper field, so 0xFFFFFFFF copper (~429,496
|
||||
// gold) is the most the game can represent. Clamp to it: an unclamped huge
|
||||
// input (e.g. 1e15) makes `gold` exceed INT_MAX, and the cast below truncates
|
||||
// it to the low 32 bits, printing a garbage count through the "%d" format.
|
||||
constexpr long long kMaxCopper = 0xFFFFFFFFLL;
|
||||
if (amount > kMaxCopper)
|
||||
amount = kMaxCopper;
|
||||
|
||||
int height = kDefaultHeight;
|
||||
if (Game::Lua::IsNumber(L, 2)) {
|
||||
const int h = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
if (h > 0)
|
||||
height = h;
|
||||
}
|
||||
|
||||
const int gold = static_cast<int>(amount / 10000);
|
||||
const int silver = static_cast<int>((amount % 10000) / 100);
|
||||
const int copper = static_cast<int>(amount % 100);
|
||||
|
||||
std::string out;
|
||||
if (amount == 0) {
|
||||
AppendDenom(L, out, "COPPER_AMOUNT_TEXTURE", kCopperFallback, 0, height);
|
||||
} else {
|
||||
if (gold > 0)
|
||||
AppendDenom(L, out, "GOLD_AMOUNT_TEXTURE", kGoldFallback, gold, height);
|
||||
if (silver > 0)
|
||||
AppendDenom(L, out, "SILVER_AMOUNT_TEXTURE", kSilverFallback, silver, height);
|
||||
if (copper > 0)
|
||||
AppendDenom(L, out, "COPPER_AMOUNT_TEXTURE", kCopperFallback, copper, height);
|
||||
}
|
||||
|
||||
Game::Lua::PushString(L, out.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetCoinTextureString", &Script_GetCoinTextureString);
|
||||
Game::Lua::RegisterTableFunction("C_CurrencyInfo", "GetCoinTextureString",
|
||||
&Script_GetCoinTextureString);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Currency::CoinText
|
||||
+2
-12
@@ -53,6 +53,7 @@
|
||||
#include "item/Link.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/QualityColor.h"
|
||||
#include "item/Record.h"
|
||||
#include "spell/Lookup.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -140,17 +141,6 @@ int PushBagItemCursor(void *L) {
|
||||
return PushItem(L, cgItem);
|
||||
}
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
// Builds a basic `"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"` link from
|
||||
// the cached ItemStats record for `itemID`. No enchant / random-
|
||||
// suffix decoration — the cursor only stores the bare itemID for
|
||||
@@ -158,7 +148,7 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
// instance state from. Returns true and writes into `out` (size
|
||||
// `outSize`) on success; returns false if the item isn't cached.
|
||||
bool BuildBasicItemLink(uint32_t itemID, char *out, size_t outSize) {
|
||||
const uint8_t *record = PeekItemRecord(itemID);
|
||||
const uint8_t *record = Item::PeekRecord(itemID);
|
||||
if (record == nullptr) return false;
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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 "Names.h"
|
||||
|
||||
#include "Lookup.h"
|
||||
#include "Offsets.h"
|
||||
#include "baselib/Ascii.h"
|
||||
|
||||
namespace DBC {
|
||||
|
||||
namespace {
|
||||
|
||||
// Reverse lookup shared by ClassIdForToken / RaceIdForToken: the id whose
|
||||
// single-string field at `offset` matches `token` (case-insensitive), or 0.
|
||||
uint32_t IdForStringField(uintptr_t recordsVar, uintptr_t countVar, int offset,
|
||||
const char *token) {
|
||||
if (token == nullptr || *token == '\0')
|
||||
return 0;
|
||||
const int count = *reinterpret_cast<const int *>(countVar);
|
||||
const uint8_t *const *records =
|
||||
*reinterpret_cast<const uint8_t *const *const *>(recordsVar);
|
||||
if (records == nullptr)
|
||||
return 0;
|
||||
for (int i = 1; i <= count; ++i) {
|
||||
const uint8_t *rec = records[i];
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
const char *field =
|
||||
*reinterpret_cast<const char *const *>(rec + offset);
|
||||
if (field != nullptr && Ascii::EqualCI(field, token))
|
||||
return static_cast<uint32_t>(i);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char *ClassName(uint32_t classID) {
|
||||
if (classID == 0)
|
||||
return nullptr;
|
||||
return LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT, classID,
|
||||
Offsets::OFF_CHRCLASSES_NAMES);
|
||||
}
|
||||
|
||||
const char *ClassToken(uint32_t classID) {
|
||||
if (classID == 0)
|
||||
return nullptr;
|
||||
return StringField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT, classID,
|
||||
Offsets::OFF_CHRCLASSES_FILENAME);
|
||||
}
|
||||
|
||||
const char *RaceName(uint32_t raceID) {
|
||||
if (raceID == 0)
|
||||
return nullptr;
|
||||
return LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT, raceID,
|
||||
Offsets::OFF_CHRRACES_NAMES);
|
||||
}
|
||||
|
||||
const char *RaceToken(uint32_t raceID) {
|
||||
if (raceID == 0)
|
||||
return nullptr;
|
||||
return StringField(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT, raceID,
|
||||
Offsets::OFF_CHRRACES_FILENAME);
|
||||
}
|
||||
|
||||
uint32_t ClassIdForToken(const char *token) {
|
||||
return IdForStringField(Offsets::VAR_CHRCLASSES_RECORDS,
|
||||
Offsets::VAR_CHRCLASSES_COUNT,
|
||||
Offsets::OFF_CHRCLASSES_FILENAME, token);
|
||||
}
|
||||
|
||||
uint32_t RaceIdForToken(const char *token) {
|
||||
return IdForStringField(Offsets::VAR_CHRRACES_RECORDS,
|
||||
Offsets::VAR_CHRRACES_COUNT,
|
||||
Offsets::OFF_CHRRACES_FILENAME, token);
|
||||
}
|
||||
|
||||
const char *AreaName(uint32_t areaID, bool resolveToParent) {
|
||||
if (areaID == 0)
|
||||
return nullptr;
|
||||
// Only the parent-resolving path needs the record; the direct read goes
|
||||
// straight through LocalizedField (which bounds-checks the id itself), so
|
||||
// the common !resolveToParent case is a single lookup.
|
||||
uint32_t use = areaID;
|
||||
if (resolveToParent) {
|
||||
const uint8_t *rec = Record(Offsets::VAR_AREATABLE_RECORDS,
|
||||
Offsets::VAR_AREATABLE_COUNT, areaID);
|
||||
if (rec != nullptr) {
|
||||
const uint32_t parent = *reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_AREATABLE_PARENT_ID);
|
||||
if (parent != 0 &&
|
||||
Record(Offsets::VAR_AREATABLE_RECORDS,
|
||||
Offsets::VAR_AREATABLE_COUNT, parent) != nullptr)
|
||||
use = parent;
|
||||
}
|
||||
}
|
||||
return LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
|
||||
Offsets::VAR_AREATABLE_COUNT, use,
|
||||
Offsets::OFF_AREATABLE_NAMES);
|
||||
}
|
||||
|
||||
} // namespace DBC
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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>
|
||||
|
||||
// `DBC::*` display-name resolvers for the character/zone DBCs. Thin wrappers
|
||||
// over `DBC::Record` / `LocalizedField` / `StringField` (see `Lookup.h`) that
|
||||
// bake in the specific table + field for a game concept, so callers don't
|
||||
// repeat the `(recordsVar, countVar, id, offset)` tuple. Shared by the
|
||||
// friends-list and /who surfaces (and any future class/race/zone display).
|
||||
// All return `nullptr` when the id is 0 / out of range / the field is empty.
|
||||
|
||||
namespace DBC {
|
||||
|
||||
// Localized class name for a `ChrClasses.dbc` id (e.g. "Mage").
|
||||
const char *ClassName(uint32_t classID);
|
||||
|
||||
// Locale-independent class token for a `ChrClasses.dbc` id (e.g. "MAGE") —
|
||||
// the key addons use for RAID_CLASS_COLORS and other class tables.
|
||||
const char *ClassToken(uint32_t classID);
|
||||
|
||||
// Localized race name for a `ChrRaces.dbc` id (e.g. "Night Elf").
|
||||
const char *RaceName(uint32_t raceID);
|
||||
|
||||
// Locale-independent race token for a `ChrRaces.dbc` id — the client-filename
|
||||
// column (e.g. "NightElf", "Scourge"), the race analog of ClassToken and what
|
||||
// `UnitRace`'s second return / GetPlayerInfoByGUID's englishRace use.
|
||||
const char *RaceToken(uint32_t raceID);
|
||||
|
||||
// Reverse of ClassToken / RaceToken: the `ChrClasses.dbc` / `ChrRaces.dbc` id
|
||||
// whose client-filename token matches `token` (case-insensitive), or 0 if none
|
||||
// matches. Walks the table (cheap — a handful of records).
|
||||
uint32_t ClassIdForToken(const char *token);
|
||||
uint32_t RaceIdForToken(const char *token);
|
||||
|
||||
// Localized zone name for an `AreaTable.dbc` id. When `resolveToParent` is
|
||||
// true, a sub-area reports its parent zone (so "Goldshire" → "Elwynn
|
||||
// Forest"); when false, the area's own name is returned (what the engine's
|
||||
// GetWhoInfo does — /who zones are already zone-level).
|
||||
const char *AreaName(uint32_t areaID, bool resolveToParent);
|
||||
|
||||
} // namespace DBC
|
||||
+74
-4
@@ -17,6 +17,7 @@ extern "C" {
|
||||
#include <cbor.h>
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
@@ -76,6 +77,27 @@ int CountTablePairs(void *L, int idx) {
|
||||
return n;
|
||||
}
|
||||
|
||||
// Encode a single Lua value at `idx` into a standalone byte vector,
|
||||
// reusing the same grow-and-retry contract as Script_SerializeCBOR.
|
||||
// Used to obtain a map key's encoded bytes for canonical sorting; the
|
||||
// Lua stack is left untouched (EncodeValue reads but never pops).
|
||||
bool EncodeValueToVector(void *L, int idx, int depth, std::vector<uint8_t> &out) {
|
||||
out.assign(64, 0);
|
||||
for (;;) {
|
||||
CborEncoder enc;
|
||||
cbor_encoder_init(&enc, out.data(), out.size(), 0);
|
||||
if (!EncodeValue(L, idx, &enc, depth))
|
||||
return false;
|
||||
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
|
||||
if (extra > 0) {
|
||||
out.resize(out.size() + extra);
|
||||
continue;
|
||||
}
|
||||
out.resize(cbor_encoder_get_buffer_size(&enc, out.data()));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
if (depth >= kMaxDepth) return false;
|
||||
if (idx < 0) idx = Game::Lua::GetTop(L) + idx + 1;
|
||||
@@ -102,15 +124,63 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
|
||||
CborEncoder map;
|
||||
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
|
||||
return false;
|
||||
|
||||
// Canonical CBOR (RFC 7049 §3.9): emit map entries sorted by their
|
||||
// encoded-key bytes (shorter key first, then bytewise). Lua gives no
|
||||
// stable iteration order for string-keyed tables, so without this the
|
||||
// same config serializes to different byte orders each call. We stash
|
||||
// the keys in a holding array table (`hold`), remember each key's
|
||||
// encoded bytes + slot, sort, then re-fetch key/value in sorted order.
|
||||
struct Entry {
|
||||
std::vector<uint8_t> key;
|
||||
int slot;
|
||||
};
|
||||
std::vector<Entry> entries;
|
||||
entries.reserve(static_cast<size_t>(pairs));
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
const int hold = Game::Lua::GetTop(L);
|
||||
int slot = 0;
|
||||
Game::Lua::PushNil(L);
|
||||
while (Game::Lua::Next(L, idx) != 0) {
|
||||
if (!EncodeValue(L, -2, &map, depth + 1) ||
|
||||
!EncodeValue(L, -1, &map, depth + 1)) {
|
||||
Game::Lua::SetTop(L, -3);
|
||||
// key @ -2, value @ -1
|
||||
std::vector<uint8_t> keyBytes;
|
||||
if (!EncodeValueToVector(L, -2, depth + 1, keyBytes)) {
|
||||
Game::Lua::SetTop(L, hold - 1); // drop hold + key + value
|
||||
return false;
|
||||
}
|
||||
Game::Lua::SetTop(L, -2);
|
||||
++slot;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slot));
|
||||
Game::Lua::PushValue(L, -3); // copy key to top
|
||||
Game::Lua::RawSet(L, hold); // hold[slot] = key
|
||||
entries.push_back(Entry{std::move(keyBytes), slot});
|
||||
Game::Lua::SetTop(L, -2); // pop value, keep key for Next
|
||||
}
|
||||
|
||||
std::sort(entries.begin(), entries.end(),
|
||||
[](const Entry &a, const Entry &b) {
|
||||
if (a.key.size() != b.key.size())
|
||||
return a.key.size() < b.key.size();
|
||||
return std::lexicographical_compare(
|
||||
a.key.begin(), a.key.end(), b.key.begin(), b.key.end());
|
||||
});
|
||||
|
||||
for (const Entry &e : entries) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(e.slot));
|
||||
Game::Lua::RawGet(L, hold); // push key = hold[slot]
|
||||
if (!EncodeValue(L, -1, &map, depth + 1)) {
|
||||
Game::Lua::SetTop(L, hold - 1);
|
||||
return false;
|
||||
}
|
||||
Game::Lua::RawGet(L, idx); // key -> value = table[key]
|
||||
if (!EncodeValue(L, -1, &map, depth + 1)) {
|
||||
Game::Lua::SetTop(L, hold - 1);
|
||||
return false;
|
||||
}
|
||||
Game::Lua::SetTop(L, -2); // pop value
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, hold - 1); // drop holding table
|
||||
return EncOk(cbor_encoder_close_container(enc, &map));
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "Set.h"
|
||||
#include "event/Custom.h"
|
||||
#include "item/CGItem.h"
|
||||
#include "item/Location.h"
|
||||
#include "item/Swap.h"
|
||||
|
||||
@@ -72,8 +73,7 @@ const char *ArgString(void *L, int idx) {
|
||||
int ResolveItemID(const uint8_t *cgItem) {
|
||||
if (cgItem == nullptr)
|
||||
return 0;
|
||||
auto *instance = *reinterpret_cast<const uint8_t *const *>(
|
||||
cgItem + Offsets::OFF_ITEM_INSTANCE_BLOCK);
|
||||
auto *instance = Item::InstanceBlock(cgItem);
|
||||
if (instance == nullptr)
|
||||
return 0;
|
||||
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
|
||||
|
||||
@@ -88,12 +88,27 @@ void Persist() {
|
||||
} // namespace
|
||||
|
||||
void EnsureLoaded() {
|
||||
if (g_loaded)
|
||||
return;
|
||||
g_path = Storage::ResolveFilePath();
|
||||
if (g_path.empty())
|
||||
return; // session globals not yet populated; retry on next call
|
||||
// Re-resolve the per-character path on every call rather than latching
|
||||
// once. The injected DLL persists across a logout→character-select→login
|
||||
// (no process restart), so a permanent `g_loaded` latch would leave the
|
||||
// previous character's file path and cached sets in place — the next
|
||||
// character would then read, and save into, character A's data. Detecting
|
||||
// a path change here makes the cache self-heal on any character/account
|
||||
// switch. Every mutation Persist()s immediately, so dropping the old
|
||||
// in-memory sets never loses unsaved work.
|
||||
std::string path = Storage::ResolveFilePath();
|
||||
if (path.empty())
|
||||
return; // session globals not yet populated; keep current, retry later
|
||||
if (g_loaded && path == g_path)
|
||||
return; // already loaded for this character
|
||||
|
||||
g_path = std::move(path);
|
||||
g_sets.clear();
|
||||
Storage::Load(g_path, &g_sets);
|
||||
// Per-session "ignore this slot on next save" state belongs to the
|
||||
// character it was set on — clear it when switching characters.
|
||||
for (int i = 0; i < SLOT_COUNT; ++i)
|
||||
g_ignoredForSave[i] = false;
|
||||
g_loaded = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "item/Location.h"
|
||||
#include "object/Resolve.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -58,13 +59,7 @@ int InvMgrSlotCount(const uint8_t *invMgr) {
|
||||
uint8_t *ResolveByGUID(int type, uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return nullptr;
|
||||
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
|
||||
uint32_t, int);
|
||||
auto fn = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
|
||||
return static_cast<uint8_t *>(fn(type, "ItemMgr",
|
||||
static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32),
|
||||
0x172));
|
||||
return static_cast<uint8_t *>(Object::ByGuid(type, guid, "ItemMgr", 0x172));
|
||||
}
|
||||
|
||||
// Walks one bag's contents for a matching GUID. Returns the 1-based
|
||||
@@ -73,7 +68,7 @@ uint8_t *ResolveByGUID(int type, uint64_t guid) {
|
||||
// bank bags (linear 63..68) — the only differences are the source of
|
||||
// the bag's GUID and how the result encodes back into a location.
|
||||
int WalkBagContents(uint64_t bagGuid, uint64_t targetGuid) {
|
||||
auto *bag = ResolveByGUID(Offsets::OBJ_TYPE_CONTAINER, bagGuid);
|
||||
auto *bag = ResolveByGUID(Offsets::TYPEMASK_CONTAINER, bagGuid);
|
||||
if (bag == nullptr)
|
||||
return 0;
|
||||
auto *bagInvMgr =
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "Storage.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -25,7 +26,7 @@ namespace EquipmentSet::Storage {
|
||||
namespace {
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return *reinterpret_cast<const char *const *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadCharacterName() {
|
||||
@@ -34,10 +35,10 @@ const char *ReadCharacterName() {
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = *reinterpret_cast<uint8_t **>(Offsets::VAR_REALM_INFO_PTR);
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<const char *const *>(info + Offsets::OFF_REALM_INFO_NAME);
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
// Removes trailing CR/LF/whitespace. Names from the engine are ASCII
|
||||
|
||||
@@ -22,9 +22,11 @@ namespace EquipmentSet::Storage {
|
||||
// Resolves the per-character file path. Returns an empty string until
|
||||
// the engine has populated all three session globals (account, realm,
|
||||
// character). Path format: `WTF\Account\<acct>\<realm>\<char>\
|
||||
// ClassicAPI_EquipmentSets.txt`. Once the engine has the player in
|
||||
// world the values are stable for the rest of the session, so callers
|
||||
// can cache after the first non-empty return.
|
||||
// ClassicAPI_EquipmentSets.txt`. The values are stable only while the
|
||||
// current character is in world — they change on a logout→login to a
|
||||
// different character (the DLL is NOT reloaded across that transition),
|
||||
// so callers must re-resolve and compare rather than caching the first
|
||||
// non-empty return permanently. See `Data::EnsureLoaded`.
|
||||
std::string ResolveFilePath();
|
||||
|
||||
// Reads the file at `path` and overwrites `outSets` with the parsed
|
||||
|
||||
@@ -40,17 +40,19 @@
|
||||
// `C_EquipmentSet.GetItemIDs` + `C_EquipmentSet.GetItemLocations`
|
||||
// themselves.
|
||||
//
|
||||
// Tooltip filling is done by calling back through Lua (`self:AddLine`
|
||||
// etc.) rather than hitting the engine's tooltip-line internals
|
||||
// directly. The Lua dispatch goes through the engine's own frame-
|
||||
// method registry, so any addon-installed hook on AddLine (DBM,
|
||||
// pfUI's tooltip skin, etc.) still runs.
|
||||
// Tooltip lines are built with the engine's own primitives — the
|
||||
// per-tooltip clear (FUN_GAMETOOLTIP_CLEAR) and the raw add-line
|
||||
// (FUN_GAMETOOLTIP_ADD_LINE), the same native path SetTotem and
|
||||
// SetHyperlinkCompareItem use; no Lua method dispatch. It shows itself
|
||||
// at the end (Script_Show), matching retail: PaperDollFrame.lua sets the
|
||||
// anchor then calls SetEquipmentSet with no trailing :Show().
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "equipmentset/Data.h"
|
||||
#include "equipmentset/Locations.h"
|
||||
#include "equipmentset/Set.h"
|
||||
#include "item/Record.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -58,57 +60,41 @@ namespace EquipmentSet::Tooltip {
|
||||
|
||||
namespace {
|
||||
|
||||
// Calls `self:Method()` with no Lua args. Stack at entry must have
|
||||
// the tooltip at index 1 (the standard `frame:method()` self slot).
|
||||
// Leaves the stack unchanged.
|
||||
void CallSelfNoArgs(void *L, const char *methodName) {
|
||||
Game::Lua::PushValue(L, 1);
|
||||
Game::Lua::PushString(L, methodName);
|
||||
Game::Lua::GetTable(L, -2); // method = self[methodName]
|
||||
Game::Lua::Insert(L, -2); // [method, self]
|
||||
Game::Lua::Call(L, 1, 0);
|
||||
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
|
||||
const void *leftColor, const void *rightColor, int wrap);
|
||||
using ClearTooltip_t = void(__fastcall *)(void *self);
|
||||
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
|
||||
|
||||
// Packs r,g,b (0..1) into the 0xAARRGGBB value FUN_GAMETOOLTIP_ADD_LINE's
|
||||
// color arg wants — its little-endian bytes are the {b,g,r,a} the engine's
|
||||
// line-color setter reads. Alpha is always opaque.
|
||||
uint32_t Pack(double r, double g, double b) {
|
||||
auto ch = [](double v) {
|
||||
const int i = static_cast<int>(v * 255.0 + 0.5);
|
||||
return static_cast<uint32_t>(i < 0 ? 0 : (i > 255 ? 255 : i));
|
||||
};
|
||||
return 0xFF000000u | (ch(r) << 16) | (ch(g) << 8) | ch(b);
|
||||
}
|
||||
|
||||
// Calls `self:AddLine(text, r, g, b)`. Same self-on-stack precondition.
|
||||
void AddLine(void *L, const char *text, double r, double g, double b) {
|
||||
Game::Lua::PushValue(L, 1);
|
||||
Game::Lua::PushString(L, "AddLine");
|
||||
Game::Lua::GetTable(L, -2);
|
||||
Game::Lua::Insert(L, -2);
|
||||
Game::Lua::PushString(L, text);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
Game::Lua::Call(L, 5, 0);
|
||||
// Left-aligned colored line via the engine's raw add-line. The packed
|
||||
// color is a local so its address stays valid across the call.
|
||||
void AddColoredLine(void *self, const char *text, double r, double g, double b) {
|
||||
const uint32_t color = Pack(r, g, b);
|
||||
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
|
||||
self, text, nullptr, &color, nullptr, 0);
|
||||
}
|
||||
|
||||
// Calls `self:SetText(text, r, g, b)`. Same precondition. Used for
|
||||
// the header line — `SetText` is the standard vanilla entry for
|
||||
// "first line of a fresh tooltip" and handles internal redraw state
|
||||
// `AddLine` skips when it's the first content after a clear.
|
||||
void SetText(void *L, const char *text, double r, double g, double b) {
|
||||
Game::Lua::PushValue(L, 1);
|
||||
Game::Lua::PushString(L, "SetText");
|
||||
Game::Lua::GetTable(L, -2);
|
||||
Game::Lua::Insert(L, -2);
|
||||
Game::Lua::PushString(L, text);
|
||||
Game::Lua::PushNumber(L, r);
|
||||
Game::Lua::PushNumber(L, g);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
Game::Lua::Call(L, 5, 0); // self + text + r + g + b
|
||||
}
|
||||
|
||||
// Format-and-add one localized count line. Calls
|
||||
// `Game::Lua::PushLocalizedFormatInt` to resolve the format string
|
||||
// (Blizzard's FrameXML globals like `ITEMS_EQUIPPED` first, with the
|
||||
// C `fallback` for stripped-down servers), then dispatches `AddLine`.
|
||||
void AddLocalizedLineInt(void *L, const char *globalName, const char *fallback, int n,
|
||||
double r, double g, double b) {
|
||||
// Adds one localized "N unit" line: `string.format(_G[globalName] or
|
||||
// fallback, n)` (the project's GlobalString localize-or-fallback helper,
|
||||
// with the C `fallback` for servers stripped of the standard strings),
|
||||
// then the native colored add-line.
|
||||
void AddLocalizedLineInt(void *L, void *self, const char *globalName,
|
||||
const char *fallback, int n, double r, double g, double b) {
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, n);
|
||||
const char *text = Game::Lua::ToString(L, -1);
|
||||
if (text != nullptr)
|
||||
AddLine(L, text, r, g, b);
|
||||
AddColoredLine(self, text, r, g, b);
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
@@ -123,17 +109,6 @@ struct Tally {
|
||||
uint32_t missingItemIDs[SLOT_COUNT];
|
||||
};
|
||||
|
||||
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
|
||||
const uint64_t *guid, void *callback,
|
||||
void *userData, int unused);
|
||||
|
||||
const uint8_t *PeekItemRecord(uint32_t itemID) {
|
||||
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
|
||||
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
|
||||
const uint64_t zeroGuid = 0;
|
||||
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
|
||||
}
|
||||
|
||||
// Walks a set's slots, classifying each non-empty entry into the
|
||||
// equipped / in-inventory / ignored / missing buckets via
|
||||
// `Locations::FindGUID`. For missing slots, also records the saved
|
||||
@@ -185,39 +160,36 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
|
||||
if (s == nullptr)
|
||||
return 0;
|
||||
|
||||
void *self = Game::Lua::ResolveObject(L, 1);
|
||||
if (self == nullptr)
|
||||
return 0;
|
||||
|
||||
const Tally t = TallySet(*s);
|
||||
const int totalSlots = t.equipped + t.inInventory + t.missing;
|
||||
|
||||
// Trim the Lua stack to just the tooltip self-arg so our internal
|
||||
// method-call sequences don't accumulate the original args past
|
||||
// their useful lifetime. `setName` was a `const char *` lifted out
|
||||
// by ToString — Lua's strings are pinned by reference count, so
|
||||
// dropping the string from the stack doesn't invalidate the
|
||||
// pointer for the lifetime of this call.
|
||||
Game::Lua::SetTop(L, 1);
|
||||
|
||||
// Header — set name, white. Doubles as the "ClearLines" boundary;
|
||||
// SetText replaces line 1 and clears subsequent lines in vanilla.
|
||||
SetText(L, s->name.c_str(), 1.0, 1.0, 1.0);
|
||||
// Header — set name, white. Clear first so it lands as line 0
|
||||
// (the clear resets the line count the add-line body indexes from).
|
||||
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
|
||||
AddColoredLine(self, s->name.c_str(), 1.0, 1.0, 1.0);
|
||||
|
||||
// Body lines — try Blizzard's localized FORMAT_* globals first.
|
||||
// Same key names retail FrameXML uses (`ITEMS_VARIABLE_QUANTITY`,
|
||||
// `ITEMS_EQUIPPED`, ...); addons that need their own wording can
|
||||
// override by reassigning `_G[name]` at runtime. The fallbacks
|
||||
// only fire on servers stripped of the standard GlobalStrings.
|
||||
AddLocalizedLineInt(L, "ITEMS_VARIABLE_QUANTITY", "%d items",
|
||||
AddLocalizedLineInt(L, self, "ITEMS_VARIABLE_QUANTITY", "%d items",
|
||||
totalSlots, 1.0, 1.0, 1.0);
|
||||
|
||||
if (t.equipped > 0) {
|
||||
AddLocalizedLineInt(L, "ITEMS_EQUIPPED", "%d equipped",
|
||||
AddLocalizedLineInt(L, self, "ITEMS_EQUIPPED", "%d equipped",
|
||||
t.equipped, 0.0, 1.0, 0.0); // green
|
||||
}
|
||||
if (t.inInventory > 0) {
|
||||
AddLocalizedLineInt(L, "ITEMS_IN_INVENTORY", "%d in inventory",
|
||||
AddLocalizedLineInt(L, self, "ITEMS_IN_INVENTORY", "%d in inventory",
|
||||
t.inInventory, 1.0, 1.0, 1.0);
|
||||
}
|
||||
if (t.ignored > 0) {
|
||||
AddLocalizedLineInt(L, "ITEM_SLOTS_IGNORED", "%d slots ignored",
|
||||
AddLocalizedLineInt(L, self, "ITEM_SLOTS_IGNORED", "%d slots ignored",
|
||||
t.ignored, 0.5, 0.5, 0.5); // gray
|
||||
}
|
||||
// Missing slots: list each by name using the itemID we stored at
|
||||
@@ -233,13 +205,13 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
|
||||
unnamedMissing++;
|
||||
continue;
|
||||
}
|
||||
auto *record = PeekItemRecord(id);
|
||||
auto *record = Item::PeekRecord(id);
|
||||
if (record == nullptr) {
|
||||
unnamedMissing++;
|
||||
continue;
|
||||
}
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
record + Offsets::OFF_ITEMSTATS_NAME);
|
||||
const char *name = Game::Read<const char *>(
|
||||
record, Offsets::OFF_ITEMSTATS_NAME);
|
||||
if (name == nullptr || name[0] == '\0') {
|
||||
unnamedMissing++;
|
||||
continue;
|
||||
@@ -255,18 +227,20 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
|
||||
Game::Lua::Call(L, 2, 1);
|
||||
const char *line = Game::Lua::ToString(L, -1);
|
||||
if (line != nullptr)
|
||||
AddLine(L, line, 1.0, 0.0, 0.0); // red
|
||||
AddColoredLine(self, line, 1.0, 0.0, 0.0); // red
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
if (unnamedMissing > 0) {
|
||||
// Pre-itemID-format files or items the cache has no record of.
|
||||
// No Blizzard format global maps cleanly here; addons can
|
||||
// override with `_G.CLASSICAPI_EQUIPMENTSET_MISSING`.
|
||||
AddLocalizedLineInt(L, "CLASSICAPI_EQUIPMENTSET_MISSING",
|
||||
AddLocalizedLineInt(L, self, "CLASSICAPI_EQUIPMENTSET_MISSING",
|
||||
"%d missing", unnamedMissing, 1.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
CallSelfNoArgs(L, "Show");
|
||||
// Show ourselves — self is still at Lua stack index 1 (Script_Show
|
||||
// reads it there). Retail's SetEquipmentSet shows internally too.
|
||||
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+210
-23
@@ -46,6 +46,22 @@ struct ReservedName {
|
||||
ReservedName g_reserved[MAX_RESERVED];
|
||||
int g_reservedCount = 0;
|
||||
bool g_writesEnabled = false;
|
||||
// One-shot latch so a genuinely full event table (no NULL slot left to
|
||||
// claim) is logged once per session instead of silently dropping the
|
||||
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
|
||||
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
|
||||
// SuperWoW/nampower clamp the FrameXML table to 700 and the engine leaves
|
||||
// ~200 NULL gaps in the low range, dwarfing our ~46 reservations. The log is
|
||||
// purely a backstop if some future install somehow drains the gap pool.
|
||||
bool g_warnedTableFull = false;
|
||||
|
||||
// One grow attempt per in-game table build. Reset in `PrepareForReload`
|
||||
// (the table is rebuilt fresh on every /reload + logout). See `Grow`.
|
||||
bool g_growLatched = false;
|
||||
// Debug: force a grow on the next table build regardless of headroom, so
|
||||
// the (otherwise dormant) grow path can actually be exercised in-game.
|
||||
// Armed by `_classicapi_ForceEventGrow`, consumed by the next `Grow`.
|
||||
bool g_forceGrowNext = false;
|
||||
|
||||
// Engine's SStrDup at `FUN_STORM_SSTRDUP` — uses `SMemAlloc` internally
|
||||
// and is exactly what the engine itself does when filling event table
|
||||
@@ -61,24 +77,48 @@ char *SStrDup(const char *s) {
|
||||
return fn(s, __FILE__, __LINE__);
|
||||
}
|
||||
|
||||
// Walk the engine's event table from the END looking for a NULL-name
|
||||
// Storm allocator/free — the same pair the engine uses for the event-table
|
||||
// buffer (see RebuildEventTable), so its reload teardown `SMemFree`s our
|
||||
// grown buffer exactly as its own. Both `__stdcall`, 4 args (`ret 0x10`).
|
||||
// SMemAlloc flag bit 8 zero-fills (matching SetEventCount's fresh entries).
|
||||
void *SMemAlloc(unsigned size) {
|
||||
using SMemAlloc_t = void *(__stdcall *)(unsigned size, const char *file,
|
||||
int line, int flags);
|
||||
auto fn = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEMALLOC);
|
||||
return fn(size, __FILE__, __LINE__, 8 /* zero-fill */);
|
||||
}
|
||||
void SMemFree(void *ptr) {
|
||||
using SMemFree_t = int(__stdcall *)(void *ptr, const char *file, int line,
|
||||
int flags);
|
||||
auto fn = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEMFREE);
|
||||
fn(ptr, __FILE__, __LINE__, 0);
|
||||
}
|
||||
|
||||
// Walk the engine's event table from the LOW end looking for a NULL-name
|
||||
// slot. The bulk-init at `0x0051AB30` leaves ~200 NULL gaps scattered
|
||||
// through the 548-slot input names array (it only writes ~350 of the
|
||||
// slots explicitly; the rest stay zero-initialized from .data). UP
|
||||
// and DOWN walks both find these gaps — we go DOWN for convention with
|
||||
// VanillaMinimapTracking and so the dump in `_classicapi_DumpAllEvents`
|
||||
// shows our events grouped at the tail, visually distinct from engine
|
||||
// events.
|
||||
// slots explicitly; the rest stay zero-initialized from .data) — a big
|
||||
// contiguous hole around 42..106 alone dwarfs our reservation count.
|
||||
//
|
||||
// We go LOW (ascending) deliberately: SuperWoW and nampower own the HIGH
|
||||
// range (fixed event IDs 549..655, and they destructively clamp the table
|
||||
// to 700), and other claim-based DLLs (VanillaMinimapTracking) also walk
|
||||
// from the tail — so the top is the contested end. Claiming from the low
|
||||
// engine gaps keeps us entirely out of their way and independent of their
|
||||
// clamp / hook order. The engine's ~200 low NULL gaps dwarf our ~46
|
||||
// reservations, so a free slot is essentially always available; the
|
||||
// skip-occupied invariant keeps this safe against future engine additions
|
||||
// regardless of direction.
|
||||
int TryClaim(const char *eventName) {
|
||||
if (!g_writesEnabled)
|
||||
return -1;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return -1;
|
||||
for (int i = count - 1; i >= 0; --i) {
|
||||
auto **namePtr = reinterpret_cast<const char **>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
for (int i = 0; i < count; ++i) {
|
||||
auto **namePtr = Game::Ptr<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (*namePtr == nullptr) {
|
||||
char *copy = SStrDup(eventName);
|
||||
@@ -88,9 +128,119 @@ int TryClaim(const char *eventName) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// Writes are enabled and the table is valid, but every slot is taken —
|
||||
// real capacity exhaustion despite `Grow`. Warn once so it's diagnosable.
|
||||
if (!g_warnedTableFull) {
|
||||
g_warnedTableFull = true;
|
||||
Debug::Log::Printf(
|
||||
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
|
||||
" and any further custom events; even Grow couldn't make room.",
|
||||
count, eventName ? eventName : "?");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// On-demand event-table grow. Correctly TIMED: called once per in-game
|
||||
// table build from `RetryClaims` (the `frame:RegisterEvent` hook), on the
|
||||
// FIRST such call — which is AFTER `RebuildEventTable` has built the fresh
|
||||
// table (frames can only register once it exists) but BEFORE any frame has
|
||||
// joined a chain (it's the first registration, and we run before the
|
||||
// original registers it). So every chain is still an empty self-sentinel,
|
||||
// and the buffer can be moved safely.
|
||||
//
|
||||
// (This is what the removed `EnsureCapacity` got wrong: it ran from
|
||||
// `LoadScriptFunctions_h`, BEFORE `RebuildEventTable`, so it saw the stale
|
||||
// previous/glue table and always aborted — it never actually grew. The
|
||||
// realloc logic itself was fine; only the timing was broken.)
|
||||
//
|
||||
// Grows only when the engine's low NULL gaps are fewer than our
|
||||
// reservations — which never happens in practice (~200 gaps vs ~46
|
||||
// reservations), so this is a dormant durability valve. `g_forceGrowNext`
|
||||
// (debug) forces it so the path can be exercised. It grows by reallocating
|
||||
// the entry buffer with the engine's own Storm allocator and swapping the
|
||||
// `{cap@0, count@4, base@8}` globals at `0x00CEEF60` — bypassing
|
||||
// SetEventCount's destructive `= 700` clamp. All consumers (RegisterEvent,
|
||||
// the dispatcher, SuperWoW, nampower) read the base pointer fresh, so the
|
||||
// swap is picked up everywhere; the engine's reload teardown `SMemFree`s
|
||||
// our buffer and frees the (shared) name strings via its per-entry helper,
|
||||
// exactly as its own.
|
||||
void Grow() {
|
||||
if (g_growLatched || !g_writesEnabled)
|
||||
return;
|
||||
g_growLatched = true; // one attempt per build, whether or not it grows
|
||||
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
|
||||
return;
|
||||
|
||||
int nulls = 0;
|
||||
for (int i = 0; i < count; ++i)
|
||||
if (Game::Read<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
|
||||
++nulls;
|
||||
|
||||
const bool force = g_forceGrowNext;
|
||||
g_forceGrowNext = false;
|
||||
if (nulls >= g_reservedCount && !force)
|
||||
return; // plenty of room — the common (only) real case
|
||||
|
||||
// Growing moves the buffer, invalidating every entry's self-referential
|
||||
// list anchor, so it's safe ONLY while all chains are empty. We run
|
||||
// before the first registration, so that holds — but VERIFY it (a live
|
||||
// chain means someone registered earlier via a path we didn't intercept;
|
||||
// moving the buffer would corrupt their subscription). Abort if so and
|
||||
// fall back to claim-NULL.
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const uint32_t head = Game::Read<uint32_t>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
|
||||
Debug::Log::Printf(
|
||||
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
constexpr int kMargin = 16;
|
||||
const int deficit = (nulls < g_reservedCount) ? (g_reservedCount - nulls) : 0;
|
||||
const int newCount = count + deficit + kMargin;
|
||||
auto *newBuf = static_cast<uint8_t *>(
|
||||
SMemAlloc(static_cast<unsigned>(newCount) * Offsets::EVENT_ENTRY_STRIDE));
|
||||
if (newBuf == nullptr)
|
||||
return; // alloc failed — keep the old table; claiming still works
|
||||
|
||||
// Rebuild each entry at its NEW address, mirroring SetEventCount's own
|
||||
// per-entry init: copy the name pointer (shared string, not duplicated);
|
||||
// the `+0x04` field stays 0 (zero-filled, matching the engine); re-init
|
||||
// the anchor as an empty self-sentinel at the new location. Old entries
|
||||
// past the copy and all new entries are already zero-filled + get fresh
|
||||
// anchors. Safe because every chain is empty.
|
||||
for (int i = 0; i < newCount; ++i) {
|
||||
auto *e = reinterpret_cast<uint32_t *>(
|
||||
newBuf + i * Offsets::EVENT_ENTRY_STRIDE);
|
||||
if (i < count) // copy name; new slots stay null
|
||||
e[0] = *reinterpret_cast<const uint32_t *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE);
|
||||
const uint32_t anchor = static_cast<uint32_t>(
|
||||
reinterpret_cast<uintptr_t>(&e[2])); // entry+0x08
|
||||
e[2] = anchor; // self-referential list anchor
|
||||
e[3] = anchor | 1; // empty-chain sentinel head (+0x0C)
|
||||
}
|
||||
|
||||
// Publish base BEFORE count so the globals never advertise more entries
|
||||
// than the buffer holds. Then free the OLD buffer only — the name
|
||||
// strings live on in newBuf and are freed by the reload teardown.
|
||||
Game::Ref<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
|
||||
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
|
||||
Game::Ref<int>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
|
||||
SMemFree(base);
|
||||
|
||||
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
|
||||
count, newCount, nulls, g_reservedCount,
|
||||
force ? ", FORCED" : "");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoReserve::AutoReserve(const char *name) {
|
||||
@@ -118,13 +268,13 @@ int Lookup(const char *name) {
|
||||
int LookupByName(const char *name) {
|
||||
if (name == nullptr)
|
||||
return -1;
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || count <= 0)
|
||||
return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const char *entryName = *reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
const char *entryName = Game::Read<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (entryName != nullptr && std::strcmp(entryName, name) == 0)
|
||||
return i;
|
||||
@@ -132,7 +282,28 @@ int LookupByName(const char *name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool HasListeners(int slot) {
|
||||
if (slot < 0)
|
||||
return false;
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
if (base == nullptr || slot >= count)
|
||||
return false;
|
||||
// The entry's chain head (`+0x0C`): a populated subscriber chain is a
|
||||
// non-zero pointer with the low bit clear; `0` or an odd (tagged)
|
||||
// value is the empty self-sentinel — same test the grow-safety scan
|
||||
// uses. So this is true iff at least one frame registered for the event.
|
||||
const uint32_t head = Game::Read<uint32_t>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE, Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
return head != 0 && (head & 1) == 0;
|
||||
}
|
||||
|
||||
void RetryClaims() {
|
||||
// Grow the table first if the low gap pool is short (dormant valve).
|
||||
// Runs at most once per table build; on the first call every chain is
|
||||
// still empty (this is the first registration), which is the only safe
|
||||
// time to move the buffer.
|
||||
Grow();
|
||||
for (int i = 0; i < g_reservedCount; ++i) {
|
||||
if (g_reserved[i].slot < 0)
|
||||
g_reserved[i].slot = TryClaim(g_reserved[i].name);
|
||||
@@ -145,6 +316,8 @@ void PrepareForReload() {
|
||||
for (int i = 0; i < g_reservedCount; ++i)
|
||||
g_reserved[i].slot = -1;
|
||||
g_writesEnabled = false;
|
||||
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
|
||||
g_growLatched = false; // re-arm the one-shot grow for the new table
|
||||
}
|
||||
|
||||
// Diagnostic Lua functions — registered via the auto-register pattern
|
||||
@@ -157,12 +330,12 @@ int __fastcall Script_DumpSlot(void *L) {
|
||||
return 0;
|
||||
}
|
||||
const int slot = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
const char *name = nullptr;
|
||||
if (base != nullptr && slot >= 0 && slot < count) {
|
||||
name = *reinterpret_cast<const char *const *>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE +
|
||||
name = Game::Read<const char *>(
|
||||
base + slot * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
}
|
||||
Debug::Log::Printf("[dumpslot] base=0x%08X count=%d slot=%d name='%s'",
|
||||
@@ -172,16 +345,16 @@ int __fastcall Script_DumpSlot(void *L) {
|
||||
}
|
||||
|
||||
int __fastcall Script_DumpAllEvents(void *) {
|
||||
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
const int count = Game::Read<int>(Offsets::VAR_EVENT_TABLE_COUNT);
|
||||
Debug::Log::Printf("[dumpall] base=0x%08X count=%d",
|
||||
static_cast<unsigned>(reinterpret_cast<uintptr_t>(base)),
|
||||
count);
|
||||
if (base == nullptr)
|
||||
return 0;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE +
|
||||
const char *name = Game::Read<const char *>(
|
||||
base + i * Offsets::EVENT_ENTRY_STRIDE,
|
||||
Offsets::OFF_EVENT_ENTRY_NAME);
|
||||
if (name != nullptr && *name != '\0')
|
||||
Debug::Log::Printf("[%d] %s", i, name);
|
||||
@@ -190,10 +363,24 @@ int __fastcall Script_DumpAllEvents(void *) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `_classicapi_ForceEventGrow()` — arms a one-shot forced table grow for
|
||||
// the NEXT in-game table build, so the (otherwise dormant) grow path can be
|
||||
// exercised: call it, then `/reload`. The grow fires at the first
|
||||
// `RegisterEvent` after the rebuild (chains still empty) and logs
|
||||
// `[event] grew table N -> M (..., FORCED)`. Diagnostic only.
|
||||
int __fastcall Script_ForceEventGrow(void *) {
|
||||
g_forceGrowNext = true;
|
||||
Debug::Log::Printf(
|
||||
"[event] force-grow armed — /reload to trigger it on the next build");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void RegisterDiagnostics() {
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_DumpSlot", &Script_DumpSlot);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_DumpAllEvents",
|
||||
&Script_DumpAllEvents);
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_ForceEventGrow",
|
||||
&Script_ForceEventGrow);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterDiagnostics};
|
||||
|
||||
@@ -64,6 +64,15 @@ int Lookup(const char *name);
|
||||
// fire ourselves (e.g., polyfilling missing event dispatches).
|
||||
int LookupByName(const char *name);
|
||||
|
||||
// True iff at least one frame is currently registered for the event in
|
||||
// `slot` (its subscriber chain is non-empty). Reads the entry's chain
|
||||
// head at `+0x0C` — a couple of pointer derefs, no allocation. Use this
|
||||
// to gate expensive per-fire work (arg synthesis, DBC lookups) so an
|
||||
// event nobody listens to costs almost nothing: `if (HasListeners(slot))
|
||||
// { …build args…; Fire(slot, …); }`. `false` for `slot < 0` or a slot
|
||||
// past the live table.
|
||||
bool HasListeners(int slot);
|
||||
|
||||
// Dispatches a custom event via the engine's printf-style event
|
||||
// dispatcher at `FUN_FIRE_EVENT` (`0x00703F50`). `format` is a
|
||||
// concatenation of `%d` (int), `%u` (uint), `%f` (double), `%s`
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `PLAYER_ENTERING_WORLD(isInitialLogin, isReloadingUi)` — the 8.0.1 payload,
|
||||
// backported. Vanilla fires PEW with no args; backported addons expect the
|
||||
// two booleans to distinguish a fresh login from a `/reload` from an
|
||||
// instance/zone transition.
|
||||
//
|
||||
// Delivery: PEW is a no-arg event, so the engine broadcasts it through
|
||||
// `FUN_FIRE_EVENT_NO_ARGS` (the `__fastcall(int)` dispatcher), NOT the
|
||||
// variadic `FUN_FIRE_EVENT`. We intercept that dispatcher (via the shared
|
||||
// `Event::SignalHook`) and, for the PEW event id, suppress the no-arg fire
|
||||
// and instead re-broadcast through the variadic `FUN_FIRE_EVENT` with a
|
||||
// two-boolean payload. Both fire paths read the same frame chain off the
|
||||
// event's table slot, so the frames registered for PEW receive the args
|
||||
// exactly once. Booleans use the `%d`/`%s`+nullptr trick (`1` / `nil`), so
|
||||
// `if isInitialLogin then …` reads naturally.
|
||||
//
|
||||
// Flags, from signals we already produce — no PEW-specific engine hook:
|
||||
// - isInitialLogin: our GlueModuleAutoRegister runs on every glue-screen
|
||||
// boot (initial launch + each logout return), so a set latch means "the
|
||||
// next enter-world is a fresh login". The first PEW consumes it.
|
||||
// - isReloadingUi: our ModuleAutoRegister runs whenever the in-game Lua
|
||||
// state is (re)built — login OR `/reload`, but NOT a zone transition. So
|
||||
// "script re-init happened and it wasn't the fresh login" == a reload.
|
||||
// - Instance/zone transition rebuilds neither, so both latches are clear →
|
||||
// both false, matching retail.
|
||||
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
#include "event/SignalHook.h"
|
||||
|
||||
namespace Event::EnteringWorld {
|
||||
|
||||
namespace {
|
||||
|
||||
// Set on glue-screen boot; a pending latch means the next enter-world is a
|
||||
// fresh character login (from the login/char-select screen).
|
||||
bool g_atGlue = false;
|
||||
// Set when the in-game Lua state is (re)built (login or /reload).
|
||||
bool g_scriptReloaded = false;
|
||||
// Cached PLAYER_ENTERING_WORLD event-table slot; -1 = unresolved. Reset per
|
||||
// in-game init (the table is rebuilt, though engine slots are stable) and
|
||||
// re-resolved lazily at fire time.
|
||||
int g_pewId = -1;
|
||||
|
||||
int PewId() {
|
||||
if (g_pewId < 0)
|
||||
g_pewId = Event::Custom::LookupByName("PLAYER_ENTERING_WORLD");
|
||||
return g_pewId;
|
||||
}
|
||||
|
||||
// Shared-hook interceptor. For PEW, re-broadcast with the payload and
|
||||
// suppress the engine's no-arg fire; everything else passes through.
|
||||
bool Intercept(int eventID) {
|
||||
const int pew = PewId();
|
||||
if (pew < 0 || eventID != pew)
|
||||
return false;
|
||||
|
||||
const bool isInitialLogin = g_atGlue;
|
||||
g_atGlue = false;
|
||||
const bool isReloadingUi = !isInitialLogin && g_scriptReloaded;
|
||||
g_scriptReloaded = false;
|
||||
|
||||
// Re-fire through the variadic dispatcher with the two booleans. true → 1
|
||||
// (%d), false → nil (%s + nullptr, which the dispatcher's pushstring
|
||||
// tail-jumps to pushnil).
|
||||
auto *const kNil = static_cast<const char *>(nullptr);
|
||||
if (isInitialLogin && isReloadingUi)
|
||||
Event::Custom::Fire(pew, "%d%d", 1, 1);
|
||||
else if (isInitialLogin)
|
||||
Event::Custom::Fire(pew, "%d%s", 1, kNil);
|
||||
else if (isReloadingUi)
|
||||
Event::Custom::Fire(pew, "%s%d", kNil, 1);
|
||||
else
|
||||
Event::Custom::Fire(pew, "%s%s", kNil, kNil);
|
||||
return true; // handled — suppress the original no-arg PEW fire
|
||||
}
|
||||
|
||||
const Event::SignalHook::AutoSubscribe _intercept{&Intercept};
|
||||
|
||||
// In-game Lua (re)init: login or /reload rebuilt the script state (never a
|
||||
// zone transition). Also re-arm the PEW slot lookup — the event table was
|
||||
// rebuilt.
|
||||
void OnInGameInit() {
|
||||
g_scriptReloaded = true;
|
||||
g_pewId = -1;
|
||||
}
|
||||
|
||||
// Glue-screen boot: we're at the login/char-select screen, so the next
|
||||
// enter-world will be a fresh login.
|
||||
void OnGlueInit() { g_atGlue = true; }
|
||||
|
||||
const Game::ModuleAutoRegister _inGame{&OnInGameInit};
|
||||
const Game::GlueModuleAutoRegister _glue{&OnGlueInit};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Event::EnteringWorld
|
||||
@@ -0,0 +1,94 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `GetFramesRegisteredForEvent(event)` → the Frame objects currently
|
||||
// subscribed to `event`, returned as multiple return values (matching
|
||||
// modern WoW's vararg signature — not a table). Empty result set returns
|
||||
// nothing.
|
||||
//
|
||||
// The engine already keeps exactly this list: every `frame:RegisterEvent`
|
||||
// appends the frame to a subscriber chain hanging off the matching event
|
||||
// table entry. We find the entry by name (same table `Event::Custom`
|
||||
// walks), then walk the chain the same way `Frame::RegisterEvent`
|
||||
// (`FUN_00702140`) does — node next at `+0x04`, subscriber frame at
|
||||
// `+0x08`, with a low-bit-set / null value marking the end sentinel — and
|
||||
// push each frame via the shared `UI::FrameObject::Push` so callers get
|
||||
// the canonical wrapper table (real frame methods, `:GetName()`, etc.).
|
||||
//
|
||||
// Event names resolve through `Event::Custom::LookupByName`, so this also
|
||||
// works for our `AutoReserve`-claimed custom events, not just engine ones.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "event/Custom.h"
|
||||
#include "ui/FrameObject.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Event::Registered {
|
||||
|
||||
namespace {
|
||||
|
||||
int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
const char *event = Game::Lua::ToString(L, 1);
|
||||
if (event == nullptr)
|
||||
return 0;
|
||||
|
||||
const int slot = Event::Custom::LookupByName(event);
|
||||
if (slot < 0)
|
||||
return 0;
|
||||
|
||||
auto *base = Game::Read<uint8_t *>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
|
||||
if (base == nullptr)
|
||||
return 0;
|
||||
|
||||
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
|
||||
const uintptr_t head = Game::Read<uintptr_t>(entry, Offsets::OFF_EVENT_ENTRY_HEAD);
|
||||
|
||||
// First pass: count subscribers so we can reserve stack up front — an
|
||||
// event with many registered frames would otherwise risk overflowing
|
||||
// the default Lua stack slack as we push.
|
||||
int count = 0;
|
||||
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
|
||||
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
if (Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME))
|
||||
++count;
|
||||
}
|
||||
if (count == 0)
|
||||
return 0;
|
||||
Game::Lua::CheckStack(L, count);
|
||||
|
||||
int pushed = 0;
|
||||
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
|
||||
node = Game::Read<uintptr_t>(node + Offsets::OFF_EVENT_NODE_NEXT)) {
|
||||
void *frame = Game::Read<void *>(node + Offsets::OFF_EVENT_NODE_FRAME);
|
||||
if (frame != nullptr) {
|
||||
UI::FrameObject::Push(L, frame);
|
||||
++pushed;
|
||||
}
|
||||
}
|
||||
return pushed;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetFramesRegisteredForEvent",
|
||||
&Script_GetFramesRegisteredForEvent);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Event::Registered
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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 "SignalHook.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
namespace Event::SignalHook {
|
||||
|
||||
namespace {
|
||||
|
||||
// Chained at static-init time by each `AutoSubscribe` constructor, before
|
||||
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic init,
|
||||
// so the head is valid when subscribers link in.
|
||||
AutoSubscribe *g_head = nullptr;
|
||||
|
||||
// `FUN_FIRE_EVENT_NO_ARGS` is `__fastcall(int eventID)`; we mirror loot's
|
||||
// long-standing shape with a dummy EDX so the trampoline call matches the
|
||||
// register layout, and forward `0` for it (unused by the callee).
|
||||
using FireNoArgs_t = void(__fastcall *)(int eventID, int edx);
|
||||
FireNoArgs_t g_orig = nullptr;
|
||||
|
||||
void __fastcall Hook(int eventID, int /*edx*/) {
|
||||
for (AutoSubscribe *s = g_head; s != nullptr; s = s->next)
|
||||
if (s->fn(eventID))
|
||||
return; // a subscriber handled it — suppress the original fire
|
||||
g_orig(eventID, 0);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hook{
|
||||
Offsets::FUN_FIRE_EVENT_NO_ARGS, reinterpret_cast<void *>(&Hook),
|
||||
reinterpret_cast<void **>(&g_orig)};
|
||||
|
||||
} // namespace
|
||||
|
||||
AutoSubscribe::AutoSubscribe(Interceptor f) : fn(f), next(g_head) { g_head = this; }
|
||||
|
||||
} // namespace Event::SignalHook
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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 interceptor for the engine's no-arg event dispatcher
|
||||
// `FUN_FIRE_EVENT_NO_ARGS` (`0x00703E50`) — the `__fastcall(int eventID)`
|
||||
// path every no-arg event broadcast goes through (PLAYER_ENTERING_WORLD,
|
||||
// LOOT_OPENED/CLOSED, zone changes, …), as opposed to the variadic
|
||||
// `FUN_FIRE_EVENT` used by events that carry a printf-style payload.
|
||||
//
|
||||
// MinHook allows only ONE hook per target address, and more than one feature
|
||||
// needs to intercept this dispatcher (loot silent-scan suppression, the
|
||||
// PLAYER_ENTERING_WORLD payload). So — exactly like `Tick::WorldTick` for the
|
||||
// per-frame tick — this module owns the single hook and fans out to
|
||||
// subscribers. Declare a file-scope `static const
|
||||
// Event::SignalHook::AutoSubscribe` in your module.
|
||||
//
|
||||
// An interceptor returns `true` when it has fully handled `eventID` and the
|
||||
// engine's original no-arg fire must be SUPPRESSED — either because it
|
||||
// swallowed the event (loot) or re-fired it a different way (the PEW payload
|
||||
// re-broadcasts through the variadic `FUN_FIRE_EVENT` with args). Returning
|
||||
// `false` lets the event fall through to the next subscriber and ultimately
|
||||
// the original. The FIRST subscriber to return `true` wins and the original
|
||||
// is skipped, so subscribers must claim disjoint event ids.
|
||||
|
||||
namespace Event::SignalHook {
|
||||
|
||||
using Interceptor = bool (*)(int eventID);
|
||||
|
||||
struct AutoSubscribe {
|
||||
explicit AutoSubscribe(Interceptor fn);
|
||||
Interceptor fn;
|
||||
AutoSubscribe *next;
|
||||
};
|
||||
|
||||
} // namespace Event::SignalHook
|
||||
@@ -0,0 +1,409 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
// FontString metric backports.
|
||||
//
|
||||
// 1.12's FontString method table (32 entries at 0x0087C1D8, registry context
|
||||
// VAR_FONTSTRING_METHOD_REGISTRY) ships GetStringWidth but none of the later
|
||||
// metric bindings. The engine already has every internal getter — these are
|
||||
// the thin Lua bindings Blizzard never wrote for 1.12:
|
||||
//
|
||||
// GetStringHeight() — 2.3.0 binding; internal getter
|
||||
// FUN_FONTSTRING_STRING_HEIGHT (wrap-aware,
|
||||
// lines×fontH + (lines−1)×spacing, cache
|
||||
// fs+0x100, 0 for empty text).
|
||||
// GetUnboundedStringWidth() — the substring measure with the WHOLE string
|
||||
// (FUN_FONTSTRING_MEASURE_SUBSTRING, len 0 =
|
||||
// strlen): the string's width with no wrap cap.
|
||||
// Icon-aware for free — the call routes through
|
||||
// text/InlineTexture.cpp's co-hook.
|
||||
// GetWrappedWidth() — the RENDERED width: the widest wrapped line
|
||||
// (the tooltip auto-size's own recipe — break
|
||||
// array + per-segment substring measures).
|
||||
// GetNumLines() — the node's built line count (render truth,
|
||||
// OFF_TEXT_NODE_BUILT_LINES) once painted; for
|
||||
// an unbuilt node, the engine's own break-array
|
||||
// computer (FUN_FONTSTRING_BREAK_ARRAY — also
|
||||
// icon-aware via the wrap-stepper co-hook).
|
||||
// GetLineHeight() — the fs font height via the same getter the
|
||||
// rebuild feeds the text node (FUN_007727b0);
|
||||
// excludes spacing, like retail (GetSpacing is
|
||||
// its own method).
|
||||
// IsTruncated() — did the engine cut the text off with an
|
||||
// ellipsis? Reads the render's own output: the
|
||||
// built text node's copy of the laid-out string
|
||||
// (OFF_TEXT_NODE_TEXT) vs the source. Same
|
||||
// "displayed vs full" comparison retail does; no
|
||||
// reconstruction of the fit/wrap/maxLines logic.
|
||||
// Reflects the last rendered layout (the display-
|
||||
// text resolver's rect is zero until a render, so
|
||||
// a Lua-time re-call cannot see the bound).
|
||||
// SetMaxLines(n)/GetMaxLines() — cap the fontstring to n wrapped lines; text
|
||||
// past the cap is ellipsized. Writes the engine's
|
||||
// own per-fontstring maxLines field (fs+0x128,
|
||||
// already honored by the truncation resolver) and
|
||||
// re-invalidates the layout like SetText does.
|
||||
// 1.12 has no SetWordWrap, so SetMaxLines(1) is
|
||||
// how you get a single-line, ellipsized label.
|
||||
// SetFormattedText(fmt,...) — string.format + SetText convenience. The format
|
||||
// runs through the engine's OWN str_format C
|
||||
// function (FUN_LUA_STR_FORMAT) under pcall — NOT
|
||||
// _G.string.format, so an addon replacing
|
||||
// string.format can't hijack it (matches retail).
|
||||
// The set goes through FUN_FONTSTRING_SET_TEXT,
|
||||
// keeping the sanitizer/pipe handling identical to
|
||||
// SetText.
|
||||
//
|
||||
// Push conversion mirrors Script_GetStringWidth (0x0079E510): resolve self,
|
||||
// call the internal getter, convert anchor units → UI pixels, push.
|
||||
//
|
||||
// No inline-icon adjustment lives HERE: the width/height internal getters are
|
||||
// already icon-adjusted by their co-hooks (calling the hooked VA routes
|
||||
// through the detour), and icons never change GetLineHeight.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace FontString::Metrics {
|
||||
namespace {
|
||||
|
||||
// FUN_FONTSTRING_STRING_HEIGHT — ECX = fs, no stack args, float in x87 ST0.
|
||||
using StringHeightInternal_t = float(__fastcall *)(void *fs);
|
||||
// FUN_FONTSTRING_MEASURE_SUBSTRING — __thiscall(fs, text, len); dummy-EDX form.
|
||||
using MeasureSubstring_t = float(__fastcall *)(void *fs, void *edx, const char *text, int len);
|
||||
// FUN_FONTSTRING_FONT_HEIGHT — __thiscall(fs, mode-on-stack); dummy-EDX form.
|
||||
using FsFontHeight_t = float(__fastcall *)(void *fs, void *edx, int mode);
|
||||
// FUN_FONTSTRING_BREAK_ARRAY — __thiscall(fs, text, wrapWidth, outBreaks, cap).
|
||||
using BreakArray_t = uint32_t(__fastcall *)(void *fs, void *edx, const char *text, float width,
|
||||
int *outBreaks, int cap);
|
||||
// FUN_FONTSTRING_SET_TEXT — __thiscall(fs, text, flag = 0); dummy-EDX form.
|
||||
using FsSetText_t = void(__fastcall *)(void *fs, void *edx, const char *text, int flag);
|
||||
// FUN_HANDLE_RELEASE — DecRef a ref-counted handle. __fastcall(handle).
|
||||
using HandleRelease_t = void(__fastcall *)(void *handle);
|
||||
// FUN_FONTSTRING_LAYOUT_INVALIDATE — region layout invalidate. __thiscall(region,
|
||||
// int); dummy-EDX form.
|
||||
using LayoutInvalidate_t = void(__fastcall *)(void *region, void *edx, int arg);
|
||||
|
||||
// Invalidate a fontstring's layout exactly as SetText (FUN_00771D80) does after
|
||||
// a content change, so a layout-affecting property change (max lines) takes
|
||||
// effect on the next draw: drop the cached measures, release the built node,
|
||||
// clear the layout-dirty bit, and notify the region to re-lay-out. Re-calling
|
||||
// SetText would NOT work — it early-outs when the text string is unchanged.
|
||||
void InvalidateLayout(void *fs) {
|
||||
Game::Ref<float>(fs, Offsets::OFF_FONTSTRING_WIDTH_CACHE) = 0.0f;
|
||||
Game::Ref<float>(fs, Offsets::OFF_FONTSTRING_HEIGHT_CACHE) = 0.0f;
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
if (block != nullptr) {
|
||||
reinterpret_cast<HandleRelease_t>(Offsets::FUN_HANDLE_RELEASE)(block);
|
||||
Game::Ref<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK) = nullptr;
|
||||
}
|
||||
Game::Ref<uint32_t>(fs, Offsets::OFF_FONTSTRING_DIRTY_FLAGS) &= ~1u;
|
||||
void *region = reinterpret_cast<void *>(reinterpret_cast<uintptr_t>(fs) + 0x24);
|
||||
reinterpret_cast<LayoutInvalidate_t>(Offsets::FUN_FONTSTRING_LAYOUT_INVALIDATE)(
|
||||
region, nullptr, 0);
|
||||
}
|
||||
|
||||
// Anchor units → UI pixels: `FUN_0041AE40(FUN_0041AD70() × DAT_007FFD68 × v)`
|
||||
// = v × [VAR_UI_COORD_SCALE_DIV] × 1024 / [VAR_UI_COORD_SCALE_MUL] — the exact
|
||||
// push chain Script_GetStringWidth uses (the inverse of
|
||||
// Tooltip::LinePool::PixelToInternal).
|
||||
double InternalToPixel(float v) {
|
||||
const float mul = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_MUL);
|
||||
const float div = Game::Read<float>(Offsets::VAR_UI_COORD_SCALE_DIV);
|
||||
if (mul == 0.0f)
|
||||
return 0.0;
|
||||
return static_cast<double>(v) * div * Offsets::UI_COORD_SCALE_UNIT / mul;
|
||||
}
|
||||
|
||||
// The fs's wrap budget in the units FUN_FONTSTRING_BREAK_ARRAY expects
|
||||
// (width-getter units — it multiplies by fs+0x7C itself before gxu, verified
|
||||
// decompile), or 0 when the rect is unresolved. rectWidth ÷ scale, calibrated
|
||||
// EMPIRICALLY against the render with a three-point probe (a 100px box at
|
||||
// scale ~0.91, text whose candidate lines measure 78.1 / 94.6 / 101.6px):
|
||||
// raw rectW → effective budget ~91px → broke one word EARLY,
|
||||
// rectW/scale² → effective budget ~110px → broke one word LATE,
|
||||
// rectW/scale → effective budget 100px → breaks exactly where the
|
||||
// render does (94.6 ≤ 100 < 101.6).
|
||||
// All three observations fit budget_px = pxOf(param) × scale, pinning the
|
||||
// single ÷scale. (A pure paper derivation from the rebuild's node budget
|
||||
// predicted /scale² — one hop of that chain models the live behaviour wrong;
|
||||
// the probe is the arbiter.)
|
||||
float WrapBudget(void *fs) {
|
||||
const float *rc = Game::Ptr<const float>(fs, Offsets::OFF_REGION_RECT);
|
||||
float width = (rc[3] > rc[1]) ? (rc[3] - rc[1]) : (rc[1] - rc[3]);
|
||||
const float s = Game::Read<float>(fs, Offsets::OFF_LAYOUT_SCALE);
|
||||
if (width > 0.0f && s > 0.0f)
|
||||
width /= s;
|
||||
return width;
|
||||
}
|
||||
|
||||
// Shared self-resolve for every method here.
|
||||
void *ResolveSelf(void *L, const char *usage) {
|
||||
void *fs = nullptr;
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
|
||||
fs = Game::Lua::ResolveObject(L, 1);
|
||||
if (fs == nullptr)
|
||||
Game::Lua::Error(L, "%s", usage);
|
||||
return fs;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetStringHeight(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetStringHeight()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const float h = reinterpret_cast<StringHeightInternal_t>(
|
||||
Offsets::FUN_FONTSTRING_STRING_HEIGHT)(fs);
|
||||
Game::Lua::PushNumber(L, InternalToPixel(h));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetUnboundedStringWidth(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetUnboundedStringWidth()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (text == nullptr || *text == '\0') {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
const float w = reinterpret_cast<MeasureSubstring_t>(
|
||||
Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING)(fs, nullptr, text, 0);
|
||||
Game::Lua::PushNumber(L, InternalToPixel(w));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetNumLines(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetNumLines()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (text == nullptr || *text == '\0') {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
// Render truth first: the built node's line count. Zero until the node
|
||||
// paints once (hidden fs, mid-rebuild) — fall back below.
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
if (block != nullptr) {
|
||||
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
|
||||
if (node != nullptr) {
|
||||
const int built = Game::Read<int>(node, Offsets::OFF_TEXT_NODE_BUILT_LINES);
|
||||
if (built > 0) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(built));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unbuilt: the engine's own break-array computer, fed the fs's wrap
|
||||
// budget (see WrapBudget's scale note). An unresolved rect (0 width)
|
||||
// means no wrap constraint exists yet — one line, like a fresh
|
||||
// single-anchor fs.
|
||||
const float width = WrapBudget(fs);
|
||||
if (width <= 0.0f) {
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
return 1;
|
||||
}
|
||||
int breaks[64];
|
||||
uint32_t lines = reinterpret_cast<BreakArray_t>(Offsets::FUN_FONTSTRING_BREAK_ARRAY)(
|
||||
fs, nullptr, text, width, breaks, 64);
|
||||
if (lines > 64u)
|
||||
lines = 64u;
|
||||
if (lines == 0u)
|
||||
lines = 1u;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(lines));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetWrappedWidth(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetWrappedWidth()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const char *text = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (text == nullptr || *text == '\0') {
|
||||
Game::Lua::PushNumber(L, 0.0);
|
||||
return 1;
|
||||
}
|
||||
auto measure =
|
||||
reinterpret_cast<MeasureSubstring_t>(Offsets::FUN_FONTSTRING_MEASURE_SUBSTRING);
|
||||
// The fs's wrap budget (see WrapBudget's scale note). Without one
|
||||
// (unresolved rect) nothing wraps, and the rendered width IS the
|
||||
// one-line width.
|
||||
const float budget = WrapBudget(fs);
|
||||
if (budget <= 0.0f) {
|
||||
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
|
||||
return 1;
|
||||
}
|
||||
// The tooltip auto-size's own recipe (FUN_00530640): break positions from
|
||||
// the break-array computer, then the widest per-segment measure. Both
|
||||
// callees are icon-aware through their co-hooks, and a segment-final icon
|
||||
// reports its ink edge — so this is the rendered width, wrap and icons
|
||||
// included.
|
||||
int breaks[64];
|
||||
uint32_t segs = reinterpret_cast<BreakArray_t>(Offsets::FUN_FONTSTRING_BREAK_ARRAY)(
|
||||
fs, nullptr, text, budget, breaks, 64);
|
||||
if (segs > 64u)
|
||||
segs = 64u;
|
||||
if (segs <= 1u) {
|
||||
Game::Lua::PushNumber(L, InternalToPixel(measure(fs, nullptr, text, 0)));
|
||||
return 1;
|
||||
}
|
||||
int len = 0;
|
||||
while (len < 0x4000 && text[len] != '\0')
|
||||
++len;
|
||||
float widest = 0.0f;
|
||||
for (uint32_t i = 0; i < segs; ++i) {
|
||||
const int start = breaks[i];
|
||||
int end = (i + 1 < segs) ? breaks[i + 1] : len;
|
||||
if (start < 0 || start >= end || end > len)
|
||||
continue;
|
||||
// A segment runs to the NEXT segment's start, which includes the
|
||||
// whitespace the wrap consumed — the render never draws it, and a
|
||||
// trailing space also flips the last real letter from ink to
|
||||
// full-advance treatment (+~1.5px, measured). Trim to what renders.
|
||||
while (end > start && (text[end - 1] == ' ' || text[end - 1] == '\n' ||
|
||||
text[end - 1] == '\r' || text[end - 1] == '\t'))
|
||||
--end;
|
||||
if (start >= end)
|
||||
continue;
|
||||
const float w = measure(fs, nullptr, text + start, end - start);
|
||||
if (w > widest)
|
||||
widest = w;
|
||||
}
|
||||
Game::Lua::PushNumber(L, InternalToPixel(widest));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetLineHeight(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetLineHeight()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
const float h = reinterpret_cast<FsFontHeight_t>(Offsets::FUN_FONTSTRING_FONT_HEIGHT)(
|
||||
fs, nullptr, 1);
|
||||
Game::Lua::PushNumber(L, InternalToPixel(h));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_IsTruncated(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:IsTruncated()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
// Retail computes this by running the display-text resolver and strcmp'ing
|
||||
// its result against the source (5.4.8 inner fn 0x0045E001). 1.12's resolver
|
||||
// needs the LAID-OUT rect (fs+0x64), which is zero until a render pass, so a
|
||||
// Lua-time re-call always sees an unbounded box and never truncates. Read the
|
||||
// render's OWN output instead: the built text node keeps a copy of exactly
|
||||
// the string it laid out (OFF_TEXT_NODE_TEXT) — the ellipsized "<prefix>..."
|
||||
// form on truncation, else the source verbatim. So this reflects the last
|
||||
// rendered layout (the fontstring must have drawn once), matching retail's
|
||||
// "displayed vs full" semantics.
|
||||
bool truncated = false;
|
||||
void *block = Game::Read<void *>(fs, Offsets::OFF_FONTSTRING_TEXT_BLOCK);
|
||||
const char *src = Game::Read<const char *>(fs, Offsets::OFF_FONTSTRING_TEXT);
|
||||
if (block != nullptr && src != nullptr) {
|
||||
void *node = Game::Read<void *>(block, Offsets::OFF_TEXTBLOCK_NODE);
|
||||
const char *shown =
|
||||
(node != nullptr) ? Game::Read<const char *>(node, Offsets::OFF_TEXT_NODE_TEXT)
|
||||
: nullptr;
|
||||
if (shown != nullptr) {
|
||||
// A truncated display is exactly <source-prefix> + "...". Require the
|
||||
// "..." suffix, a length strictly shorter than the source, and a
|
||||
// matching byte-prefix. This rejects a STALE node — one left from a
|
||||
// previous, different text after a fresh SetText, not yet re-laid-out
|
||||
// — which would otherwise read as a truncation of the new text.
|
||||
const size_t nlen = std::strlen(shown);
|
||||
const size_t slen = std::strlen(src);
|
||||
if (nlen >= 3 && nlen < slen && shown[nlen - 3] == '.' &&
|
||||
shown[nlen - 2] == '.' && shown[nlen - 1] == '.' &&
|
||||
std::memcmp(shown, src, nlen - 3) == 0)
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::PushBool(L, truncated);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetMaxLines(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:SetMaxLines(maxLines)");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
// <= 0 or a non-number means "no cap" — the engine treats 0 as unlimited.
|
||||
int maxLines = 0;
|
||||
if (Game::Lua::IsNumber(L, 2)) {
|
||||
const double n = Game::Lua::ToNumber(L, 2);
|
||||
if (n > 0.0)
|
||||
maxLines = static_cast<int>(n);
|
||||
}
|
||||
if (maxLines != Game::Read<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES)) {
|
||||
Game::Ref<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES) = maxLines;
|
||||
InvalidateLayout(fs);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetMaxLines(void *L) {
|
||||
void *fs = ResolveSelf(L, "Usage: fontstring:GetMaxLines()");
|
||||
if (fs == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(
|
||||
L, static_cast<double>(Game::Read<int>(fs, Offsets::OFF_FONTSTRING_MAX_LINES)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetFormattedText(void *L) {
|
||||
void *fs = nullptr;
|
||||
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE)
|
||||
fs = Game::Lua::ResolveObject(L, 1);
|
||||
if (fs == nullptr || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: fontstring:SetFormattedText(\"format\"[, ...])");
|
||||
return 0;
|
||||
}
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::CheckStack(L, top + 4);
|
||||
Game::Lua::PushCClosure(
|
||||
L, reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_FORMAT), 0);
|
||||
for (int i = 2; i <= top; ++i)
|
||||
Game::Lua::PushValue(L, i);
|
||||
if (Game::Lua::PCall(L, top - 1, 1, 0) != 0) {
|
||||
const char *msg = Game::Lua::ToString(L, -1);
|
||||
Game::Lua::Error(L, "%s", (msg != nullptr) ? msg : "SetFormattedText: format failed");
|
||||
return 0;
|
||||
}
|
||||
const char *result = Game::Lua::ToString(L, -1);
|
||||
reinterpret_cast<FsSetText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(
|
||||
fs, nullptr, (result != nullptr) ? result : "", 0);
|
||||
Game::Lua::SetTop(L, top);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"GetStringHeight", &Script_GetStringHeight},
|
||||
{"GetUnboundedStringWidth", &Script_GetUnboundedStringWidth},
|
||||
{"GetWrappedWidth", &Script_GetWrappedWidth},
|
||||
{"GetNumLines", &Script_GetNumLines},
|
||||
{"GetLineHeight", &Script_GetLineHeight},
|
||||
{"IsTruncated", &Script_IsTruncated},
|
||||
{"SetMaxLines", &Script_SetMaxLines},
|
||||
{"GetMaxLines", &Script_GetMaxLines},
|
||||
{"SetFormattedText", &Script_SetFormattedText},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FONTSTRING_METHOD_REGISTRY), g_methods,
|
||||
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
} // namespace FontString::Metrics
|
||||
@@ -0,0 +1,973 @@
|
||||
// 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/>.
|
||||
|
||||
// Frame attributes + unit-frame mouseover/click backport.
|
||||
//
|
||||
// Backports `frame:SetAttribute` / `frame:SetAttributeNoHandler` /
|
||||
// `frame:ClearAttribute` / `frame:GetAttribute` to 1.12 as native methods on the base Frame registry
|
||||
// (every frame gets them, like the modern clients). Vanilla has no attribute
|
||||
// system at all — it arrived in 2.0 with secure frames. Attributes are just a
|
||||
// per-frame, case-insensitive key→value store (verified from the 3.3.5
|
||||
// implementation: a name→Lua-value map plus a getter with prefix/name/suffix
|
||||
// wildcard precedence). We store the map in the Lua registry keyed by the frame
|
||||
// (registry[frame]) — invisible to Lua script and tamper-proof, like retail's
|
||||
// C-side attribute store (see PushSub).
|
||||
//
|
||||
// The headline use — SecureUnitButton behavior, minus the secure wrapper (1.12
|
||||
// has no combat lockdown or taint, so protected actions just work):
|
||||
//
|
||||
// unitFrame:SetAttribute("unit", "party1") -- hover → mouseover = party1
|
||||
// unitFrame:SetAttribute("type1", "target") -- left-click → target party1
|
||||
//
|
||||
// ── Mouseover (needs C — the engine's mouseover is a GUID slot) ──
|
||||
//
|
||||
// Installing OnEnter/OnLeave would be clobbered by an addon's own
|
||||
// `SetScript("OnEnter", …)` (pfUI sets `unit` before its scripts), and there's
|
||||
// nothing to read from Lua anyway — the mouseover unit is an engine global. So
|
||||
// we mirror retail / SuperWoW's `SetMouseoverUnit`: watch the engine's
|
||||
// mouse-focus frame (`*(*VAR_UI_CONTEXT_PTR + OFF_UI_CONTEXT_MOUSE_FOCUS)`, the
|
||||
// CFrameScriptObject* `GetMouseFocus` returns) once per frame on the shared
|
||||
// WorldTick, and when it's a `unit`-attributed frame call the engine's FULL
|
||||
// mouseover setter (FUN_00492890) — 1:1 with hovering the unit's 3D model:
|
||||
// model highlight, mouseover tooltip, UPDATE_MOUSEOVER_UNIT, and the GUID slot.
|
||||
// Stomp-proof: the engine's own 3D-hover setter is event-driven, so nothing
|
||||
// overwrites the slot while the cursor is over UI.
|
||||
//
|
||||
// ── Clicks (the frame's own OnClick — scoped, native) ──
|
||||
//
|
||||
// Clicks are events, not state, so they belong on the frame's OnClick — but
|
||||
// ONLY on frames that opt in with a `type` attribute. When a `type*` attribute
|
||||
// is set we install a chained OnClick on that frame (protected, because OnClick
|
||||
// is Button-only). It mirrors retail's SecureActionButton_OnClick: resolve ONE
|
||||
// verb per click from the modifier/button-qualified `type` attribute, perform
|
||||
// it, done. No global hook, no handler on frames that never asked for one.
|
||||
//
|
||||
// Resolution (retail-style): the attribute is `[prefix]type[suffix]`, where the
|
||||
// prefix is the held modifiers ("alt-"/"ctrl-"/"shift-") and the suffix is the
|
||||
// button number (1=Left, 2=Right, …). So `type1`, `shift-type1`, `type` all
|
||||
// resolve via ReadModAttr's precedence. Verbs: `target` (with the engine's
|
||||
// default-interaction precedence — pending spell / cursor item cast/drop on the
|
||||
// unit instead of switching target), `assist`, `focus`, `spell` (reads the
|
||||
// `spell` attribute and casts it: with a `unit` attribute, on that unit via our
|
||||
// native C_Spell.CastAtUnit — the unit's GUID goes straight to the cast
|
||||
// dispatcher, no target juggling, and ground-target spells land at its feet;
|
||||
// with no `unit`, on the current target via the plain CastSpellByName), `item`
|
||||
// (uses the `item`
|
||||
// attribute — a name/itemID/link via C_Item.UseItemByName with the unit as the
|
||||
// use target, or a "bag slot" string like "0 1" via UseContainerItem; the
|
||||
// deprecated `bag`/`slot` attributes also work), `macro` (from the
|
||||
// `macrotext`/`macro` attribute — prefers an addon RunMacro, else runs natively
|
||||
// via the stock ChatEdit_ParseText), `stopcasting`, `menu`/`togglemenu` (pops
|
||||
// the standard unit dropdown at the cursor via the addon's
|
||||
// ClassicAPI_ToggleUnitMenu). Actions call ordinary Lua globals where the entry
|
||||
// is the engine's (TargetUnit, IsAltKeyDown, …); where we own a C++ module the
|
||||
// dispatch goes straight to it, no Lua round-trip (Spell::AtUnit for `spell`,
|
||||
// Unit::Focus for `focus`). All run under the engine's protected OnClick.
|
||||
//
|
||||
// One verb per click is the point: because we own the click when a verb
|
||||
// resolves (and only then chain the previous handler), a configured `type1` no
|
||||
// longer runs *alongside* the addon's own conditional OnClick — that double
|
||||
// dispatch (our fixed "target" + pfUI's ClickAction) was the conflict. An addon
|
||||
// expresses all its click behavior as attributes; unconfigured clicks fall
|
||||
// through to its OnClick (e.g. a right-click menu).
|
||||
//
|
||||
// Ordering / clobbering: it chains the handler present when `type*` is set, so
|
||||
// set `type*` AFTER the frame's own `OnClick`. Addons that re-`SetScript`
|
||||
// `OnClick` later (pfUI does on every raid relayout) replace our closure — so
|
||||
// re-set `type*` after re-SetScript to recover. Re-wiring is safe: WireOnClick
|
||||
// re-wraps a clobbered (Lua) handler but skips its own C closure, so it never
|
||||
// double-chains. The frame must be a Button registered for the relevant clicks
|
||||
// (`RegisterForClicks`) — left is the Button default; right needs
|
||||
// `RegisterForClicks("RightButtonUp")`, which real unit frames do.
|
||||
//
|
||||
// `OnAttributeChanged` is a real SetScript-able handler: SetAttribute (not
|
||||
// SetAttributeNoHandler) fires it with `this` = frame, `arg1` = name, `arg2` =
|
||||
// value. Implemented by co-hooking the base-frame script-name resolver
|
||||
// (`FUN_FRAME_SCRIPT_RESOLVER`) and handing out an external per-frame cell for
|
||||
// that name — the `Tooltip::SetEvents` analog, applied to every frame.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "baselib/Ascii.h"
|
||||
#include "cursor/Info.h"
|
||||
#include "macro/Execute.h"
|
||||
#include "object/Resolve.h"
|
||||
#include "spell/AtCursor.h"
|
||||
#include "spell/AtUnit.h"
|
||||
#include "tick/WorldTick.h"
|
||||
#include "unit/Focus.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Frame::Attributes {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
|
||||
int CallScript(uintptr_t fn, void *L) {
|
||||
return reinterpret_cast<ScriptFn_t>(fn)(L);
|
||||
}
|
||||
|
||||
// ---- small string helpers --------------------------------------------------
|
||||
|
||||
void LowerCopy(char *dst, const char *src, size_t n) {
|
||||
size_t i = 0;
|
||||
for (; src && src[i] && i + 1 < n; ++i)
|
||||
dst[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(src[i])));
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// Bounded, always-terminated copy (avoids the C4996 strncpy/strcpy warnings).
|
||||
void BoundedCopy(char *dst, const char *src, size_t n) {
|
||||
size_t i = 0;
|
||||
for (; src && src[i] && i + 1 < n; ++i)
|
||||
dst[i] = src[i];
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// dst = tolower(a .. b .. c). Truncates at n-1.
|
||||
void Compose3Lower(char *dst, size_t n, const char *a, const char *b, const char *c) {
|
||||
size_t i = 0;
|
||||
const char *parts[3] = {a, b, c};
|
||||
for (const char *s : parts)
|
||||
for (; s && *s && i + 1 < n; ++s)
|
||||
dst[i++] = static_cast<char>(std::tolower(static_cast<unsigned char>(*s)));
|
||||
dst[i] = '\0';
|
||||
}
|
||||
|
||||
// True if `lname` (already lowercase) is a click "type" action attribute: an
|
||||
// optional modifier prefix (alt-/ctrl-/shift-, any order/combo) followed by
|
||||
// "type" and an optional button digit — i.e. anything the click resolver
|
||||
// (ButtonSuffix + ReadModAttr) reads as a verb selector: type, type1..type5,
|
||||
// shift-type1, alt-ctrl-type2, … . This is the exact set WireOnClick must
|
||||
// (re)install for; keying the wiring off it (rather than a fixed
|
||||
// type/type1/type2 list) means a middle-click (type3) or modifier-only config
|
||||
// still installs the chained OnClick.
|
||||
bool IsTypeAttr(const char *lname) {
|
||||
const char *p = lname;
|
||||
for (bool advanced = true; advanced;) {
|
||||
if (std::strncmp(p, "alt-", 4) == 0) p += 4;
|
||||
else if (std::strncmp(p, "ctrl-", 5) == 0) p += 5;
|
||||
else if (std::strncmp(p, "shift-", 6) == 0) p += 6;
|
||||
else advanced = false;
|
||||
}
|
||||
if (std::strncmp(p, "type", 4) != 0)
|
||||
return false;
|
||||
p += 4;
|
||||
if (p[0] == '\0')
|
||||
return true; // "type"
|
||||
return p[0] >= '1' && p[0] <= '5' && p[1] == '\0'; // "typeN"
|
||||
}
|
||||
|
||||
// ---- attribute storage (in the Lua registry, keyed by the frame) -----------
|
||||
|
||||
// Pushes the frame's attribute subtable onto the stack. Stored as
|
||||
// registry[frame] in the Lua registry (LUA_REGISTRYINDEX) rather than as a
|
||||
// field on the frame's own table — the registry is C-side and has no _G path,
|
||||
// so the store is invisible to `pairs(frame)` and can't be read or tampered
|
||||
// with from Lua, matching how retail keeps attributes C-side. With create=true,
|
||||
// lazily creates and stores it. Returns true iff a subtable is left on top;
|
||||
// false (nothing left on the stack) when absent and !create. 1.12 frames are
|
||||
// immortal, so the strong registry key never leaks.
|
||||
bool PushSub(void *L, int frameIdx, bool create) {
|
||||
Game::Lua::PushValue(L, frameIdx); // [.., frame] (key)
|
||||
Game::Lua::RawGet(L, Game::Lua::REGISTRY_INDEX); // [.., sub|nil]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
|
||||
return true; // [.., sub]
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the nil -> [..]
|
||||
if (!create)
|
||||
return false;
|
||||
Game::Lua::NewTable(L); // [.., sub]
|
||||
Game::Lua::PushValue(L, frameIdx); // [.., sub, frame] (key)
|
||||
Game::Lua::PushValue(L, -2); // [.., sub, frame, sub] (value)
|
||||
Game::Lua::RawSet(L, Game::Lua::REGISTRY_INDEX); // registry[frame] = sub -> [.., sub]
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copies string attribute `lname` (already lowercase) of the frame at
|
||||
// frameIdx into buf. Returns true iff a string value was found. Restores the
|
||||
// stack.
|
||||
bool CopyAttr(void *L, int frameIdx, const char *lname, char *buf, size_t n) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool ok = false;
|
||||
if (PushSub(L, frameIdx, false)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
if (s) {
|
||||
BoundedCopy(buf, s, n);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// If the frame's attribute subtable has a non-nil value for keyLower, leave that
|
||||
// value on the stack top and return true; otherwise restore the stack and
|
||||
// return false.
|
||||
bool TryPushValue(void *L, int frameIdx, const char *keyLower) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
if (PushSub(L, frameIdx, false)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, keyLower);
|
||||
Game::Lua::RawGet(L, si); // [.., sub, val]
|
||||
if (Game::Lua::Type(L, -1) != Game::Lua::TYPE_NIL) {
|
||||
Game::Lua::Remove(L, si); // [.., val]
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unit token → GUID goes through `Unit::Identity::GuidForToken` (engine
|
||||
// resolver + `"player"` fast-path, handling our focus / nameplateN / raw-GUID
|
||||
// extensions). For recognized tokens it never raises; unit attributes are
|
||||
// tokens by contract.
|
||||
|
||||
// ---- Lua-global call helpers (used by the click dispatcher) -----------------
|
||||
//
|
||||
// The click dispatch mirrors retail's SecureActionButton_OnClick: it's C. It
|
||||
// performs engine actions by calling ordinary Lua globals (TargetUnit,
|
||||
// IsAltKeyDown, …) and our own actions by calling the C++ module directly
|
||||
// (Spell::AtUnit, Unit::Focus). Both run under the engine's protected OnClick
|
||||
// invocation, so a Lua error inside one is caught there (no crash).
|
||||
|
||||
// The plain global-call primitives (push-if-function, call with no/one arg)
|
||||
// live in Game::Lua now (PushGlobalFunction / CallGlobal / CallGlobalString) —
|
||||
// shared with the other modules that dispatch to FrameXML globals by name. The
|
||||
// two shapes below are specific enough to stay here.
|
||||
|
||||
// _G[name]() -> boolean (false if the global isn't a function). Distinct from
|
||||
// Game::Lua::CallGlobal in that it returns the call's boolean RESULT (used for
|
||||
// the IsAltKeyDown/IsShiftKeyDown modifier probes).
|
||||
bool CallBoolGlobal(void *L, const char *name) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool r = false;
|
||||
if (Game::Lua::PushGlobalFunction(L, name)) {
|
||||
Game::Lua::Call(L, 0, 1);
|
||||
r = Game::Lua::ToBoolean(L, -1) != 0;
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return r;
|
||||
}
|
||||
|
||||
// _G[table][method](a, b) — two string args (null → nil, since PushString maps
|
||||
// NULL to nil), no results. Returns true iff the method resolved to a function
|
||||
// and got called. Used when the target is our own C_* function but the call must
|
||||
// cross the Lua boundary — e.g. C_Item.UseItemByName, whose bag walk does
|
||||
// SetTop(L, 0); going through lua_call gives it its own stack frame so that reset
|
||||
// clears only its frame, not the dispatcher's (a direct C++ call would wipe our
|
||||
// stack — the reason `spell`, which is stack-free, calls Spell::AtUnit directly
|
||||
// while `item` routes through here).
|
||||
bool CallTableFunc2Str(void *L, const char *table, const char *method,
|
||||
const char *a, const char *b) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
bool called = false;
|
||||
Game::Lua::PushString(L, table);
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // _G[table]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE) {
|
||||
const int t = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, method);
|
||||
Game::Lua::GetTable(L, t); // _G[table][method]
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushString(L, a);
|
||||
Game::Lua::PushString(L, b);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
called = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// ---- mouse-focus poll (drives the native mouseover slot) -------------------
|
||||
|
||||
// frame CFrameScriptObject* → current `unit` token, for the mouse-focus poll.
|
||||
// Keyed by the same pointer the engine stores at the mouse-focus slot. Single-
|
||||
// threaded (Lua + WorldTick on the main thread), so no synchronization needed.
|
||||
std::unordered_map<const void *, std::string> g_unitByFrame;
|
||||
|
||||
// The last GUID *we* wrote into the mouseover slot. We only touch the slot on a
|
||||
// change of our own value, so when the cursor isn't over a unit frame (guid 0)
|
||||
// we never clobber the engine's own 3D mouseover.
|
||||
uint64_t g_lastSet = 0;
|
||||
|
||||
const void *CurrentMouseFocus() {
|
||||
const void *ctx = Game::Read<const void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_UI_CONTEXT_PTR));
|
||||
if (ctx == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const void *>(ctx, Offsets::OFF_UI_CONTEXT_MOUSE_FOCUS);
|
||||
}
|
||||
|
||||
// The engine's real mouseover setter — `__stdcall(guidLo, guidHi, prevLo,
|
||||
// prevHi)`. Highlights the unit's model, builds the mouseover tooltip, fires
|
||||
// UPDATE_MOUSEOVER_UNIT, and writes the GUID slot; pass 0,0 for the optional
|
||||
// previous, and (0,0,0,0) to clear. Same address `Unit::Mouseover` co-hooks, so
|
||||
// calling it here goes through that hook (loss events keep firing correctly).
|
||||
void CallEngineMouseover(uint64_t guid) {
|
||||
reinterpret_cast<void(__stdcall *)(uint32_t, uint32_t, uint32_t, uint32_t)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SET_MOUSEOVER_UNIT))(
|
||||
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32), 0, 0);
|
||||
}
|
||||
|
||||
// True when the GUID resolves to a live unit/player object (loaded, in range).
|
||||
// False for an offline or far out-of-range party/raid member — the case the
|
||||
// engine mouseover setter skips the tooltip for. Same resolver Unit::Mouseover
|
||||
// uses for its unit test.
|
||||
bool GuidHasLiveUnit(uint64_t guid) {
|
||||
if (guid == 0)
|
||||
return false;
|
||||
constexpr int kUnitOrPlayerMask =
|
||||
(1 << Offsets::OBJECT_TYPE_UNIT) | (1 << Offsets::OBJECT_TYPE_PLAYER);
|
||||
return Object::ByGuid(kUnitOrPlayerMask, guid, "ClassicAPI", 0) != nullptr;
|
||||
}
|
||||
|
||||
// Build the mouseover tooltip for a unit with no live object, straight from the
|
||||
// party/raid roster. FUN_00529FE0 is the same unit-tooltip builder SetUnit and
|
||||
// the engine mouseover use; with no object it fills name/level/class + the
|
||||
// "Offline" line from the roster (verified: `GameTooltip:SetUnit("party1")`
|
||||
// works for an offline member). The engine's setter (FUN_00492890) gates this
|
||||
// call on a live object, so offline members get nothing — we do it here.
|
||||
void BuildRosterUnitTooltip(uint64_t guid) {
|
||||
void *tooltip = Game::Read<void *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_GAMETOOLTIP_OBJECT_PTR));
|
||||
if (tooltip == nullptr)
|
||||
return;
|
||||
|
||||
// Fire OnTooltipSetDefaultAnchor first, exactly as the engine mouseover
|
||||
// setter (FUN_00492890) does before it builds — FrameXML's handler sets
|
||||
// the tooltip's owner + anchor, which is what actually shows it. Without
|
||||
// this, a rebuild after the tooltip was hidden on the previous leave has
|
||||
// no owner and never re-appears (only a subsequent live-unit hover, which
|
||||
// takes the engine's own anchor path, would). Use the self-contained
|
||||
// invoker FUN_FRAME_INVOKE_SCRIPT, safe to call from the WorldTick.
|
||||
const int anchorHandler = Game::Read<int>(
|
||||
tooltip, Offsets::OFF_TOOLTIP_SET_DEFAULT_ANCHOR_HANDLER);
|
||||
if (anchorHandler != 0)
|
||||
reinterpret_cast<void(__fastcall *)(int, void *)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT))(anchorHandler,
|
||||
tooltip);
|
||||
|
||||
uint32_t packed[2] = {static_cast<uint32_t>(guid),
|
||||
static_cast<uint32_t>(guid >> 32)};
|
||||
reinterpret_cast<void(__thiscall *)(void *, uint32_t *)>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GAMETOOLTIP_BUILD_UNIT))(tooltip, packed);
|
||||
}
|
||||
|
||||
void MouseoverTick() {
|
||||
const void *focus = CurrentMouseFocus();
|
||||
|
||||
uint64_t guid = 0;
|
||||
if (focus != nullptr) {
|
||||
auto it = g_unitByFrame.find(focus);
|
||||
if (it != g_unitByFrame.end())
|
||||
guid = Unit::Identity::GuidForToken(
|
||||
it->second.c_str()); // live — follows target changes
|
||||
}
|
||||
|
||||
if (guid != g_lastSet) {
|
||||
CallEngineMouseover(guid); // sets slot + highlight + tooltip + event
|
||||
// Offline / far out-of-range party or raid member: the setter wrote the
|
||||
// GUID slot but skipped the tooltip (no live object). Build it from the
|
||||
// roster so hovering the frame still shows name / level / "Offline".
|
||||
if (guid != 0 && !GuidHasLiveUnit(guid))
|
||||
BuildRosterUnitTooltip(guid);
|
||||
g_lastSet = guid;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- click dispatch (the frame's own chained OnClick) ----------------------
|
||||
|
||||
// Runs the chained-onto previous handler (upvalue 1), vanilla-style with no
|
||||
// args (it reads the `this`/`arg1` globals the engine set for this invocation).
|
||||
void ChainOld(void *L) {
|
||||
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(1)) == Game::Lua::TYPE_FUNCTION) {
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
|
||||
Game::Lua::Call(L, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the modifier prefix ("alt-"/"ctrl-"/"shift-", in that order — retail's
|
||||
// order) from the live key state, e.g. "alt-shift-". Empty when none held.
|
||||
void BuildModifierPrefix(void *L, char *buf, size_t n) {
|
||||
buf[0] = '\0';
|
||||
size_t i = 0;
|
||||
auto add = [&](const char *s) {
|
||||
for (const char *p = s; *p && i + 1 < n; ++p)
|
||||
buf[i++] = *p;
|
||||
buf[i] = '\0';
|
||||
};
|
||||
if (CallBoolGlobal(L, "IsAltKeyDown")) add("alt-");
|
||||
if (CallBoolGlobal(L, "IsControlKeyDown")) add("ctrl-");
|
||||
if (CallBoolGlobal(L, "IsShiftKeyDown")) add("shift-");
|
||||
}
|
||||
|
||||
// Button name -> attribute suffix (retail's convention: the button number).
|
||||
const char *ButtonSuffix(const char *btn) {
|
||||
if (Ascii::EqualCI(btn, "RightButton")) return "2";
|
||||
if (Ascii::EqualCI(btn, "MiddleButton")) return "3";
|
||||
if (Ascii::EqualCI(btn, "Button4")) return "4";
|
||||
if (Ascii::EqualCI(btn, "Button5")) return "5";
|
||||
return "1"; // LeftButton / unknown
|
||||
}
|
||||
|
||||
// Resolves a modified attribute into `buf`. Precedence (practical subset of the
|
||||
// GetAttribute wildcard rules): prefix..name..suffix (e.g. "shift-type1"),
|
||||
// then name..suffix ("type1"), then name ("type"). So a plain `type1` still
|
||||
// applies under any modifier unless a modifier-specific attribute overrides it.
|
||||
bool ReadModAttr(void *L, int fi, const char *prefix, const char *name,
|
||||
const char *suffix, char *buf, size_t n) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, name, suffix);
|
||||
if (CopyAttr(L, fi, key, buf, n)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, suffix);
|
||||
if (CopyAttr(L, fi, key, buf, n)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, "");
|
||||
return CopyAttr(L, fi, key, buf, n);
|
||||
}
|
||||
|
||||
// Reads attribute `keyLower` as an int (stored as a number, or a numeric
|
||||
// string) into *out. Leaves the stack as it found it. Returns true on success.
|
||||
bool ReadAttrInt(void *L, int fi, const char *keyLower, int *out) {
|
||||
bool ok = false;
|
||||
if (TryPushValue(L, fi, keyLower)) { // value left on top
|
||||
const int t = Game::Lua::Type(L, -1);
|
||||
if (t == Game::Lua::TYPE_NUMBER) {
|
||||
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
|
||||
ok = true;
|
||||
} else if (t == Game::Lua::TYPE_STRING) {
|
||||
const char *s = Game::Lua::ToString(L, -1);
|
||||
char *end = nullptr;
|
||||
const long v = s ? std::strtol(s, &end, 10) : 0;
|
||||
if (s && end != s) {
|
||||
*out = static_cast<int>(v);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // pop the peeked value
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Numeric analog of ReadModAttr (prefix..name..suffix precedence).
|
||||
bool ReadModAttrInt(void *L, int fi, const char *prefix, const char *name,
|
||||
const char *suffix, int *out) {
|
||||
char key[128];
|
||||
Compose3Lower(key, sizeof key, prefix, name, suffix);
|
||||
if (ReadAttrInt(L, fi, key, out)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, suffix);
|
||||
if (ReadAttrInt(L, fi, key, out)) return true;
|
||||
Compose3Lower(key, sizeof key, "", name, "");
|
||||
return ReadAttrInt(L, fi, key, out);
|
||||
}
|
||||
|
||||
// Parses a "bag slot" string (two integers, e.g. "0 1") into bag/slot. Returns
|
||||
// false unless the whole string is exactly two integers, so a normal item name
|
||||
// or a bare itemID ("12345") falls through to the name path.
|
||||
bool ParseBagSlot(const char *s, int *bag, int *slot) {
|
||||
char *end = nullptr;
|
||||
const long b = std::strtol(s, &end, 10);
|
||||
if (end == s) return false; // not a leading number
|
||||
const char *p = end;
|
||||
const long sl = std::strtol(p, &end, 10);
|
||||
if (end == p) return false; // only one number
|
||||
while (*end == ' ' || *end == '\t') ++end;
|
||||
if (*end != '\0') return false; // trailing junk
|
||||
*bag = static_cast<int>(b);
|
||||
*slot = static_cast<int>(sl);
|
||||
return true;
|
||||
}
|
||||
|
||||
// _G[name](a, b) — two number args, no results. Returns true iff called.
|
||||
bool CallGlobalNum2(void *L, const char *name, double a, double b) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
const bool called = Game::Lua::PushGlobalFunction(L, name);
|
||||
if (called) {
|
||||
Game::Lua::PushNumber(L, a);
|
||||
Game::Lua::PushNumber(L, b);
|
||||
Game::Lua::Call(L, 2, 0);
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
return called;
|
||||
}
|
||||
|
||||
// Performs the resolved `verb` on `unit` (a token attribute value, may be null).
|
||||
// Returns true if it owned the click (so the chained handler is skipped).
|
||||
bool DispatchVerb(void *L, int fi, const char *prefix, const char *suffix,
|
||||
const char *verb, const char *unit) {
|
||||
if (Ascii::EqualCI(verb, "target")) {
|
||||
if (!unit) return false;
|
||||
// `unit="none"` clears the target (retail's SecureActionButton behavior).
|
||||
if (Ascii::EqualCI(unit, "none")) {
|
||||
Game::Lua::CallGlobal(L, "ClearTarget");
|
||||
return true;
|
||||
}
|
||||
// Cursor / pending-spell take precedence, matching the engine's default
|
||||
// unit interaction — cast the pending spell / drop the item on the unit
|
||||
// instead of switching target. The two predicates are ours (direct C++);
|
||||
// the actions are engine-only, so they go through the Lua globals (which
|
||||
// are the engine's own entries, with token→GUID resolution).
|
||||
if (Spell::AtCursor::IsPlacementActive())
|
||||
Game::Lua::CallGlobalString(L, "SpellTargetUnit", unit);
|
||||
else if (Cursor::Info::HasItem())
|
||||
Game::Lua::CallGlobalString(L, "DropItemOnUnit", unit);
|
||||
else
|
||||
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "assist")) {
|
||||
if (!unit) return false;
|
||||
Game::Lua::CallGlobalString(L, "AssistUnit", unit);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "focus")) {
|
||||
if (!unit) return false;
|
||||
Unit::Focus::Set(Unit::Identity::GuidForToken(unit));
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "spell")) {
|
||||
char spell[128];
|
||||
if (!ReadModAttr(L, fi, prefix, "spell", suffix, spell, sizeof spell))
|
||||
return false;
|
||||
// With a `unit`, cast straight at it (GUID → dispatcher, no target
|
||||
// juggling; a ground-target spell lands at its feet). With no `unit`,
|
||||
// fall back to the plain global cast on the current target — a
|
||||
// `type="spell"` button that sets no `unit` attribute (issue #18) now
|
||||
// works like `/cast <spell>` instead of doing nothing.
|
||||
if (unit)
|
||||
Spell::AtUnit::CastByName(spell, unit);
|
||||
else
|
||||
Game::Lua::CallGlobalString(L, "CastSpellByName", spell);
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "item")) {
|
||||
char item[128];
|
||||
int bag, slot;
|
||||
if (ReadModAttr(L, fi, prefix, "item", suffix, item, sizeof item)) {
|
||||
if (ParseBagSlot(item, &bag, &slot))
|
||||
CallGlobalNum2(L, "UseContainerItem", bag, slot);
|
||||
else
|
||||
CallTableFunc2Str(L, "C_Item", "UseItemByName", item, unit);
|
||||
return true;
|
||||
}
|
||||
// Deprecated form: no `item`, take the `bag` + `slot` attributes.
|
||||
if (ReadModAttrInt(L, fi, prefix, "bag", suffix, &bag) &&
|
||||
ReadModAttrInt(L, fi, prefix, "slot", suffix, &slot)) {
|
||||
CallGlobalNum2(L, "UseContainerItem", bag, slot);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "macro")) {
|
||||
// Give the macro a unit to act on. Vanilla macros have no `@unit`
|
||||
// conditional, and unlike the `spell` verb (which feeds a GUID straight
|
||||
// to the cast dispatcher) we can't inject a target into arbitrary macro
|
||||
// text — so a plain `/cast Flash Heal` would hit the current target, not
|
||||
// the clicked unit. Emulate the classic Clique click-heal: snapshot the
|
||||
// current target, retarget the clicked unit, run the macro, then restore
|
||||
// the previous target so the click doesn't leave the player retargeted.
|
||||
// The macro's actions (/cast etc.) dispatch against the clicked unit
|
||||
// synchronously while it's selected, so restoring the target afterward
|
||||
// doesn't affect them.
|
||||
uint64_t prevTarget = 0;
|
||||
const bool swapped = unit != nullptr;
|
||||
if (swapped) {
|
||||
prevTarget =
|
||||
(static_cast<uint64_t>(Game::Read<volatile uint32_t>(
|
||||
Offsets::VAR_CURRENT_SELECTION_GUID_HI))
|
||||
<< 32) |
|
||||
Game::Read<volatile uint32_t>(
|
||||
Offsets::VAR_CURRENT_SELECTION_GUID_LO);
|
||||
Game::Lua::CallGlobalString(L, "TargetUnit", unit);
|
||||
}
|
||||
char macro[512];
|
||||
bool handled = false;
|
||||
// `macrotext` is raw macro text (the modern default). Run it as text
|
||||
// through the stock chat parser so each line dispatches via
|
||||
// SlashCmdList — that routes /cast etc. through any addon slash hooks
|
||||
// (e.g. SuperCleveRoidMacros' conditional /cast). A name-based RunMacro
|
||||
// (which is what SuperCleveRoidMacros ships) can't interpret text and
|
||||
// would silently resolve nothing.
|
||||
if (ReadModAttr(L, fi, prefix, "macrotext", suffix, macro, sizeof macro)) {
|
||||
Macro::Execute::Text(L, macro);
|
||||
handled = true;
|
||||
}
|
||||
// Deprecated `macro` form: a saved-macro name/index. Prefer an
|
||||
// addon-provided RunMacro (SuperCleveRoidMacros, pfUI, …) to run the
|
||||
// named macro's body; fall back to the stock parser when none exists.
|
||||
else if (ReadModAttr(L, fi, prefix, "macro", suffix, macro, sizeof macro)) {
|
||||
handled = Macro::Execute::Saved(L, macro);
|
||||
}
|
||||
// Restore the target the click swapped away from. `FUN_TARGET_BY_GUID`
|
||||
// validates the GUID resolves to a live unit and bails otherwise, so a
|
||||
// target that despawned mid-macro is dropped cleanly rather than
|
||||
// committed; a zero prior target means "no target" → ClearTarget.
|
||||
if (swapped) {
|
||||
if (prevTarget)
|
||||
reinterpret_cast<void(__fastcall *)(const uint64_t *)>(
|
||||
Offsets::FUN_TARGET_BY_GUID)(&prevTarget);
|
||||
else
|
||||
Game::Lua::CallGlobal(L, "ClearTarget");
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "stop") || Ascii::EqualCI(verb, "stopcasting")) {
|
||||
Game::Lua::CallGlobal(L, "SpellStopCasting");
|
||||
return true;
|
||||
}
|
||||
if (Ascii::EqualCI(verb, "menu") || Ascii::EqualCI(verb, "togglemenu")) {
|
||||
if (!unit) return false;
|
||||
// The unit dropdown is pure FrameXML work (UnitPopup + ToggleDropDown),
|
||||
// so it lives in the !!!ClassicAPI addon; we just pop it at the cursor.
|
||||
Game::Lua::CallGlobalString(L, "ClassicAPI_ToggleUnitMenu", unit);
|
||||
return true;
|
||||
}
|
||||
// Unknown verb → not handled here; the chained handler runs.
|
||||
return false;
|
||||
}
|
||||
|
||||
// The chained OnClick handler. Upvalues: 1 = previous handler (or nil), 2 = the
|
||||
// frame (captured at install time, so we don't depend on the `this` global).
|
||||
// Resolves one verb per click from the (modifier/button) `type` attribute and
|
||||
// performs it — retail's one-action-per-click model. Only chains the previous
|
||||
// handler when we DIDN'T own the click, so a configured `type1` no longer runs
|
||||
// alongside the addon's own OnClick (that double-dispatch was the conflict).
|
||||
int __fastcall OnClick_c(void *L) {
|
||||
const int top = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2)); // frame
|
||||
const int fi = Game::Lua::GetTop(L);
|
||||
bool handled = false;
|
||||
if (Game::Lua::Type(L, fi) == Game::Lua::TYPE_TABLE) {
|
||||
char btn[32] = {0};
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING) {
|
||||
const char *b = Game::Lua::ToString(L, -1);
|
||||
if (b)
|
||||
BoundedCopy(btn, b, sizeof btn);
|
||||
}
|
||||
Game::Lua::SetTop(L, fi); // drop arg1, keep frame
|
||||
|
||||
const char *suffix = ButtonSuffix(btn);
|
||||
char prefix[24];
|
||||
BuildModifierPrefix(L, prefix, sizeof prefix);
|
||||
|
||||
char verb[32];
|
||||
if (ReadModAttr(L, fi, prefix, "type", suffix, verb, sizeof verb)) {
|
||||
char unit[128];
|
||||
const bool haveUnit =
|
||||
ReadModAttr(L, fi, prefix, "unit", suffix, unit, sizeof unit);
|
||||
handled = DispatchVerb(L, fi, prefix, suffix, verb,
|
||||
haveUnit ? unit : nullptr);
|
||||
}
|
||||
}
|
||||
Game::Lua::SetTop(L, top);
|
||||
if (!handled)
|
||||
ChainOld(L); // let the frame's own OnClick handle unconfigured clicks
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Installs OnClick as a closure over (previous handler, frame). Operates on
|
||||
// self at stack index 1. Raises if `self` isn't a Button (OnClick is a Button
|
||||
// script) — callers run it under PCall.
|
||||
//
|
||||
// Self-healing against clobbering: addons re-`SetScript("OnClick", …)` their
|
||||
// own handler (pfUI does it on every raid relayout via UpdateScripts), which
|
||||
// replaces our closure. So we re-wire whenever a `type*` attribute is (re)set,
|
||||
// but only when the current OnClick is a *Lua* handler (a real clobber) or nil
|
||||
// — if it's already a C function it's our own closure, and re-wrapping would
|
||||
// double-chain. `IsCFunction` cleanly distinguishes the two here (addon click
|
||||
// handlers are Lua; the only C-function OnClick we ever see is ours).
|
||||
int __fastcall WireOnClick(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushString(L, "OnClick"); // (self, name)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (self, name, old)
|
||||
if (Game::Lua::GetTop(L) < 3) // defensive
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::SetTop(L, 3); // (self, name, old|nil)
|
||||
if (Game::Lua::Type(L, 3) == Game::Lua::TYPE_FUNCTION &&
|
||||
Game::Lua::IsCFunction(L, 3) != 0)
|
||||
return 0; // already our closure
|
||||
Game::Lua::PushValue(L, 1); // (self, name, old, self)
|
||||
Game::Lua::PushCClosure(L, &OnClick_c, 2); // upvalues (old, self)
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---- OnAttributeChanged script (settable via SetScript, like retail) --------
|
||||
//
|
||||
// Backport of the modern `OnAttributeChanged` widget script: SetAttribute (but
|
||||
// NOT SetAttributeNoHandler) fires the frame's OnAttributeChanged handler. 1.12
|
||||
// has no such script, so we make it SetScript/GetScript/HookScript-able the same
|
||||
// way Tooltip::SetEvents adds OnTooltipSet* — co-hook the frame script-name
|
||||
// resolver (FUN_FRAME_SCRIPT_RESOLVER, the base every frame type delegates to)
|
||||
// and, for the one name the engine doesn't know, hand back an external 8-byte
|
||||
// {handler, context} cell. Frames are immortal in 1.12 (no destroy), so a cell
|
||||
// keyed by the frame's C object pointer never goes stale.
|
||||
//
|
||||
// 1.12 passes script args as globals, not params (see OnClick_c reading arg1),
|
||||
// so we fire with `this` = frame (bound by the invoker), `arg1` = name and
|
||||
// `arg2` = the new value: `function() local name, value = arg1, arg2 … end`.
|
||||
|
||||
struct AttrScriptSlot { uint32_t v[2]; }; // {handler, exec context}
|
||||
std::unordered_map<const void *, AttrScriptSlot> g_attrHandlers;
|
||||
|
||||
uint32_t *AttrSlotFor(const void *frame, bool create) {
|
||||
auto it = g_attrHandlers.find(frame);
|
||||
if (it != g_attrHandlers.end())
|
||||
return it->second.v;
|
||||
if (!create)
|
||||
return nullptr;
|
||||
AttrScriptSlot &s = g_attrHandlers[frame]; // node-based → stable address
|
||||
s.v[0] = 0;
|
||||
s.v[1] = 0;
|
||||
return s.v;
|
||||
}
|
||||
|
||||
// Resolver co-hook. The engine's __thiscall(frame, name) is modelled as
|
||||
// __fastcall with a dummy edx — `name` lands on the first stack slot either way
|
||||
// (same trick as Tooltip::SetEvents). The original returns 0 for a name it
|
||||
// doesn't know; we then claim "OnAttributeChanged".
|
||||
using FrameResolver_t = int(__fastcall *)(void *frame, void *edx, const char *name);
|
||||
FrameResolver_t g_frameResolverOriginal = nullptr;
|
||||
|
||||
int __fastcall FrameResolver_h(void *frame, void *edx, const char *name) {
|
||||
const int slot = g_frameResolverOriginal(frame, edx, name);
|
||||
if (slot != 0) // a real base-frame / subtype script — leave it
|
||||
return slot;
|
||||
if (Ascii::EqualCI(name, "onattributechanged"))
|
||||
return reinterpret_cast<int>(AttrSlotFor(frame, /*create*/ true));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _attrResolverHook{
|
||||
Offsets::FUN_FRAME_SCRIPT_RESOLVER,
|
||||
reinterpret_cast<void *>(&FrameResolver_h),
|
||||
reinterpret_cast<void **>(&g_frameResolverOriginal)};
|
||||
|
||||
// Fire OnAttributeChanged on `frame`, with arg1 = name and arg2 = the new value
|
||||
// (still at stack[3] in DoSet). Recursion-guarded so a handler that itself calls
|
||||
// SetAttribute doesn't loop. The invoker (FUN_FRAME_INVOKE_SCRIPT) binds `this`
|
||||
// and runs the handler under its own protected pcall; it doesn't restore the
|
||||
// stack top, so snapshot/restore around it (as Tooltip::SetEvents does).
|
||||
int g_firingAttr = 0;
|
||||
|
||||
using InvokeAttrScript_t = void(__fastcall *)(uint32_t handler, void *frame);
|
||||
|
||||
void FireAttributeChanged(void *L, void *frame, const char *name) {
|
||||
if (frame == nullptr || g_firingAttr > 0)
|
||||
return;
|
||||
uint32_t *slot = AttrSlotFor(frame, /*create*/ false);
|
||||
if (slot == nullptr || slot[0] == 0)
|
||||
return;
|
||||
|
||||
const int savedTop = Game::Lua::GetTop(L);
|
||||
|
||||
// Snapshot the caller's arg1/arg2 first. SetAttribute can be called from
|
||||
// inside a running script handler that still needs them, and the invoker
|
||||
// below neither saves the globals nor restores the stack top — so we save
|
||||
// (onto the stack, below anything the invoker pushes), overwrite, fire,
|
||||
// then put them back. 1.12 passes handler args as globals, not params.
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1]
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [.., oldArg1, oldArg2]
|
||||
const int oldArg1 = savedTop + 1;
|
||||
const int oldArg2 = savedTop + 2;
|
||||
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::PushString(L, name);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg1 = name
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
if (savedTop >= 3)
|
||||
Game::Lua::PushValue(L, 3); // the new value
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX); // _G.arg2 = value
|
||||
|
||||
++g_firingAttr;
|
||||
reinterpret_cast<InvokeAttrScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], frame);
|
||||
--g_firingAttr;
|
||||
|
||||
// Restore arg1/arg2 from the snapshot (the fired handler may also have
|
||||
// written them; the caller's values win).
|
||||
Game::Lua::PushString(L, "arg1");
|
||||
Game::Lua::PushValue(L, oldArg1);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
|
||||
Game::Lua::PushString(L, "arg2");
|
||||
Game::Lua::PushValue(L, oldArg2);
|
||||
Game::Lua::RawSet(L, Game::Lua::GLOBALS_INDEX);
|
||||
|
||||
Game::Lua::SetTop(L, savedTop);
|
||||
}
|
||||
|
||||
// ---- the methods -----------------------------------------------------------
|
||||
|
||||
// Enables the frame's mouse so it can become the mouse-focus (a bare frame
|
||||
// otherwise never registers as hovered). Operates on self at index 1.
|
||||
void EnableFrameMouse(void *L) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushBoolean(L, 1);
|
||||
CallScript(Offsets::FUN_SCRIPT_FRAME_ENABLEMOUSE, L);
|
||||
}
|
||||
|
||||
int DoSet(void *L, bool fireHandler) { // (self, name, value)
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:SetAttribute(\"name\", value)");
|
||||
return 0;
|
||||
}
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
|
||||
if (PushSub(L, 1, true)) { // [.., sub]
|
||||
const int si = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, lname); // [.., sub, key]
|
||||
Game::Lua::PushValue(L, 3); // [.., sub, key, value] (nil if absent)
|
||||
Game::Lua::RawSet(L, si); // sub[lname] = value
|
||||
Game::Lua::SetTop(L, si - 1); // drop sub
|
||||
}
|
||||
|
||||
const bool isString = Game::Lua::Type(L, 3) == Game::Lua::TYPE_STRING;
|
||||
|
||||
// `unit` drives the mouseover binding: index it by the frame's C object
|
||||
// (the key the mouse-focus poll uses) and enable its mouse so it can be
|
||||
// hovered.
|
||||
if (std::strcmp(lname, "unit") == 0) {
|
||||
void *obj = Game::Lua::ResolveObject(L, 1);
|
||||
if (obj != nullptr) {
|
||||
if (isString) {
|
||||
const char *tok = Game::Lua::ToString(L, 3);
|
||||
const bool isNew = g_unitByFrame.find(obj) == g_unitByFrame.end();
|
||||
g_unitByFrame[obj] = tok ? tok : "";
|
||||
if (isNew)
|
||||
EnableFrameMouse(L);
|
||||
} else {
|
||||
g_unitByFrame.erase(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A `type*` attribute makes the frame a clickable unit button: (re)install
|
||||
// the chained OnClick (PCall-guarded — OnClick is Button-only). WireOnClick
|
||||
// is idempotent and self-healing, so calling it on every `type*` set both
|
||||
// avoids double-chaining and reinstalls after an addon clobbered our
|
||||
// OnClick — set `type*` again after re-SetScript to recover. The handler
|
||||
// reads all the attributes fresh at click time. IsTypeAttr covers every
|
||||
// form the resolver reads (type, typeN, modifier-prefixed), so a middle-
|
||||
// click or modifier-only binding still wires.
|
||||
else if (IsTypeAttr(lname) && isString) {
|
||||
Game::Lua::PushCClosure(L, &WireOnClick, 0);
|
||||
Game::Lua::PushValue(L, 1); // arg = self
|
||||
Game::Lua::PCall(L, 1, 0, 0); // ignore errors (non-Button frame)
|
||||
}
|
||||
|
||||
if (fireHandler)
|
||||
FireAttributeChanged(L, Game::Lua::ResolveObject(L, 1), lname);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int __fastcall Script_SetAttribute(void *L) { return DoSet(L, /*fireHandler*/ true); }
|
||||
int __fastcall Script_SetAttributeNoHandler(void *L) { return DoSet(L, /*fireHandler*/ false); }
|
||||
|
||||
// `cleared = frame:ClearAttribute("name")` — retail (11.2.0) attribute removal;
|
||||
// `cleared` is true iff the attribute was set (and is now gone). Routes through
|
||||
// DoSet's nil-value path so it also drops a `unit` mouseover binding, but does
|
||||
// NOT fire OnAttributeChanged — verified against retail (a live 12.0 test showed
|
||||
// ClearAttribute firing no handler, unlike SetAttribute).
|
||||
int __fastcall Script_ClearAttribute(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:ClearAttribute(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
|
||||
const bool existed = TryPushValue(L, 1, lname); // leaves the value on top if set
|
||||
if (existed)
|
||||
Game::Lua::SetTop(L, Game::Lua::GetTop(L) - 1); // drop the peeked value
|
||||
|
||||
Game::Lua::SetTop(L, 2); // (self, name)
|
||||
Game::Lua::PushNil(L); // (self, name, nil) → value = nil clears the key
|
||||
DoSet(L, /*fireHandler*/ false); // retail ClearAttribute fires no OnAttributeChanged
|
||||
|
||||
Game::Lua::PushBool(L, existed);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAttribute(void *L) {
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: frame:GetAttribute(\"name\")");
|
||||
return 0;
|
||||
}
|
||||
// 3-arg form: (prefix, name, suffix) with wildcard precedence.
|
||||
if (Game::Lua::GetTop(L) >= 4 && Game::Lua::IsString(L, 3)) {
|
||||
const char *pfx = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : "";
|
||||
const char *nm = Game::Lua::ToString(L, 3);
|
||||
const char *sfx = Game::Lua::IsString(L, 4) ? Game::Lua::ToString(L, 4) : "";
|
||||
char key[256];
|
||||
Compose3Lower(key, sizeof key, pfx, nm, sfx);
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "*", nm, sfx);
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, pfx, nm, "*");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "*", nm, "*");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Compose3Lower(key, sizeof key, "", nm, "");
|
||||
if (TryPushValue(L, 1, key)) return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
char lname[256];
|
||||
LowerCopy(lname, Game::Lua::ToString(L, 2), sizeof lname);
|
||||
if (TryPushValue(L, 1, lname))
|
||||
return 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---- registration ----------------------------------------------------------
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
|
||||
{"SetAttribute", &Script_SetAttribute},
|
||||
{"SetAttributeNoHandler", &Script_SetAttributeNoHandler},
|
||||
{"ClearAttribute", &Script_ClearAttribute},
|
||||
{"GetAttribute", &Script_GetAttribute},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
|
||||
g_frameMethods,
|
||||
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Tick::WorldTick::AutoSubscribe _tick{&MouseoverTick};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Frame::Attributes
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user