mirror of
https://github.com/brues-code/ClassicAPI.git
synced 2026-11-13 06:38:57 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d14f0b44c1 | ||
|
|
008282d65a | ||
|
|
9a8c314d69 | ||
|
|
4b1bf16ba5 | ||
|
|
5528fb5fa4 | ||
|
|
7ab32df2aa | ||
|
|
8e1429bfac | ||
|
|
9322382596 | ||
|
|
c3cf9e6f3a | ||
|
|
f5d6046523 | ||
|
|
e1ebf6ecce | ||
|
|
698827e9c5 | ||
|
|
60530e32fc | ||
|
|
3cffb71b20 | ||
|
|
7887237a6e | ||
|
|
98af79137b | ||
|
|
622882725f | ||
|
|
0517bb3753 | ||
|
|
fb17488237 | ||
|
|
b1ee8cc183 | ||
|
|
c08ded6db4 | ||
|
|
640f78b2eb | ||
|
|
a51ec40e52 | ||
|
|
2ce5b007af | ||
|
|
be92d721f0 | ||
|
|
b81f672ad7 | ||
|
|
fdbe66b057 | ||
|
|
bd1bd9f5d8 | ||
|
|
b6698ecc7e | ||
|
|
e123a6facc |
@@ -43,6 +43,7 @@ Util\Rectangle.lua
|
||||
Util\Vector2D.lua
|
||||
Util\Vector3D.lua
|
||||
Util\Vector4D.lua
|
||||
Util\UiMapPoint.lua
|
||||
Util\UIParent.lua
|
||||
Util\AuraDurationModifiers.lua
|
||||
Util\AuraUtil.lua
|
||||
|
||||
@@ -79,6 +79,53 @@ end
|
||||
|
||||
CAPI_MouseoverClearedCompat(GameTooltip)
|
||||
|
||||
-- Restores vanilla calling for ONE frame's script handler. Our DLL hands script
|
||||
-- handlers modern positional arguments (`self`, then the event's own args) on
|
||||
-- top of the `this` / `arg1` globals vanilla uses. That is additive for a
|
||||
-- handler declared `function()`, but a handler declared with a parameter of its
|
||||
-- own -- one written to be BOTH called directly and used as a script -- now
|
||||
-- receives `self` in that parameter instead of the nil it was written for.
|
||||
--
|
||||
-- The wrapper below takes no parameters, so those extra arguments stop here,
|
||||
-- and the original runs with the empty argument list vanilla gave it. The
|
||||
-- globals are untouched by the nesting, so the handler still reads `this` and
|
||||
-- `event` normally.
|
||||
function CAPI_VanillaScriptArgsCompat(frame, script)
|
||||
if not frame then return end
|
||||
local original = frame:GetScript(script)
|
||||
if not original then return end
|
||||
frame:SetScript(script, function()
|
||||
original()
|
||||
end)
|
||||
end
|
||||
|
||||
-- LunaUnitFrames sets `SetupGroupHeader(groupType)` as its group headers'
|
||||
-- OnEvent while also calling it directly with a group name. As a handler it
|
||||
-- expects `groupType` to be nil so it falls back to `this.unitGroup`. Given
|
||||
-- `self` instead, it looks the header up by frame rather than by name, finds
|
||||
-- nothing, and indexes nil (units.lua:358, issue #41). Its raid counterpart
|
||||
-- takes the header frame itself, so `self` is already what it wants -- only the
|
||||
-- group headers need this.
|
||||
if C_AddOns.DoesAddOnExist("LunaUnitFrames") then
|
||||
EventUtil.ContinueOnAddOnLoaded("LunaUnitFrames", function()
|
||||
if not (LunaUF and LunaUF.Units) then return end
|
||||
|
||||
local function FixGroupHeader(unit)
|
||||
CAPI_VanillaScriptArgsCompat(getglobal("LUFHeader" .. (unit or "")),
|
||||
"OnEvent")
|
||||
end
|
||||
|
||||
-- Headers are built on demand, so cover the ones already up and every
|
||||
-- one created later.
|
||||
FixGroupHeader("party")
|
||||
FixGroupHeader("partytarget")
|
||||
FixGroupHeader("partypet")
|
||||
hooksecurefunc(LunaUF.Units, "LoadGroupHeader", function(self, unit)
|
||||
FixGroupHeader(unit)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
if C_AddOns.DoesAddOnExist("Puppeteer") then
|
||||
EventUtil.ContinueOnAddOnLoaded("Puppeteer", function()
|
||||
CAPI_MouseoverClearedCompat(PTEnemyUpdater)
|
||||
|
||||
@@ -14,24 +14,61 @@
|
||||
-- * FindAuraByName(auraName, unit, filter)
|
||||
-- * UnpackAuraData(auraData)
|
||||
--
|
||||
-- Iteration batches through C_UnitAuras.GetAuraSlots + a by-slot fetch
|
||||
-- (continuation tokens), exactly as retail's does: one enumeration per batch,
|
||||
-- then a direct fetch per aura -- linear in the aura count. The by-index getters
|
||||
-- (GetAuraDataByIndex / UnitAura) re-walk the aura array from slot 0 on every
|
||||
-- call, so iterating by index is quadratic; don't go back to it here.
|
||||
--
|
||||
-- Two deliberate differences from that source:
|
||||
-- 1. Retail's ForEachAura/FindAura batch through GetAuraSlots / GetAuraDataBySlot
|
||||
-- (continuation tokens) -- 1.12 has no slot-batching API, so the bodies here
|
||||
-- iterate by index (1, 2, ...) until the accessor returns no aura. Same public
|
||||
-- contract, different internals.
|
||||
-- 2. The non-packed ForEachAura and FindAura source each aura from the positional
|
||||
-- C_UnitAuras.UnitAura (no table), so both are zero-allocation here -- FindAura
|
||||
-- in particular, where the retail version allocates a table per aura scanned.
|
||||
-- 1. Retail's helpers receive GetAuraSlots' return list as varargs
|
||||
-- (ForEachAuraHelper / FindAuraRecurse). Lua 5.0 builds an `arg` table for
|
||||
-- EVERY vararg call, so that shape allocates one table per batch here --
|
||||
-- exactly the per-frame garbage this library exists to avoid. We use
|
||||
-- GetAuraSlots' fill-a-table form instead (a table as its 5th argument,
|
||||
-- a ClassicAPI extension): the slot ids land in a reusable buffer and the
|
||||
-- call returns `continuationToken, n`. No vararg frame anywhere.
|
||||
-- 2. The non-packed ForEachAura and FindAura fetch each aura through the
|
||||
-- positional C_UnitAuras.UnitAuraBySlot (no table), the way retail's
|
||||
-- AuraUtil did before it retired the positional by-slot accessor. So both
|
||||
-- are zero-allocation here -- FindAura in particular, where the current
|
||||
-- retail version allocates a table per aura scanned.
|
||||
--
|
||||
-- The comparator / dispel-priority / border helpers (DefaultAuraCompare,
|
||||
-- UnitFrameDebuffComparator, ShouldDisplayDebuff, SetAuraBorder*, IsPriorityDebuff)
|
||||
-- are NOT ported: they depend on isPriorityAura / auraInstanceID / the aura
|
||||
-- visualization pipeline / secure-call machinery, none of which exist on 1.12.
|
||||
|
||||
local UnitAura = C_UnitAuras.UnitAura
|
||||
local GetAuraSlots = C_UnitAuras.GetAuraSlots
|
||||
local GetAuraDataBySlot = C_UnitAuras.GetAuraDataBySlot
|
||||
local UnitAuraBySlot = C_UnitAuras.UnitAuraBySlot
|
||||
|
||||
AuraUtil = AuraUtil or {};
|
||||
|
||||
-- Slot-id buffers for GetAuraSlots' fill form, one per nesting level: a `func`
|
||||
-- or `predicate` that itself iterates another unit's auras gets its own buffer
|
||||
-- instead of clobbering the outer scan's. Costs one table per nesting level for
|
||||
-- the session (retail's varargs are re-entrant for free; ours must be explicit).
|
||||
-- An error thrown out of `func` skips the release and leaves `depth` one higher
|
||||
-- for the rest of the session -- harmless: later scans use the next buffer up,
|
||||
-- allocated once.
|
||||
local slotBufs = {};
|
||||
local depth = 0;
|
||||
|
||||
local function AcquireSlotBuf()
|
||||
depth = depth + 1;
|
||||
local buf = slotBufs[depth];
|
||||
if not buf then
|
||||
buf = {};
|
||||
slotBufs[depth] = buf;
|
||||
end
|
||||
return buf;
|
||||
end
|
||||
|
||||
local function ReleaseSlotBuf()
|
||||
depth = depth - 1;
|
||||
end
|
||||
|
||||
-- Returns the AuraData table's fields in the classic `UnitAura` positional order,
|
||||
-- for code written against that shape. Retail appends `unpack(auraData.points)`;
|
||||
-- vanilla auras carry no points, so the tuple ends at timeMod (15 values). Field
|
||||
@@ -58,66 +95,56 @@ function AuraUtil.UnpackAuraData(auraData)
|
||||
auraData.timeMod;
|
||||
end
|
||||
|
||||
-- Calls `func` for each aura on `unit` matching `filter`, in index order, until
|
||||
-- `func` returns a truthy value or the auras run out. Non-packed (default) passes
|
||||
-- the 15 positional `UnitAura` values (no table); `usePackedAura` passes the
|
||||
-- AuraData table from GetAuraDataByIndex. `batchSize <= 0` does nothing; any other
|
||||
-- value (or nil) visits every matching aura -- `batchSize` is retail's slot-batch
|
||||
-- hint, not a cap on how many auras are visited.
|
||||
-- Calls `func` for each aura on `unit` matching `filter`, in order, until `func`
|
||||
-- returns a truthy value or the auras run out. Non-packed (default) passes the 15
|
||||
-- positional `UnitAura` values (no table); `usePackedAura` passes the AuraData
|
||||
-- table. `batchSize` is the number of slots fetched per GetAuraSlots call (nil =
|
||||
-- all at once); it never caps how many auras are visited. `batchSize <= 0` does
|
||||
-- nothing.
|
||||
function AuraUtil.ForEachAura(unit, filter, batchSize, func, usePackedAura)
|
||||
if batchSize and batchSize <= 0 then
|
||||
return;
|
||||
end
|
||||
local index = 1;
|
||||
while true do
|
||||
local done;
|
||||
if usePackedAura then
|
||||
local auraData = C_UnitAuras.GetAuraDataByIndex(unit, index, filter);
|
||||
if auraData == nil then
|
||||
local buf = AcquireSlotBuf();
|
||||
local continuationToken, n;
|
||||
repeat
|
||||
continuationToken, n = GetAuraSlots(unit, filter, batchSize, continuationToken, buf);
|
||||
for i = 1, n do
|
||||
local done;
|
||||
if usePackedAura then
|
||||
done = func(GetAuraDataBySlot(unit, buf[i]));
|
||||
else
|
||||
done = func(UnitAuraBySlot(unit, buf[i]));
|
||||
end
|
||||
if done then
|
||||
ReleaseSlotBuf();
|
||||
return;
|
||||
end
|
||||
done = func(auraData);
|
||||
else
|
||||
local name, icon, count, dispelType, duration, expirationTime, source,
|
||||
isStealable, nameplateShowPersonal, spellId, canApplyAura,
|
||||
isBossDebuff, castByPlayer, nameplateShowAll, timeMod =
|
||||
UnitAura(unit, index, filter);
|
||||
if name == nil then
|
||||
return;
|
||||
end
|
||||
done = func(name, icon, count, dispelType, duration, expirationTime,
|
||||
source, isStealable, nameplateShowPersonal, spellId, canApplyAura,
|
||||
isBossDebuff, castByPlayer, nameplateShowAll, timeMod);
|
||||
end
|
||||
if done then
|
||||
return;
|
||||
end
|
||||
index = index + 1;
|
||||
end
|
||||
end
|
||||
|
||||
-- Recursive worker: `...` is the current aura's positional values (nil when the
|
||||
-- index is past the last aura). Tail-recurses to the next index. Lua 5.0 has
|
||||
-- proper tail calls, so depth is constant regardless of aura count.
|
||||
local function FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3, ...)
|
||||
if ... == nil then
|
||||
return nil; -- past the last aura -> not found
|
||||
end
|
||||
if predicate(arg1, arg2, arg3, ...) then
|
||||
return ...;
|
||||
end
|
||||
index = index + 1;
|
||||
return FindAuraRecurse(predicate, unit, filter, index, arg1, arg2, arg3,
|
||||
UnitAura(unit, index, filter));
|
||||
until continuationToken == nil;
|
||||
ReleaseSlotBuf();
|
||||
end
|
||||
|
||||
-- Returns the positional values of the first aura for which `predicate` returns
|
||||
-- truthy, or nil. `predicate` receives up to three caller args followed by the
|
||||
-- aura's positional values: predicate(arg1, arg2, arg3, name, icon, ...). Sources
|
||||
-- auras from the positional accessor, so no table is allocated while scanning.
|
||||
-- auras from the positional by-slot accessor, so no table is allocated while
|
||||
-- scanning.
|
||||
function AuraUtil.FindAura(predicate, unit, filter, arg1, arg2, arg3)
|
||||
return FindAuraRecurse(predicate, unit, filter, 1, arg1, arg2, arg3,
|
||||
UnitAura(unit, 1, filter));
|
||||
local buf = AcquireSlotBuf();
|
||||
local continuationToken, n;
|
||||
repeat
|
||||
continuationToken, n = GetAuraSlots(unit, filter, nil, continuationToken, buf);
|
||||
for i = 1, n do
|
||||
local slot = buf[i];
|
||||
if predicate(arg1, arg2, arg3, UnitAuraBySlot(unit, slot)) then
|
||||
ReleaseSlotBuf();
|
||||
return UnitAuraBySlot(unit, slot);
|
||||
end
|
||||
end
|
||||
until continuationToken == nil;
|
||||
ReleaseSlotBuf();
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- Returns the positional values of the first aura whose name matches `auraName`,
|
||||
|
||||
@@ -92,3 +92,37 @@ function CreateSimpleTextureMarkup(file, width, height, xOffset, yOffset)
|
||||
, yOffset or 0
|
||||
);
|
||||
end
|
||||
|
||||
function CreateAtlasMarkup(atlasName, width, height, offsetX, offsetY)
|
||||
return string.format("|A:%s:%d:%d:%d:%d|a",
|
||||
atlasName
|
||||
, height or 0
|
||||
, width or 0
|
||||
, offsetX or 0
|
||||
, offsetY or 0
|
||||
);
|
||||
end
|
||||
|
||||
-- Raid target markers, published as atlas names.
|
||||
--
|
||||
-- All eight markers live in one 4x4 sheet, so naming a single marker means
|
||||
-- naming a cell inside that sheet — exactly what an atlas is for. Blizzard
|
||||
-- publishes no atlas names for these, so the names here follow the standalone
|
||||
-- marker files that other clients ship. An addon written against those files
|
||||
-- finds the art under the name it already knows.
|
||||
--
|
||||
-- Marker N sits at column (N-1) mod 4 and row floor((N-1) / 4), counting from
|
||||
-- the top left. That is the same cell math the chat marker tokens use.
|
||||
local RAID_TARGET_SHEET = "Interface\\TargetingFrame\\UI-RaidTargetingIcons";
|
||||
local RAID_TARGET_COLUMNS = 4;
|
||||
local RAID_TARGET_CELL = 0.25; -- a 64 pixel cell in a 256 pixel sheet
|
||||
|
||||
for marker = 1, 8 do
|
||||
local index = marker - 1;
|
||||
local left = math.mod(index, RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
|
||||
local top = math.floor(index / RAID_TARGET_COLUMNS) * RAID_TARGET_CELL;
|
||||
C_Texture.RegisterAtlas("UI-RaidTargetingIcon_"..marker, RAID_TARGET_SHEET,
|
||||
64, 64,
|
||||
left, left + RAID_TARGET_CELL,
|
||||
top, top + RAID_TARGET_CELL);
|
||||
end
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Backport of retail's Blizzard_ObjectAPI/UiMapPoint.lua. A UiMapPoint is the
|
||||
-- plain struct the C_Map waypoint calls take and return:
|
||||
-- { uiMapID = number, position = Vector2DMixin, z = number? }
|
||||
local CreateVector2D = CreateVector2D
|
||||
|
||||
UiMapPoint = {};
|
||||
|
||||
function UiMapPoint.CreateFromCoordinates(uiMapID, x, y, z)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(x, y), z = z };
|
||||
end
|
||||
|
||||
function UiMapPoint.CreateFromVector2D(uiMapID, position, z)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = z };
|
||||
end
|
||||
|
||||
function UiMapPoint.CreateFromVector3D(uiMapID, position)
|
||||
return { uiMapID = uiMapID, position = CreateVector2D(position.x, position.y), z = position.z };
|
||||
end
|
||||
@@ -29,12 +29,14 @@ The flagship feature: run modern Lua 5.1 addon code on 1.12's Lua 5.0 VM.
|
||||
|
||||
| Feature | Effect |
|
||||
|---------|--------|
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) as icons in FontStrings, chat, and tooltips. 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. |
|
||||
| Inline textures | Draws inline texture markup (`\|T…\|t`) and atlas markup (`\|A…\|a`) as icons in FontStrings, chat, and tooltips. This covers item and spell icons, raid-target markers, and the coin icons in money strings. `GetStringWidth` and `GetStringHeight` count the icons, so measured width and text wrapping stay correct. Done in pure C++ by hooking the engine's text pipeline — no addon. |
|
||||
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
|
||||
| [Event-driven nameplates](docs/API.md#nameplate) | The modern `C_NamePlate` API, driven by real events. `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` fire as plates appear and vanish, `nameplate1`..`nameplateN` tokens resolve with every `UnitX` function (and fire `UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "nameplateN"`), and `C_NamePlate.GetNamePlateForUnit` / `GetNamePlates` hand back the live frames. |
|
||||
| [Focus target](docs/API.md#focus) | A sticky focus unit. `FocusUnit("target")` pins it and `ClearFocus()` drops it, with `PLAYER_FOCUS_CHANGED` on every change. The `focus` / `focustarget` tokens resolve with every `UnitX` function and fire unit events (`UNIT_HEALTH`, `UNIT_AURA`, … as `arg1 == "focus"`), and the predefined `FOCUSTARGET` / `TARGETFOCUS` keybinds are ready to set. |
|
||||
| [Retail-like `/reload`](docs/API.md#reload-picks-up-new-addons-and-new-files) | `/reload` picks up addon changes made while the game runs. A new folder under `Interface\AddOns\` registers and loads as a normal addon, new files added to an existing addon's TOC load, a newly installed addon's first SavedVariables save survives `/reload`, `##` metadata edits (`## SavedVariables:`, `## Dependencies:`, `## Title:`, …) take effect, and a deleted addon folder drops from the addon list. |
|
||||
| [Multi-flavor & conditional TOC](docs/API.md#conditional-and-multi-flavor-toc-loading) | Loads modern multi-flavor addons that ship one folder. Selects a version-specific TOC (`<Name>_ClassicAPI.toc` or `<Name>_Turtle.toc`) and the matching keybinding file (`Bindings_ClassicAPI.xml` / `Bindings_Turtle.xml`), accepts a comma-separated `## Interface:` version list (compatible when it includes the client version `11200`), and honors per-line `[AllowLoadGameType]` / `[AllowLoadTextLocale]` conditions and `[Family]` / `[Game]` / `[TextLocale]` path variables inside a TOC. |
|
||||
| Settings profiles | A `-config <name>` launch switch. The client reads and writes `WTF\<name>` in place of `WTF\Config.wtf`, so one install can hold several settings profiles. Add it to the launcher shortcut, for example `VanillaFixes.exe -config Config2.wtf`. The name must be a plain filename, with no folder in it. On first use the new file does not exist yet, so the client starts from its defaults and writes the file when you quit. |
|
||||
| [Any texture size](docs/API.md#texture-size-and-shape) | Textures load at any size and any shape. Width and height do not need to be powers of two, and there is no fixed upper limit. The only limit is the maximum texture size of your graphics card. This applies to every texture path: `SetTexture`, sprite sheets, inline textures, tooltips, and masks. The client allocates memory for a large texture only when an addon loads one, so small textures cost nothing extra. |
|
||||
|
||||
## Full API reference
|
||||
|
||||
@@ -48,7 +50,7 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| Namespace | Calls |
|
||||
|-----------|-------|
|
||||
| [Action](docs/API.md#action) | `GetActionInfo` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
|
||||
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnLocalTable`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded`, `C_AddOns.LoadAddOn` |
|
||||
| [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` |
|
||||
@@ -65,9 +67,9 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
|
||||
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
|
||||
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByID`, `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.IsFactionActive`, `C_Reputation.IsFactionActiveByID`, `C_Reputation.IsFactionInactive`, `C_Reputation.SetFactionActive`, `C_Reputation.SetFactionActiveByID`, `C_Reputation.SetFactionInactive`, `C_Reputation.SetFactionInactiveByID`, `C_Reputation.SetSelectedFaction`, `C_Reputation.SetSelectedFactionByID`, `C_Reputation.SetWatchedFactionByID`, `C_Reputation.ToggleFactionAtWar`, `C_Reputation.ToggleFactionAtWarByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
|
||||
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
|
||||
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `region:IsMouseOver`, `region:GetRect`, `region:IsDragging`, `GetMouseFoci`, `frame:SetShown`, `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`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
|
||||
| [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`, `texture:SetAtlas`, `texture:GetAtlas`, `texture:SetSpriteSheetCell`, `texture:SetMask`, `frame:CreateMaskTexture`, `texture:AddMaskTexture`, `texture:RemoveMaskTexture`, `texture:GetNumMaskTextures`, `texture:GetMaskTexture`, `fontstring:SetRotation`, `fontstring:GetRotation`, `editBox:SetCursorPosition`, `editBox:GetCursorPosition`, `editBox:GetUTF8CursorPosition`, `editBox:ClearHighlightText`, `editBox:HasFocus`, `editBox:HasText`, `editBox:SetHighlightColor`, `editBox:GetHighlightColor`, `editBox:ClearHistory`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered`, `frame:GetEffectiveAlpha`, `frame:SetAttribute`, `frame:SetAttributeNoHandler`, `frame:ClearAttribute`, `frame:GetAttribute`, `OnAttributeChanged` (script), `SetModernScriptArgs`, `GetModernScriptArgs`, `PreClick` (script), `PostClick` (script), `GetClickFrame` |
|
||||
| [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` |
|
||||
@@ -83,7 +85,7 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| [Lua](docs/API.md#lua) | `collectgarbage` (5.1 options), `coroutine.create`, `coroutine.resume`, `coroutine.running`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.gsub` (table replacement), `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.maxn`, `table.wipe`, `unpack` (range args) |
|
||||
| [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` |
|
||||
| [Map](docs/API.md#map) | `C_Map.CanSetUserWaypointOnMap`, `C_Map.ClearUserWaypoint`, `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetFallbackWorldMapID`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapArtLayers`, `C_Map.GetMapArtLayerTextures`, `C_Map.GetMapChildrenInfo`, `C_Map.GetMapInfo`, `C_Map.GetMapInfoAtPosition`, `C_Map.GetMapOverlays`, `C_Map.GetMapPosFromWorldPos`, `C_Map.GetMapRectOnMap`, `C_Map.GetMapWorldSize`, `C_Map.GetPlayerMapPosition`, `C_Map.GetUserWaypoint`, `C_Map.GetUserWaypointFromHyperlink`, `C_Map.GetUserWaypointHyperlink`, `C_Map.GetUserWaypointPositionForMap`, `C_Map.GetWorldPosFromMapPos`, `C_Map.HasUserWaypoint`, `C_Map.MapHasArt`, `C_Map.SetUserWaypoint` |
|
||||
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
|
||||
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
|
||||
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
|
||||
@@ -98,13 +100,14 @@ reference in **[docs/API.md](docs/API.md)**.
|
||||
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
|
||||
| [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` |
|
||||
| [Texture](docs/API.md#texture) | `C_Texture.GetAtlasElementID`, `C_Texture.GetAtlasElements`, `C_Texture.GetAtlasExists`, `C_Texture.GetAtlasID`, `C_Texture.GetAtlasInfo`, `C_Texture.RegisterAtlas` |
|
||||
| [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` |
|
||||
| [Tracking](docs/API.md#tracking) | `GetNumTrackingTypes`, `GetTrackingInfo`, `SetTracking` |
|
||||
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
|
||||
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `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`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
|
||||
| [Unit](docs/API.md#unit) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatedBySpell`, `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.GetAuraDataBySlot`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetAuraSlots`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterAuraDurationModifierByTrigger`, `C_UnitAuras.RegisterComboDuration`, `C_UnitAuras.UnitAura`, `C_UnitAuras.UnitAuraBySlot`, `C_UnitAuras.UnitBuff`, `C_UnitAuras.UnitDebuff` |
|
||||
| [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` |
|
||||
|
||||
@@ -213,6 +216,7 @@ when launching with `-console`), not as Lua functions. See the
|
||||
| `UNIT_SPELLCAST_RETICLE_CLEAR` | `"player", "", spellID, spellName, rank` |
|
||||
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
|
||||
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
|
||||
| `USER_WAYPOINT_UPDATED` | *(none)* |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
|
||||
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
|
||||
|
||||
+811
-33
File diff suppressed because it is too large
Load Diff
@@ -556,9 +556,26 @@ Render the modern inline-texture escape (warcraft.wiki.gg/wiki/UI_escape_sequenc
|
||||
|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.
|
||||
Atlas markup is also supported:
|
||||
|
||||
```
|
||||
|A:atlasName:height:width[:offsetX:offsetY]|a
|
||||
```
|
||||
|
||||
It shares every scanner, measure, wrap and placement stage with `|T` — only the
|
||||
payload parser differs. `ParseAtlasIcon` resolves the name through
|
||||
`Texture::Atlas` and fills the SAME `IconDesc`, because an atlas is exactly the
|
||||
"path + normalized sub-rect" that struct already carries for a `|T` span with
|
||||
texcoords. A `height`/`width` of `0` resolves to the atlas's own pixel size at
|
||||
PARSE time (not the line font height `|T` falls back to), which is what keeps the
|
||||
rest of the pipeline marker-agnostic. Tiling atlases draw one quad, so
|
||||
`tilesHorizontally` / `tilesVertically` are not honored here.
|
||||
|
||||
Adding any further marker means touching `IconStartLen`, `FindIconClose`,
|
||||
`InlineSpanLen`, the ghost guard in `FlushLayout` — and, mandatory,
|
||||
`chat/IconFilter.cpp`, or the marker becomes a chat-spoof vector.
|
||||
|
||||
FileDataID paths are Legion+ — 1.12 resolves texture PATH strings only.
|
||||
|
||||
## Confirmed: 1.12 has ZERO inline-texture support
|
||||
|
||||
|
||||
@@ -213,6 +213,8 @@ filenames in Lua 5.0 by behaviour.
|
||||
|--------------|--------------------------|---------------------------------------------------|
|
||||
| `0x006F3140` | `setobj` / TValue copy | 16-byte memcpy |
|
||||
| `0x006F58B0` | `equalobj` | called by `lua_equal` |
|
||||
| `0x006F6050` | `luaD_precall` | frame setup for a call; Lua closure: reads Proto (`+0xc` of the closure), `is_vararg` at Proto `+0x46` → `adjust_varargs`, `numparams` `+0x45`, `maxstacksize` `+0x47`; C closure: reserves 0x14 slots and calls the C function |
|
||||
| `0x006F6200` | `adjust_varargs` | stock 5.0: pads missing fixed params with nil, then `luaH_new(L, nvar, 1)` + `luaH_setnum` per extra arg + sets field `"n"` and pushes the table. So EVERY call to a vararg Lua function allocates one table, whether the body uses `arg` or `...` — keep vararg helpers off per-frame paths |
|
||||
| `0x006F65A0` | `luaD_call` | the actual call dispatcher |
|
||||
| `0x006F6960` | `luaD_pcall` | protected call dispatcher |
|
||||
| `0x006F7340` | `luaD_growstack` (or checkstack) | invoked when stack needs space |
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "Game.h"
|
||||
#include "MinHook.h"
|
||||
#include "Offsets.h"
|
||||
#include "config/FileSwitch.h"
|
||||
#include "event/Custom.h"
|
||||
#include "player/NameCache.h"
|
||||
#include "text/InlineTexture.h"
|
||||
@@ -207,6 +208,14 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
|
||||
// The ONE thing that has to happen this early, and the only module this
|
||||
// file names directly. The engine reads its config file during boot,
|
||||
// several calls before VanillaFixes invokes `Load`, so a `-config`
|
||||
// switch cannot be applied from the normal install path — by then the
|
||||
// file has been read. This is a single 4-byte store, not a hook, so it
|
||||
// carries none of the loader-lock cost described above.
|
||||
Config::FileSwitch::Apply();
|
||||
|
||||
// Install nothing here (loader lock — see the block comment above).
|
||||
//
|
||||
// Under VanillaFixes, `Load` is the ONLY install trigger. VfPatcher.dll
|
||||
|
||||
+318
@@ -474,6 +474,13 @@ enum Offsets {
|
||||
// handler as the 7.0 `SetColorTexture` (which IS exactly the numeric form), so
|
||||
// the clamp and opaque-alpha default come straight from the engine.
|
||||
FUN_SCRIPT_TEXTURE_SET_TEXTURE = 0x0079BB40,
|
||||
// Script_Texture_SetTexCoord — the Lua `texture:SetTexCoord(...)` handler
|
||||
// (entry 17 of the Texture method batch at 0x0087C128). Takes the 4-arg
|
||||
// (left, right, top, bottom) and 8-arg corner forms and writes through to
|
||||
// FUN_SIMPLETEXTURE_SET_TEXCOORD below. texture/Atlas.cpp delegates to this
|
||||
// handler rather than the native setter so an atlas set inherits the engine's
|
||||
// own arg validation and both coordinate forms.
|
||||
FUN_SCRIPT_TEXTURE_SET_TEXCOORD = 0x0079BEB0,
|
||||
// CSimpleTexture::SetTexCoord — `__thiscall(tex, float[4])`. Struct field
|
||||
// order is {top, left, bottom, right} = {v0, u0, v1, u1} (verified from the
|
||||
// SetTexCoord handler 0x0079BEB0's Lua-arg → struct mapping). Natural
|
||||
@@ -890,6 +897,29 @@ enum Offsets {
|
||||
FUN_FIND_CVAR = 0x0063DEC0,
|
||||
OFF_CVAR_VALUE_STR = 0x20,
|
||||
|
||||
// The config filename, as an immediate operand. Boot calls
|
||||
// `FUN_0063D380("Config.wtf")` from `FUN_00402350` at `0x00402370`
|
||||
// (`B9 80 E5 82 00` = `MOV ECX, 0x0082E580`), and that function keeps the
|
||||
// pointer at `VAR_CONFIG_FILENAME_PTR`, which then drives BOTH sides:
|
||||
// load — `0x0063D428`: `MOV ECX,[0x00C4EDD4]; CALL 0x0063D820`
|
||||
// save — `FUN_0063D980` builds `"WTF\"` + the same pointer
|
||||
// So one pointer names the file for reading and writing, and swapping it
|
||||
// redirects both consistently. This constant is the 4-byte OPERAND (the
|
||||
// instruction VA + 1), which `Config::FileSwitch` overwrites with its own
|
||||
// string to implement the `-config` switch.
|
||||
//
|
||||
// It is patched rather than hooked because the call happens during boot,
|
||||
// BEFORE VanillaFixes calls our `Load` export — VF hooks GetCPUFrequency
|
||||
// (`0x0042C060`), reached via `FUN_00641260` from the `FUN_0063A230()`
|
||||
// that `FUN_00402350` runs several calls AFTER the config is already read.
|
||||
// Our DllMain does run in time (VF injects every dlls.txt DLL while the
|
||||
// process is still CREATE_SUSPENDED, resuming only afterwards), and a
|
||||
// 4-byte store needs no trampoline.
|
||||
PATCH_CONFIG_FILENAME_PTR = 0x00402371,
|
||||
// Where `FUN_0063D380` stores that pointer. Read by the loader call above
|
||||
// and by the save.
|
||||
VAR_CONFIG_FILENAME_PTR = 0x00C4EDD4,
|
||||
|
||||
// Internal CVar registrar — what `Script_RegisterCVar` calls after a
|
||||
// `FindCVar` miss (the call at `0x00488B8A`). `__fastcall`; ECX=name,
|
||||
// EDX is a second string slot the script path leaves 0, then six
|
||||
@@ -1497,6 +1527,30 @@ enum Offsets {
|
||||
UNIT_AURA_DEBUFF_COUNT = 16, // slot range 32..47 (harmful)
|
||||
UNIT_AURA_TOTAL = 48,
|
||||
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
|
||||
// Per-slot flag nibble bits. THE MEANING OF 0x02/0x04/0x08 IS NOT THE SAME
|
||||
// ON EVERY SERVER, so read them only through `Aura::Data::IsSlotHarmful`,
|
||||
// which picks the right interpretation.
|
||||
//
|
||||
// Stock (vmangos SpellAuraDefines.h + SetAuraFlag, verified): the bits are
|
||||
// CANCELABLE 0x01, EFF_INDEX_2 0x02, EFF_INDEX_1 0x04, EFF_INDEX_0 0x08 —
|
||||
// one bit per spell effect index that carries an aura, plus cancelable for
|
||||
// a positive aura. NO POLARITY IS ENCODED. Polarity comes from the slot
|
||||
// range instead (0..31 helpful, 32..47 harmful), which is exact because
|
||||
// the slot search is a strict if/else on IsPositive and never crosses over.
|
||||
// Reading 0x08 as "harmful" here would really be reading "effect index 0
|
||||
// has an aura", which is true of nearly every aura — so nearly every BUFF
|
||||
// would come back as a debuff.
|
||||
//
|
||||
// Turtle (tortoise-wow SetAuraFlag, verified) replaces the effect-index
|
||||
// bits with polarity: positive gets 0x04 (+ CANCELABLE unless
|
||||
// SPELL_ATTR_CANT_CANCEL), negative gets 0x08. It has to, because Turtle
|
||||
// also drops the `else` in the slot search so a debuff spills into 0..31
|
||||
// once the 16 harmful slots are full (and sets UNIT_FLAG_AURAS_VISIBLE so
|
||||
// the client still renders it). There the range is unreliable and the
|
||||
// nibble is the real answer — the exact inverse of stock.
|
||||
UNIT_AURA_FLAG_CANCELABLE = 0x01,
|
||||
UNIT_AURA_FLAG_HELPFUL = 0x04, // Turtle only; stock = EFF_INDEX_1
|
||||
UNIT_AURA_FLAG_HARMFUL = 0x08, // Turtle only; stock = EFF_INDEX_0
|
||||
|
||||
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
|
||||
// damage bonus. Offsets from the engine's own UpdateField name/index
|
||||
@@ -1528,6 +1582,15 @@ enum Offsets {
|
||||
// `+0x2B0`. Both `Script_UnitBuff` and `Script_UnitDebuff` call
|
||||
// it to filter their aura iteration; we call it the same way.
|
||||
FUN_SPELL_IS_VISIBLE_AURA = 0x00519860,
|
||||
// The TOOLTIP variant of the gate: what `Script_GameTooltip_SetUnitBuff`
|
||||
// (0x00534AC0) / `SetUnitDebuff` (0x00534E30) apply per slot while
|
||||
// turning a Lua index into a slot. `__fastcall(spellRecord) -> bool`:
|
||||
// Attributes & 0x80 (HIDDEN_CLIENTSIDE) clear, AttributesEx & 0x10000000
|
||||
// (NO_AURA_ICON) clear, and no EffectApplyAuraName in {44, 45, 151}
|
||||
// (the tracking auras). Verified by decompile; differs from the UnitBuff
|
||||
// gate above, so an index into one space is not an index into the other
|
||||
// — `GameTooltip:SetUnitAura` counts with THIS one when it translates.
|
||||
FUN_GAMETOOLTIP_AURA_VISIBLE = 0x00534DF0,
|
||||
|
||||
// Player-only aura timing tables. Unlike UNIT_FIELD_AURA (which is
|
||||
// populated for any unit but has no timing info), these are the
|
||||
@@ -3628,6 +3691,22 @@ enum Offsets {
|
||||
VAR_FACTION_DISPLAY_COUNT = 0x00B73764,
|
||||
VAR_FACTION_VISIBLE_MAX_INDEX = 0x00B73760,
|
||||
|
||||
// Reputation-pane selection. Stored as a FACTION ID, not a list
|
||||
// index: `Script_SetSelectedFaction` (below) takes a 1-based
|
||||
// displayed-list index but immediately resolves it through
|
||||
// `FUN_RESOLVE_FACTION_INDEX` and stores the resulting factionID
|
||||
// here, and `Script_GetSelectedFaction` (0x004D6C00) walks the
|
||||
// displayed list to map it back to a 1-based index (0 when the
|
||||
// stored id isn't currently listed). So selecting by ID is a
|
||||
// direct write — no index round-trip. Verified by decompiling
|
||||
// both accessors.
|
||||
VAR_FACTION_SELECTED_ID = 0x00B73768,
|
||||
|
||||
// `Script_SetSelectedFaction` — the stock `SetSelectedFaction(index)`
|
||||
// global. `int __fastcall(void *L)`, standard Lua C ABI, so it can be
|
||||
// re-registered under a namespace table as-is.
|
||||
FUN_SCRIPT_SET_SELECTED_FACTION = 0x004D6BB0,
|
||||
|
||||
// Faction.dbc — standard 5-DWORD class shape, records pointer at +0x08
|
||||
// and count at +0x0C of the class instance at 0x00C0DD48. Records is an
|
||||
// array of `FactionRec *` indexed directly by factionID (1-based;
|
||||
@@ -3821,6 +3900,20 @@ enum Offsets {
|
||||
// original (see `src/faction/UnitFactionPolyfill.cpp`).
|
||||
FUN_FACTION_SET_AT_WAR = 0x004D5FD0,
|
||||
|
||||
// `FactionGetAtWar(factionID)` — `uint __fastcall(ecx = factionID)`.
|
||||
// Resolves factionID → repListID and returns the `AT_WAR` flag bit as
|
||||
// 0/1; returns 0 when the faction has no rep slot. The read half of
|
||||
// `Script_FactionToggleAtWar`'s read-negate-write pair.
|
||||
FUN_FACTION_GET_AT_WAR = 0x004D61B0,
|
||||
|
||||
// `Script_FactionToggleAtWar` — the stock `FactionToggleAtWar(index)`
|
||||
// global. Resolves its 1-based index through
|
||||
// `FUN_RESOLVE_FACTION_INDEX`, then (for a non-zero factionID) calls
|
||||
// GET_AT_WAR and hands the negated value to SET_AT_WAR. Standard Lua C
|
||||
// ABI, so it re-registers under a namespace table as-is. Toggling by
|
||||
// faction id is that same pair without the index resolve.
|
||||
FUN_SCRIPT_FACTION_TOGGLE_AT_WAR = 0x004D6950,
|
||||
|
||||
// `FactionSetInactive(factionID, newState)` — `__fastcall(ecx = factionID,
|
||||
// edx = char newState)`. Inner setter behind `Script_SetFactionInactive`
|
||||
// (`0x004D69B0`, sets INACTIVE bit) and `Script_SetFactionActive`
|
||||
@@ -3836,6 +3929,22 @@ enum Offsets {
|
||||
// war setter. Polyfilled in `src/faction/UnitFactionPolyfill.cpp`.
|
||||
FUN_FACTION_SET_INACTIVE = 0x004D60F0,
|
||||
|
||||
// `Script_SetFactionInactive` / `Script_SetFactionActive` — the stock
|
||||
// `SetFactionInactive(index)` / `SetFactionActive(index)` globals. Each
|
||||
// resolves its 1-based index through `FUN_RESOLVE_FACTION_INDEX` and,
|
||||
// for a non-zero factionID, calls SET_INACTIVE with 1 / 0 respectively.
|
||||
// Standard Lua C ABI, so both re-register under a namespace table as-is.
|
||||
// Setting by faction id is the same call without the index resolve.
|
||||
FUN_SCRIPT_SET_FACTION_INACTIVE = 0x004D69B0,
|
||||
FUN_SCRIPT_SET_FACTION_ACTIVE = 0x004D6A00,
|
||||
|
||||
// `Script_IsFactionInactive` — the stock `IsFactionInactive(index)`
|
||||
// global (pushes 1 / nil, not a boolean). Its worker at `0x004D6210`
|
||||
// takes the 0-based INDEX rather than a faction id — unlike the at-war
|
||||
// getter — resolving index → factionID → repListID before reading the
|
||||
// INACTIVE bit. Standard Lua C ABI, so it re-registers as-is.
|
||||
FUN_SCRIPT_IS_FACTION_INACTIVE = 0x004D6AF0,
|
||||
|
||||
// SMSG_SET_FACTION_ATWAR handler — `__stdcall(uint32_t opcode,
|
||||
// void *packet)`. Fires when the server force-changes the player's
|
||||
// at-war state on a faction (e.g., a flag the player can't toggle
|
||||
@@ -5700,6 +5809,29 @@ enum Offsets {
|
||||
// (0=SECURE, 1=INSECURE, 2=BANNED). Defaults to `1` (INSECURE)
|
||||
// when the entry isn't in the override table.
|
||||
FUN_ADDON_IS_LOADED = 0x0051E6F0,
|
||||
|
||||
// The per-addon loader — `uint __fastcall(const char *name, char flag,
|
||||
// void *progressCtx)`. Returns non-zero (low byte) once the addon is
|
||||
// loaded, including the early-out when it already was.
|
||||
//
|
||||
// **It sets the entry's loaded byte (`+0x18`) BEFORE running anything**,
|
||||
// then loads LoadWith + required deps (clearing the byte again if a
|
||||
// required dep fails), runs the TOC files, `Bindings.xml` and the
|
||||
// SavedVariables, fires `ADDON_LOADED`, and finally loads the entries
|
||||
// that name this one in their LoadWith. So the byte means "load has
|
||||
// begun", i.e. `loadedOrLoading` — NOT "load has finished". The window
|
||||
// is observable from Lua, because the addon's own files (and every
|
||||
// dependency pulled in during them) execute inside it.
|
||||
// `AddOns::LoadState` co-hooks this to tell the two states apart.
|
||||
FUN_ADDON_LOAD_ONE = 0x0051F240,
|
||||
|
||||
// `Script_LoadAddOn` — the stock `LoadAddOn(index or name)` global.
|
||||
// Pushes two values either way: `(1, nil)` on success, or
|
||||
// `(nil, "REASON")` with a locale-independent reason token
|
||||
// ("DISABLED", "MISSING", … / "UNKNOWN_ERROR") on failure — already the
|
||||
// modern `loaded, value` shape apart from `1` standing in for `true`.
|
||||
// Standard Lua C ABI, so it re-registers under a namespace table as-is.
|
||||
FUN_SCRIPT_LOAD_ADDON = 0x0048E980,
|
||||
FUN_ADDON_CAN_LOAD = 0x0051E780,
|
||||
FUN_ADDON_GET_SECURITY_INDEX = 0x0051E990,
|
||||
|
||||
@@ -7366,6 +7498,192 @@ enum Offsets {
|
||||
FUN_GX_TEXFLAGS_INIT = 0x0058A980,
|
||||
FUN_TEXTURE_GET_RENDERABLE = 0x0044ACF0,
|
||||
|
||||
// --- Texture dimension gates (power-of-two / max size) --------------------
|
||||
// 1.12 rejects a non-power-of-two texture TWICE, and both are plain software
|
||||
// checks — no device caps are consulted for the POT rule at either site:
|
||||
// 1. the allocator/recycler FUN_00448450, which returns NULL outright
|
||||
// (`TEST w,w-1 / JNZ reject` per axis, then `CMP dim,0x400 / JAE`);
|
||||
// 2. the GX create validator FUN_0058AB10, which re-tests POT for every
|
||||
// texture type but 2 (type 0 = 2D, 1 = cubemap — the BLP loader passes
|
||||
// `width == height*6` as the type, and FUN_00542180-style atlas creates
|
||||
// pass 0).
|
||||
// Verified unchanged in both later clients: 3.3.5 FUN_00681d90 and 4.3.4
|
||||
// FUN_00840d30 carry the validator's POT test verbatim, same type-2
|
||||
// exemption. What they DO change is the allocator: its dimension test is
|
||||
// demoted to a pool-eligibility window (`dim - 0x20 < 0x1e1`, i.e. [32,512]),
|
||||
// so an out-of-range dimension skips the recycle pool and allocates fresh
|
||||
// rather than failing. That demotion is only safe there because 3.3.5 clamps
|
||||
// its device-caps max to the scratch size (1024) and lets the validator carry
|
||||
// the bound. 1.12's validator ALSO compares against the per-type caps max
|
||||
// (`caps + 0x60 + type*4`, caps via FUN_0058A230) — but that value is raw
|
||||
// hardware (a 2560x1080 passed it), so in 1.12 the allocator's hardcoded
|
||||
// 0x400 is the ONLY thing protecting the decode scratch described below. It
|
||||
// is not redundant. Do not port the demotion without porting a bound.
|
||||
//
|
||||
// The recycle pool is indexed by log2(dim >> 5) and its hit test compares
|
||||
// only flags/type/miplevels — never width/height — so an NPOT texture must
|
||||
// never reach it or it recycles a bucket of unrelated dimensions. Any patch
|
||||
// relaxing the POT rule therefore has to route the failures through a bound
|
||||
// check and THEN to the pool-skip site — never simply delete them, and never
|
||||
// straight to pool-skip (see CODE ORDER MATTERS below).
|
||||
FUN_GX_TEXTURE_ALLOC = 0x00448450,
|
||||
PATCH_TEXALLOC_POT_W_JMP = 0x004484AE, // 0F 85 rel32 -> VA_TEXALLOC_REJECT
|
||||
PATCH_TEXALLOC_POT_H_JMP = 0x004484B9, // 0F 85 rel32 -> VA_TEXALLOC_REJECT
|
||||
// `CMP EBX,imm32` (81 FB) / `CMP EAX,imm32` (3D) — the size bound per axis,
|
||||
// 0x400 stock. VanillaHelpers.dll leaves these and flips the jumps below
|
||||
// from 0F 83 (JAE) to 0F 87 (JA) so exactly-1024 passes.
|
||||
PATCH_TEXALLOC_SIZE_W_IMM = 0x004484C1,
|
||||
PATCH_TEXALLOC_SIZE_H_IMM = 0x004484CC,
|
||||
PATCH_TEXALLOC_SIZE_W_JMP = 0x004484C5, // 0F 83 rel32 -> VA_TEXALLOC_REJECT
|
||||
PATCH_TEXALLOC_SIZE_H_JMP = 0x004484D0, // 0F 83 rel32 -> VA_TEXALLOC_REJECT
|
||||
VA_TEXALLOC_POOL_SKIP = 0x0044864B, // "skip the pool, create fresh"
|
||||
VA_TEXALLOC_REJECT = 0x00448665, // XOR EAX,EAX; RET 0x14
|
||||
// CODE ORDER MATTERS: the POT jumps sit ABOVE the size CMPs, so a POT
|
||||
// failure retargeted straight to VA_TEXALLOC_POOL_SKIP jumps OVER the size
|
||||
// bound — an NPOT 3000x3000 would reach the decoder unbounded. Any NPOT
|
||||
// relaxation must route POT failures through a bound check first
|
||||
// (Texture::DimensionGate does it with a code cave). At the jump sites EBX =
|
||||
// width and EAX = height, EDI = 0 and the 9-dword descriptor at [EBP-0x2C]
|
||||
// is complete, so the pool-skip tail is enterable from any of them.
|
||||
//
|
||||
// Recycle-pool index arithmetic, `index = h_log + w_log * SIDE`, SIDE = 5
|
||||
// for the stock 5x5 (32..512) table. VanillaHelpers grows the table to 6x6
|
||||
// (its init-count immediate below reads 36 instead of 25) but leaves BOTH of
|
||||
// these at stride 5, so a log of 5 on either axis collides: 32x1024 shares a
|
||||
// bucket with 64x32, 64x1024 with 128x32 — and the bucket match never
|
||||
// compares w/h, so the wrong-size texture is silently recycled. Fix = rewrite
|
||||
// each 5-byte pair to `IMUL r,r,6; ADD r,other` (6B F6 06 03 F3 at the
|
||||
// allocator, 6B DB 06 03 D8 at free), which preserves whichever operand
|
||||
// stock treats as the row and only changes the stride. Stock's alloc and
|
||||
// free agree with each other, so the fixed pair does too.
|
||||
PATCH_TEXPOOL_INDEX_STRIDE_ALLOC = 0x00448529, // 8D 04 B3 03 F0 LEA EAX,[EBX+ESI*4]; ADD ESI,EAX
|
||||
PATCH_TEXPOOL_INDEX_STRIDE_FREE = 0x004487AD, // 8D 0C 98 03 D9 LEA ECX,[EAX+EBX*4]; ADD EBX,ECX
|
||||
PATCH_TEXPOOL_INIT_COUNT = 0x00447C45, // B9 imm32 — bucket count: 25 stock, 36 VanillaHelpers
|
||||
// The stock table lives at 0x00B05BE8 (25 x 12 bytes) and ends EXACTLY at
|
||||
// VAR_TEXTURE_DECODE_SCRATCH — an out-of-range bucket index reads the scratch
|
||||
// pointer as a list head. So the size gate may never admit a dimension the
|
||||
// pool can't index unless that dimension is also routed around the pool.
|
||||
FUN_GX_TEXTURE_VALIDATE = 0x0058AB10,
|
||||
// The recycle-pool FREE path — the mirror of the allocator, and the other
|
||||
// half the size relaxation has to cover. It files a freed texture into a
|
||||
// bucket indexed by `tz(w>>5) + tz(h>>5)*stride` (tz = power-of-two factor),
|
||||
// gated ONLY by `w>31 && h>31 && mips<8` — there is NO upper bound. Stock
|
||||
// never overran because the allocator refused anything >=1024, so an
|
||||
// oversized texture never existed to be freed; the cave breaks that
|
||||
// invariant, so a 2048x2048 (tz 6 per axis) files at bucket 36-42, past the
|
||||
// 36-entry table, and writes through a garbage list head (verified crash:
|
||||
// MOV [EAX],ECX at 0x004487D9, EAX = out-of-table pointer + 4). Fix
|
||||
// (Texture::DimensionGate): route a texture larger than the pool can index to
|
||||
// the engine's own non-pooled free FUN_TEXTURE_FREE_RAW — exactly what the
|
||||
// guard's else-branch already does for too-small / too-many-mip textures.
|
||||
FUN_TEXTURE_POOL_FREE = 0x00448670, // __fastcall(tex); the pool free path
|
||||
FUN_TEXTURE_FREE_RAW = 0x0058AD60, // __fastcall(tex); non-pooled free (device release)
|
||||
FUN_TEXTURE_GET_DESC = 0x0058ADA0, // __fastcall(tex, out*) — fills width@+4, height@+8
|
||||
OFF_TEXDESC_WIDTH = 0x04,
|
||||
OFF_TEXDESC_HEIGHT = 0x08,
|
||||
// THE ACTUAL SIZE CEILING. FUN_00448BD0 runs once at startup and allocates a
|
||||
// SINGLE global decode scratch buffer, sized `FUN_005a4b80(2, 0x200, 0x200)`
|
||||
// — one 512x512 texture's worth. Every loader points the decoder at it
|
||||
// (`texObj + 0x120`; see FUN_0044A260's param_1==0 branch and FUN_0044A560),
|
||||
// and the decoder writes rows with no bounds check, so a texture larger than
|
||||
// this buffer overruns the heap. The allocator's `< 0x400` gate is therefore
|
||||
// NOT redundant with the validator's device-caps max — the caps max runs to
|
||||
// thousands, this buffer does not. VanillaHelpers.dll patches both 0x200
|
||||
// immediates to 0x400 in the same function it relaxes the size limit in;
|
||||
// the pairing is mandatory, not incidental. Verified by crash: a 2560x1080
|
||||
// TGA faulted in the decoder's pixel writer at 0x004492E5 (ACCESS_VIOLATION,
|
||||
// write) 2.6x past a 1024x1024 buffer.
|
||||
// Single caller (boot, 0x00402862); the matching free + NULL is the exit
|
||||
// teardown FUN_00448D30. No mid-session re-init path exists, and the five
|
||||
// readers all re-read the global at use time, so the pointer can be swapped
|
||||
// for a larger engine-allocated buffer without a hook.
|
||||
FUN_TEXTURE_DECODE_SCRATCH_ALLOC = 0x00448BD0,
|
||||
VAR_TEXTURE_DECODE_SCRATCH = 0x00B05D14, // void* — the buffer itself
|
||||
// The two size immediates the boot alloc feeds FUN_GX_FORMAT_IMAGE_BYTES:
|
||||
// `MOV EDX,imm32` is width (BA imm32), `PUSH imm32` is height (68 imm32).
|
||||
// Read them back (+1) to learn what the buffer was ACTUALLY sized to on this
|
||||
// DLL stack — 0x200 stock, 0x400 once VanillaHelpers has run.
|
||||
PATCH_TEXDECODE_SCRATCH_DIM_W = 0x00448BE1,
|
||||
PATCH_TEXDECODE_SCRATCH_DIM_H = 0x00448BDC,
|
||||
// __fastcall(ecx = format, edx = width, [stack] = height) -> total bytes for
|
||||
// the image plus its mip chain plus the per-level row-pointer table.
|
||||
FUN_GX_FORMAT_IMAGE_BYTES = 0x005A4B80,
|
||||
// The engine's own decode-buffer constructor: `__fastcall(ecx = format,
|
||||
// edx = width, [stack] height, file, line) -> void*` (RET 0xC). Exactly
|
||||
// `SMemAlloc(FUN_GX_FORMAT_IMAGE_BYTES(fmt,w,h), file, line, 0)` followed by
|
||||
// the row-pointer-table init FUN_005A4BB0 performs per decode. Both decoders
|
||||
// call it (via the small-texture recycle front FUN_0044AF90, which falls
|
||||
// through to it above 256px) when the scratch is NULL, so a buffer it
|
||||
// returns is precisely what the scratch slot expects — and SMemAlloc-backed,
|
||||
// so the exit teardown's SMemFree on it is valid.
|
||||
FUN_TEXTURE_DECODE_BUFFER_ALLOC = 0x005A4AF0,
|
||||
// Device-relative: the caps block at +0x23C (what FUN_0058A230 returns) plus
|
||||
// the type-0 (2D) max-texture-dimension slot at +0x60 — the exact field the
|
||||
// create validator FUN_0058AB10 compares width and height against
|
||||
// (`CMP EDI,[EAX + ESI*4 + 0x60]`, ESI = type). This is the GPU's own limit,
|
||||
// and the only size ceiling the dimension gate enforces: reading it before
|
||||
// growing the decode scratch means we never allocate for a texture the
|
||||
// validator would refuse a moment later. Raw hardware max on modern cards
|
||||
// (a 2560x1080 passed it) — 3.3.5 clamps the equivalent to 1024, 1.12 does
|
||||
// not.
|
||||
OFF_GXDEV_CAPS_MAX_TEX_DIM = 0x29C,
|
||||
// --- Async texture I/O: the force-load path and its 512 KiB trap ----------
|
||||
// FUN_TEXTURE_GET_RENDERABLE(hTex, force=1) calls this when the GxTex does
|
||||
// not exist yet — i.e. for every UI texture on first draw. `__fastcall(ecx =
|
||||
// HTEXTURE)`. If the pending read at [hTex + OFF_HTEXTURE_ASYNC_REQUEST] was
|
||||
// never dispatched (request byte +0x1D == 0: it didn't fit the 2 MiB shared
|
||||
// buffer at 0x00905BE4, or the buffer was busy), it unlinks the request,
|
||||
// points it at a STATIC fallback buffer, dispatches, and blocks until the
|
||||
// read completes. There is no size check. The fallback spans
|
||||
// 0x00885804..0x00905804 — 0x80000 bytes, bounded exactly by the first live
|
||||
// global after it — so a file larger than 512 KiB read through this path
|
||||
// overruns the async list heads, the recycle-pool table and the shared
|
||||
// buffer. That is VanillaHelpers' "crashes while loading textures bigger
|
||||
// than 2 MiB"; its 32 MiB shared buffer makes the path rarer, not safe. The
|
||||
// stock size gate hid the bug by keeping every BLP under ~1.4 MB. Guard
|
||||
// (Texture::DimensionGate): refuse to force a request larger than the
|
||||
// fallback — the texture stays blank instead of overrunning. Growing the
|
||||
// fallback to fit was tried and reverted: the read runs on the engine's
|
||||
// async reader thread (no crash handler), and a read into a right-sized heap
|
||||
// buffer faulted where the same read into the oversized shared buffer does
|
||||
// not — not yet isolated. TGA reads are synchronous and never on this path.
|
||||
FUN_TEXTURE_FORCE_LOAD = 0x0044AD50,
|
||||
OFF_HTEXTURE_ASYNC_REQUEST = 0x138, // async request node, or 0
|
||||
OFF_ASYNCREQ_SIZE = 0x08, // file size (FUN_006487F0 result)
|
||||
OFF_ASYNCREQ_IN_SHARED = 0x1D, // byte: 1 once assigned a buffer and dispatched
|
||||
VAR_ASYNC_FALLBACK_BUFFER = 0x00885804,
|
||||
ASYNC_FALLBACK_BUFFER_SIZE = 0x80000,
|
||||
// --- BLP decompresses BEFORE it allocates -----------------------------------
|
||||
// The .blp loader FUN_0044A560 copies the shared scratch pointer into
|
||||
// texObj+0x120, calls this decompressor to write the decoded image THROUGH
|
||||
// THAT COPY, and only then calls the allocator FUN_00448450. TGA does it the
|
||||
// other way round (allocate, decode later at upload), which is why the
|
||||
// allocator's gate protects TGA but never sees a BLP's dimensions in time; a
|
||||
// 2048x2048 BLP has always overrun the scratch on this client. The other
|
||||
// caller, the IMG path FUN_00449840 (completion FUN_004497F0), does NOT use
|
||||
// the scratch: it allocates a private buffer sized for the image through
|
||||
// FUN_0044AF90, stores it in the same slot, and its release frees whatever
|
||||
// the slot holds — so a hook here must act only when the slot equals
|
||||
// VAR_TEXTURE_DECODE_SCRATCH, and must repoint that slot after growing. (The BLP pre-scale FUN_00448B60 halves against caps+0x60, the
|
||||
// raw hardware max, so it never fires on a modern GPU and protects nothing.)
|
||||
// `__thiscall(reader, format, int *scratchSlot, mipBase, flag)`, RET 0x10;
|
||||
// the reader carries the level-0 dimensions, `>> mipBase` is the level
|
||||
// actually written. Both callers are completion callbacks that the
|
||||
// main-thread pump FUN_00443E70 runs, so a pre-hook here is main-thread and
|
||||
// never inside another decode. Encoding 3 (uncompressed) only writes the row
|
||||
// table into the scratch and points the rows at the file buffer; DXT and
|
||||
// palettized decompress into it — the crash case.
|
||||
FUN_BLP_DECOMPRESS = 0x005A8430,
|
||||
OFF_BLPREADER_WIDTH = 0x10,
|
||||
OFF_BLPREADER_HEIGHT = 0x14,
|
||||
// The `NEG EAX; SBB EAX,EAX` (F7 D8 1B C0) pair inside the validator's isPOT
|
||||
// idiom `(x & (x-1)) -> NEG/SBB -> INC -> TEST/JZ`, once per axis. The two
|
||||
// axes share the final TEST/JZ with the type-2 branch, so the arithmetic is
|
||||
// the surgical patch point — neutering the shared JZ would also disable the
|
||||
// type-2 flags check.
|
||||
PATCH_TEXVALIDATE_POT_W = 0x0058AB7C,
|
||||
PATCH_TEXVALIDATE_POT_H = 0x0058AB8D,
|
||||
|
||||
// --- Inline-texture positioning (slice 1: measure/emit integration) --------
|
||||
// The 1.12 text pipeline builds a layout as a list of render "nodes" (one
|
||||
// per wrapped line); each node owns per-font-page glyph vertex batches and a
|
||||
|
||||
+24
-10
@@ -26,6 +26,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "addons/LoadState.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -251,19 +252,23 @@ int __fastcall Script_GetAddOnSecurity(void *L) {
|
||||
|
||||
// `C_AddOns.IsAddOnLoaded(indexOrName)` → `(loadedOrLoading, loaded)`.
|
||||
//
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded" —
|
||||
// matters for LoD addons whose load is split across multiple
|
||||
// `LoadAddOn` callbacks. Vanilla 1.12's `FUN_0051F240` is fully
|
||||
// synchronous: the loaded byte at `entry+0x18` flips from 0 → 1
|
||||
// inside a single call, so the "loading" state is never observable
|
||||
// from Lua. We return the same boolean twice — `loadedOrLoading`
|
||||
// and `loaded` are identical here.
|
||||
// Modern WoW distinguishes "load-in-progress" from "fully loaded", and
|
||||
// the two really are distinguishable here: the engine's loaded byte
|
||||
// (`entry+0x18`) is set at the START of a load, before the addon's files
|
||||
// run, so the byte alone answers `loadedOrLoading`, not `loaded`. The
|
||||
// window is reachable from Lua — an addon's own file-scope code, and any
|
||||
// dependency pulled in during it, run while the byte already reads set.
|
||||
//
|
||||
// So `loadedOrLoading` is the engine's byte, and `loaded` additionally
|
||||
// requires the addon not to be mid-load, which `AddOns::LoadState`
|
||||
// tracks from the loader itself.
|
||||
//
|
||||
// Resolves both numeric indices (via `FUN_ADDON_GET_BY_INDEX`) and
|
||||
// string names. `IS_LOADED` accepts an entry pointer as its name arg
|
||||
// because the entry's first 12 bytes are the inline NUL-terminated
|
||||
// directory name (same as the other per-field accessors), so we can
|
||||
// feed it either an entry or a Lua-side string transparently.
|
||||
// feed it either an entry or a Lua-side string transparently — and that
|
||||
// same pointer is the name `LoadState` matches on.
|
||||
int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
const uint8_t *entry = ResolveAddOnName(L);
|
||||
if (entry == nullptr) {
|
||||
@@ -273,8 +278,11 @@ int __fastcall Script_IsAddOnLoaded(void *L) {
|
||||
}
|
||||
auto isLoaded = reinterpret_cast<EntryByteFn_t>(
|
||||
Offsets::FUN_ADDON_IS_LOADED);
|
||||
const bool loaded = isLoaded(entry) != 0;
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
const bool loadedOrLoading = isLoaded(entry) != 0;
|
||||
const bool loaded =
|
||||
loadedOrLoading &&
|
||||
!AddOns::LoadState::IsLoading(reinterpret_cast<const char *>(entry));
|
||||
Game::Lua::PushBool(L, loadedOrLoading);
|
||||
Game::Lua::PushBool(L, loaded);
|
||||
return 2;
|
||||
}
|
||||
@@ -385,6 +393,12 @@ static void RegisterLuaFunctions() {
|
||||
&Script_DoesAddOnExist);
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
|
||||
&Script_IsAddOnLoaded);
|
||||
// The stock global, mirrored into the namespace — the engine's own
|
||||
// handler already has the Lua C ABI and the modern return shape.
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_AddOns", "LoadAddOn",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_LOAD_ADDON)));
|
||||
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
|
||||
&Script_GetAddOnOptionalDependencies);
|
||||
Game::Lua::RegisterIntegerEnum(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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/>.
|
||||
|
||||
// Tracks in-progress addon loads so `C_AddOns.IsAddOnLoaded` can report
|
||||
// `loadedOrLoading` and `loaded` separately. See `LoadState.h` for why the
|
||||
// engine's own byte can't answer both.
|
||||
//
|
||||
// Loads nest — an addon's dependencies load inside its own call — so this
|
||||
// keeps a stack rather than a single name. The name is copied rather than
|
||||
// stored by pointer: `LoadAddOn` passes a Lua string, which does not
|
||||
// outlive the call.
|
||||
|
||||
#include "addons/LoadState.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace AddOns::LoadState {
|
||||
|
||||
namespace {
|
||||
|
||||
// Dependency chains are shallow; anything deeper simply isn't tracked
|
||||
// (the depth counter still balances, so the stack can't desync).
|
||||
constexpr int kMaxDepth = 16;
|
||||
constexpr int kMaxName = 64;
|
||||
|
||||
char g_stack[kMaxDepth][kMaxName];
|
||||
int g_depth = 0;
|
||||
|
||||
bool EqualsIgnoreCase(const char *a, const char *b) {
|
||||
for (; *a != '\0' && *b != '\0'; ++a, ++b) {
|
||||
char ca = *a;
|
||||
char cb = *b;
|
||||
if (ca >= 'A' && ca <= 'Z')
|
||||
ca = static_cast<char>(ca - 'A' + 'a');
|
||||
if (cb >= 'A' && cb <= 'Z')
|
||||
cb = static_cast<char>(cb - 'A' + 'a');
|
||||
if (ca != cb)
|
||||
return false;
|
||||
}
|
||||
return *a == *b;
|
||||
}
|
||||
|
||||
using LoadOne_t = unsigned int(__fastcall *)(const char *name, char flag,
|
||||
void *progressCtx);
|
||||
LoadOne_t s_loadOne_o = nullptr;
|
||||
|
||||
unsigned int __fastcall LoadOne_h(const char *name, char flag, void *progressCtx) {
|
||||
const int slot = g_depth;
|
||||
if (slot >= 0 && slot < kMaxDepth) {
|
||||
if (name != nullptr) {
|
||||
std::strncpy(g_stack[slot], name, kMaxName - 1);
|
||||
g_stack[slot][kMaxName - 1] = '\0';
|
||||
} else {
|
||||
g_stack[slot][0] = '\0';
|
||||
}
|
||||
}
|
||||
++g_depth;
|
||||
|
||||
const unsigned int result = s_loadOne_o(name, flag, progressCtx);
|
||||
|
||||
--g_depth;
|
||||
if (g_depth < 0)
|
||||
g_depth = 0; // defensive: never let the stack run negative
|
||||
return result;
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _hookLoadOne{
|
||||
Offsets::FUN_ADDON_LOAD_ONE,
|
||||
reinterpret_cast<void *>(&LoadOne_h),
|
||||
reinterpret_cast<void **>(&s_loadOne_o)};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool IsLoading(const char *name) {
|
||||
if (name == nullptr || *name == '\0')
|
||||
return false;
|
||||
const int depth = (g_depth < kMaxDepth) ? g_depth : kMaxDepth;
|
||||
for (int i = 0; i < depth; ++i)
|
||||
if (EqualsIgnoreCase(g_stack[i], name))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace AddOns::LoadState
|
||||
@@ -0,0 +1,33 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
// `AddOns::LoadState` — tracks which addons are mid-load.
|
||||
//
|
||||
// The engine's own "loaded" byte (`entry+0x18`) is set at the START of a
|
||||
// load, before the addon's files run, so on its own it can't tell
|
||||
// "loading" from "loaded". That distinction is observable: an addon's
|
||||
// file-scope code, and every dependency pulled in during it, execute
|
||||
// while the byte already reads set.
|
||||
//
|
||||
// A co-hook on the per-addon loader keeps a stack of the names currently
|
||||
// being loaded, which is the missing half of
|
||||
// `C_AddOns.IsAddOnLoaded`'s `(loadedOrLoading, loaded)` pair.
|
||||
namespace AddOns::LoadState {
|
||||
|
||||
// True while `name` is inside the engine's per-addon load call. Matching
|
||||
// is case-insensitive, as addon-name matching is everywhere else.
|
||||
bool IsLoading(const char *name);
|
||||
|
||||
} // namespace AddOns::LoadState
|
||||
+135
-15
@@ -21,7 +21,8 @@
|
||||
// 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` /
|
||||
// Honored: `HELPFUL` / `HARMFUL` (the aura's real polarity, read however this
|
||||
// server encodes it — see `Aura::Data::IsSlotHarmful`), `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/
|
||||
@@ -352,22 +353,135 @@ int __fastcall Script_GetPlayerAuraBySpellID(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Iterates one slot range and pushes AuraData tables into `outer` at
|
||||
// sequential keys starting from `nextKey`. Updates `nextKey` so a
|
||||
// follow-up call can append to the same outer table.
|
||||
// C_UnitAuras.GetAuraSlots(unit [, filter [, maxSlots [, continuationToken]]])
|
||||
// -> continuationToken, slot1, slot2, ...
|
||||
//
|
||||
// Enumerates the opaque slot ids of every aura on `unit` matching `filter`, in
|
||||
// the order the by-index getters visit them, `maxSlots` at a time (nil / 0 =
|
||||
// all). The first return is the token to pass back for the next batch, or nil
|
||||
// when this batch reached the end — retail's batching contract, and what makes
|
||||
// AuraUtil.ForEachAura linear: one enumeration per batch plus a direct by-slot
|
||||
// fetch per aura, instead of a fresh from-slot-0 walk per index. The token is
|
||||
// the 1-based ordinal to resume at; opaque to callers. Encoding of the slot ids
|
||||
// is documented at `Data::OPAQUE_STRIDE`.
|
||||
int __fastcall Script_GetAuraSlots(void *L) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
const char *filterStr = ArgOptString(L, 2);
|
||||
const int maxSlots = ArgInt(L, 3);
|
||||
const int token = ArgInt(L, 4);
|
||||
// ClassicAPI extension: a table as the 5th argument selects FILL mode —
|
||||
// the slot ids are written into it (t[1..n], stale tail cleared, t.n = n)
|
||||
// and the call returns `continuationToken, n` instead of the vararg list.
|
||||
// Lua 5.0 builds an `arg` table for every vararg CALL, so a Lua helper
|
||||
// that receives the retail multi-return allocates one table per batch;
|
||||
// filling the caller's table here is what keeps a per-frame scan
|
||||
// allocation-free (AuraUtil and pfUI's ScanAuraSlots use this form).
|
||||
const bool fill = Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE;
|
||||
|
||||
int slots[Data::OPAQUE_SLOTS_MAX];
|
||||
int total = 0;
|
||||
if (unitToken != nullptr) {
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
|
||||
const ParsedFilter pf = ParseFilters(filterStr);
|
||||
total = Data::CollectSlots(unit, guid, RangeFilter(pf), pf.ToMatch(), slots,
|
||||
Data::OPAQUE_SLOTS_MAX);
|
||||
}
|
||||
int start = (token > 0) ? token - 1 : 0;
|
||||
if (start > total)
|
||||
start = total;
|
||||
int n = total - start;
|
||||
if (maxSlots > 0 && n > maxSlots)
|
||||
n = maxSlots;
|
||||
const bool more = start + n < total;
|
||||
|
||||
if (fill) {
|
||||
for (int i = 0; i < n; ++i) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
|
||||
Game::Lua::RawSet(L, 5);
|
||||
}
|
||||
// Clear what a previous, longer fill left past n. The array is dense
|
||||
// by construction, so stop at the first hole.
|
||||
for (int k = n + 1;; ++k) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(k));
|
||||
Game::Lua::RawGet(L, 5);
|
||||
const bool hole = Game::Lua::Type(L, -1) == Game::Lua::TYPE_NIL;
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (hole)
|
||||
break;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(k));
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::RawSet(L, 5);
|
||||
}
|
||||
Game::Lua::PushString(L, "n");
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
Game::Lua::RawSet(L, 5);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (more)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(n));
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Vararg form. Everything below only pushes, so the args can go; a full
|
||||
// batch is up to OPAQUE_SLOTS_MAX + 1 values, past Lua's guaranteed C-call
|
||||
// headroom.
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (Game::Lua::CheckStack(L, n + 1) == 0) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
if (more)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(start + n + 1));
|
||||
else
|
||||
Game::Lua::PushNil(L);
|
||||
for (int i = 0; i < n; ++i)
|
||||
Game::Lua::PushNumber(L, static_cast<double>(slots[start + i]));
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
// Shared body of GetAuraDataBySlot (table) / UnitAuraBySlot (positional):
|
||||
// pushes the aura an opaque slot id from GetAuraSlots names, or a single nil
|
||||
// for an id that no longer names one.
|
||||
int PushAuraBySlot(void *L, Data::Emit emit) {
|
||||
const char *unitToken = ArgUnit(L, 1);
|
||||
if (unitToken == nullptr || !Game::Lua::IsNumber(L, 2)) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
const int slot = ArgInt(L, 2);
|
||||
const uint8_t *unit = ResolveUnit(unitToken);
|
||||
const uint64_t guid = (unit == nullptr) ? GuidForOutOfRange(unitToken) : 0;
|
||||
if (Data::PushBySlot(L, unit, guid, slot, emit))
|
||||
return (emit == Data::Emit::Positional) ? 15 : 1;
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// C_UnitAuras.GetAuraDataBySlot(unit, slot) -> AuraData | nil
|
||||
int __fastcall Script_GetAuraDataBySlot(void *L) {
|
||||
return PushAuraBySlot(L, Data::Emit::Table);
|
||||
}
|
||||
|
||||
// C_UnitAuras.UnitAuraBySlot(unit, slot) -> the 15 positional UnitAura values | nil
|
||||
// Zero-allocation sibling of GetAuraDataBySlot, namespaced under C_UnitAuras
|
||||
// like C_UnitAuras.UnitAura. What AuraUtil's non-packed iteration reads.
|
||||
int __fastcall Script_UnitAuraBySlot(void *L) {
|
||||
return PushAuraBySlot(L, Data::Emit::Positional);
|
||||
}
|
||||
|
||||
// Iterates the auras of one polarity (in `SlotInFilterOrder`, the same order
|
||||
// the by-index getters use) and pushes AuraData tables into `outer` at
|
||||
// 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, Data::Match match) {
|
||||
const int start = (filter == Data::Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Data::Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!Data::IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (!Data::MatchesAura(match, Data::IsPlayerCast(unit, slot),
|
||||
Data::ReadSpellID(unit, slot)))
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = Data::SlotInFilterOrder(filter, i);
|
||||
if (!Data::SlotMatchesFilter(unit, slot, filter, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
Data::Push(L, unit, slot);
|
||||
@@ -497,6 +611,12 @@ static void RegisterLuaFunctions() {
|
||||
&Script_UnitBuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitDebuff",
|
||||
&Script_UnitDebuff);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraSlots",
|
||||
&Script_GetAuraSlots);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetAuraDataBySlot",
|
||||
&Script_GetAuraDataBySlot);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "UnitAuraBySlot",
|
||||
&Script_UnitAuraBySlot);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetUnitAuraBySpellID",
|
||||
&Script_GetUnitAuraBySpellID);
|
||||
Game::Lua::RegisterTableFunction("C_UnitAuras", "GetPlayerAuraBySpellID",
|
||||
|
||||
+368
-147
@@ -22,6 +22,7 @@
|
||||
#include "spell/CrowdControl.h"
|
||||
#include "spell/IsSelfBuff.h"
|
||||
#include "time/Clock.h"
|
||||
#include "turtle/Detect.h"
|
||||
#include "unit/Identity.h"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -272,6 +273,94 @@ bool IsSlotPopulated(const uint8_t *unit, int slot) {
|
||||
return IsVisible(SpellRecord(spellID));
|
||||
}
|
||||
|
||||
// Every nibble Turtle's SetAuraFlag can write for an occupied slot: HELPFUL,
|
||||
// HELPFUL|CANCELABLE, or HARMFUL alone. Nothing else is reachable there — a
|
||||
// negative aura gets exactly HARMFUL, and a positive one always carries HELPFUL.
|
||||
bool IsTurtlePolarityNibble(uint8_t nibble) {
|
||||
return nibble == Offsets::UNIT_AURA_FLAG_HELPFUL ||
|
||||
nibble == (Offsets::UNIT_AURA_FLAG_HELPFUL | Offsets::UNIT_AURA_FLAG_CANCELABLE) ||
|
||||
nibble == Offsets::UNIT_AURA_FLAG_HARMFUL;
|
||||
}
|
||||
|
||||
// Latched for the process once a nibble proves the flags are not polarity here.
|
||||
// A server does not change its encoding mid-session, so this never resets.
|
||||
bool g_nibbleContradicted = false;
|
||||
|
||||
bool IsSlotHarmful(const uint8_t *unit, int slot) {
|
||||
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
|
||||
return false;
|
||||
|
||||
// Normally the slot range IS the polarity: an aura is written to 0..31 when
|
||||
// helpful and 32..47 when harmful, and nothing ever crosses over. The flag
|
||||
// nibble cannot be used here, because in a stock descriptor those bits mean
|
||||
// something else entirely — one bit per spell effect index that carries an
|
||||
// aura, plus a cancelable bit. Reading bit 0x08 as "harmful" would then be
|
||||
// reading "effect index 0 has an aura", which is true of nearly every aura,
|
||||
// and would report almost every BUFF as a debuff.
|
||||
if (!Turtle::Detected())
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
// Turtle lets a debuff spill into a buff slot once the harmful range is
|
||||
// full, so the range stops being reliable — and it repurposes those same
|
||||
// nibble bits to carry the real polarity (0x04 helpful, 0x08 harmful) so
|
||||
// the information survives the spill. There, the nibble is the answer.
|
||||
if (g_nibbleContradicted)
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
|
||||
auto *desc = Descriptor(unit);
|
||||
if (desc == nullptr)
|
||||
return false;
|
||||
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
|
||||
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
|
||||
|
||||
// Fail safe. Detection says this server SHOULD be writing polarity here, so
|
||||
// a nibble it could not have produced means that assumption is stale — the
|
||||
// server changed how it writes the flags. Fall back to the slot range for
|
||||
// good rather than keep reading bits whose meaning we no longer know: the
|
||||
// range is merely incomplete for spilled debuffs, whereas a misread nibble
|
||||
// reports nearly every buff as a debuff. Revocation only, never promotion —
|
||||
// nothing here can turn nibble reading ON, because the agreeing case is not
|
||||
// provable (an all-0x08 run is equally consistent with single-effect auras
|
||||
// under the other encoding).
|
||||
if (nibble != 0 && !IsTurtlePolarityNibble(nibble)) {
|
||||
g_nibbleContradicted = true;
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
}
|
||||
return (nibble & Offsets::UNIT_AURA_FLAG_HARMFUL) != 0;
|
||||
}
|
||||
|
||||
bool IsSlotTooltipVisible(const uint8_t *unit, int slot) {
|
||||
if (slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
|
||||
return false;
|
||||
auto *desc = Descriptor(unit);
|
||||
if (desc == nullptr)
|
||||
return false;
|
||||
const int spellID = *reinterpret_cast<const int *>(
|
||||
desc + Offsets::OFF_UNIT_FIELD_AURA + slot * 4);
|
||||
if (spellID <= 0) // the engine's own `0 < id` test (signed)
|
||||
return false;
|
||||
const uint8_t byte = *(desc + Offsets::OFF_UNIT_FIELD_AURAFLAGS + slot / 2);
|
||||
const uint8_t nibble = (byte >> ((slot & 1) * 4)) & 0xF;
|
||||
if ((nibble & Offsets::UNIT_AURA_VISIBLE_MASK) == 0)
|
||||
return false;
|
||||
const uint8_t *rec = SpellRecord(static_cast<uint32_t>(spellID));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
using Fn = char(__fastcall *)(const uint8_t *);
|
||||
auto fn = reinterpret_cast<Fn>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GAMETOOLTIP_AURA_VISIBLE));
|
||||
return fn(rec) != 0;
|
||||
}
|
||||
|
||||
int SlotInFilterOrder(Filter filter, int i) {
|
||||
// Harmful: 32..47 first, then 0..31 (where the server parks debuffs once
|
||||
// the 16 are full). Helpful: 0..31 then 32..47 — positive auras never
|
||||
// spill downward in practice, so that tail is a no-op safety net.
|
||||
if (filter == Filter::Harmful)
|
||||
return (i + Offsets::UNIT_AURA_BUFF_COUNT) % Offsets::UNIT_AURA_TOTAL;
|
||||
return i;
|
||||
}
|
||||
|
||||
bool IsPlayerCast(const uint8_t *unit, int slot) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
if (spellID == 0)
|
||||
@@ -327,6 +416,30 @@ bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID) {
|
||||
CcMatches(match.cc, spellID);
|
||||
}
|
||||
|
||||
// `MatchesAura` for a descriptor slot the caller has ALREADY established is
|
||||
// populated. The caster lookup is the expensive predicate (a scan of the
|
||||
// Aura::Source cache per slot), and `CasterMatches` ignores its answer when the
|
||||
// match has no caster restriction — so skip the lookup entirely in that case.
|
||||
static bool PopulatedSlotMatches(const uint8_t *unit, int slot,
|
||||
const Match &match) {
|
||||
const bool castByPlayer =
|
||||
match.caster != CasterMode::Any && IsPlayerCast(unit, slot);
|
||||
return MatchesAura(match, castByPlayer, ReadSpellID(unit, slot));
|
||||
}
|
||||
|
||||
bool SlotMatches(const uint8_t *unit, int slot, const Match &match) {
|
||||
return IsSlotPopulated(unit, slot) && PopulatedSlotMatches(unit, slot, match);
|
||||
}
|
||||
|
||||
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
|
||||
const Match &match) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
return false;
|
||||
if (IsSlotHarmful(unit, slot) != (filter == Filter::Harmful))
|
||||
return false;
|
||||
return PopulatedSlotMatches(unit, slot, match);
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -340,17 +453,10 @@ int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
Match match) {
|
||||
if (unit == nullptr || oneBasedIndex < 1)
|
||||
return -1;
|
||||
const int start = (filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Filter::Harmful)
|
||||
? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
int matches = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = SlotInFilterOrder(filter, i);
|
||||
if (!SlotMatchesFilter(unit, slot, filter, match))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex)
|
||||
return slot;
|
||||
@@ -362,18 +468,16 @@ int FindSlotBySpellID(const uint8_t *unit, uint32_t spellID,
|
||||
const Filter *filter, Match match) {
|
||||
if (unit == nullptr || spellID == 0)
|
||||
return -1;
|
||||
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) {
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (ReadSpellID(unit, slot) != spellID)
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
if (filter != nullptr &&
|
||||
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
|
||||
continue;
|
||||
if (!PopulatedSlotMatches(unit, slot, match))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -384,19 +488,17 @@ int FindSlotBySpellName(const uint8_t *unit, const char *spellName,
|
||||
const Filter *filter, Match match) {
|
||||
if (unit == nullptr || spellName == nullptr || *spellName == '\0')
|
||||
return -1;
|
||||
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) {
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL; ++i) {
|
||||
const int slot = (filter != nullptr) ? SlotInFilterOrder(*filter, i) : i;
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
const char *name = LocalizedSpellName(SpellRecord(ReadSpellID(unit, slot)));
|
||||
if (name == nullptr || std::strcmp(name, spellName) != 0)
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
if (filter != nullptr &&
|
||||
IsSlotHarmful(unit, slot) != (*filter == Filter::Harmful))
|
||||
continue;
|
||||
if (!PopulatedSlotMatches(unit, slot, match))
|
||||
continue;
|
||||
return slot;
|
||||
}
|
||||
@@ -657,7 +759,7 @@ static Attribution CachedAttribution(const Aura::Source::CachedAura &c) {
|
||||
|
||||
void Push(void *L, const uint8_t *unit, int slot, Emit emit) {
|
||||
const uint32_t spellID = ReadSpellID(unit, slot);
|
||||
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
const bool isHelpful = !IsSlotHarmful(unit, slot); // nibble, not slot range
|
||||
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
|
||||
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
|
||||
unitLevel, unit != nullptr && unit == LocalPlayer(), slot,
|
||||
@@ -722,36 +824,16 @@ bool DescriptorHasVisibleAura(const uint8_t *unit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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)
|
||||
? Offsets::UNIT_AURA_BUFF_COUNT
|
||||
: 0;
|
||||
const int end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
|
||||
: Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
int n = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!IsSlotPopulated(unit, slot))
|
||||
continue;
|
||||
if (!MatchesAura(match, IsPlayerCast(unit, slot), ReadSpellID(unit, slot)))
|
||||
continue;
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
|
||||
// `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.
|
||||
// Whether cache entry `c` may surface as a fallback for `unit` under `filter`:
|
||||
// the spell must be a user-visible aura and must not already be in a populated
|
||||
// descriptor slot (no double-listing the same live aura). The caller's
|
||||
// caster/dispel/CC predicates are applied separately by `CachedMatches`.
|
||||
// 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, Match match) {
|
||||
Filter filter) {
|
||||
if (!IsVisible(SpellRecord(c.spellId)))
|
||||
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
|
||||
// the descriptor path drops these via IsSlotPopulated, so
|
||||
@@ -759,37 +841,58 @@ bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
|
||||
// they leak through as tooltip-less phantom buffs.
|
||||
if (FindSlotBySpellID(unit, c.spellId, &filter, Match{}) >= 0)
|
||||
return false; // still in the descriptor — returned via the slot path
|
||||
if (!MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(), c.spellId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Applies `match` to a cache entry. The entry carries its caster, so the caster
|
||||
// test is a plain GUID compare — no scan, nothing to defer.
|
||||
bool CachedMatches(const Aura::Source::CachedAura &c, const Match &match) {
|
||||
return MatchesAura(match, c.casterGuid == Unit::Identity::PlayerGuid(),
|
||||
c.spellId);
|
||||
}
|
||||
|
||||
// The eligible cache-fallback entries for `unit` under `filter`, in `Enumerate`
|
||||
// order, unfiltered by any `Match` (callers apply `CachedMatches`). Applies the
|
||||
// fallback gates: reconcile the cache against the descriptor, then yield nothing
|
||||
// while the descriptor shows any visible aura (it is authoritative then — see
|
||||
// DescriptorHasVisibleAura). `buf` must hold kFallbackMax entries. Returns the
|
||||
// count. An entry's ordinal here is what an opaque cache slot id encodes, which
|
||||
// is why the sequence must not depend on the caller's match.
|
||||
int EligibleFallbacks(const uint8_t *unit, Filter filter,
|
||||
Aura::Source::CachedAura *buf) {
|
||||
if (unit == nullptr)
|
||||
return 0;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return 0; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
Aura::Source::CachedAura raw[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, raw, kFallbackMax);
|
||||
int m = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (FallbackEligible(unit, raw[i], filter))
|
||||
buf[m++] = raw[i];
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
Filter filter, Match match, Emit emit) {
|
||||
if (unit == nullptr)
|
||||
return false;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return false; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
// The descriptor held `descCount` matches; the caller already found fewer
|
||||
// than `oneBasedIndex` there, so the cache supplies index
|
||||
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
|
||||
const int target = oneBasedIndex - CountSlots(unit, filter, match);
|
||||
if (target < 1)
|
||||
return false;
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return false;
|
||||
// The fallback yields only while the descriptor shows no visible aura, so
|
||||
// the descriptor held none of the caller's matches: the cache supplies index
|
||||
// `oneBasedIndex` (1-based) of the entries passing `match` directly.
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
const int n = EligibleFallbacks(unit, filter, buf);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
if (++seen == target) {
|
||||
if (++seen == oneBasedIndex) {
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful, emit);
|
||||
return true;
|
||||
}
|
||||
@@ -799,19 +902,10 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
|
||||
|
||||
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
Match match, int outerIdx, int &nextKey) {
|
||||
if (unit == nullptr)
|
||||
return;
|
||||
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
|
||||
if (DescriptorHasVisibleAura(unit))
|
||||
return; // descriptor authoritative — see DescriptorHasVisibleAura
|
||||
const uint64_t guid = UnitGuid(unit);
|
||||
if (guid == 0)
|
||||
return;
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(
|
||||
guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
const int n = EligibleFallbacks(unit, filter, buf);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!FallbackEligible(unit, buf[i], filter, match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushFromCache(L, unit, buf[i], filter == Filter::Helpful);
|
||||
@@ -823,8 +917,10 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
|
||||
|
||||
namespace {
|
||||
|
||||
// Absolute slot range [start, end) for a filter, matching the descriptor
|
||||
// convention (helpful 0..31, harmful 32..47).
|
||||
// Absolute slot range [start, end) for a filter in the GROUP ARRAY (helpful
|
||||
// 0..31, harmful 32..47). The array carries spell IDs only — no flag nibble —
|
||||
// so the seating range is the only polarity the out-of-range path has. (The
|
||||
// descriptor path reads the nibble instead; see SlotInFilterOrder.)
|
||||
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
|
||||
@@ -838,21 +934,27 @@ uint32_t GroupSpellID(const uint16_t *arr, int slot) {
|
||||
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) {
|
||||
// Whether the group-array slot holds a surfacable aura. 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).
|
||||
bool GroupSlotVisible(const uint16_t *arr, int slot) {
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
if (id == 0 || !IsVisible(SpellRecord(id)))
|
||||
return id != 0 && IsVisible(SpellRecord(id));
|
||||
}
|
||||
|
||||
// `GroupSlotVisible` plus the caller's `match` predicates. The caster answer
|
||||
// (`GroupIsPlayerCast` — a cache scan; a miss counts as not-player, since the
|
||||
// array alone can't confirm the caster) is computed only when `match` restricts
|
||||
// on caster.
|
||||
bool GroupSlotMatches(uint64_t guid, const uint16_t *arr, int slot,
|
||||
const Match &match) {
|
||||
if (!GroupSlotVisible(arr, slot))
|
||||
return false;
|
||||
if (!MatchesAura(match, GroupIsPlayerCast(guid, id), id))
|
||||
return false;
|
||||
return true;
|
||||
const uint32_t id = GroupSpellID(arr, slot);
|
||||
const bool castByPlayer =
|
||||
match.caster != CasterMode::Any && GroupIsPlayerCast(guid, id);
|
||||
return MatchesAura(match, castByPlayer, id);
|
||||
}
|
||||
|
||||
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
|
||||
@@ -872,28 +974,52 @@ int GroupMemberLevel(uint64_t guid) {
|
||||
// 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)))
|
||||
if (GroupSlotVisible(arr, slot))
|
||||
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) {
|
||||
// aura not already present in the array. The caller's `match` is applied
|
||||
// separately by `CachedMatches`. See EligibleGroupFallbacks for when this path
|
||||
// runs.
|
||||
bool GroupFallbackEligible(const uint16_t *arr,
|
||||
const Aura::Source::CachedAura &c) {
|
||||
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;
|
||||
}
|
||||
|
||||
// The eligible group cache-fallback entries for out-of-range member `guid`, in
|
||||
// `Enumerate` order, unfiltered by any `Match` (callers apply `CachedMatches`).
|
||||
// Yields nothing while the group array exposes ANY visible aura: the server's
|
||||
// out-of-range aura data is then present and authoritative, mirroring how the
|
||||
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
|
||||
// Only an entirely empty array falls 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. `buf` must hold kFallbackMax
|
||||
// entries. An entry's ordinal here is what an opaque group-cache slot id
|
||||
// encodes, which is why the sequence must not depend on the caller's match.
|
||||
int EligibleGroupFallbacks(const uint16_t *arr, uint64_t guid, bool harmful,
|
||||
Aura::Source::CachedAura *buf) {
|
||||
if (arr == nullptr || GroupArrayHasVisibleAura(arr))
|
||||
return 0;
|
||||
Aura::Source::CachedAura raw[kFallbackMax];
|
||||
const int n = Aura::Source::Enumerate(guid, harmful, raw, kFallbackMax);
|
||||
int m = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (GroupFallbackEligible(arr, raw[i]))
|
||||
buf[m++] = raw[i];
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -901,10 +1027,10 @@ bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
|
||||
Emit emit = Emit::Table) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
|
||||
int seen = 0;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
if (++seen == oneBasedIndex) {
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
@@ -922,10 +1048,10 @@ void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
|
||||
int &nextKey) {
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int n =
|
||||
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
|
||||
EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
@@ -941,7 +1067,7 @@ 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);
|
||||
const int n = EligibleGroupFallbacks(arr, guid, harmful, buf);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
if (spellID != 0 && buf[i].spellId != spellID)
|
||||
continue;
|
||||
@@ -950,7 +1076,7 @@ bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
|
||||
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
|
||||
continue;
|
||||
}
|
||||
if (!GroupFallbackEligible(arr, buf[i], match))
|
||||
if (!CachedMatches(buf[i], match))
|
||||
continue;
|
||||
const Attribution a = CachedAttribution(buf[i]);
|
||||
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
|
||||
@@ -974,7 +1100,7 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
GroupRange(filter, start, end);
|
||||
int matches = 0;
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(guid, arr, slot, match))
|
||||
continue;
|
||||
if (++matches == oneBasedIndex) {
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
@@ -985,10 +1111,8 @@ bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
|
||||
}
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
if (!GroupArrayHasVisibleAura(arr))
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
|
||||
match, emit);
|
||||
return false;
|
||||
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter, match,
|
||||
emit);
|
||||
}
|
||||
|
||||
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
@@ -1008,23 +1132,20 @@ bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (GroupSpellID(arr, slot) != spellID)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(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;
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1049,23 +1170,20 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
const char *name = LocalizedSpellName(SpellRecord(id));
|
||||
if (name == nullptr || std::strcmp(name, spellName) != 0)
|
||||
continue;
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(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;
|
||||
}
|
||||
// Empty array (server delta-resend gap): supplement from the cache.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1081,7 +1199,7 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
GroupRange(filter, start, end);
|
||||
const int level = GroupMemberLevel(guid);
|
||||
for (int slot = start; slot < end; ++slot) {
|
||||
if (!GroupSlotEligible(guid, arr, slot, match))
|
||||
if (!GroupSlotMatches(guid, arr, slot, match))
|
||||
continue;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
|
||||
PushEnriched(L, guid, GroupSpellID(arr, slot),
|
||||
@@ -1090,9 +1208,112 @@ void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
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);
|
||||
AppendGroupCacheFallbacks(L, guid, arr, filter, match, outerIdx, nextKey);
|
||||
}
|
||||
|
||||
// ── Opaque aura slots ──────────────────────────────────────────────────────
|
||||
|
||||
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
|
||||
int *out, int cap) {
|
||||
int n = 0;
|
||||
if (unit != nullptr) {
|
||||
for (int i = 0; i < Offsets::UNIT_AURA_TOTAL && n < cap; ++i) {
|
||||
const int slot = SlotInFilterOrder(filter, i);
|
||||
if (SlotMatchesFilter(unit, slot, filter, match))
|
||||
out[n++] = slot;
|
||||
}
|
||||
// The cache fallback yields only while the descriptor shows no visible
|
||||
// aura, so these never interleave with live slots — they follow them,
|
||||
// exactly as the by-index getters order them.
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int m = EligibleFallbacks(unit, filter, buf);
|
||||
const int base = (filter == Filter::Harmful) ? OPAQUE_CACHE_HARMFUL
|
||||
: OPAQUE_CACHE_HELPFUL;
|
||||
for (int k = 0; k < m && n < cap; ++k) {
|
||||
if (CachedMatches(buf[k], match))
|
||||
out[n++] = base + k;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
// No live CGUnit — the out-of-range group path.
|
||||
if (guid == 0)
|
||||
return 0;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return 0;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
int start, end;
|
||||
GroupRange(filter, start, end);
|
||||
for (int slot = start; slot < end && n < cap; ++slot) {
|
||||
if (GroupSlotMatches(guid, arr, slot, match))
|
||||
out[n++] = OPAQUE_GROUP + slot;
|
||||
}
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
const int m = EligibleGroupFallbacks(arr, guid, filter == Filter::Harmful, buf);
|
||||
const int base = (filter == Filter::Harmful) ? OPAQUE_GROUP_CACHE_HARMFUL
|
||||
: OPAQUE_GROUP_CACHE_HELPFUL;
|
||||
for (int k = 0; k < m && n < cap; ++k) {
|
||||
if (CachedMatches(buf[k], match))
|
||||
out[n++] = base + k;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit) {
|
||||
if (slot < 0)
|
||||
return false;
|
||||
const int kind = slot / OPAQUE_STRIDE;
|
||||
const int k = slot % OPAQUE_STRIDE;
|
||||
switch (kind) {
|
||||
case 0: // live descriptor slot
|
||||
if (unit == nullptr || !IsSlotPopulated(unit, k))
|
||||
return false;
|
||||
Push(L, unit, k, emit);
|
||||
return true;
|
||||
case 1: // helpful cache fallback
|
||||
case 2: { // harmful cache fallback
|
||||
if (unit == nullptr || k >= kFallbackMax)
|
||||
return false;
|
||||
const Filter filter = (kind == 2) ? Filter::Harmful : Filter::Helpful;
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
if (k >= EligibleFallbacks(unit, filter, buf))
|
||||
return false;
|
||||
PushFromCache(L, unit, buf[k], filter == Filter::Helpful, emit);
|
||||
return true;
|
||||
}
|
||||
case 3: { // out-of-range group-array slot
|
||||
if (guid == 0 || k >= Offsets::UNIT_AURA_TOTAL)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
Aura::Source::ObserveGroupAuras(guid, arr);
|
||||
if (!GroupSlotVisible(arr, k))
|
||||
return false;
|
||||
PushEnriched(L, guid, GroupSpellID(arr, k),
|
||||
k < Offsets::UNIT_AURA_BUFF_COUNT, 1, GroupMemberLevel(guid),
|
||||
false, Aura::Source::SLOT_UNBOUND, /*known*/ nullptr, emit);
|
||||
return true;
|
||||
}
|
||||
case 4: // helpful group cache fallback
|
||||
case 5: { // harmful group cache fallback
|
||||
if (guid == 0 || k >= kFallbackMax)
|
||||
return false;
|
||||
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
|
||||
if (arr == nullptr)
|
||||
return false;
|
||||
const bool harmful = (kind == 5);
|
||||
Aura::Source::CachedAura buf[kFallbackMax];
|
||||
if (k >= EligibleGroupFallbacks(arr, guid, harmful, buf))
|
||||
return false;
|
||||
const Attribution a = CachedAttribution(buf[k]);
|
||||
PushEnriched(L, guid, buf[k].spellId, !harmful, 1, GroupMemberLevel(guid),
|
||||
false, buf[k].slot, &a, emit);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
+104
-13
@@ -10,6 +10,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Shared aura-table primitives consumed by `C_UnitAuras.*` (and any
|
||||
@@ -18,19 +20,38 @@
|
||||
// `unit + OFF_CGUNIT_OBJECT_FIELDS`; the layout is documented in
|
||||
// `Offsets.h` under `OFF_UNIT_FIELD_AURA*`.
|
||||
//
|
||||
// Slot indexing convention used here matches the engine's:
|
||||
// Slots are absolute 0..47, and how polarity is read out of them DEPENDS ON THE
|
||||
// SERVER. `IsSlotHarmful` owns that decision; nothing else should look at either
|
||||
// signal directly.
|
||||
//
|
||||
// slot 0..31 → buffs (helpful)
|
||||
// slot 32..47 → debuffs (harmful)
|
||||
// Normally the slot range IS the polarity: positive auras are written to 0..31
|
||||
// and negative ones to 32..47 by a strict either/or, so nothing crosses over.
|
||||
// The flag nibble is no help — in a stock descriptor those bits record which
|
||||
// spell effect indices carry an aura, not whether the aura is good or bad.
|
||||
//
|
||||
// Callers that take a 1-based Lua index into "buffs" or "debuffs"
|
||||
// translate to the absolute 0..47 slot before calling in.
|
||||
// Turtle changes both halves. Once the 16 harmful slots are full it spills
|
||||
// further debuffs into 0..31 (setting UNIT_FLAG_AURAS_VISIBLE so the client
|
||||
// still renders them), which makes the range unreliable — and it repurposes the
|
||||
// nibble to carry the true polarity so the information survives the spill.
|
||||
// There, the nibble is authoritative. See `Offsets::UNIT_AURA_FLAG_HARMFUL`.
|
||||
//
|
||||
// Either way `Filter` selects on the aura's REAL polarity, so a spilled debuff
|
||||
// is reported as a debuff. The engine's own UnitBuff/UnitDebuff split purely by
|
||||
// slot range and would call it a buff; that is a 1.12 UI limitation we
|
||||
// deliberately do not mirror, since retail's contract is actual polarity. A
|
||||
// polarity walk visits its home range first and then the other range, so
|
||||
// indices are unchanged wherever no spill has happened — which is everywhere,
|
||||
// on a server that does not spill.
|
||||
//
|
||||
// Callers that take a 1-based Lua index into "buffs" or "debuffs" translate
|
||||
// to the absolute 0..47 slot before calling in.
|
||||
|
||||
namespace Aura::Data {
|
||||
|
||||
// Filter for slot iteration. The engine's `UnitBuff` / `UnitDebuff`
|
||||
// pick one direction; modern `C_UnitAuras.GetAuraDataByIndex` defaults
|
||||
// to helpful when no filter is specified.
|
||||
// Filter for slot iteration: the aura's polarity per its flag nibble (see the
|
||||
// file header). The engine's `UnitBuff` / `UnitDebuff` pick one direction;
|
||||
// modern `C_UnitAuras.GetAuraDataByIndex` defaults to helpful when no filter
|
||||
// is specified.
|
||||
enum class Filter { Helpful, Harmful };
|
||||
|
||||
// How a resolved aura is emitted onto the Lua stack by the single-aura push
|
||||
@@ -83,11 +104,33 @@ uint32_t ReadSpellID(const uint8_t *unit, int slot);
|
||||
// to decide whether to surface an aura through Lua.
|
||||
bool IsSlotPopulated(const uint8_t *unit, int slot);
|
||||
|
||||
// Finds the absolute slot of the `oneBasedIndex`-th populated aura
|
||||
// matching `filter`. Returns -1 if no such aura. Walks
|
||||
// 0..UNIT_AURA_BUFF_COUNT-1 for Helpful, UNIT_AURA_BUFF_COUNT..
|
||||
// UNIT_AURA_TOTAL-1 for Harmful. When `playerOnly` is true, only auras
|
||||
// the local player cast (per `IsPlayerCast`) are counted/returned.
|
||||
// The aura's real polarity, read the way this server encodes it: the slot range
|
||||
// normally, the flag nibble on Turtle (see the file header). False for an empty
|
||||
// slot or a null unit. This is what `Filter` selects on, and the ONE place the
|
||||
// two encodings are chosen between.
|
||||
bool IsSlotHarmful(const uint8_t *unit, int slot);
|
||||
|
||||
// The engine's TOOLTIP visibility gate for a slot: occupied, visible nibble,
|
||||
// and the spell passes `FUN_GAMETOOLTIP_AURA_VISIBLE` — exactly what
|
||||
// `SetUnitBuff` / `SetUnitDebuff` count while turning an index into a slot.
|
||||
// Differs from `IsSlotPopulated` (the UnitBuff gate), so use THIS to compute
|
||||
// the index the engine's tooltip methods expect for a given slot.
|
||||
bool IsSlotTooltipVisible(const uint8_t *unit, int slot);
|
||||
|
||||
// The i-th slot (0..UNIT_AURA_TOTAL-1) in the order a `filter` walk visits the
|
||||
// descriptor: the polarity's home range first (helpful 0..31, harmful 32..47),
|
||||
// then the other range, where spilled debuffs live. Every enumeration that
|
||||
// hands out indices or slot lists uses this so they agree on order.
|
||||
int SlotInFilterOrder(Filter filter, int i);
|
||||
|
||||
// `SlotMatches` plus the polarity test: populated, of `filter`'s polarity, and
|
||||
// passing `match`.
|
||||
bool SlotMatchesFilter(const uint8_t *unit, int slot, Filter filter,
|
||||
const Match &match);
|
||||
|
||||
// Finds the absolute slot of the `oneBasedIndex`-th populated aura of
|
||||
// `filter`'s polarity that passes `match`. Returns -1 if no such aura. Walks
|
||||
// all 48 slots in `SlotInFilterOrder`.
|
||||
int FindNthSlot(const uint8_t *unit, int oneBasedIndex, Filter filter,
|
||||
Match match = {});
|
||||
|
||||
@@ -124,6 +167,12 @@ bool IsDispellable(uint32_t spellID);
|
||||
// per-aura "was cast by the local player" answer and its spell ID.
|
||||
bool MatchesAura(const Match &match, bool isPlayerCast, uint32_t spellID);
|
||||
|
||||
// True iff descriptor `slot` on `unit` is populated (per `IsSlotPopulated`) AND
|
||||
// passes `match`. The caster answer (`IsPlayerCast` — a scan of the
|
||||
// `Aura::Source` cache, ~1000 entries in a raid) is computed ONLY when `match`
|
||||
// restricts on caster, so the common filter-less walk never pays for it.
|
||||
bool SlotMatches(const uint8_t *unit, int slot, const Match &match);
|
||||
|
||||
// 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);
|
||||
@@ -230,4 +279,46 @@ bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
|
||||
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, Match match,
|
||||
int outerIdx, int &nextKey);
|
||||
|
||||
// ── Opaque aura slots (GetAuraSlots / GetAuraDataBySlot) ────────────────────
|
||||
//
|
||||
// The by-index getters re-walk the descriptor from slot 0 on every call, so a
|
||||
// full scan through them is quadratic in the aura count. `CollectSlots`
|
||||
// enumerates once into opaque slot ids and `PushBySlot` fetches one aura by id
|
||||
// with no walk — the modern `C_UnitAuras.GetAuraSlots` / `GetAuraDataBySlot`
|
||||
// batching contract. An id is valid for the frame it was enumerated in.
|
||||
//
|
||||
// Encoding (kind = id / OPAQUE_STRIDE, k = id % OPAQUE_STRIDE):
|
||||
// kind 0 live descriptor slot k (0..UNIT_AURA_TOTAL-1)
|
||||
// kind 1 k-th eligible helpful cache-fallback entry (OPAQUE_CACHE_HELPFUL)
|
||||
// kind 2 k-th eligible harmful cache-fallback entry (OPAQUE_CACHE_HARMFUL)
|
||||
// kind 3 out-of-range group-array slot k (0..47) (OPAQUE_GROUP)
|
||||
// kind 4 k-th helpful group cache-fallback entry (OPAQUE_GROUP_CACHE_HELPFUL)
|
||||
// kind 5 k-th harmful group cache-fallback entry (OPAQUE_GROUP_CACHE_HARMFUL)
|
||||
// Fallback ordinals count the UNFILTERED eligible sequence (visible, not
|
||||
// already in the live array), so `PushBySlot` needs no filter; `CollectSlots`
|
||||
// applies the caller's `match` only when choosing which ids to emit.
|
||||
constexpr int OPAQUE_STRIDE = 0x100;
|
||||
constexpr int OPAQUE_CACHE_HELPFUL = 1 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_CACHE_HARMFUL = 2 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP = 3 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP_CACHE_HELPFUL = 4 * OPAQUE_STRIDE;
|
||||
constexpr int OPAQUE_GROUP_CACHE_HARMFUL = 5 * OPAQUE_STRIDE;
|
||||
|
||||
// Upper bound on ids one `CollectSlots` call can yield: the live array (or the
|
||||
// group array) plus its cache fallback, each at most UNIT_AURA_TOTAL entries.
|
||||
constexpr int OPAQUE_SLOTS_MAX = 2 * Offsets::UNIT_AURA_TOTAL;
|
||||
|
||||
// Writes the opaque ids of every aura on the unit matching `filter` + `match`
|
||||
// into `out` (at most `cap`), in the order the by-index getters visit them:
|
||||
// live slots (or, with no live CGUnit, the group array) first, then the cache
|
||||
// fallback. `unit` null selects the out-of-range group path for `guid`. Returns
|
||||
// the count written.
|
||||
int CollectSlots(const uint8_t *unit, uint64_t guid, Filter filter, Match match,
|
||||
int *out, int cap);
|
||||
|
||||
// Pushes the aura an opaque id names — table or positional per `emit` — and
|
||||
// returns true; pushes nothing and returns false for an id that names nothing
|
||||
// (stale, malformed, or the wrong path for the unit's current state).
|
||||
bool PushBySlot(void *L, const uint8_t *unit, uint64_t guid, int slot, Emit emit);
|
||||
|
||||
} // namespace Aura::Data
|
||||
|
||||
+143
-17
@@ -360,6 +360,12 @@ Entry *Claim(uint32_t now) {
|
||||
// refresh path too.
|
||||
void SignalAuraChanged(uint64_t guid);
|
||||
|
||||
// Defined with the duration-modifier machinery below. A trigger can CREATE the
|
||||
// aura it then edits, in which case the edit is armed at trigger time and
|
||||
// applied here, when the aura it was waiting for actually lands. See PendingMod.
|
||||
struct Entry;
|
||||
void ConsumePendingMods(Entry &e, uint32_t now);
|
||||
|
||||
// The SpellGo hook: authoritative caster + caster-modified (talented) timing.
|
||||
// Identity is `(target, spell, caster)`, so a second caster of the same spell
|
||||
// opens its own entry instead of overwriting the first's, and a recast by the
|
||||
@@ -387,6 +393,7 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
|
||||
e = Claim(now);
|
||||
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs,
|
||||
now, SLOT_UNBOUND, KIND_UNKNOWN, true};
|
||||
ConsumePendingMods(*e, now);
|
||||
return;
|
||||
}
|
||||
e->stampMs = now; // refresh EvictAbsent grace on any touch
|
||||
@@ -399,6 +406,7 @@ void StoreFromCast(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
|
||||
// ourselves (deferred to the world tick, coalesced). A NEW application
|
||||
// doesn't need this: its descriptor write fires the engine's own event.
|
||||
SignalAuraChanged(targetGuid);
|
||||
ConsumePendingMods(*e, now);
|
||||
}
|
||||
|
||||
// The OnAuraAdded / OnAuraStacksChanged application hooks: a descriptor slot,
|
||||
@@ -429,6 +437,7 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
|
||||
e = Claim(now);
|
||||
*e = {targetGuid, casterGuid, spellId, expirationMs, durationMs, now,
|
||||
static_cast<int16_t>(slot), kind, true};
|
||||
ConsumePendingMods(*e, now);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -440,6 +449,11 @@ void StoreFromApplication(uint64_t targetGuid, uint32_t spellId,
|
||||
// back to unknown.
|
||||
if (kind != KIND_UNKNOWN)
|
||||
e->kind = kind;
|
||||
// An armed server edit applies whoever owns the timing — the server made it
|
||||
// AFTER the cast this entry came from, so it is the newer truth. It also
|
||||
// adopts the entry, which makes the ownership guard below keep the edited
|
||||
// value instead of the application's base-duration guess.
|
||||
ConsumePendingMods(*e, now);
|
||||
if (e->casterGuid != 0)
|
||||
return; // SpellGo owns this entry; keep its caster + talented timing
|
||||
if (casterGuid != 0)
|
||||
@@ -523,8 +537,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId, int slot) {
|
||||
// Molten Blast -> Flame Shock: refresh (RefreshHolder → reset to max)
|
||||
// A trigger is matched either by exact spellID (from the server's script
|
||||
// binding, stable across ranks) or by SpellFamilyName + school; the affected
|
||||
// aura by SpellFamilyName + a family-flag overlap (+ optional icon) — rank-proof,
|
||||
// exactly how the server's scripts find it. Conflagrate's *full*-consume path
|
||||
// aura by SpellFamilyName plus a family-flag overlap and/or a SpellIconID —
|
||||
// rank-proof, exactly how the server's scripts find it, and the icon half is
|
||||
// what reaches a custom spell with no family flags at all (see
|
||||
// AffectedMatchesRaw). Conflagrate's *full*-consume path
|
||||
// removes Immolate, which clears
|
||||
// the descriptor slot → OnAuraRemoved already handles it; the reduce rule
|
||||
// covers the keep-ticking case. Probabilistic refreshes (Carnage's roll) are
|
||||
@@ -549,9 +565,23 @@ int g_modCount = 0;
|
||||
|
||||
// The affected-aura selector alone, shared with the Lua-facing by-family
|
||||
// refresh so the two cannot disagree about what they match.
|
||||
//
|
||||
// SpellFamilyName always has to match. Past that a rule discriminates by a
|
||||
// SpellFamilyFlags overlap, by SpellIconID, or by both; a zero mask or a zero
|
||||
// icon drops that half of the test. The icon-only form is what names an aura
|
||||
// whose SpellFamilyFlags are ZERO — every one of Turtle's custom spells is
|
||||
// (308 in the hunter family alone), so the mask cannot select them while the
|
||||
// icon still can, and an icon is shared by a spell's whole rank ladder, which
|
||||
// keeps it rank-proof exactly like a family flag. Registrars reject the
|
||||
// degenerate mask-0 + icon-0 rule, which would match a whole class family.
|
||||
bool AffectedMatchesRaw(const uint8_t *rec, uint32_t family, uint64_t mask,
|
||||
uint32_t icon) {
|
||||
if (!Spell::Lookup::IsFitToFamily(rec, family, mask))
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
if (*reinterpret_cast<const uint32_t *>(
|
||||
rec + Offsets::OFF_SPELL_RECORD_FAMILY_NAME) != family)
|
||||
return false;
|
||||
if (mask != 0 && !Spell::Lookup::IsFitToFamily(rec, family, mask))
|
||||
return false;
|
||||
if (icon != 0 &&
|
||||
*reinterpret_cast<const uint32_t *>(
|
||||
@@ -616,8 +646,8 @@ void FlushAuraSignals() {
|
||||
g_pendingSignalCount = 0;
|
||||
}
|
||||
|
||||
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
switch (m.op) {
|
||||
void ApplyModOp(Entry &e, int32_t op, int32_t valueMs, uint32_t now) {
|
||||
switch (op) {
|
||||
case MOD_REFRESH:
|
||||
if (e.durationMs > 0) {
|
||||
e.expirationMs = now + e.durationMs; // RefreshHolder → reset to max
|
||||
@@ -625,14 +655,14 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
}
|
||||
break;
|
||||
case MOD_SET:
|
||||
e.durationMs = static_cast<uint32_t>(m.valueMs);
|
||||
e.expirationMs = now + static_cast<uint32_t>(m.valueMs);
|
||||
e.durationMs = static_cast<uint32_t>(valueMs);
|
||||
e.expirationMs = now + static_cast<uint32_t>(valueMs);
|
||||
SignalAuraChanged(e.targetGuid);
|
||||
break;
|
||||
case MOD_REDUCE:
|
||||
if (e.expirationMs != 0) {
|
||||
if (e.expirationMs > now + static_cast<uint32_t>(m.valueMs)) {
|
||||
e.expirationMs -= static_cast<uint32_t>(m.valueMs);
|
||||
if (e.expirationMs > now + static_cast<uint32_t>(valueMs)) {
|
||||
e.expirationMs -= static_cast<uint32_t>(valueMs);
|
||||
SignalAuraChanged(e.targetGuid);
|
||||
} else {
|
||||
e.used = false; // shaved to/past now → server removes it
|
||||
@@ -645,6 +675,88 @@ void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyMod(Entry &e, const DurationMod &m, uint32_t now) {
|
||||
ApplyModOp(e, m.op, m.valueMs, now);
|
||||
}
|
||||
|
||||
// ---- Deferred duration edits (the trigger creates the aura it edits) ------
|
||||
//
|
||||
// A trigger does not always act on an aura that is ALREADY there. Bestial
|
||||
// Wrath's effect 0 is TRIGGER_SPELL(Scent of Blood), and the server's script
|
||||
// then stretches the very holder that trigger just created (tortoise-wow
|
||||
// spell_hunter_bestial_wrath). The trigger's own SMSG_SPELL_GO reaches us
|
||||
// first, so at trigger time either nothing matches yet, or something matches
|
||||
// and is overwritten moments later when the triggered cast stores its own base
|
||||
// duration. Either way the edit is lost.
|
||||
//
|
||||
// So a trigger also ARMS its targets, and the next matching store inside the
|
||||
// window re-applies the edit — from the cast path or the application path,
|
||||
// whichever the triggered aura arrives on.
|
||||
//
|
||||
// Only the IDEMPOTENT ops arm. Re-running refresh or set lands on the same
|
||||
// value, while reduce would shave a second time and remove would kill an aura
|
||||
// that was legitimately re-applied. That keeps Conflagrate (reduce) behaving
|
||||
// exactly as before.
|
||||
bool OpIsIdempotent(int32_t op) {
|
||||
return op == MOD_REFRESH || op == MOD_SET;
|
||||
}
|
||||
|
||||
struct PendingMod {
|
||||
uint64_t target;
|
||||
uint64_t caster;
|
||||
uint32_t affectedFamily;
|
||||
uint64_t affectedMask;
|
||||
uint32_t affectedIcon;
|
||||
int32_t op;
|
||||
int32_t valueMs;
|
||||
uint32_t untilMs;
|
||||
bool used;
|
||||
};
|
||||
// The triggered cast follows its trigger within one packet burst, so the window
|
||||
// only has to cover network jitter. Sized like the other arm tables.
|
||||
constexpr int kPendingModMax = 8;
|
||||
constexpr uint32_t kPendingModTtlMs = 2000;
|
||||
PendingMod g_pendingMods[kPendingModMax];
|
||||
|
||||
void ArmPendingMod(uint64_t target, uint64_t caster, const DurationMod &m,
|
||||
uint32_t now) {
|
||||
PendingMod *slot = nullptr;
|
||||
for (auto &p : g_pendingMods) {
|
||||
// Re-arming the same rule on the same target refreshes it in place.
|
||||
if (p.used && p.target == target && p.affectedFamily == m.affectedFamily &&
|
||||
p.affectedMask == m.affectedMask && p.affectedIcon == m.affectedIcon) {
|
||||
slot = &p;
|
||||
break;
|
||||
}
|
||||
if (slot == nullptr && (!p.used || Time::Clock::Reached(now, p.untilMs)))
|
||||
slot = &p;
|
||||
}
|
||||
if (slot == nullptr)
|
||||
slot = &g_pendingMods[0]; // all live — steal the first
|
||||
*slot = {target, caster, m.affectedFamily, m.affectedMask,
|
||||
m.affectedIcon, m.op, m.valueMs, now + kPendingModTtlMs, true};
|
||||
}
|
||||
|
||||
void ConsumePendingMods(Entry &e, uint32_t now) {
|
||||
for (auto &p : g_pendingMods) {
|
||||
if (!p.used || p.target != e.targetGuid ||
|
||||
Time::Clock::Reached(now, p.untilMs))
|
||||
continue;
|
||||
// Caster-scoped exactly like the immediate path, adoption included.
|
||||
if (e.casterGuid != 0 && e.casterGuid != p.caster)
|
||||
continue;
|
||||
if (!AffectedMatchesRaw(
|
||||
Spell::Lookup::RecordForID(static_cast<int>(e.spellId)),
|
||||
p.affectedFamily, p.affectedMask, p.affectedIcon))
|
||||
continue;
|
||||
if (e.casterGuid == 0)
|
||||
e.casterGuid = p.caster;
|
||||
ApplyModOp(e, p.op, p.valueMs, now);
|
||||
p.used = false; // one-shot, like the server's single-holder edit
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// On a trigger cast landing on its hit targets, mirror the server's duration
|
||||
// edit on the caster's own matching cached aura. Called from SpellGo_h before
|
||||
// the aura gate — triggers (Conflagrate, Molten Blast) apply no aura of their
|
||||
@@ -681,6 +793,11 @@ void ApplyDurationModifiers(uint32_t triggerSpellId, uint64_t caster,
|
||||
ApplyMod(e, m, now);
|
||||
break; // one matching aura per (rule, target), like the server
|
||||
}
|
||||
// The aura may not be here yet, or may be about to be overwritten by
|
||||
// the cast this trigger sets off. Arm the idempotent ops so the edit
|
||||
// lands when it arrives — see PendingMod.
|
||||
if (OpIsIdempotent(m.op))
|
||||
ArmPendingMod(targets[t], caster, m, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,8 +806,11 @@ bool RegisterDurationMod(uint32_t triggerSpellId, uint32_t triggerFamily,
|
||||
int32_t triggerSchool, uint32_t affectedFamily,
|
||||
uint64_t affectedMask, uint32_t affectedIcon, int op,
|
||||
int32_t valueMs) {
|
||||
// Trigger must be identified one way or the other.
|
||||
if ((triggerSpellId == 0 && triggerFamily == 0) || affectedMask == 0 ||
|
||||
// Trigger must be identified one way or the other, and the affected aura
|
||||
// needs at least one discriminator past its family — a flag overlap, an
|
||||
// icon, or both. Neither would match every aura of a class.
|
||||
if ((triggerSpellId == 0 && triggerFamily == 0) ||
|
||||
(affectedMask == 0 && affectedIcon == 0) ||
|
||||
op < MOD_REFRESH || op > MOD_REMOVE)
|
||||
return false;
|
||||
for (int i = 0; i < g_modCount; ++i) { // replace an identical rule
|
||||
@@ -1395,10 +1515,14 @@ const Net::PacketDispatch::AutoSubscribe _spellGoSub{&SpellGoSub};
|
||||
|
||||
// ---- Aura-application co-hooks (timing for proc / triggered auras) -------
|
||||
|
||||
// Classify by the absolute aura slot: 0..BUFF_COUNT-1 = buff (helpful),
|
||||
// BUFF_COUNT..TOTAL-1 = debuff (harmful).
|
||||
int8_t KindForSlot(int slot) {
|
||||
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
|
||||
// Defer to `Aura::Data::IsSlotHarmful` rather than testing the range here — how
|
||||
// polarity is encoded differs between servers, and that function is the one
|
||||
// place that decides. The descriptor write pass has already landed both the
|
||||
// slot and its flags by the time the application hooks fire.
|
||||
int8_t KindForSlot(const void *unit, int slot) {
|
||||
return Aura::Data::IsSlotHarmful(static_cast<const uint8_t *>(unit), slot)
|
||||
? KIND_HARMFUL
|
||||
: KIND_HELPFUL;
|
||||
}
|
||||
|
||||
// Stamp expiration for an aura that just landed/refreshed in `slot` on `unit`.
|
||||
@@ -1454,7 +1578,7 @@ void StampApplication(void *unit, uint32_t spellId, int slot) {
|
||||
}
|
||||
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
|
||||
StoreFromApplication(unitGuid, spellId, caster, expirationMs, durationMs,
|
||||
slot, KindForSlot(slot));
|
||||
slot, KindForSlot(unit, slot));
|
||||
}
|
||||
|
||||
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
|
||||
@@ -1641,7 +1765,9 @@ int RefreshJudgements(uint64_t unitGuid, uint64_t attackerGuid) {
|
||||
uint32_t RefreshDurationByFamily(uint64_t unitGuid, uint32_t family,
|
||||
uint64_t mask, uint32_t icon,
|
||||
uint64_t casterGuid) {
|
||||
if (unitGuid == 0 || mask == 0 || casterGuid == 0)
|
||||
// Same selector invariant the rule registrar enforces: family plus at
|
||||
// least one of flag-overlap / icon.
|
||||
if (unitGuid == 0 || (mask == 0 && icon == 0) || casterGuid == 0)
|
||||
return 0;
|
||||
const uint32_t now = NowMs();
|
||||
for (int i = 0; i < g_usedHigh; ++i) {
|
||||
|
||||
+9
-6
@@ -61,10 +61,11 @@ 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.
|
||||
// duration rules use — SpellFamilyName plus a family-flag overlap and/or a
|
||||
// SpellIconID, at least one of the two — 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);
|
||||
@@ -108,8 +109,10 @@ enum DurationModOp {
|
||||
};
|
||||
|
||||
// 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
|
||||
// exact `triggerSpellId`; the affected aura by SpellFamilyName plus an
|
||||
// `affectedMask` family-flag overlap and/or an `affectedIcon`, at least one of
|
||||
// the two (0 drops that half of the test; a custom spell with no family flags
|
||||
// is named by its icon alone). `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).
|
||||
|
||||
+30
-20
@@ -11,28 +11,32 @@
|
||||
// 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.
|
||||
// Inline-texture (`|T`, `|A`) 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.
|
||||
// breaks that parity for the two texture markers: it detects the neutralized
|
||||
// `||T` / `||A` form and draws the art 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.
|
||||
// This restores vanilla's behavior for both markers by defanging them 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 escape 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
|
||||
// Defang = replace the `|` of each opener with a space, breaking the pipe→letter
|
||||
// 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.
|
||||
// `|T` substring, so this catches the doubled form too). The payload text stays
|
||||
// readable, the closer is inert without an opener, and every other escape is
|
||||
// untouched.
|
||||
//
|
||||
// ANY new marker the emitter learns MUST be added here in the same change, or it
|
||||
// becomes a chat-spoof vector.
|
||||
|
||||
#include "IconFilter.h"
|
||||
|
||||
@@ -40,10 +44,16 @@ namespace Chat::IconFilter {
|
||||
|
||||
namespace {
|
||||
|
||||
// True if `s` contains an inline-texture opener (`|T`) anywhere.
|
||||
// True if `s` contains an inline-texture opener anywhere. Both markers count:
|
||||
// `|T` names a texture path and `|A` names an atlas, and either one would render
|
||||
// art from player-typed text if it survived to the fontstring.
|
||||
bool IsIconOpener(const char *s) {
|
||||
return s[0] == '|' && (s[1] == 'T' || s[1] == 'A');
|
||||
}
|
||||
|
||||
bool HasIconEscape(const char *s) {
|
||||
for (; *s != '\0'; ++s)
|
||||
if (s[0] == '|' && s[1] == 'T')
|
||||
if (IsIconOpener(s))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
@@ -68,12 +78,12 @@ const char *Sanitize(const char *msg, char *buf, size_t bufSize) {
|
||||
// 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') {
|
||||
if (IsIconOpener(msg + i)) {
|
||||
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`
|
||||
buf[j++] = ' '; // blank the `T` / `A`
|
||||
i += 2; // consumed the opener
|
||||
} else {
|
||||
buf[j++] = msg[i++];
|
||||
}
|
||||
|
||||
@@ -86,13 +86,18 @@ std::string StripMarkup(const char *s, unsigned len) {
|
||||
++i;
|
||||
break; // the |h is dropped by the 'h' case on the next iteration
|
||||
case 't': // stray texture closer — drop
|
||||
case 'a': // stray atlas closer — drop
|
||||
i += 2;
|
||||
break;
|
||||
case 'T': // texture opener — skip through to the next |t
|
||||
case 'A': // atlas opener — skip through to the next |a
|
||||
{
|
||||
const char closer = (s[i + 1] == 'A') ? 'a' : 't';
|
||||
i += 2;
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
|
||||
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == closer))
|
||||
++i;
|
||||
break; // the |t is dropped by the 't' case on the next iteration
|
||||
break; // the closer is dropped by the 't'/'a' case next iteration
|
||||
}
|
||||
default: // unknown sequence — keep the pipe literally
|
||||
out.push_back('|');
|
||||
++i;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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.
|
||||
|
||||
// `-config <name>` — a launch switch that swaps which settings file the client
|
||||
// uses, so one install can hold several profiles.
|
||||
//
|
||||
// The engine already names the config file through a single pointer: boot passes
|
||||
// `"Config.wtf"` to the config initializer, which keeps it and uses it to both
|
||||
// read the file at startup and write it at exit. Supplying a different string is
|
||||
// therefore the whole feature — no second code path, and no way for reading and
|
||||
// writing to disagree. See `Offsets::PATCH_CONFIG_FILENAME_PTR`.
|
||||
|
||||
#include "FileSwitch.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace Config::FileSwitch {
|
||||
|
||||
namespace {
|
||||
|
||||
// The engine holds this pointer for the life of the process and reads through it
|
||||
// again at exit to save, so the storage has to outlive everything — hence a
|
||||
// file-static buffer rather than anything owned by a scope.
|
||||
char g_configName[64];
|
||||
|
||||
bool IsSpace(char c) { return c == ' ' || c == '\t'; }
|
||||
|
||||
bool EqualsIgnoreCase(const char *a, const char *b, size_t len) {
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
char ca = a[i];
|
||||
char cb = b[i];
|
||||
if (ca >= 'A' && ca <= 'Z')
|
||||
ca = static_cast<char>(ca - 'A' + 'a');
|
||||
if (cb >= 'A' && cb <= 'Z')
|
||||
cb = static_cast<char>(cb - 'A' + 'a');
|
||||
if (ca != cb)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A filename is required to be a plain name. The engine builds the save path as
|
||||
// `WTF\` + this, and a subdirectory that does not exist makes the save fail
|
||||
// silently, which would throw away a session's settings with nothing to show for
|
||||
// it. Refusing the switch is the loud failure that a lost config file is not.
|
||||
bool IsPlainFilename(const char *name) {
|
||||
if (*name == '\0')
|
||||
return false;
|
||||
for (const char *p = name; *p != '\0'; ++p) {
|
||||
if (*p == '\\' || *p == '/' || *p == ':')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copies the token at `p` into `out`, honoring double quotes so a name with a
|
||||
// space still arrives whole. Returns false when the token is empty or too long.
|
||||
bool ReadToken(const char *p, char *out, size_t outSize) {
|
||||
const bool quoted = (*p == '"');
|
||||
if (quoted)
|
||||
++p;
|
||||
|
||||
size_t n = 0;
|
||||
while (*p != '\0' && (quoted ? (*p != '"') : !IsSpace(*p))) {
|
||||
if (n + 1 >= outSize)
|
||||
return false; // longer than we will accept
|
||||
out[n++] = *p++;
|
||||
}
|
||||
out[n] = '\0';
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
// Finds `-config <name>` (or `-config=<name>`) on the command line and copies the
|
||||
// name into `out`. Also accepts a `/` switch prefix, which Windows tools allow.
|
||||
bool ParseSwitch(char *out, size_t outSize) {
|
||||
const char *cmdLine = ::GetCommandLineA();
|
||||
if (cmdLine == nullptr)
|
||||
return false;
|
||||
|
||||
static const char kSwitch[] = "config";
|
||||
const size_t kSwitchLen = sizeof(kSwitch) - 1;
|
||||
|
||||
for (const char *p = cmdLine; *p != '\0'; ++p) {
|
||||
if (*p != '-' && *p != '/')
|
||||
continue;
|
||||
// Only at a token boundary, so a path containing "-config" cannot match.
|
||||
if (p != cmdLine && !IsSpace(p[-1]) && p[-1] != '"')
|
||||
continue;
|
||||
|
||||
const char *name = p + 1;
|
||||
if (!EqualsIgnoreCase(name, kSwitch, kSwitchLen))
|
||||
continue;
|
||||
|
||||
const char *after = name + kSwitchLen;
|
||||
if (*after == '=') {
|
||||
++after;
|
||||
} else if (IsSpace(*after)) {
|
||||
while (IsSpace(*after))
|
||||
++after;
|
||||
} else {
|
||||
continue; // a longer switch that merely starts with "config"
|
||||
}
|
||||
return ReadToken(after, out, outSize);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Apply() {
|
||||
if (!ParseSwitch(g_configName, sizeof g_configName))
|
||||
return;
|
||||
if (!IsPlainFilename(g_configName))
|
||||
return;
|
||||
|
||||
void *target = reinterpret_cast<void *>(Offsets::PATCH_CONFIG_FILENAME_PTR);
|
||||
DWORD previous = 0;
|
||||
if (!::VirtualProtect(target, sizeof(const char *), PAGE_EXECUTE_READWRITE, &previous))
|
||||
return;
|
||||
*reinterpret_cast<const char **>(target) = g_configName;
|
||||
::VirtualProtect(target, sizeof(const char *), previous, &previous);
|
||||
}
|
||||
|
||||
} // namespace Config::FileSwitch
|
||||
@@ -0,0 +1,37 @@
|
||||
// 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
|
||||
|
||||
// `-config <name>` — makes the client read and write `WTF\<name>` in place of
|
||||
// `WTF\Config.wtf`, so several settings profiles can live side by side.
|
||||
namespace Config::FileSwitch {
|
||||
|
||||
// Reads the switch from the process command line and, when present, points the
|
||||
// engine's config filename at it.
|
||||
//
|
||||
// MUST be called from DllMain, and is the one thing in this project that is.
|
||||
// The engine loads the config during boot, several calls before the point where
|
||||
// VanillaFixes invokes our `Load` export, so by the time the normal
|
||||
// initialization path runs the file has already been read — see
|
||||
// `Offsets::PATCH_CONFIG_FILENAME_PTR` for the full call ordering. DllMain is
|
||||
// early enough because VanillaFixes injects every DLL while the game process is
|
||||
// still suspended and resumes it only afterwards.
|
||||
//
|
||||
// This does not install a hook. It is a single 4-byte store into an immediate
|
||||
// operand, so it costs none of what makes hooking from DllMain a problem: no
|
||||
// trampoline, no thread suspension, no loader-lock work, and nothing for another
|
||||
// DLL's hook to collide with.
|
||||
void Apply();
|
||||
|
||||
} // namespace Config::FileSwitch
|
||||
@@ -13,8 +13,7 @@
|
||||
|
||||
#include "Storage.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "settings/Paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -25,22 +24,6 @@ namespace EquipmentSet::Storage {
|
||||
|
||||
namespace {
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadCharacterName() {
|
||||
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
|
||||
return (p[0] == '\0') ? nullptr : p;
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
// Removes trailing CR/LF/whitespace. Names from the engine are ASCII
|
||||
// only (the chat input filter rejects high bytes) so we don't have to
|
||||
// think about multi-byte trailing whitespace.
|
||||
@@ -74,23 +57,7 @@ bool ParseField(const std::string &line, const std::string &key, std::string *va
|
||||
} // namespace
|
||||
|
||||
std::string ResolveFilePath() {
|
||||
const char *account = ReadAccountName();
|
||||
if (account == nullptr || account[0] == '\0')
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (realm == nullptr || realm[0] == '\0')
|
||||
return {};
|
||||
const char *character = ReadCharacterName();
|
||||
if (character == nullptr)
|
||||
return {};
|
||||
std::string out = "WTF\\Account\\";
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += realm;
|
||||
out += '\\';
|
||||
out += character;
|
||||
out += "\\ClassicAPI_EquipmentSets.txt";
|
||||
return out;
|
||||
return Settings::Paths::CharacterFile("ClassicAPI_EquipmentSets.txt");
|
||||
}
|
||||
|
||||
bool Load(const std::string &path, std::vector<Set> *outSets) {
|
||||
|
||||
@@ -407,6 +407,30 @@ static int __fastcall Script_C_Reputation_GetWatchedFactionData(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.GetFactionDataByID(factionID)` — modern table-shaped
|
||||
// accessor keyed by faction ID rather than by displayed-list position.
|
||||
// Returns nil when the ID has no `Faction.dbc` record.
|
||||
//
|
||||
// Unlike the index form, this doesn't need the faction to be in the
|
||||
// player's displayed reputation list: `ReadFactionData` fills an
|
||||
// unencountered faction cleanly (currentStanding 0, not at war), so a
|
||||
// lookup works for any real faction.
|
||||
static int __fastcall Script_C_Reputation_GetFactionDataByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, "Usage: C_Reputation.GetFactionDataByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
|
||||
FactionData d;
|
||||
if (!ReadFactionData(factionID, &d))
|
||||
return 0; // nil — no such faction
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
PushFactionDataTable(L, d);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.GetFactionDataByIndex(factionSortIndex)` — modern
|
||||
// table-shaped accessor over the displayed reputation list. 1-based
|
||||
// index covering the same range as vanilla's `GetFactionInfo(index)`
|
||||
@@ -440,6 +464,182 @@ static int __fastcall Script_C_Reputation_GetFactionDataByIndex(void *L) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.ToggleFactionAtWarByID(factionID)` — ClassicAPI
|
||||
// extension. Flips a faction's at-war state by ID rather than by
|
||||
// displayed-list position.
|
||||
//
|
||||
// Mirrors the stock `FactionToggleAtWar(index)` body exactly, minus the
|
||||
// index resolve: read the current at-war flag, negate it, hand it to the
|
||||
// engine's own setter. Every rule therefore stays with the engine —
|
||||
// refusing to make peace below -3000 standing, honouring the
|
||||
// peace-forced flag, the loot-session bail, and sending
|
||||
// `CMSG_SET_FACTION_ATWAR`. Going through the engine's setter also means
|
||||
// `Faction::UnitFactionPolyfill`'s hook on it fires `UNIT_FACTION`
|
||||
// ("any future C++ caller of the setter inherits the polyfill"), and its
|
||||
// change-detection keeps that silent when the engine refuses the toggle.
|
||||
//
|
||||
// Non-positive IDs are ignored (the stock path likewise skips
|
||||
// factionID 0, which is what its resolver returns for header rows).
|
||||
static int __fastcall Script_C_Reputation_ToggleFactionAtWarByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.ToggleFactionAtWarByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (factionID <= 0)
|
||||
return 0;
|
||||
|
||||
// `newState` is declared `int`, not `char`, to match the detour
|
||||
// `Faction::UnitFactionPolyfill` installs on this setter: a `char`
|
||||
// argument only defines the low byte of EDX, leaving the detour's
|
||||
// `int` parameter with undefined high bits.
|
||||
using GetAtWar_t = unsigned int(__fastcall *)(int factionID);
|
||||
using SetAtWar_t = void(__fastcall *)(int factionID, int newState);
|
||||
auto getAtWar = reinterpret_cast<GetAtWar_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FACTION_GET_AT_WAR));
|
||||
auto setAtWar = reinterpret_cast<SetAtWar_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FACTION_SET_AT_WAR));
|
||||
|
||||
setAtWar(factionID, (getAtWar(factionID) != 0) ? 0 : 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `factionID` → its 0..63 reputation-slot index, or -1 when the faction
|
||||
// has no slot (no record, or a header category). Same `Faction.dbc`
|
||||
// `RepListIndex` field `ReadFactionData` reads.
|
||||
static int RepSlotForFaction(int factionID) {
|
||||
if (factionID <= 0)
|
||||
return -1;
|
||||
const uint8_t *record = FactionRecord(factionID);
|
||||
if (record == nullptr)
|
||||
return -1;
|
||||
const int repListIndex = *reinterpret_cast<const int32_t *>(
|
||||
record + Offsets::OFF_FACTION_REP_LIST_INDEX);
|
||||
if (repListIndex < 0 || repListIndex >= Offsets::MAX_REP_SLOTS)
|
||||
return -1;
|
||||
return repListIndex;
|
||||
}
|
||||
|
||||
// Whether a reputation slot carries the INACTIVE flag.
|
||||
static bool RepSlotIsInactive(int repSlot) {
|
||||
auto *slot = reinterpret_cast<const uint8_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_PLAYER_REP_SLOTS) +
|
||||
static_cast<uintptr_t>(repSlot) * Offsets::REP_SLOT_STRIDE);
|
||||
return (*(slot + Offsets::OFF_REP_SLOT_FLAGS) &
|
||||
Offsets::REP_SLOT_FLAG_INACTIVE) != 0;
|
||||
}
|
||||
|
||||
// `C_Reputation.IsFactionActive(factionSortIndex)` — whether the faction
|
||||
// at a 1-based displayed-list position is NOT filed under "Inactive".
|
||||
//
|
||||
// Runs the same chain as the engine's own inactive check (resolve the
|
||||
// index to a faction id, then read the slot's INACTIVE bit) and returns
|
||||
// the inverse as a real boolean. A position that names no faction — out
|
||||
// of range, or a category header — reports `false`.
|
||||
static int __fastcall Script_C_Reputation_IsFactionActive(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.IsFactionActive(factionSortIndex)");
|
||||
return 0;
|
||||
}
|
||||
const int idx = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
|
||||
const int maxIdx = *reinterpret_cast<const int *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_FACTION_VISIBLE_MAX_INDEX));
|
||||
|
||||
const int factionID = (idx >= 0 && idx <= maxIdx) ? Resolver()(idx) : 0;
|
||||
const int repSlot = RepSlotForFaction(factionID);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, repSlot >= 0 && !RepSlotIsInactive(repSlot));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Reputation.IsFactionActiveByID(factionID)` — ClassicAPI extension.
|
||||
// The same answer keyed by faction id. Reports `false` for a faction
|
||||
// with no reputation slot.
|
||||
static int __fastcall Script_C_Reputation_IsFactionActiveByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.IsFactionActiveByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int repSlot =
|
||||
RepSlotForFaction(static_cast<int>(Game::Lua::ToNumber(L, 1)));
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, repSlot >= 0 && !RepSlotIsInactive(repSlot));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Shared body for the two by-ID inactive setters below. Hands the id to
|
||||
// the engine's own inactive setter, which flips the rep slot's INACTIVE
|
||||
// bit, tells the server, and rebuilds the displayed faction list so the
|
||||
// faction moves into or out of the "Inactive" category. That setter is
|
||||
// also what `Faction::UnitFactionPolyfill` hooks, so `UNIT_FACTION`
|
||||
// fires when the flag actually changes.
|
||||
//
|
||||
// `newState` is `int` rather than `char` to match that detour's
|
||||
// signature — see the note in ToggleFactionAtWarByID.
|
||||
static int SetFactionInactiveByID(void *L, int newState, const char *usage) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L, usage);
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (factionID <= 0)
|
||||
return 0;
|
||||
|
||||
using SetInactive_t = void(__fastcall *)(int factionID, int newState);
|
||||
auto fn = reinterpret_cast<SetInactive_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FACTION_SET_INACTIVE));
|
||||
fn(factionID, newState);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `C_Reputation.SetFactionInactiveByID(factionID)` — ClassicAPI
|
||||
// extension. Moves a faction into the "Inactive" category by ID rather
|
||||
// than by displayed-list position.
|
||||
static int __fastcall Script_C_Reputation_SetFactionInactiveByID(void *L) {
|
||||
return SetFactionInactiveByID(
|
||||
L, 1, "Usage: C_Reputation.SetFactionInactiveByID(factionID)");
|
||||
}
|
||||
|
||||
// `C_Reputation.SetFactionActiveByID(factionID)` — the inverse: takes a
|
||||
// faction back out of the "Inactive" category.
|
||||
static int __fastcall Script_C_Reputation_SetFactionActiveByID(void *L) {
|
||||
return SetFactionInactiveByID(
|
||||
L, 0, "Usage: C_Reputation.SetFactionActiveByID(factionID)");
|
||||
}
|
||||
|
||||
// `C_Reputation.SetSelectedFactionByID(factionID)` — ClassicAPI
|
||||
// extension. Selects a faction in the reputation pane by ID rather
|
||||
// than by displayed-list position, the same convenience
|
||||
// `SetWatchedFactionByID` gives over `SetWatchedFactionIndex`.
|
||||
//
|
||||
// The engine already stores the selection AS a factionID
|
||||
// (`VAR_FACTION_SELECTED_ID`) — the stock index form only resolves its
|
||||
// argument to an id before storing it — so this writes the id straight
|
||||
// through, with no list walk and no dependence on the faction being
|
||||
// currently listed. `GetSelectedFaction()` maps it back to an index,
|
||||
// reporting 0 while the faction isn't in the displayed list.
|
||||
//
|
||||
// Negative IDs are ignored; 0 clears the selection.
|
||||
static int __fastcall Script_C_Reputation_SetSelectedFactionByID(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 1)) {
|
||||
Game::Lua::Error(L,
|
||||
"Usage: C_Reputation.SetSelectedFactionByID(factionID)");
|
||||
return 0;
|
||||
}
|
||||
const int factionID = static_cast<int>(Game::Lua::ToNumber(L, 1));
|
||||
if (factionID < 0)
|
||||
return 0;
|
||||
|
||||
*reinterpret_cast<int32_t *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_FACTION_SELECTED_ID)) = factionID;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `C_Reputation.SetWatchedFactionByID(factionID)` — sets the faction
|
||||
// shown above the XP bar by ID rather than by displayed-list index.
|
||||
// Modern API; vanilla 1.12 only exposes `SetWatchedFactionIndex(idx)`,
|
||||
@@ -475,10 +675,46 @@ static void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterGlobalFunction("GetFactionParentID", &Script_GetFactionParentID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetWatchedFactionByID",
|
||||
&Script_C_Reputation_SetWatchedFactionByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetSelectedFactionByID",
|
||||
&Script_C_Reputation_SetSelectedFactionByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "ToggleFactionAtWarByID",
|
||||
&Script_C_Reputation_ToggleFactionAtWarByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "IsFactionActive",
|
||||
&Script_C_Reputation_IsFactionActive);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "IsFactionActiveByID",
|
||||
&Script_C_Reputation_IsFactionActiveByID);
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "IsFactionInactive",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_IS_FACTION_INACTIVE)));
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetFactionInactiveByID",
|
||||
&Script_C_Reputation_SetFactionInactiveByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "SetFactionActiveByID",
|
||||
&Script_C_Reputation_SetFactionActiveByID);
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "SetFactionInactive",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_FACTION_INACTIVE)));
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "SetFactionActive",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_FACTION_ACTIVE)));
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "ToggleFactionAtWar",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_FACTION_TOGGLE_AT_WAR)));
|
||||
// The stock global, mirrored into the namespace — the engine's own
|
||||
// handler already has the Lua C ABI, so it registers as-is.
|
||||
Game::Lua::RegisterTableFunction(
|
||||
"C_Reputation", "SetSelectedFaction",
|
||||
reinterpret_cast<Game::Lua::CFunction>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_SCRIPT_SET_SELECTED_FACTION)));
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetWatchedFactionData",
|
||||
&Script_C_Reputation_GetWatchedFactionData);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionStandings",
|
||||
&Script_C_Reputation_GetFactionStandings);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionDataByID",
|
||||
&Script_C_Reputation_GetFactionDataByID);
|
||||
Game::Lua::RegisterTableFunction("C_Reputation", "GetFactionDataByIndex",
|
||||
&Script_C_Reputation_GetFactionDataByIndex);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
|
||||
#include "Notes.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "settings/Paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -41,43 +40,11 @@ std::unordered_map<uint64_t, Entry> g_notes;
|
||||
std::string g_path;
|
||||
bool g_loaded = false;
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
const char *ReadCharacterName() {
|
||||
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
|
||||
return (p[0] == '\0') ? nullptr : p;
|
||||
}
|
||||
|
||||
// WTF\Account\<acct>\<realm>\<char>\ClassicAPI_FriendNotes.txt, or empty until
|
||||
// the account + realm + character session globals are populated. Per-character
|
||||
// because the friends list is per-character in vanilla.
|
||||
std::string ResolveFilePath() {
|
||||
const char *account = ReadAccountName();
|
||||
if (account == nullptr || account[0] == '\0')
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (realm == nullptr || realm[0] == '\0')
|
||||
return {};
|
||||
const char *character = ReadCharacterName();
|
||||
if (character == nullptr)
|
||||
return {};
|
||||
std::string out = "WTF\\Account\\";
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += realm;
|
||||
out += '\\';
|
||||
out += character;
|
||||
out += "\\ClassicAPI_FriendNotes.txt";
|
||||
return out;
|
||||
return Settings::Paths::CharacterFile("ClassicAPI_FriendNotes.txt");
|
||||
}
|
||||
|
||||
// Notes are a single line each; the file is tab-delimited, so a note must
|
||||
|
||||
+100
-4
@@ -54,10 +54,10 @@ double FloatField(const uint8_t *rec, int off) {
|
||||
return *reinterpret_cast<const float *>(rec + off);
|
||||
}
|
||||
|
||||
// Vanilla zone world-map canvas size (WorldMapDetailFrame); WorldMapOverlay
|
||||
// hit rects live in this pixel space.
|
||||
constexpr double kZoneCanvasW = 1002.0;
|
||||
constexpr double kZoneCanvasH = 668.0;
|
||||
// Zone world-map canvas size (WorldMapDetailFrame); WorldMapOverlay hit
|
||||
// rects live in this pixel space. Shared with the map-art readers.
|
||||
constexpr double kZoneCanvasW = kMapCanvasWidth;
|
||||
constexpr double kZoneCanvasH = kMapCanvasHeight;
|
||||
|
||||
// How deep inside its containing WorldMapOverlay hit rect the map-percent point
|
||||
// (0..100) sits on WMA row `wmaRow` — the max, over that zone's overlays whose
|
||||
@@ -211,6 +211,102 @@ bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMap
|
||||
return false;
|
||||
}
|
||||
|
||||
int RowForUiMapID(int uiMapID) {
|
||||
if (uiMapID > 0)
|
||||
return RowForAreaID(static_cast<uint32_t>(uiMapID));
|
||||
if (uiMapID < 0) {
|
||||
const int row = -uiMapID;
|
||||
if (DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row)) != nullptr)
|
||||
return row;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int UiMapIDForRow(int row) {
|
||||
if (row <= 0)
|
||||
return 0;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row));
|
||||
if (rec == nullptr)
|
||||
return 0;
|
||||
const int areaID = Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID);
|
||||
return (areaID != 0) ? areaID : -row;
|
||||
}
|
||||
|
||||
int ContinentRowForMapID(int mapID) {
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
if (Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID) != mapID)
|
||||
continue;
|
||||
if (Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID) != 0)
|
||||
continue; // a zone row, not the continent-spanning one
|
||||
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
|
||||
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
|
||||
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
|
||||
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
if (left - right <= 0.0 || top - bottom <= 0.0)
|
||||
continue; // stray zero-rect continent row (the "World" row)
|
||||
return id;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool RowRect(int row, double *outLeft, double *outRight, double *outTop,
|
||||
double *outBottom, int *outMapID) {
|
||||
if (row <= 0)
|
||||
return false;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row));
|
||||
if (rec == nullptr)
|
||||
return false;
|
||||
if (outLeft != nullptr)
|
||||
*outLeft = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
|
||||
if (outRight != nullptr)
|
||||
*outRight = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
|
||||
if (outTop != nullptr)
|
||||
*outTop = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
|
||||
if (outBottom != nullptr)
|
||||
*outBottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
|
||||
if (outMapID != nullptr)
|
||||
*outMapID = Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PercentInRow(int row, float x, float y, double *outPx, double *outPy) {
|
||||
double left, right, top, bottom;
|
||||
if (!RowRect(row, &left, &right, &top, &bottom, nullptr))
|
||||
return false;
|
||||
const double spanY = left - right; // horizontal (world Y)
|
||||
const double spanX = top - bottom; // vertical (world X)
|
||||
if (spanX <= 0.0 || spanY <= 0.0)
|
||||
return false;
|
||||
*outPx = (left - y) / spanY;
|
||||
*outPy = (top - x) / spanX;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WorldFromRow(int row, double px, double py, double *outX, double *outY) {
|
||||
double left, right, top, bottom;
|
||||
if (!RowRect(row, &left, &right, &top, &bottom, nullptr))
|
||||
return false;
|
||||
const double spanY = left - right;
|
||||
const double spanX = top - bottom;
|
||||
if (spanX <= 0.0 || spanY <= 0.0)
|
||||
return false;
|
||||
*outY = left - px * spanY;
|
||||
*outX = top - py * spanX;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy) {
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
// place.
|
||||
namespace Map::Area {
|
||||
|
||||
// The world map's detail canvas in pixels (`WorldMapDetailFrame`) and the
|
||||
// size of one background tile — both taken from the client's own
|
||||
// `WorldMapFrame.xml`, where the frame is 1002x668 and each of the twelve
|
||||
// `WorldMapDetailTile` textures is 256x256 (tile 5 anchors below tile 1, so
|
||||
// the grid is 4 wide by 3 tall). A map's background is that grid, and
|
||||
// `WorldMapOverlay` placement/hit rects are authored in the same space.
|
||||
//
|
||||
// These are deliberately CONSTANTS rather than a live read of
|
||||
// `WorldMapDetailFrame`, which would look like the more "engine-driven"
|
||||
// choice and is in fact the wrong one, twice over:
|
||||
//
|
||||
// - The frame is addon-mutable. Map addons resize and rescale it, but the
|
||||
// DBC coordinates these normalize don't move — they were authored
|
||||
// against the shipped size. Reading the frame live would silently skew
|
||||
// every overlay the moment someone scaled the map.
|
||||
// - They are LOGICAL sizes, not on-disk ones. An HD map patch ships
|
||||
// larger tile files; `Map::Overlays` detects that from the real BLP
|
||||
// dimensions and scales. Reporting a patched tile's true size here
|
||||
// would break the `canvas / tile` division that yields the 4x3 grid.
|
||||
//
|
||||
// So the authored value is the truth, and the runtime one is the mutable
|
||||
// approximation — the reverse of the usual case.
|
||||
constexpr double kMapCanvasWidth = 1002.0;
|
||||
constexpr double kMapCanvasHeight = 668.0;
|
||||
constexpr int kMapTileSize = 256;
|
||||
|
||||
// WorldMapArea row for an AreaTable `areaID` (the stable zone identity
|
||||
// `C_Map.GetBestMapForUnit` returns). Linear walk of the ~175-row table.
|
||||
// Returns -1 when no row carries that areaID.
|
||||
@@ -76,4 +102,45 @@ bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMap
|
||||
// projection retail's TaxiNodeInfo.position uses.
|
||||
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy);
|
||||
|
||||
// --- uiMapID identity ------------------------------------------------------
|
||||
//
|
||||
// A ClassicAPI `uiMapID` names either a zone or a whole map:
|
||||
// positive = an `AreaTable.dbc` area id (a zone) — what
|
||||
// `C_Map.GetBestMapForUnit` returns.
|
||||
// negative = -(WorldMapArea row) — continent / world / instance maps,
|
||||
// which carry no AreaTable id (their rows have areaID 0).
|
||||
// The negative half mirrors the engine's own two-namespaces-in-one-int idiom
|
||||
// (a quest's `zoneOrSort`: positive = AreaTable zone, negative = -QuestSort
|
||||
// row), so the two id spaces can never collide.
|
||||
|
||||
// WorldMapArea row for a `uiMapID` in either namespace. Returns -1 when the
|
||||
// id resolves to no row.
|
||||
int RowForUiMapID(int uiMapID);
|
||||
|
||||
// The `uiMapID` that names WorldMapArea row `row` — its areaID when it has
|
||||
// one, else -(row). Returns 0 for an unreadable row.
|
||||
int UiMapIDForRow(int row);
|
||||
|
||||
// The continent-level WorldMapArea row for a `Map.dbc` map: the areaID == 0
|
||||
// row with a non-degenerate rect (the same selection `ContinentPercent`
|
||||
// makes, so the stray zero-rect "World" row never wins). -1 when none.
|
||||
int ContinentRowForMapID(int mapID);
|
||||
|
||||
// Reads WorldMapArea row `row`'s placement rect and its `Map.dbc` map id.
|
||||
// Every out-parameter is optional (pass null to skip it). False when the row
|
||||
// is unreadable.
|
||||
bool RowRect(int row, double *outLeft, double *outRight, double *outTop,
|
||||
double *outBottom, int *outMapID);
|
||||
|
||||
// Projects world (x, y) into row `row`'s rect as 0..1 (`outPx` horizontal off
|
||||
// world Y, `outPy` vertical off world X). Unlike `PercentInZone` there is NO
|
||||
// containment gate — a point outside the rect yields a value outside 0..1, and
|
||||
// the caller decides whether that is meaningful. False for a missing or
|
||||
// degenerate rect.
|
||||
bool PercentInRow(int row, float x, float y, double *outPx, double *outPy);
|
||||
|
||||
// Inverse of `PercentInRow`: a 0..1 position on row `row` back to
|
||||
// continent-space world coordinates. False for a missing or degenerate rect.
|
||||
bool WorldFromRow(int row, double px, double py, double *outX, double *outY);
|
||||
|
||||
} // namespace Map::Area
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map` coordinate transforms — the world↔map projection layer that sits on
|
||||
// top of `WorldMapArea.dbc`'s per-zone placement rects:
|
||||
//
|
||||
// GetPlayerMapPosition(uiMapID, unitToken) -> position(vector2)
|
||||
// GetWorldPosFromMapPos(uiMapID, mapPos) -> continentID, worldPos(vector2)
|
||||
// GetMapPosFromWorldPos(continentID, worldPos [, overrideUiMapID])
|
||||
// -> uiMapID, mapPos(vector2)
|
||||
// GetMapRectOnMap(uiMapID, topUiMapID) -> minX, maxX, minY, maxY
|
||||
//
|
||||
// ## The identity scheme (shared with the rest of `Map::*`)
|
||||
//
|
||||
// `uiMapID` is a vanilla `AreaTable.dbc` area id for a zone — the same
|
||||
// identity `C_Map.GetBestMapForUnit` returns and `GetMapWorldSize` /
|
||||
// `GetMapOverlays` accept — or a NEGATIVE `-(WorldMapArea row)` for a
|
||||
// continent / world / instance map (see `Map::Area`'s uiMapID section), so a
|
||||
// continent map works anywhere a zone does. `continentID` is a `Map.dbc` map
|
||||
// id (0 = Eastern Kingdoms, 1 = Kalimdor, or an instance map). World
|
||||
// coordinates are continent-space
|
||||
// (WoW world axes: +X north, +Y west), exactly what `AreaTrigger.dbc` stores
|
||||
// and what the object `GetPosition` virtual returns. All four functions reuse
|
||||
// `Map::Area`'s zone rect math (`PercentInZone`, `ZonePercent`,
|
||||
// `ContinentPercent`, `RowForAreaID`) so the world↔map convention lives in one
|
||||
// place.
|
||||
//
|
||||
// ## vector2 returns
|
||||
//
|
||||
// Positions are returned as `Vector2DMixin` objects (built through the
|
||||
// `CreateVector2D` global) so consumers can call `pos:GetXY()` / read `pos.x`.
|
||||
// If that global is unavailable, a plain `{x=, y=}` table carries the same
|
||||
// coordinates.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "map/Area.h"
|
||||
#include "map/Vector2.h"
|
||||
#include "unit/Position.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Map::Coordinates {
|
||||
|
||||
namespace {
|
||||
|
||||
// True when a projected 0..1 map position actually lands on the map.
|
||||
bool OnMap(double px, double py) {
|
||||
return px >= 0.0 && px <= 1.0 && py >= 0.0 && py <= 1.0;
|
||||
}
|
||||
|
||||
using Map::PushVector2D;
|
||||
|
||||
// Reads `t.x` / `t.y` from the table at absolute stack index `idx`.
|
||||
bool ReadXY(void *L, int idx, double *x, double *y) {
|
||||
return Map::ReadVector2D(L, idx, x, y);
|
||||
}
|
||||
|
||||
// `C_Map.GetPlayerMapPosition(uiMapID, unitToken)` — the unit's normalized
|
||||
// (0..1) position within zone `uiMapID`, or nil when the unit's live world
|
||||
// position isn't inside that zone's rect (wrong zone, an instance with no map
|
||||
// row, or no position yet). Invalid unit tokens error like other unit
|
||||
// functions (`UnitHealth("garbage")`).
|
||||
int __fastcall Script_GetPlayerMapPosition(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) && Game::Lua::IsString(L, 2);
|
||||
const int areaID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const char *token = ok ? Game::Lua::ToString(L, 2) : nullptr;
|
||||
|
||||
const int row = ok ? Map::Area::RowForUiMapID(areaID) : -1;
|
||||
float pos[3];
|
||||
double px = 0.0, py = 0.0;
|
||||
const bool have = ok && token != nullptr && row > 0 &&
|
||||
Unit::Position::ReadToken(token, pos) &&
|
||||
Map::Area::PercentInRow(row, pos[0], pos[1], &px, &py) &&
|
||||
OnMap(px, py);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushVector2D(L, px, py);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetWorldPosFromMapPos(uiMapID, mapPosition)` — the continent-space
|
||||
// world position of a normalized (0..1) point on zone `uiMapID`. Returns
|
||||
// `continentID, worldPosition`; nil when the zone has no map row. Inverse of
|
||||
// `GetMapPosFromWorldPos`.
|
||||
int __fastcall Script_GetWorldPosFromMapPos(void *L) {
|
||||
const bool ok =
|
||||
Game::Lua::IsNumber(L, 1) && Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE;
|
||||
const int areaID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
|
||||
const int row = ok ? Map::Area::RowForUiMapID(areaID) : -1;
|
||||
double mx = 0.0, my = 0.0;
|
||||
double worldX = 0.0, worldY = 0.0;
|
||||
int continentID = 0;
|
||||
const bool have = ok && ReadXY(L, 2, &mx, &my) && row > 0 &&
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr,
|
||||
&continentID) &&
|
||||
Map::Area::WorldFromRow(row, mx, my, &worldX, &worldY);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
Game::Lua::PushNumber(L, static_cast<double>(continentID));
|
||||
PushVector2D(L, worldX, worldY); // vector.x = world X (north), .y = world Y (west)
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapPosFromWorldPos(continentID, worldPosition [, overrideUiMapID])`
|
||||
// — the map position of a continent-space world point. With `overrideUiMapID`,
|
||||
// projects into that specific zone (nil if the point is outside its rect);
|
||||
// without it, resolves the zone the point falls in. Returns `uiMapID,
|
||||
// mapPosition`. (Vanilla has no continent-level uiMapID, so the no-override
|
||||
// form resolves to the containing zone rather than continent-normalizing.)
|
||||
int __fastcall Script_GetMapPosFromWorldPos(void *L) {
|
||||
const bool ok =
|
||||
Game::Lua::IsNumber(L, 1) && Game::Lua::Type(L, 2) == Game::Lua::TYPE_TABLE;
|
||||
const int continentID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const bool hasOverride = Game::Lua::IsNumber(L, 3);
|
||||
const int overrideArea = hasOverride ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
|
||||
|
||||
double wx = 0.0, wy = 0.0;
|
||||
const bool readOk = ok && ReadXY(L, 2, &wx, &wy);
|
||||
|
||||
int areaID = 0;
|
||||
double mapX = 0.0, mapY = 0.0;
|
||||
bool have = false;
|
||||
if (readOk) {
|
||||
if (hasOverride) {
|
||||
const int row = Map::Area::RowForUiMapID(overrideArea);
|
||||
double px = 0.0, py = 0.0;
|
||||
have = row > 0 &&
|
||||
Map::Area::PercentInRow(row, static_cast<float>(wx),
|
||||
static_cast<float>(wy), &px, &py) &&
|
||||
OnMap(px, py);
|
||||
if (have) {
|
||||
areaID = overrideArea;
|
||||
mapX = px * 100.0; // normalized below with the ZonePercent path
|
||||
mapY = py * 100.0;
|
||||
}
|
||||
} else {
|
||||
have = Map::Area::ZonePercent(continentID, static_cast<float>(wx),
|
||||
static_cast<float>(wy), &areaID, &mapX, &mapY);
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Game::Lua::PushNumber(L, static_cast<double>(areaID));
|
||||
PushVector2D(L, mapX / 100.0, mapY / 100.0);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapRectOnMap(uiMapID, topUiMapID)` — the normalized (0..1)
|
||||
// rectangle that zone `uiMapID` occupies on its continent map, as
|
||||
// `minX, maxX, minY, maxY`. `topUiMapID` names the containing continent; since
|
||||
// vanilla has no continent uiMapID, the rect is computed on the zone's own
|
||||
// continent. Returns nothing (→ nil ×4) when the zone has no map row.
|
||||
int __fastcall Script_GetMapRectOnMap(void *L) {
|
||||
const int areaID = Game::Lua::IsNumber(L, 1) ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int topID = Game::Lua::IsNumber(L, 2) ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
|
||||
|
||||
const int row = Map::Area::RowForUiMapID(areaID);
|
||||
double left = 0.0, right = 0.0, top = 0.0, bottom = 0.0;
|
||||
int continentID = 0;
|
||||
double aPx = 0.0, aPy = 0.0, bPx = 0.0, bPy = 0.0;
|
||||
bool have = row > 0 && Map::Area::RowRect(row, &left, &right, &top, &bottom,
|
||||
&continentID);
|
||||
if (have) {
|
||||
// `topUiMapID` names the map to measure against; fall back to the
|
||||
// zone's own continent when it isn't a usable map.
|
||||
int topRow = (topID != 0) ? Map::Area::RowForUiMapID(topID) : -1;
|
||||
if (topRow <= 0)
|
||||
topRow = Map::Area::ContinentRowForMapID(continentID);
|
||||
// Project the rect's two extreme world corners onto that map.
|
||||
// Corner A = (world X=top, Y=left); B = (bottom, right).
|
||||
have = topRow > 0 &&
|
||||
Map::Area::PercentInRow(topRow, static_cast<float>(top),
|
||||
static_cast<float>(left), &aPx, &aPy) &&
|
||||
Map::Area::PercentInRow(topRow, static_cast<float>(bottom),
|
||||
static_cast<float>(right), &bPx, &bPy);
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have)
|
||||
return 0; // nil, nil, nil, nil
|
||||
|
||||
const double minX = aPx < bPx ? aPx : bPx;
|
||||
const double maxX = aPx > bPx ? aPx : bPx;
|
||||
const double minY = aPy < bPy ? aPy : bPy;
|
||||
const double maxY = aPy > bPy ? aPy : bPy;
|
||||
Game::Lua::PushNumber(L, minX);
|
||||
Game::Lua::PushNumber(L, maxX);
|
||||
Game::Lua::PushNumber(L, minY);
|
||||
Game::Lua::PushNumber(L, maxY);
|
||||
return 4;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetPlayerMapPosition",
|
||||
&Script_GetPlayerMapPosition);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetWorldPosFromMapPos",
|
||||
&Script_GetWorldPosFromMapPos);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapPosFromWorldPos",
|
||||
&Script_GetMapPosFromWorldPos);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapRectOnMap",
|
||||
&Script_GetMapRectOnMap);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Coordinates
|
||||
@@ -0,0 +1,446 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map` map hierarchy — the tree the coordinate layer hangs off:
|
||||
//
|
||||
// GetMapInfo(uiMapID) -> UiMapDetails table
|
||||
// GetMapChildrenInfo(uiMapID) -> array of UiMapDetails
|
||||
// GetMapInfoAtPosition(uiMapID,x,y) -> UiMapDetails at a point
|
||||
// GetFallbackWorldMapID() -> the world map's uiMapID
|
||||
//
|
||||
// ## The tree
|
||||
//
|
||||
// Three levels, all read from `WorldMapArea.dbc` + `Map.dbc`:
|
||||
//
|
||||
// World (the "World" row)
|
||||
// +- Continent (Kalimdor, Eastern Kingdoms)
|
||||
// +- Zone (Durotar, Elwynn Forest, ...)
|
||||
//
|
||||
// A zone is named by its positive `AreaTable.dbc` area id. The World and
|
||||
// continent levels carry no AreaTable id (their `WorldMapArea` rows have
|
||||
// areaID 0), so they use the negative `-(WorldMapArea row)` half of the
|
||||
// uiMapID space — see the uiMapID section in `Map::Area`. Instance maps
|
||||
// (dungeons, raids, battlegrounds) are the same shape as continents: an
|
||||
// areaID-0 row, addressed negatively.
|
||||
//
|
||||
// A zone's parent is the continent-spanning row of the `Map.dbc` map it sits
|
||||
// on. Each continent's parent is the World row. The World row's own rect is
|
||||
// all zeroes, so it names the map but carries no geometry — the coordinate
|
||||
// functions correctly report nothing for it.
|
||||
//
|
||||
// ## mapType
|
||||
//
|
||||
// `Enum.UIMapType`: Cosmic 0, World 1, Continent 2, Zone 3, Dungeon 4. A
|
||||
// positive id is always a Zone. A negative id takes its type from the
|
||||
// `Map.dbc` instance type of the map it names (0 = world map → Continent,
|
||||
// anything else → Dungeon), except the World row itself.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "dbc/Lookup.h"
|
||||
#include "dbc/Names.h"
|
||||
#include "map/Area.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace Map::MapInfo {
|
||||
|
||||
namespace {
|
||||
|
||||
// Enum.UIMapType values we can produce.
|
||||
constexpr int kMapTypeWorld = 1;
|
||||
constexpr int kMapTypeContinent = 2;
|
||||
constexpr int kMapTypeZone = 3;
|
||||
constexpr int kMapTypeDungeon = 4;
|
||||
|
||||
// `Map.dbc` is indexed by map id, and map id 0 (Eastern Kingdoms) is a real
|
||||
// row — so the shared `DBC::Record` (which treats id 0 as "unused slot") can't
|
||||
// be used here. Mirrors the reader in `Instance::Info`.
|
||||
const uint8_t *MapRecord(int mapID) {
|
||||
if (mapID < 0)
|
||||
return nullptr;
|
||||
const int count = Game::Read<int>(Offsets::VAR_MAP_COUNT);
|
||||
if (mapID > count)
|
||||
return nullptr;
|
||||
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_MAP_RECORDS));
|
||||
if (records == nullptr)
|
||||
return nullptr;
|
||||
return records[mapID];
|
||||
}
|
||||
|
||||
// Localized `Map.dbc` display name ("Eastern Kingdoms", "Deadmines"), or null.
|
||||
const char *MapName(int mapID) {
|
||||
const uint8_t *rec = MapRecord(mapID);
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
const uint32_t locale = Game::Read<uint32_t>(Offsets::VAR_LOCALE_INDEX);
|
||||
const char *name = *reinterpret_cast<const char *const *>(
|
||||
rec + Offsets::OFF_MAP_NAME + locale * 4);
|
||||
if (name == nullptr || name[0] == '\0')
|
||||
return nullptr;
|
||||
return name;
|
||||
}
|
||||
|
||||
// `Map.dbc` instance type (0 = world map, 1 = dungeon, 2 = raid, 3 = BG).
|
||||
// -1 when the map has no row.
|
||||
int MapInstanceType(int mapID) {
|
||||
const uint8_t *rec = MapRecord(mapID);
|
||||
if (rec == nullptr)
|
||||
return -1;
|
||||
return Game::Read<int>(rec, Offsets::OFF_MAP_INSTANCE_TYPE);
|
||||
}
|
||||
|
||||
// The WorldMapArea directory name of a row ("Kalimdor", "World", ""), or null.
|
||||
const char *RowName(int row) {
|
||||
if (row <= 0)
|
||||
return nullptr;
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(row));
|
||||
if (rec == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(rec, Offsets::OFF_WMA_NAME);
|
||||
}
|
||||
|
||||
bool IsWorldRow(int row) {
|
||||
const char *name = RowName(row);
|
||||
return name != nullptr && std::strcmp(name, "World") == 0;
|
||||
}
|
||||
|
||||
// The WorldMapArea row the client uses for the whole-world map: the areaID-0
|
||||
// row named "World". -1 when the client has none.
|
||||
int WorldRow() {
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr ||
|
||||
Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID) != 0)
|
||||
continue;
|
||||
if (IsWorldRow(id))
|
||||
return id;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Builds the UiMapDetails table for `uiMapID` and leaves it on the stack top.
|
||||
// Returns false (pushing nothing) when the id names no map.
|
||||
bool PushMapInfo(void *L, int uiMapID) {
|
||||
const int row = Map::Area::RowForUiMapID(uiMapID);
|
||||
if (row <= 0)
|
||||
return false;
|
||||
|
||||
const char *name = nullptr;
|
||||
int mapType = kMapTypeZone;
|
||||
int parentMapID = 0;
|
||||
|
||||
if (uiMapID > 0) {
|
||||
// A zone: AreaTable name, parented to its continent.
|
||||
name = DBC::AreaName(static_cast<uint32_t>(uiMapID),
|
||||
/*resolveToParent=*/false);
|
||||
mapType = kMapTypeZone;
|
||||
int continentID = 0;
|
||||
if (Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr,
|
||||
&continentID)) {
|
||||
const int contRow = Map::Area::ContinentRowForMapID(continentID);
|
||||
if (contRow > 0)
|
||||
parentMapID = -contRow;
|
||||
}
|
||||
} else if (IsWorldRow(row)) {
|
||||
// The whole-world map: named by the row itself, no parent.
|
||||
name = RowName(row);
|
||||
mapType = kMapTypeWorld;
|
||||
parentMapID = 0;
|
||||
} else {
|
||||
// A continent or instance map: Map.dbc supplies the display name and
|
||||
// the type; continents hang off the world map.
|
||||
int mapID = 0;
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr, &mapID);
|
||||
name = MapName(mapID);
|
||||
if (name == nullptr)
|
||||
name = RowName(row); // custom map with no Map.dbc row
|
||||
const int instanceType = MapInstanceType(mapID);
|
||||
if (instanceType == 0) {
|
||||
mapType = kMapTypeContinent;
|
||||
const int worldRow = WorldRow();
|
||||
if (worldRow > 0)
|
||||
parentMapID = -worldRow;
|
||||
} else {
|
||||
mapType = kMapTypeDungeon;
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "mapID", static_cast<double>(uiMapID));
|
||||
Game::Lua::SetFieldString(L, "name", name);
|
||||
Game::Lua::SetFieldNumber(L, "mapType", static_cast<double>(mapType));
|
||||
Game::Lua::SetFieldNumber(L, "parentMapID", static_cast<double>(parentMapID));
|
||||
Game::Lua::SetFieldNumber(L, "flags", 0.0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapInfo(uiMapID)` — the map's details table, or nil.
|
||||
int __fastcall Script_GetMapInfo(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!ok || !PushMapInfo(L, uiMapID))
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapChildrenInfo(uiMapID)` — the maps one level below `uiMapID`:
|
||||
// the continents of the world map, or the zones of a continent / instance map.
|
||||
// Always returns a table (empty for a zone, which has no children).
|
||||
int __fastcall Script_GetMapChildrenInfo(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
|
||||
// A zone (positive id) has no children; anything else enumerates below.
|
||||
const bool wantContinents = (row > 0) && IsWorldRow(row);
|
||||
int parentMapDbcID = 0;
|
||||
const bool wantZones = (row > 0) && uiMapID < 0 && !wantContinents &&
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr,
|
||||
nullptr, &parentMapDbcID);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (!wantContinents && !wantZones)
|
||||
return 1;
|
||||
|
||||
const int count = Game::Read<int>(Offsets::VAR_WORLDMAP_AREA_COUNT);
|
||||
int outIdx = 0;
|
||||
for (int id = 1; id <= count; ++id) {
|
||||
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
|
||||
Offsets::VAR_WORLDMAP_AREA_COUNT,
|
||||
static_cast<uint32_t>(id));
|
||||
if (rec == nullptr)
|
||||
continue;
|
||||
const int areaID = Game::Read<int>(rec, Offsets::OFF_WMA_AREA_ID);
|
||||
const int mapID = Game::Read<int>(rec, Offsets::OFF_WMA_MAP_ID);
|
||||
|
||||
int childID = 0;
|
||||
if (wantContinents) {
|
||||
// Continent-spanning rows of real world maps, minus the World row.
|
||||
if (areaID != 0 || id == row || MapInstanceType(mapID) != 0)
|
||||
continue;
|
||||
if (Map::Area::ContinentRowForMapID(mapID) != id)
|
||||
continue; // skip the stray zero-rect duplicate
|
||||
childID = -id;
|
||||
} else {
|
||||
// Zone rows of this map.
|
||||
if (areaID == 0 || mapID != parentMapDbcID)
|
||||
continue;
|
||||
childID = areaID;
|
||||
}
|
||||
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
if (!PushMapInfo(L, childID)) {
|
||||
Game::Lua::SetTop(L, -2); // drop the index; nothing to store
|
||||
outIdx -= 1;
|
||||
continue;
|
||||
}
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapInfoAtPosition(uiMapID, x, y)` — the zone at a normalized
|
||||
// (0..1) point on `uiMapID`, or nil when the point is on no zone.
|
||||
int __fastcall Script_GetMapInfoAtPosition(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) && Game::Lua::IsNumber(L, 2) &&
|
||||
Game::Lua::IsNumber(L, 3);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const double px = ok ? Game::Lua::ToNumber(L, 2) : 0.0;
|
||||
const double py = ok ? Game::Lua::ToNumber(L, 3) : 0.0;
|
||||
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
double worldX = 0.0, worldY = 0.0;
|
||||
int mapID = 0;
|
||||
int areaID = 0;
|
||||
double mapX = 0.0, mapY = 0.0;
|
||||
const bool have =
|
||||
row > 0 &&
|
||||
Map::Area::RowRect(row, nullptr, nullptr, nullptr, nullptr, &mapID) &&
|
||||
Map::Area::WorldFromRow(row, px, py, &worldX, &worldY) &&
|
||||
Map::Area::ZonePercent(mapID, static_cast<float>(worldX),
|
||||
static_cast<float>(worldY), &areaID, &mapX, &mapY);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have || !PushMapInfo(L, areaID))
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A map's background is this many tiles (`NUM_WORLDMAP_DETAIL_TILES`).
|
||||
constexpr int kWorldMapDetailTiles = 12;
|
||||
|
||||
bool FileExists(const char *path) {
|
||||
using FileExists_t = int(__stdcall *)(const char *path, int mode);
|
||||
auto fn = reinterpret_cast<FileExists_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_FILE_EXISTS));
|
||||
return fn(path, 1) != 0;
|
||||
}
|
||||
|
||||
// Builds tile `n`'s texture path (1-based) for map directory `dir`, without
|
||||
// the file extension — the form `SetTexture` takes.
|
||||
void BuildTilePath(char *out, size_t size, const char *dir, int n) {
|
||||
std::snprintf(out, size, "Interface\\WorldMap\\%s\\%s%d", dir, dir, n);
|
||||
}
|
||||
|
||||
// True when the client ships the map directory's first background tile —
|
||||
// the test for "this map has art".
|
||||
bool DirHasArt(const char *dir) {
|
||||
if (dir == nullptr || dir[0] == '\0')
|
||||
return false;
|
||||
char path[0x120];
|
||||
BuildTilePath(path, sizeof(path), dir, 1);
|
||||
char probe[0x140];
|
||||
std::snprintf(probe, sizeof(probe), "%s.blp", path);
|
||||
return FileExists(probe);
|
||||
}
|
||||
|
||||
// `C_Map.MapHasArt(uiMapID)` — whether the map has a drawable background.
|
||||
//
|
||||
// A map's art is the tile set `Interface\WorldMap\<dir>\<dir>1..12`, where
|
||||
// `<dir>` is the WorldMapArea row's own name — the same directory the world
|
||||
// map frame builds its tile paths from. So the question is answered by
|
||||
// probing for the first tile: a row with no name, or one whose art the
|
||||
// client doesn't ship, has none.
|
||||
//
|
||||
// Note this is independent of whether a map has a coordinate rect. The
|
||||
// whole-world map has art but no rect, and an instance row can have a rect
|
||||
// but ship no art.
|
||||
int __fastcall Script_MapHasArt(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
|
||||
const bool hasArt = DirHasArt(RowName(row));
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, hasArt);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapArtLayers(uiMapID)` — the map's art layers, as an array of
|
||||
// layer descriptions. Always returns a table; a map with no art gives an
|
||||
// empty one.
|
||||
//
|
||||
// This client draws a map's background as one fixed layer, so there is at
|
||||
// most a single entry, and it is the same for every map that has art: a
|
||||
// 1002x668 canvas tiled in 256-pixel squares (a 4x3 grid — the twelve tiles
|
||||
// the world map frame draws). There is no zoom within a map, so the scale
|
||||
// range is 1 to 1 with no extra zoom steps.
|
||||
int __fastcall Script_GetMapArtLayers(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
const bool hasArt = DirHasArt(RowName(row));
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (!hasArt)
|
||||
return 1;
|
||||
|
||||
Game::Lua::PushNumber(L, 1.0);
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "layerWidth", Map::Area::kMapCanvasWidth);
|
||||
Game::Lua::SetFieldNumber(L, "layerHeight", Map::Area::kMapCanvasHeight);
|
||||
Game::Lua::SetFieldNumber(L, "tileWidth",
|
||||
static_cast<double>(Map::Area::kMapTileSize));
|
||||
Game::Lua::SetFieldNumber(L, "tileHeight",
|
||||
static_cast<double>(Map::Area::kMapTileSize));
|
||||
Game::Lua::SetFieldNumber(L, "minScale", 1.0);
|
||||
Game::Lua::SetFieldNumber(L, "maxScale", 1.0);
|
||||
Game::Lua::SetFieldNumber(L, "additionalZoomSteps", 0.0);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetMapArtLayerTextures(uiMapID, layerIndex)` — the background tile
|
||||
// textures for one of a map's art layers, in draw order (left to right, top
|
||||
// to bottom).
|
||||
//
|
||||
// A map here has exactly one art layer, the 12-tile background the world map
|
||||
// frame draws, so `layerIndex` 1 returns it and any other index returns an
|
||||
// empty table. Always returns a table.
|
||||
//
|
||||
// Entries are texture PATHS, ready to hand to `SetTexture`, rather than the
|
||||
// numeric file ids the modern call returns — this client identifies a texture
|
||||
// by its path, which is the same substitution `C_Map.GetMapOverlays` makes
|
||||
// for its `fileDataIDs`. Tiles the client doesn't ship are skipped, so a map
|
||||
// with no art gives an empty table.
|
||||
int __fastcall Script_GetMapArtLayerTextures(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) && Game::Lua::IsNumber(L, 2);
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const int layerIndex = ok ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
|
||||
const int row = ok ? Map::Area::RowForUiMapID(uiMapID) : -1;
|
||||
const char *dir = (layerIndex == 1) ? RowName(row) : nullptr;
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
if (dir == nullptr || dir[0] == '\0')
|
||||
return 1;
|
||||
|
||||
int outIdx = 0;
|
||||
for (int n = 1; n <= kWorldMapDetailTiles; ++n) {
|
||||
char path[0x120];
|
||||
BuildTilePath(path, sizeof(path), dir, n);
|
||||
char probe[0x140];
|
||||
std::snprintf(probe, sizeof(probe), "%s.blp", path);
|
||||
if (!FileExists(probe))
|
||||
continue;
|
||||
outIdx += 1;
|
||||
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
|
||||
Game::Lua::PushString(L, path);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetFallbackWorldMapID()` — the uiMapID of the whole-world map, the
|
||||
// safe default when no better map is known.
|
||||
int __fastcall Script_GetFallbackWorldMapID(void *L) {
|
||||
const int row = WorldRow();
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushNumber(L, (row > 0) ? static_cast<double>(-row) : 0.0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapInfo", &Script_GetMapInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapChildrenInfo",
|
||||
&Script_GetMapChildrenInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapInfoAtPosition",
|
||||
&Script_GetMapInfoAtPosition);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetFallbackWorldMapID",
|
||||
&Script_GetFallbackWorldMapID);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "MapHasArt", &Script_MapHasArt);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapArtLayerTextures",
|
||||
&Script_GetMapArtLayerTextures);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetMapArtLayers",
|
||||
&Script_GetMapArtLayers);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::MapInfo
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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 "Game.h"
|
||||
|
||||
// Shared `vector2` marshalling for the `C_Map` surface. Map positions cross
|
||||
// the C/Lua boundary as `Vector2DMixin` objects (so consumers can call
|
||||
// `pos:GetXY()` as well as read `.x` / `.y`), and several modules both return
|
||||
// and accept them — this keeps the stack handling in one place.
|
||||
|
||||
namespace Map {
|
||||
|
||||
// Pushes a `Vector2DMixin` built from (x, y) via the `CreateVector2D` global.
|
||||
// Falls back to a plain `{x=, y=}` table when that global isn't reachable
|
||||
// (the addon half isn't loaded). Leaves exactly one value on the stack.
|
||||
inline void PushVector2D(void *L, double x, double y) {
|
||||
const int base = Game::Lua::GetTop(L);
|
||||
Game::Lua::PushString(L, "CreateVector2D");
|
||||
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
|
||||
Game::Lua::PushNumber(L, x);
|
||||
Game::Lua::PushNumber(L, y);
|
||||
if (Game::Lua::PCall(L, 2, 1, 0) == 0)
|
||||
return; // Vector2DMixin object on top
|
||||
// pcall failed (global missing / not callable): drop the error object and
|
||||
// build a plain coordinate table instead.
|
||||
Game::Lua::SetTop(L, base);
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "x", x);
|
||||
Game::Lua::SetFieldNumber(L, "y", y);
|
||||
}
|
||||
|
||||
// Reads `t.x` / `t.y` from the table at absolute stack index `idx` (a
|
||||
// `Vector2DMixin` or a plain `{x, y}`). `idx` must be positive so it survives
|
||||
// the key pushes. Returns false when the slot isn't a table. Stack-balanced.
|
||||
inline bool ReadVector2D(void *L, int idx, double *x, double *y) {
|
||||
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_TABLE)
|
||||
return false;
|
||||
Game::Lua::PushString(L, "x");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
*x = Game::Lua::ToNumber(L, -1);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
Game::Lua::PushString(L, "y");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
*y = Game::Lua::ToNumber(L, -1);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace Map
|
||||
@@ -0,0 +1,309 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along with
|
||||
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// `C_Map` user waypoint — the single player-placed map pin:
|
||||
//
|
||||
// CanSetUserWaypointOnMap(uiMapID) -> bool
|
||||
// SetUserWaypoint(uiMapPoint) -> wasSet
|
||||
// GetUserWaypoint() -> UiMapPoint
|
||||
// HasUserWaypoint() -> bool
|
||||
// ClearUserWaypoint()
|
||||
// GetUserWaypointPositionForMap(id) -> vector2
|
||||
// GetUserWaypointHyperlink() -> worldmap link string
|
||||
// GetUserWaypointFromHyperlink(link) -> UiMapPoint
|
||||
//
|
||||
// Fires `USER_WAYPOINT_UPDATED` whenever the waypoint changes.
|
||||
//
|
||||
// The waypoint is one piece of client-side state: a `uiMapID` (zone or
|
||||
// continent — see `Map::MapInfo`), a 0..1 position on that map, and an
|
||||
// optional `z`. It lives for the session and survives a UI reload.
|
||||
//
|
||||
// `GetUserWaypointPositionForMap` re-projects the pin onto a DIFFERENT map by
|
||||
// going through world coordinates (`Map::Area`'s row transforms), so a pin
|
||||
// dropped on a zone still resolves on the continent map and the reverse.
|
||||
//
|
||||
// The hyperlink is the standard `worldmap:uiMapID:x:y` payload with both
|
||||
// coordinates scaled by 10000, wrapped in a clickable link.
|
||||
|
||||
#include "Game.h"
|
||||
#include "event/Custom.h"
|
||||
#include "map/Area.h"
|
||||
#include "map/Vector2.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace Map::Waypoint {
|
||||
|
||||
namespace {
|
||||
|
||||
const Event::Custom::AutoReserve _evtUpdated{"USER_WAYPOINT_UPDATED"};
|
||||
|
||||
struct UserWaypoint {
|
||||
bool has = false;
|
||||
int uiMapID = 0;
|
||||
double x = 0.0;
|
||||
double y = 0.0;
|
||||
bool hasZ = false;
|
||||
double z = 0.0;
|
||||
};
|
||||
|
||||
UserWaypoint g_wp;
|
||||
|
||||
void FireUpdated() { Event::Custom::Fire(_evtUpdated.Slot(), ""); }
|
||||
|
||||
// A map can hold a pin when it has a usable coordinate rect. The world map's
|
||||
// row carries an all-zero rect, so it correctly reports false.
|
||||
bool MapSupportsWaypoint(int uiMapID) {
|
||||
const int row = Map::Area::RowForUiMapID(uiMapID);
|
||||
double left, right, top, bottom;
|
||||
if (row <= 0 || !Map::Area::RowRect(row, &left, &right, &top, &bottom, nullptr))
|
||||
return false;
|
||||
return (left - right) > 0.0 && (top - bottom) > 0.0;
|
||||
}
|
||||
|
||||
// Pushes a UiMapPoint table: { uiMapID, position (vector2), z? }.
|
||||
void PushUiMapPoint(void *L, int uiMapID, double x, double y, bool hasZ, double z) {
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldNumber(L, "uiMapID", static_cast<double>(uiMapID));
|
||||
Game::Lua::PushString(L, "position");
|
||||
Map::PushVector2D(L, x, y);
|
||||
Game::Lua::SetTable(L, -3);
|
||||
if (hasZ)
|
||||
Game::Lua::SetFieldNumber(L, "z", z);
|
||||
}
|
||||
|
||||
// Reads a UiMapPoint from the table at absolute stack index `idx`.
|
||||
bool ReadUiMapPoint(void *L, int idx, int *uiMapID, double *x, double *y,
|
||||
bool *hasZ, double *z) {
|
||||
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_TABLE)
|
||||
return false;
|
||||
|
||||
Game::Lua::PushString(L, "uiMapID");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
const bool idOk = Game::Lua::IsNumber(L, -1) != 0;
|
||||
if (idOk)
|
||||
*uiMapID = static_cast<int>(Game::Lua::ToNumber(L, -1));
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (!idOk)
|
||||
return false;
|
||||
|
||||
Game::Lua::PushString(L, "position");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
const int posIdx = Game::Lua::GetTop(L);
|
||||
const bool posOk = Map::ReadVector2D(L, posIdx, x, y);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
if (!posOk)
|
||||
return false;
|
||||
|
||||
Game::Lua::PushString(L, "z");
|
||||
Game::Lua::GetTable(L, idx);
|
||||
*hasZ = Game::Lua::IsNumber(L, -1) != 0;
|
||||
if (*hasZ)
|
||||
*z = Game::Lua::ToNumber(L, -1);
|
||||
Game::Lua::SetTop(L, -2);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parses `worldmap:<uiMapID>:<x>:<y>` out of `s` — either the bare payload or
|
||||
// a full `|Hworldmap:…|h[…]|h` link. Coordinates arrive scaled by 10000.
|
||||
bool ParseWorldmapLink(const char *s, int *uiMapID, double *x, double *y) {
|
||||
if (s == nullptr)
|
||||
return false;
|
||||
const char *p = std::strstr(s, "worldmap:");
|
||||
if (p == nullptr)
|
||||
return false;
|
||||
p += 9; // past "worldmap:"
|
||||
|
||||
char *end = nullptr;
|
||||
const long id = std::strtol(p, &end, 10);
|
||||
if (end == p || *end != ':')
|
||||
return false;
|
||||
p = end + 1;
|
||||
const long xi = std::strtol(p, &end, 10);
|
||||
if (end == p || *end != ':')
|
||||
return false;
|
||||
p = end + 1;
|
||||
const long yi = std::strtol(p, &end, 10);
|
||||
if (end == p)
|
||||
return false;
|
||||
|
||||
*uiMapID = static_cast<int>(id);
|
||||
*x = static_cast<double>(xi) / 10000.0;
|
||||
*y = static_cast<double>(yi) / 10000.0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// `C_Map.CanSetUserWaypointOnMap(uiMapID)` — whether a pin can be placed on
|
||||
// the given map.
|
||||
int __fastcall Script_CanSetUserWaypointOnMap(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) != 0;
|
||||
const int uiMapID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
const bool can = ok && MapSupportsWaypoint(uiMapID);
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, can);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.SetUserWaypoint(uiMapPoint)` — places the pin. Returns whether it
|
||||
// was set (false for a malformed point or a map that can't hold one).
|
||||
int __fastcall Script_SetUserWaypoint(void *L) {
|
||||
int uiMapID = 0;
|
||||
double x = 0.0, y = 0.0, z = 0.0;
|
||||
bool hasZ = false;
|
||||
const bool ok = ReadUiMapPoint(L, 1, &uiMapID, &x, &y, &hasZ, &z) &&
|
||||
MapSupportsWaypoint(uiMapID);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!ok) {
|
||||
Game::Lua::PushBool(L, false);
|
||||
return 1;
|
||||
}
|
||||
g_wp.has = true;
|
||||
g_wp.uiMapID = uiMapID;
|
||||
g_wp.x = x;
|
||||
g_wp.y = y;
|
||||
g_wp.hasZ = hasZ;
|
||||
g_wp.z = z;
|
||||
FireUpdated();
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypoint()` — the pin as a UiMapPoint, or nil.
|
||||
int __fastcall Script_GetUserWaypoint(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!g_wp.has) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushUiMapPoint(L, g_wp.uiMapID, g_wp.x, g_wp.y, g_wp.hasZ, g_wp.z);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.HasUserWaypoint()` — whether a pin is placed.
|
||||
int __fastcall Script_HasUserWaypoint(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::PushBool(L, g_wp.has);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.ClearUserWaypoint()` — removes the pin.
|
||||
int __fastcall Script_ClearUserWaypoint(void *L) {
|
||||
const bool had = g_wp.has;
|
||||
g_wp = UserWaypoint{};
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (had)
|
||||
FireUpdated();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypointPositionForMap(uiMapID)` — the pin's position on the
|
||||
// given map as a vector2, or nil when it doesn't land on that map. Re-projects
|
||||
// through world coordinates, so a zone pin resolves on the continent too.
|
||||
int __fastcall Script_GetUserWaypointPositionForMap(void *L) {
|
||||
const bool ok = Game::Lua::IsNumber(L, 1) != 0;
|
||||
const int wantID = ok ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
|
||||
|
||||
bool have = false;
|
||||
double px = 0.0, py = 0.0;
|
||||
if (ok && g_wp.has) {
|
||||
if (wantID == g_wp.uiMapID) {
|
||||
px = g_wp.x;
|
||||
py = g_wp.y;
|
||||
have = true;
|
||||
} else {
|
||||
const int fromRow = Map::Area::RowForUiMapID(g_wp.uiMapID);
|
||||
const int toRow = Map::Area::RowForUiMapID(wantID);
|
||||
double worldX = 0.0, worldY = 0.0;
|
||||
have = fromRow > 0 && toRow > 0 &&
|
||||
Map::Area::WorldFromRow(fromRow, g_wp.x, g_wp.y, &worldX, &worldY) &&
|
||||
Map::Area::PercentInRow(toRow, static_cast<float>(worldX),
|
||||
static_cast<float>(worldY), &px, &py) &&
|
||||
px >= 0.0 && px <= 1.0 && py >= 0.0 && py <= 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!have) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
Map::PushVector2D(L, px, py);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypointHyperlink()` — a clickable worldmap link for the pin,
|
||||
// or nil when none is placed.
|
||||
int __fastcall Script_GetUserWaypointHyperlink(void *L) {
|
||||
const bool has = g_wp.has;
|
||||
const int uiMapID = g_wp.uiMapID;
|
||||
const long xi = static_cast<long>(g_wp.x * 10000.0 + 0.5);
|
||||
const long yi = static_cast<long>(g_wp.y * 10000.0 + 0.5);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!has) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
char link[160];
|
||||
std::snprintf(link, sizeof(link),
|
||||
"|cffffff00|Hworldmap:%d:%ld:%ld|h[Map Pin Location]|h|r",
|
||||
uiMapID, xi, yi);
|
||||
Game::Lua::PushString(L, link);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Map.GetUserWaypointFromHyperlink(hyperlink)` — the UiMapPoint a worldmap
|
||||
// link describes, or nil when the string isn't one.
|
||||
int __fastcall Script_GetUserWaypointFromHyperlink(void *L) {
|
||||
const char *s = Game::Lua::IsString(L, 1) ? Game::Lua::ToString(L, 1) : nullptr;
|
||||
int uiMapID = 0;
|
||||
double x = 0.0, y = 0.0;
|
||||
const bool ok = ParseWorldmapLink(s, &uiMapID, &x, &y);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
if (!ok) {
|
||||
Game::Lua::PushNil(L);
|
||||
return 1;
|
||||
}
|
||||
PushUiMapPoint(L, uiMapID, x, y, /*hasZ=*/false, 0.0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterTableFunction("C_Map", "CanSetUserWaypointOnMap",
|
||||
&Script_CanSetUserWaypointOnMap);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "SetUserWaypoint",
|
||||
&Script_SetUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypoint",
|
||||
&Script_GetUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "HasUserWaypoint",
|
||||
&Script_HasUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "ClearUserWaypoint",
|
||||
&Script_ClearUserWaypoint);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypointPositionForMap",
|
||||
&Script_GetUserWaypointPositionForMap);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypointHyperlink",
|
||||
&Script_GetUserWaypointHyperlink);
|
||||
Game::Lua::RegisterTableFunction("C_Map", "GetUserWaypointFromHyperlink",
|
||||
&Script_GetUserWaypointFromHyperlink);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Map::Waypoint
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
#include "Account.h"
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "Paths.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -27,11 +27,6 @@ AutoRegister *g_head = nullptr;
|
||||
std::string g_loadedAccount;
|
||||
bool g_anyLoadAttempted = false;
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return *reinterpret_cast<const char *const *>(
|
||||
static_cast<uintptr_t>(Offsets::VAR_ACCOUNT_NAME_PTR));
|
||||
}
|
||||
|
||||
void ResetAllSections() {
|
||||
for (auto *s = g_head; s != nullptr; s = s->next)
|
||||
s->reset();
|
||||
@@ -62,19 +57,10 @@ AutoRegister::AutoRegister(ResetFn r, SerializeFn s, ParseFn p)
|
||||
g_head = this;
|
||||
}
|
||||
|
||||
std::string Path() {
|
||||
const char *acct = ReadAccountName();
|
||||
if (acct == nullptr || acct[0] == '\0')
|
||||
return {};
|
||||
std::string out = "WTF\\Account\\";
|
||||
out += acct;
|
||||
out += "\\ClassicAPI.txt";
|
||||
return out;
|
||||
}
|
||||
std::string Path() { return Paths::AccountFile("ClassicAPI.txt"); }
|
||||
|
||||
bool EnsureLoaded() {
|
||||
const char *acctRaw = ReadAccountName();
|
||||
const std::string account = (acctRaw != nullptr) ? acctRaw : "";
|
||||
const std::string account = Paths::AccountName();
|
||||
if (account.empty())
|
||||
return false;
|
||||
if (g_anyLoadAttempted && g_loadedAccount == account)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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.
|
||||
|
||||
// See `Paths.h`. The account / realm / character names each come from a
|
||||
// different engine global, so the three reads live here together rather than
|
||||
// once per module that wants a file path.
|
||||
|
||||
#include "Paths.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Settings::Paths {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *kAccountRoot = "WTF\\Account\\";
|
||||
|
||||
const char *ReadAccountName() {
|
||||
return Game::Read<const char *>(Offsets::VAR_ACCOUNT_NAME_PTR);
|
||||
}
|
||||
|
||||
const char *ReadRealmName() {
|
||||
auto *info = Game::Read<uint8_t *>(Offsets::VAR_REALM_INFO_PTR);
|
||||
if (info == nullptr)
|
||||
return nullptr;
|
||||
return Game::Read<const char *>(info, Offsets::OFF_REALM_INFO_NAME);
|
||||
}
|
||||
|
||||
// The global is an inline buffer rather than a pointer, so "absent" reads as an
|
||||
// empty string instead of null.
|
||||
const char *ReadCharacterName() {
|
||||
auto *p = reinterpret_cast<const char *>(Offsets::VAR_CHARACTER_NAME);
|
||||
return (p[0] == '\0') ? nullptr : p;
|
||||
}
|
||||
|
||||
bool Usable(const char *s) { return s != nullptr && s[0] != '\0'; }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string AccountName() {
|
||||
const char *account = ReadAccountName();
|
||||
return Usable(account) ? std::string(account) : std::string();
|
||||
}
|
||||
|
||||
std::string AccountFile(const char *leaf) {
|
||||
const char *account = ReadAccountName();
|
||||
if (!Usable(account))
|
||||
return {};
|
||||
std::string out = kAccountRoot;
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += leaf;
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string CharacterFile(const char *leaf) {
|
||||
const char *account = ReadAccountName();
|
||||
if (!Usable(account))
|
||||
return {};
|
||||
const char *realm = ReadRealmName();
|
||||
if (!Usable(realm))
|
||||
return {};
|
||||
const char *character = ReadCharacterName();
|
||||
if (!Usable(character))
|
||||
return {};
|
||||
std::string out = kAccountRoot;
|
||||
out += account;
|
||||
out += '\\';
|
||||
out += realm;
|
||||
out += '\\';
|
||||
out += character;
|
||||
out += '\\';
|
||||
out += leaf;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace Settings::Paths
|
||||
@@ -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
|
||||
|
||||
#include <string>
|
||||
|
||||
// `Settings::Paths` — the one place ClassicAPI builds a path to its own data
|
||||
// files under `WTF\Account\`.
|
||||
//
|
||||
// Three modules used to resolve these independently, each re-reading the same
|
||||
// account / realm / character globals and re-joining the same separators. The
|
||||
// engine's own `WTF\Account\%s` format strings are deliberately NOT used: they
|
||||
// belong to the engine's saved-variable layout, and a module mirroring THAT
|
||||
// (see `AddOns::SavedVarsFirst`) has to match it byte for byte and so keeps its
|
||||
// own copies. These functions describe ClassicAPI's files, which is a separate
|
||||
// concern that happens to share a directory.
|
||||
//
|
||||
// Every function returns an empty string until the session globals it needs are
|
||||
// populated, so a caller can treat "empty" as "not available yet" and never has
|
||||
// to null-check the individual names.
|
||||
namespace Settings::Paths {
|
||||
|
||||
// The logged-in account name, upper-cased by the engine. Empty before login.
|
||||
std::string AccountName();
|
||||
|
||||
// `WTF\Account\<account>\<leaf>` — for data shared by every character on the
|
||||
// account. Empty until the account is known.
|
||||
std::string AccountFile(const char *leaf);
|
||||
|
||||
// `WTF\Account\<account>\<realm>\<character>\<leaf>` — for per-character data.
|
||||
// Empty until all three are known, which is only true in-world: a
|
||||
// logout to character select clears the character, so callers that re-resolve
|
||||
// on each access pick up a new character without a reload.
|
||||
std::string CharacterFile(const char *leaf);
|
||||
|
||||
} // namespace Settings::Paths
|
||||
@@ -180,6 +180,7 @@ constexpr int kInterruptDedupMs = 1000;
|
||||
|
||||
int g_chanSpell = 0;
|
||||
int g_chanGuid = 0;
|
||||
int g_chanStart = 0; // startMs we last fired CHANNEL_START for (detects a recast)
|
||||
|
||||
// castGUID minted at SENT, pending until the matching downstream event
|
||||
// (START / SUCCEEDED / FAILED for the same spell) consumes it — so all of
|
||||
@@ -478,14 +479,28 @@ void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
|
||||
}
|
||||
|
||||
// ---- Channel ------------------------------------------------------
|
||||
// Detect a new channel by spellID ONLY (not startMs): a channel is
|
||||
// stamped twice at start — SMSG_SPELL_START, then MSG_CHANNEL_START
|
||||
// re-stamps with the server duration — which changes startMs but is the
|
||||
// SAME channel. A startMs check would spuriously fire STOP+START on the
|
||||
// re-stamp. A genuine re-channel of the same spell passes through
|
||||
// g_chanSpell==0 (CHANNEL_STOP) first, so spellID alone still catches it.
|
||||
(void)channelStartMs;
|
||||
const bool newChan = channelSpellID != 0 && channelSpellID != g_chanSpell;
|
||||
// A DIFFERENT channel spell is trivially a new channel. A SAME-spell
|
||||
// re-channel (recasting Fishing while already fishing) is ALSO a new
|
||||
// channel and must fire CHANNEL_STOP + CHANNEL_START, but it keeps
|
||||
// channelSpellID == g_chanSpell (the channel never returns to 0 between the
|
||||
// two — +0x228 stays set for a continuous channel), so spellID alone can't
|
||||
// see it. Two independent tells together identify it without a timing
|
||||
// heuristic — BOTH required:
|
||||
// - the channel was RE-STAMPED: channelStartMs advanced past the value we
|
||||
// fired CHANNEL_START for, AND
|
||||
// - a fresh CMSG_CAST_SPELL is pending for this spell (g_pendingGuid — the
|
||||
// recast's SENT, not yet consumed).
|
||||
// Each start stamps the channel TWICE (SMSG_SPELL_START then
|
||||
// MSG_CHANNEL_START), which advances startMs on the SAME start — but the
|
||||
// first CHANNEL_START already consumed g_pendingGuid, so the second stamp
|
||||
// fails the pending test → no spurious restart. A REJECTED recast
|
||||
// (SPELL_IN_PROGRESS) sends a CMSG (guid pending) but the server never
|
||||
// re-stamps the channel, so startMs is unchanged → also no restart.
|
||||
const bool reChannel = channelSpellID != 0 && channelSpellID == g_chanSpell &&
|
||||
channelStartMs != g_chanStart && g_pendingGuid != 0 &&
|
||||
g_pendingSpell == channelSpellID;
|
||||
const bool newChan = channelSpellID != 0 &&
|
||||
(channelSpellID != g_chanSpell || reChannel);
|
||||
if (g_chanSpell != 0 && (channelSpellID == 0 || newChan)) {
|
||||
// Channels only ever fire CHANNEL_STOP — never INTERRUPTED — whether
|
||||
// they end naturally or are cut short (retail behavior, verified).
|
||||
@@ -499,6 +514,7 @@ void PollPlayer(int castSpellID, int castStartMs, int castEndMs,
|
||||
// SUCCEEDED / CHANNEL_STOP on one castGUID.
|
||||
g_chanGuid = NextCastGuid(channelSpellID);
|
||||
g_chanSpell = channelSpellID;
|
||||
g_chanStart = channelStartMs;
|
||||
Fire(kChannelStart, channelSpellID, g_chanGuid);
|
||||
// Modern fires SUCCEEDED right after CHANNEL_START. SPELL_GO deferred
|
||||
// it to here so it lands after START, sharing the channel's guid.
|
||||
|
||||
+133
-33
@@ -11,12 +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/>.
|
||||
|
||||
// Inline texture escape (`|Tpath:height:width:...|t`) backport.
|
||||
// Inline texture escape (`|Tpath:height:width:...|t`) backport, plus its atlas
|
||||
// sibling (`|A:atlasName:height:width[:offsetX:offsetY]|a`).
|
||||
//
|
||||
// Vanilla 1.12 has ZERO inline-texture support: the shared `|`-tokenizer
|
||||
// (FUN_005c2810) has no `T`/`t` case, so `|T...|t` renders as literal text.
|
||||
// This module teaches the text engine to render the icon inline.
|
||||
//
|
||||
// The two markers share EVERYTHING except the payload parser. An atlas is a
|
||||
// texture path plus a normalized sub-rect, which is exactly what IconDesc already
|
||||
// carries for a `|T` span with texcoords, so `ParseAtlasIcon` resolves the name
|
||||
// through Texture::Atlas and fills the same struct — every scanner, measure, wrap,
|
||||
// record and placement stage below is marker-agnostic from that point on. Adding
|
||||
// a THIRD marker means touching IconStartLen / FindIconClose / InlineSpanLen, the
|
||||
// ghost guard in FlushLayout, and — mandatory — the chat anti-spoof in
|
||||
// chat/IconFilter.cpp, which is what stops a player rendering art by typing it.
|
||||
//
|
||||
// Two working pieces:
|
||||
// 1. POSITIONING — co-hook the per-line glyph emitter (FUN_005ccbe0) and, for
|
||||
// a line containing `|T…|t`, render the plain text runs by DELEGATING to the
|
||||
@@ -63,6 +73,7 @@
|
||||
#include "Offsets.h"
|
||||
#include "text/InlineTexturePool.h"
|
||||
#include "text/PtrProbe.h"
|
||||
#include "texture/Atlas.h"
|
||||
#include "texture/Transform.h"
|
||||
|
||||
#include <windows.h>
|
||||
@@ -176,6 +187,70 @@ bool ParseIcon(const char *payload, size_t len, IconDesc &out) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parses the payload between `|A` and `|a` — `atlasName:height:width[:offsetX:offsetY]`
|
||||
// — into the SAME IconDesc a `|T` span produces, so every stage after this point
|
||||
// (measure, wrap, record, placement, texcoord) is shared between the two markers.
|
||||
// An atlas is exactly "a path plus a normalized sub-rect", which is what IconDesc
|
||||
// already carries. Returns false when the name is unbound, and the span then
|
||||
// renders as ordinary text.
|
||||
bool ParseAtlasIcon(const char *payload, size_t len, IconDesc &out) {
|
||||
const char *end = payload + len;
|
||||
// The marker is `|A` and the retail payload is `:name:h:w…`, so the name is
|
||||
// the first field AFTER a leading colon — unlike `|T`, whose path starts
|
||||
// immediately. Consuming that colon here is what makes the two forms differ.
|
||||
const char *nameStart = payload;
|
||||
if (nameStart < end && *nameStart == ':')
|
||||
++nameStart;
|
||||
const char *colon = nameStart;
|
||||
while (colon < end && *colon != ':')
|
||||
++colon;
|
||||
if (colon == nameStart)
|
||||
return false; // no name
|
||||
const std::string name(nameStart, static_cast<size_t>(colon - nameStart));
|
||||
|
||||
const Texture::Atlas::Info *info = Texture::Atlas::Find(name.c_str());
|
||||
if (info == nullptr) {
|
||||
Texture::Atlas::RecordMiss(name.c_str());
|
||||
return false;
|
||||
}
|
||||
out.path = info->file;
|
||||
out.u0 = info->left;
|
||||
out.u1 = info->right;
|
||||
out.v0 = info->top;
|
||||
out.v1 = info->bottom;
|
||||
|
||||
float f[4] = {0};
|
||||
int nf = 0;
|
||||
const char *p = (colon < end) ? colon + 1 : end;
|
||||
while (p < end && nf < 4) {
|
||||
const char *nx = p;
|
||||
f[nf++] = ParseField(p, end, &nx);
|
||||
p = nx;
|
||||
}
|
||||
// An omitted or 0 height means "the atlas's own pixel size" — resolved HERE,
|
||||
// not deferred to the line font height the `|T` path falls back to. Doing it
|
||||
// at parse time is what keeps the rest of the pipeline identical for both.
|
||||
const float nativeH = info->height;
|
||||
const float nativeW = info->width;
|
||||
out.height = (nf >= 1 && f[0] > 0.0f) ? f[0] : nativeH;
|
||||
if (out.height <= 0.0f)
|
||||
return false; // no usable size anywhere → nothing to draw
|
||||
if (nf >= 2 && f[1] > 0.0f)
|
||||
out.width = f[1];
|
||||
else if (nativeH > 0.0f && nativeW > 0.0f)
|
||||
out.width = out.height * (nativeW / nativeH); // keep the atlas's aspect
|
||||
else
|
||||
out.width = out.height;
|
||||
out.offsetX = (nf >= 3) ? f[2] : 0.0f;
|
||||
out.offsetY = (nf >= 4) ? f[3] : 0.0f;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parses a span's payload with the parser its marker kind calls for.
|
||||
bool ParseIconSpan(const char *payload, size_t len, char kind, IconDesc &out) {
|
||||
return (kind == 'A') ? ParseAtlasIcon(payload, len, out) : ParseIcon(payload, len, out);
|
||||
}
|
||||
|
||||
// --- per-node recorded icons -----------------------------------------------
|
||||
|
||||
// One icon to draw, in the render node's node-local coordinate space (the same
|
||||
@@ -526,12 +601,25 @@ constexpr float g_iconPadFrac = 0.18f;
|
||||
// length: 3 for the doubled `||T`, 2 for a clean `|T`; 0 otherwise. The doubled
|
||||
// form is checked first so the leading `|` of `||T` wins over reading the 2nd `|`
|
||||
// as a clean `|T`.
|
||||
int IconStartLen(const uint8_t *text, int len, int i) {
|
||||
if (i + 2 < len && text[i] == '|' && text[i + 1] == '|' && text[i + 2] == 'T')
|
||||
return 3;
|
||||
if (i + 1 < len && text[i] == '|' && text[i + 1] == 'T')
|
||||
return 2;
|
||||
return 0;
|
||||
// `outKind` (optional) receives the marker letter — 'T' for a path span, 'A' for
|
||||
// an atlas span (`|A:name:h:w|a`), whose name resolves through Texture::Atlas.
|
||||
// Both markers share every scanner and every downstream stage; only the payload
|
||||
// parser differs.
|
||||
int IconStartLen(const uint8_t *text, int len, int i, char *outKind = nullptr) {
|
||||
char kind = 0;
|
||||
int mlen = 0;
|
||||
if (i + 2 < len && text[i] == '|' && text[i + 1] == '|' &&
|
||||
(text[i + 2] == 'T' || text[i + 2] == 'A')) {
|
||||
kind = static_cast<char>(text[i + 2]);
|
||||
mlen = 3;
|
||||
} else if (i + 1 < len && text[i] == '|' &&
|
||||
(text[i + 1] == 'T' || text[i + 1] == 'A')) {
|
||||
kind = static_cast<char>(text[i + 1]);
|
||||
mlen = 2;
|
||||
}
|
||||
if (mlen != 0 && outKind != nullptr)
|
||||
*outKind = kind;
|
||||
return mlen;
|
||||
}
|
||||
|
||||
// True if [text,text+len) contains any inline-texture escape.
|
||||
@@ -544,17 +632,19 @@ bool HasInlineTexture(const uint8_t *text, int len) {
|
||||
|
||||
// Finds the matching close (`||t` for the doubled form, `|t` for clean) at or
|
||||
// after `from`. Returns its offset and sets *closeLen, or npos.
|
||||
size_t FindIconClose(const uint8_t *text, int len, size_t from, bool doubled, int *closeLen) {
|
||||
size_t FindIconClose(const uint8_t *text, int len, size_t from, bool doubled, int *closeLen,
|
||||
char kind = 'T') {
|
||||
const size_t n = static_cast<size_t>(len);
|
||||
const uint8_t closer = (kind == 'A') ? 'a' : 't'; // the marker's own closer
|
||||
if (doubled) {
|
||||
for (size_t i = from; i + 2 < n; ++i)
|
||||
if (text[i] == '|' && text[i + 1] == '|' && text[i + 2] == 't') {
|
||||
if (text[i] == '|' && text[i + 1] == '|' && text[i + 2] == closer) {
|
||||
*closeLen = 3;
|
||||
return i;
|
||||
}
|
||||
} else {
|
||||
for (size_t i = from; i + 1 < n; ++i)
|
||||
if (text[i] == '|' && text[i + 1] == 't') {
|
||||
if (text[i] == '|' && text[i + 1] == closer) {
|
||||
*closeLen = 2;
|
||||
return i;
|
||||
}
|
||||
@@ -567,19 +657,20 @@ size_t FindIconClose(const uint8_t *text, int len, size_t from, bool doubled, in
|
||||
// gets no length. Bounded so a malformed/unterminated span can't run away.
|
||||
int InlineSpanLen(const uint8_t *text) {
|
||||
int mlen;
|
||||
if (text[0] == '|' && text[1] == '|' && text[2] == 'T')
|
||||
mlen = 3; // doubled ||T
|
||||
else if (text[0] == '|' && text[1] == 'T')
|
||||
mlen = 2; // clean |T
|
||||
if (text[0] == '|' && text[1] == '|' && (text[2] == 'T' || text[2] == 'A'))
|
||||
mlen = 3; // doubled ||T / ||A
|
||||
else if (text[0] == '|' && (text[1] == 'T' || text[1] == 'A'))
|
||||
mlen = 2; // clean |T / |A
|
||||
else
|
||||
return 0;
|
||||
const bool doubled = (mlen == 3);
|
||||
const uint8_t closer = (text[mlen - 1] == 'A') ? 'a' : 't';
|
||||
for (int i = mlen; i < 2048 && text[i] != '\0'; ++i) {
|
||||
if (doubled) {
|
||||
if (text[i] == '|' && text[i + 1] == '|' && text[i + 2] == 't')
|
||||
if (text[i] == '|' && text[i + 1] == '|' && text[i + 2] == closer)
|
||||
return i + 3;
|
||||
} else {
|
||||
if (text[i] == '|' && text[i + 1] == 't')
|
||||
if (text[i] == '|' && text[i + 1] == closer)
|
||||
return i + 2;
|
||||
}
|
||||
}
|
||||
@@ -638,7 +729,8 @@ float SumIconAdvances(const uint8_t *text, int len, float fontHPen, float outlin
|
||||
float sum = 0.0f;
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
const int ml = IconStartLen(text, len, i);
|
||||
char kind = 'T';
|
||||
const int ml = IconStartLen(text, len, i, &kind);
|
||||
if (ml == 0) {
|
||||
// Skip an escaped pipe as a pair so its 2nd `|` isn't re-read as a
|
||||
// clean `|T` next iteration (same walk the emitter does).
|
||||
@@ -649,12 +741,13 @@ float SumIconAdvances(const uint8_t *text, int len, float fontHPen, float outlin
|
||||
continue;
|
||||
}
|
||||
int cl = 0;
|
||||
const size_t ce = FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl);
|
||||
const size_t ce =
|
||||
FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl, kind);
|
||||
if (ce == static_cast<size_t>(-1))
|
||||
break; // unterminated → the rest is plain text
|
||||
IconDesc d;
|
||||
if (ParseIcon(reinterpret_cast<const char *>(text) + i + ml,
|
||||
ce - (static_cast<size_t>(i) + ml), d))
|
||||
if (ParseIconSpan(reinterpret_cast<const char *>(text) + i + ml,
|
||||
ce - (static_cast<size_t>(i) + ml), kind, d))
|
||||
sum += IconAdvancePen(d, fontHPen, outlineInk, snap);
|
||||
i = static_cast<int>(ce) + cl;
|
||||
}
|
||||
@@ -676,7 +769,8 @@ float TrailingIconTrimPen(const uint8_t *text, int len, float fontHPen, float ou
|
||||
int i = 0;
|
||||
bool endsInIcon = false;
|
||||
while (i < len) {
|
||||
const int ml = IconStartLen(text, len, i);
|
||||
char kind = 'T';
|
||||
const int ml = IconStartLen(text, len, i, &kind);
|
||||
if (ml == 0) {
|
||||
if (text[i] == '|' && i + 1 < len && text[i + 1] == '|')
|
||||
i += 2;
|
||||
@@ -686,12 +780,13 @@ float TrailingIconTrimPen(const uint8_t *text, int len, float fontHPen, float ou
|
||||
continue;
|
||||
}
|
||||
int cl = 0;
|
||||
const size_t ce = FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl);
|
||||
const size_t ce =
|
||||
FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl, kind);
|
||||
if (ce == static_cast<size_t>(-1))
|
||||
return 0.0f; // unterminated → the tail renders as plain text
|
||||
IconDesc d;
|
||||
endsInIcon = ParseIcon(reinterpret_cast<const char *>(text) + i + ml,
|
||||
ce - (static_cast<size_t>(i) + ml), d);
|
||||
endsInIcon = ParseIconSpan(reinterpret_cast<const char *>(text) + i + ml,
|
||||
ce - (static_cast<size_t>(i) + ml), kind, d);
|
||||
i = static_cast<int>(ce) + cl;
|
||||
}
|
||||
if (!endsInIcon)
|
||||
@@ -710,7 +805,8 @@ float MaxIconOverflowPx(const uint8_t *text, int len, float fontHPx) {
|
||||
float maxH = 0.0f;
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
const int ml = IconStartLen(text, len, i);
|
||||
char kind = 'T';
|
||||
const int ml = IconStartLen(text, len, i, &kind);
|
||||
if (ml == 0) {
|
||||
if (text[i] == '|' && i + 1 < len && text[i + 1] == '|')
|
||||
i += 2;
|
||||
@@ -719,12 +815,13 @@ float MaxIconOverflowPx(const uint8_t *text, int len, float fontHPx) {
|
||||
continue;
|
||||
}
|
||||
int cl = 0;
|
||||
const size_t ce = FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl);
|
||||
const size_t ce =
|
||||
FindIconClose(text, len, static_cast<size_t>(i) + ml, ml == 3, &cl, kind);
|
||||
if (ce == static_cast<size_t>(-1))
|
||||
break;
|
||||
IconDesc d;
|
||||
if (ParseIcon(reinterpret_cast<const char *>(text) + i + ml,
|
||||
ce - (static_cast<size_t>(i) + ml), d)) {
|
||||
if (ParseIconSpan(reinterpret_cast<const char *>(text) + i + ml,
|
||||
ce - (static_cast<size_t>(i) + ml), kind, d)) {
|
||||
const float h = ((d.height > 0.0f) ? d.height : fontHPx) * g_sizeScale;
|
||||
if (h > maxH)
|
||||
maxH = h;
|
||||
@@ -1551,7 +1648,8 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
int mlen = IconStartLen(text, len, i);
|
||||
char kind = 'T';
|
||||
int mlen = IconStartLen(text, len, i, &kind);
|
||||
if (mlen == 0) {
|
||||
// Not an icon. Skip an escaped pipe as a pair so its 2nd `|` isn't
|
||||
// re-read as a clean `|T` next iteration; otherwise advance one.
|
||||
@@ -1563,7 +1661,8 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
|
||||
}
|
||||
const bool doubled = (mlen == 3);
|
||||
int closeLen = 0;
|
||||
size_t close = FindIconClose(text, len, static_cast<size_t>(i) + mlen, doubled, &closeLen);
|
||||
size_t close =
|
||||
FindIconClose(text, len, static_cast<size_t>(i) + mlen, doubled, &closeLen, kind);
|
||||
if (close == static_cast<size_t>(-1)) {
|
||||
// Unterminated on this line — treat the rest as plain text.
|
||||
break;
|
||||
@@ -1574,8 +1673,9 @@ void __fastcall Emitter_h(void *node, void *edx, uint8_t *text, int len, uint32_
|
||||
IconDesc d;
|
||||
const char *payload = reinterpret_cast<const char *>(text) + i + mlen;
|
||||
size_t payloadLen = close - (static_cast<size_t>(i) + mlen);
|
||||
if (ParseIcon(payload, payloadLen, d)) {
|
||||
// height/width of 0 => size to the line's font (retail :0:0).
|
||||
if (ParseIconSpan(payload, payloadLen, kind, d)) {
|
||||
// height/width of 0 => size to the line's font (retail :0:0). An
|
||||
// atlas span has already resolved its own size at parse time.
|
||||
const float baseH = (d.height > 0.0f) ? d.height : fontH;
|
||||
const float baseW = (d.width > 0.0f) ? d.width : baseH;
|
||||
const float w = baseW * g_sizeScale;
|
||||
@@ -1766,7 +1866,7 @@ void FlushLayout(void *layout) {
|
||||
bool fsHasMarkup = false;
|
||||
if (LooksReadable(ftext)) {
|
||||
for (int k = 1; k < 2048 && ftext[k] != '\0'; ++k)
|
||||
if (ftext[k] == 'T' && ftext[k - 1] == '|') {
|
||||
if ((ftext[k] == 'T' || ftext[k] == 'A') && ftext[k - 1] == '|') {
|
||||
fsHasMarkup = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// 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.
|
||||
|
||||
// The atlas system: `C_Texture.GetAtlasInfo` / `GetAtlasExists` / `GetAtlasID` /
|
||||
// `GetAtlasElementID` / `GetAtlasElements`, `C_Texture.RegisterAtlas`, and the
|
||||
// `texture:SetAtlas` / `texture:GetAtlas` widget methods.
|
||||
//
|
||||
// An atlas names a texture plus a sub-rect inside it, so `SetAtlas` is exactly the
|
||||
// two calls 1.12 already has — SetTexture then SetTexCoord — driven off one name.
|
||||
// Both are performed by delegating to the engine's OWN Lua handlers rather than
|
||||
// the native setters, so every argument guard and the UI-scale conversion behind
|
||||
// the size setters stay in force; the only thing this module adds is the name
|
||||
// lookup. `AtlasData.h` explains where the built-in bindings come from.
|
||||
//
|
||||
// All five readers key off the same bound set, so `GetAtlasExists` is true exactly
|
||||
// when `GetAtlasInfo` returns a table. There is no state where one disagrees.
|
||||
|
||||
#include "Atlas.h"
|
||||
|
||||
#include "AtlasData.h"
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "debug/Log.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace Texture::Atlas {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
|
||||
int CallScript(uintptr_t fn, void *L) { return reinterpret_cast<ScriptFn_t>(fn)(L); }
|
||||
|
||||
std::string Lower(const char *s) {
|
||||
std::string out;
|
||||
if (s == nullptr)
|
||||
return out;
|
||||
for (const char *p = s; *p != '\0'; ++p) {
|
||||
char c = *p;
|
||||
if (c >= 'A' && c <= 'Z')
|
||||
c = static_cast<char>(c - 'A' + 'a');
|
||||
out.push_back(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The whole registry, keyed by lowercased name. `std::map` node storage means an
|
||||
// `Info *` handed out by `Find` stays valid as later atlases are registered.
|
||||
std::map<std::string, Info> &Registry() {
|
||||
static std::map<std::string, Info> registry;
|
||||
static bool seeded = false;
|
||||
if (!seeded) {
|
||||
seeded = true;
|
||||
for (int i = 0; i < AtlasData::kAtlasCount; ++i) {
|
||||
const AtlasData::Entry &e = AtlasData::kAtlases[i];
|
||||
Info info;
|
||||
info.name = e.name;
|
||||
info.file = e.file;
|
||||
info.width = static_cast<float>(e.width);
|
||||
info.height = static_cast<float>(e.height);
|
||||
info.left = e.left;
|
||||
info.right = e.right;
|
||||
info.top = e.top;
|
||||
info.bottom = e.bottom;
|
||||
info.atlasID = e.atlasID;
|
||||
info.elementID = e.elementID;
|
||||
info.tilesHorizontally = e.tilesHorizontally;
|
||||
info.tilesVertically = e.tilesVertically;
|
||||
registry[Lower(e.name)] = info;
|
||||
}
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
// Names asked for that nothing could resolve. Bounded so a misbehaving addon
|
||||
// calling SetAtlas in an OnUpdate can't grow it without limit.
|
||||
constexpr size_t kMaxMisses = 512;
|
||||
std::set<std::string> g_misses;
|
||||
|
||||
// `texture:GetAtlas` bookkeeping: which atlas a region was last set to, and the
|
||||
// HTEXTURE that set installed. Comparing the stored handle against the region's
|
||||
// current one on read means a plain `SetTexture` afterwards correctly clears the
|
||||
// atlas — and a recycled region can't report a stale name either, which is what
|
||||
// lets this module skip a `FUN_SIMPLETEXTURE_CTOR` hook. It must skip one:
|
||||
// `Texture::Mask` already owns that target, and MinHook refuses a duplicate,
|
||||
// which would abort the entire hook install.
|
||||
struct Applied {
|
||||
std::string name;
|
||||
void *hTexture = nullptr;
|
||||
};
|
||||
std::unordered_map<void *, Applied> g_applied;
|
||||
|
||||
void *RegionTexture(void *region) {
|
||||
if (region == nullptr)
|
||||
return nullptr;
|
||||
return *reinterpret_cast<void *const *>(static_cast<uint8_t *>(region) +
|
||||
Offsets::OFF_SIMPLETEXTURE_HTEXTURE);
|
||||
}
|
||||
|
||||
// Synthetic ids for addon-registered atlases, counting down from -1 so they can
|
||||
// never be mistaken for one of Blizzard's (see Info's comment).
|
||||
int g_nextSyntheticID = -1;
|
||||
|
||||
// --- C_Texture readers ------------------------------------------------------
|
||||
|
||||
int __fastcall Script_GetAtlasInfo(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
const Info *info = Find(Game::Lua::ToString(L, 1));
|
||||
if (info == nullptr) {
|
||||
RecordMiss(Game::Lua::ToString(L, 1));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Game::Lua::NewTable(L);
|
||||
Game::Lua::SetFieldString(L, "elementName", info->name.c_str());
|
||||
Game::Lua::SetFieldNumber(L, "width", info->width);
|
||||
Game::Lua::SetFieldNumber(L, "height", info->height);
|
||||
Game::Lua::SetFieldNumber(L, "leftTexCoord", info->left);
|
||||
Game::Lua::SetFieldNumber(L, "rightTexCoord", info->right);
|
||||
Game::Lua::SetFieldNumber(L, "topTexCoord", info->top);
|
||||
Game::Lua::SetFieldNumber(L, "bottomTexCoord", info->bottom);
|
||||
Game::Lua::SetFieldBool(L, "tilesHorizontally", info->tilesHorizontally);
|
||||
Game::Lua::SetFieldBool(L, "tilesVertically", info->tilesVertically);
|
||||
// `filename` carries the texture. `file` is a fileDataID, and 1.12 has no
|
||||
// such id space at all, so it is left absent rather than filled with a value
|
||||
// that would not resolve — both fields are optional in the contract.
|
||||
Game::Lua::SetFieldString(L, "filename", info->file.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAtlasExists(void *L) {
|
||||
const bool exists =
|
||||
Game::Lua::IsString(L, 1) && Find(Game::Lua::ToString(L, 1)) != nullptr;
|
||||
Game::Lua::PushBool(L, exists);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAtlasID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
const Info *info = Find(Game::Lua::ToString(L, 1));
|
||||
if (info == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(L, info->atlasID);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAtlasElementID(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1))
|
||||
return 0;
|
||||
const Info *info = Find(Game::Lua::ToString(L, 1));
|
||||
if (info == nullptr)
|
||||
return 0;
|
||||
Game::Lua::PushNumber(L, info->elementID);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __fastcall Script_GetAtlasElements(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
int n = 0;
|
||||
for (const auto &pair : Registry()) {
|
||||
Game::Lua::PushNumber(L, ++n);
|
||||
Game::Lua::PushString(L, pair.second.name.c_str());
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// `C_Texture.RegisterAtlas(name, file, width, height, left, right, top, bottom
|
||||
// [, tilesHorizontally, tilesVertically])`
|
||||
// A ClassicAPI extension: it publishes an addon's own sprite sheet under atlas
|
||||
// names, which is the case that needs no built-in binding to work.
|
||||
int __fastcall Script_RegisterAtlas(void *L) {
|
||||
if (!Game::Lua::IsString(L, 1) || !Game::Lua::IsString(L, 2)) {
|
||||
Game::Lua::Error(L, "Usage: C_Texture.RegisterAtlas(name, file, width, height, "
|
||||
"left, right, top, bottom [, tilesH, tilesV])");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Info info;
|
||||
info.name = Game::Lua::ToString(L, 1);
|
||||
info.file = Game::Lua::ToString(L, 2);
|
||||
info.width = static_cast<float>(Game::Lua::ToNumber(L, 3));
|
||||
info.height = static_cast<float>(Game::Lua::ToNumber(L, 4));
|
||||
if (Game::Lua::IsNumber(L, 5))
|
||||
info.left = static_cast<float>(Game::Lua::ToNumber(L, 5));
|
||||
if (Game::Lua::IsNumber(L, 6))
|
||||
info.right = static_cast<float>(Game::Lua::ToNumber(L, 6));
|
||||
if (Game::Lua::IsNumber(L, 7))
|
||||
info.top = static_cast<float>(Game::Lua::ToNumber(L, 7));
|
||||
if (Game::Lua::IsNumber(L, 8))
|
||||
info.bottom = static_cast<float>(Game::Lua::ToNumber(L, 8));
|
||||
info.tilesHorizontally = Game::Lua::ToBoolean(L, 9) != 0;
|
||||
info.tilesVertically = Game::Lua::ToBoolean(L, 10) != 0;
|
||||
|
||||
const std::string key = Lower(info.name.c_str());
|
||||
auto ®istry = Registry();
|
||||
auto existing = registry.find(key);
|
||||
if (existing != registry.end()) {
|
||||
// Re-registering a name keeps its ids stable, so a `/reload` that re-runs
|
||||
// an addon's registration doesn't renumber anything behind a caller that
|
||||
// cached the id.
|
||||
info.atlasID = existing->second.atlasID;
|
||||
info.elementID = existing->second.elementID;
|
||||
} else {
|
||||
info.atlasID = g_nextSyntheticID;
|
||||
info.elementID = g_nextSyntheticID;
|
||||
--g_nextSyntheticID;
|
||||
}
|
||||
registry[key] = info;
|
||||
|
||||
g_misses.erase(key);
|
||||
Game::Lua::PushBool(L, true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --- texture:SetAtlas / GetAtlas -------------------------------------------
|
||||
|
||||
// `texture:SetAtlas(atlas [, useAtlasSize, filterMode, resetTexCoords,
|
||||
// wrapModeHorizontal, wrapModeVertical])`
|
||||
// The trailing arguments are accepted and ignored: 1.12 texture regions have no
|
||||
// filter or wrap mode to set, and dropping them silently is what lets modern call
|
||||
// sites pass through unchanged.
|
||||
int __fastcall Script_SetAtlas(void *L) {
|
||||
void *region = Game::Lua::ResolveObject(L, 1);
|
||||
if (region == nullptr)
|
||||
return 0;
|
||||
|
||||
if (!Game::Lua::IsString(L, 2)) {
|
||||
// A nil atlas clears the binding without touching the texture, mirroring
|
||||
// the unknown-name path below.
|
||||
g_applied.erase(region);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *name = Game::Lua::ToString(L, 2);
|
||||
const bool useAtlasSize = Game::Lua::ToBoolean(L, 3) != 0;
|
||||
|
||||
const Info *info = Find(name);
|
||||
if (info == nullptr) {
|
||||
// Deliberately NOT retail behaviour, which clears the texture. With few
|
||||
// built-in bindings a clear would make a ported frame vanish entirely and
|
||||
// give the author nothing to look at; leaving the previous texture in
|
||||
// place keeps the frame visible while the miss log says what to bind.
|
||||
RecordMiss(name);
|
||||
g_applied.erase(region);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Reshaping the stack for each delegated handler leaves `info` untouched — it
|
||||
// points into the registry, which none of these calls can reach.
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushString(L, info->file.c_str());
|
||||
CallScript(Offsets::FUN_SCRIPT_TEXTURE_SET_TEXTURE, L);
|
||||
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushNumber(L, info->left);
|
||||
Game::Lua::PushNumber(L, info->right);
|
||||
Game::Lua::PushNumber(L, info->top);
|
||||
Game::Lua::PushNumber(L, info->bottom);
|
||||
CallScript(Offsets::FUN_SCRIPT_TEXTURE_SET_TEXCOORD, L);
|
||||
|
||||
if (useAtlasSize && info->width > 0.0f && info->height > 0.0f) {
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushNumber(L, info->width);
|
||||
CallScript(Offsets::FUN_SCRIPT_REGION_SETWIDTH, L);
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushNumber(L, info->height);
|
||||
CallScript(Offsets::FUN_SCRIPT_REGION_SETHEIGHT, L);
|
||||
}
|
||||
|
||||
Applied applied;
|
||||
applied.name = info->name;
|
||||
applied.hTexture = RegionTexture(region);
|
||||
g_applied[region] = applied;
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// `texture:GetAtlas()` — the atlas last set on this texture, or nil if the
|
||||
// texture has been pointed somewhere else since.
|
||||
int __fastcall Script_GetAtlas(void *L) {
|
||||
void *region = Game::Lua::ResolveObject(L, 1);
|
||||
if (region == nullptr)
|
||||
return 0;
|
||||
auto it = g_applied.find(region);
|
||||
if (it == g_applied.end())
|
||||
return 0;
|
||||
if (RegionTexture(region) != it->second.hTexture) {
|
||||
g_applied.erase(it);
|
||||
return 0;
|
||||
}
|
||||
Game::Lua::PushString(L, it->second.name.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --- diagnostics ------------------------------------------------------------
|
||||
|
||||
// Returns every atlas name something asked for that nothing could resolve, and
|
||||
// writes the same list to the debug log. This is the growth loop for the built-in
|
||||
// table: load an addon, read what it really wanted, bind those names.
|
||||
int __fastcall Script_DumpAtlasMisses(void *L) {
|
||||
Game::Lua::SetTop(L, 0);
|
||||
Game::Lua::NewTable(L);
|
||||
int n = 0;
|
||||
Debug::Log::Printf("[atlas] %d unresolved atlas name(s)",
|
||||
static_cast<int>(g_misses.size()));
|
||||
for (const std::string &name : g_misses) {
|
||||
Debug::Log::Printf("[atlas] %s", name.c_str());
|
||||
Game::Lua::PushNumber(L, ++n);
|
||||
Game::Lua::PushString(L, name.c_str());
|
||||
Game::Lua::SetTable(L, -3);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --- registration -----------------------------------------------------------
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
|
||||
{"SetAtlas", &Script_SetAtlas},
|
||||
{"GetAtlas", &Script_GetAtlas},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_TEXTURE_METHOD_REGISTRY), g_textureMethods,
|
||||
static_cast<int>(sizeof(g_textureMethods) / sizeof(g_textureMethods[0])));
|
||||
|
||||
Game::Lua::RegisterTableFunction("C_Texture", "GetAtlasInfo", &Script_GetAtlasInfo);
|
||||
Game::Lua::RegisterTableFunction("C_Texture", "GetAtlasExists", &Script_GetAtlasExists);
|
||||
Game::Lua::RegisterTableFunction("C_Texture", "GetAtlasID", &Script_GetAtlasID);
|
||||
Game::Lua::RegisterTableFunction("C_Texture", "GetAtlasElementID",
|
||||
&Script_GetAtlasElementID);
|
||||
Game::Lua::RegisterTableFunction("C_Texture", "GetAtlasElements",
|
||||
&Script_GetAtlasElements);
|
||||
Game::Lua::RegisterTableFunction("C_Texture", "RegisterAtlas", &Script_RegisterAtlas);
|
||||
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_DumpAtlasMisses", &Script_DumpAtlasMisses);
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Game::ReloadAutoRegister _reloadReg{&PrepareForReload};
|
||||
|
||||
} // namespace
|
||||
|
||||
const Info *Find(const char *name) {
|
||||
if (name == nullptr || *name == '\0')
|
||||
return nullptr;
|
||||
auto ®istry = Registry();
|
||||
auto it = registry.find(Lower(name));
|
||||
return (it == registry.end()) ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
void RecordMiss(const char *name) {
|
||||
if (name == nullptr || *name == '\0' || g_misses.size() >= kMaxMisses)
|
||||
return;
|
||||
g_misses.insert(Lower(name));
|
||||
}
|
||||
|
||||
void PrepareForReload() {
|
||||
g_applied.clear();
|
||||
g_misses.clear();
|
||||
}
|
||||
|
||||
} // namespace Texture::Atlas
|
||||
@@ -0,0 +1,65 @@
|
||||
// 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 <string>
|
||||
|
||||
// `Texture::Atlas` — the atlas name registry behind `C_Texture.*`,
|
||||
// `texture:SetAtlas`, and the `|A:name:h:w|a` inline markup. An atlas is a name
|
||||
// bound to a texture plus a sub-rect within it; resolving one is what lets a
|
||||
// caller set both in a single step. See `AtlasData.h` for where the built-in
|
||||
// bindings come from and why there are so few of them.
|
||||
//
|
||||
// The registry is seeded from that table on first use and extended at runtime by
|
||||
// `C_Texture.RegisterAtlas`, so an addon that ships its own sprite sheet works
|
||||
// without needing any binding here.
|
||||
namespace Texture::Atlas {
|
||||
|
||||
// One resolved atlas. Built-in and addon-registered entries normalize to this
|
||||
// same shape, so every reader treats them identically.
|
||||
struct Info {
|
||||
std::string name; // canonical casing, reported as `elementName`
|
||||
std::string file; // texture path
|
||||
float width = 0.0f;
|
||||
float height = 0.0f;
|
||||
float left = 0.0f;
|
||||
float right = 1.0f;
|
||||
float top = 0.0f;
|
||||
float bottom = 1.0f;
|
||||
// Blizzard's own ids for a built-in entry. An addon-registered atlas has no
|
||||
// Blizzard id, so it gets a NEGATIVE synthetic one — the same
|
||||
// two-namespaces-in-one-int idiom `Map::Area` uses for uiMapIDs, which keeps
|
||||
// the two spaces from ever colliding.
|
||||
int atlasID = 0;
|
||||
int elementID = 0;
|
||||
bool tilesHorizontally = false;
|
||||
bool tilesVertically = false;
|
||||
};
|
||||
|
||||
// Resolves an atlas by name, case-insensitively. Null when the name is unbound.
|
||||
// The returned pointer stays valid for the life of the process.
|
||||
const Info *Find(const char *name);
|
||||
|
||||
// Records an unresolved atlas name for `_classicapi_DumpAtlasMisses()`. This is
|
||||
// how the built-in table grows: load a real addon, read what it actually asked
|
||||
// for, bind those names instead of guessing at thousands.
|
||||
void RecordMiss(const char *name);
|
||||
|
||||
// Drops the per-region `GetAtlas` bookkeeping and the miss log before a
|
||||
// `/reload`. Registered atlases are deliberately kept — addon Lua re-runs and
|
||||
// re-registers by name, and holding the old entries keeps every name resolvable
|
||||
// across the gap.
|
||||
void PrepareForReload();
|
||||
|
||||
} // namespace Texture::Atlas
|
||||
@@ -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/>.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Built-in atlas bindings, derived from `UiTextureAtlas` + `UiTextureAtlasMember`
|
||||
// as shipped by Classic Era 1.15.9 (build 69547). Vanilla 1.12 has no atlas table
|
||||
// at all, so a modern addon's `SetAtlas("name")` has nothing to resolve against;
|
||||
// each row here binds one Blizzard atlas name to the 1.12 texture that carries the
|
||||
// same art, plus the sub-rect within it.
|
||||
//
|
||||
// WHY THIS TABLE IS TINY AND NOT FIVE THOUSAND ROWS
|
||||
//
|
||||
// The member table has 5,353 named regions. Almost none of them can be bound
|
||||
// automatically, for three independent reasons established by measurement:
|
||||
//
|
||||
// 1. The sheets do not exist here. Resolving all 868 atlas sheet fileDataIDs
|
||||
// through the community listfile and testing every path against the client's
|
||||
// own art (7,640 files) gives ZERO hits — Classic Era repacked its art into
|
||||
// sheets 1.12 has never shipped.
|
||||
// 2. Only the names carry over. Blizzard kept the original vanilla filename as
|
||||
// the atlas name when packing, so 16 member names match a 1.12 file basename.
|
||||
// That is the entire mechanical overlap.
|
||||
// 3. A matching name does not prove matching art. Comparing the real BLP headers
|
||||
// against the member table's sprite sizes, only 5 of those 16 agree.
|
||||
// `ui-scrollbar-scrollupbutton-up` is 18x16 in the atlas while the 1.12 file
|
||||
// is 32x32 — and vanilla's own `UIPanelScrollBarButton` template draws it at
|
||||
// TexCoords 0.25,0.75,0.25,0.75, a CENTERED 16x16 sprite that is neither the
|
||||
// atlas's size nor a top-left crop. `macropopup-topleft` is 18x71 against a
|
||||
// 256x256 file: a different asset that happens to share a name. So texcoords
|
||||
// cannot be derived from the two tables, and a wrong guess renders wrong art
|
||||
// rather than failing loudly.
|
||||
//
|
||||
// HOW A ROW GETS DERIVED
|
||||
//
|
||||
// Two methods, both evidence-based. Every row records which one it came from.
|
||||
//
|
||||
// NAME MATCH — the atlas name IS a 1.12 filename AND the dimensions agree, so
|
||||
// the whole file is the sprite and the texcoords are a trivial 0..1. Only 5
|
||||
// names clear both bars. Do NOT skip the dimension check: 11 more names match
|
||||
// while the art does not.
|
||||
// WIDGET MATCH — the strong one. Classic Era's UI is this UI rebuilt, so the
|
||||
// same widget exists in both trees: one names its art by atlas, the other by
|
||||
// file plus TexCoords. Grep `atlas="name"` in Classic Era's exported
|
||||
// FrameXML, find the same widget in the client's own exported FrameXML, and
|
||||
// read the binding straight off it. Take the SIZE from the 1.12 file rather
|
||||
// than the member table — packing adds a pixel or two of bleed.
|
||||
//
|
||||
// Anything neither method reaches needs a human to pick the equivalent art by
|
||||
// eye, which is the lowest-confidence case and always needs a render check.
|
||||
// `C_Texture.RegisterAtlas` replaces a built-in binding at runtime, so a
|
||||
// candidate row can be tested live before it is added here.
|
||||
//
|
||||
// Growth is driven by the miss log (`_classicapi_DumpAtlasMisses`), never done
|
||||
// speculatively, so the table only ever moves toward what real addons ask for.
|
||||
//
|
||||
// Two source-data traps, handled during extraction:
|
||||
// - 28 members have an EMPTY name and must be skipped.
|
||||
// - 73 names repeat case-insensitively; keep the higher ID, the same rule
|
||||
// `ui/ColorData.h` documents for duplicate color tags.
|
||||
//
|
||||
// `tilesHorizontally` / `tilesVertically` come from `CommittedFlags` bits 0 and 1.
|
||||
// They are stored as resolved booleans rather than the raw flags word because the
|
||||
// other set bits (4, 8, 16 appear in the data) have no known meaning and must not
|
||||
// leak into the public contract.
|
||||
|
||||
namespace Texture::AtlasData {
|
||||
|
||||
struct Entry {
|
||||
const char *name; // Blizzard's canonical casing, returned as `elementName`
|
||||
const char *file; // 1.12 texture path, extension omitted as WoW expects
|
||||
int16_t width; // sprite size, used by SetAtlas's `useAtlasSize`
|
||||
int16_t height;
|
||||
float left, right, top, bottom; // normalized sub-rect within `file`
|
||||
int32_t atlasID; // Blizzard UiTextureAtlas.ID
|
||||
int32_t elementID; // Blizzard UiTextureAtlasMember.UiTextureAtlasElementID
|
||||
bool tilesHorizontally;
|
||||
bool tilesVertically;
|
||||
};
|
||||
|
||||
constexpr Entry kAtlases[] = {
|
||||
// WIDGET MATCH. Classic Era's FloatingChatFrame.xml draws the chat menu
|
||||
// button's `Flash` overlay with atlas "chatframe-button-highlightalert";
|
||||
// this client's FloatingChatFrame.xml draws the same `$parentFlash` region
|
||||
// with UI-ChatIcon-BlinkHilight and no TexCoords, so the whole file is the
|
||||
// sprite. Size is the 1.12 file's 32x32, not the member table's 34x32 (that
|
||||
// extra bleed came from packing). Verified in-game: a blue glow ring.
|
||||
{"chatframe-button-highlightalert", "Interface\\ChatFrame\\UI-ChatIcon-BlinkHilight",
|
||||
32, 32, 0.0f, 1.0f, 0.0f, 1.0f, 924, 10841, false, false},
|
||||
// NAME MATCH, dimensions confirmed against the BLP headers.
|
||||
{"MinimapArrow", "Interface\\Minimap\\MinimapArrow", 32, 32, 0.0f, 1.0f, 0.0f, 1.0f, 647,
|
||||
9441, false, false},
|
||||
{"Repair", "Interface\\CURSOR\\Repair", 32, 32, 0.0f, 1.0f, 0.0f, 1.0f, 647, 9509, false,
|
||||
false},
|
||||
{"Rotating-MinimapArrow", "Interface\\Minimap\\ROTATING-MINIMAPARROW", 32, 32, 0.0f, 1.0f,
|
||||
0.0f, 1.0f, 647, 9442, false, false},
|
||||
{"Rotating-MinimapGroupArrow", "Interface\\Minimap\\Rotating-MinimapGroupArrow", 32, 32,
|
||||
0.0f, 1.0f, 0.0f, 1.0f, 647, 9443, false, false},
|
||||
{"Rotating-MinimapGuideArrow", "Interface\\Minimap\\ROTATING-MINIMAPGUIDEARROW", 32, 32,
|
||||
0.0f, 1.0f, 0.0f, 1.0f, 647, 9444, false, false},
|
||||
};
|
||||
|
||||
constexpr int kAtlasCount = sizeof(kAtlases) / sizeof(kAtlases[0]);
|
||||
|
||||
} // namespace Texture::AtlasData
|
||||
@@ -0,0 +1,503 @@
|
||||
// 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/>.
|
||||
|
||||
// Texture dimension gates — let any texture the GPU can hold load, whatever its
|
||||
// size or shape, without ever letting the decoder outrun its buffer.
|
||||
//
|
||||
// THE INVARIANT. Every texture decode in this engine writes into ONE global
|
||||
// scratch buffer (VAR_TEXTURE_DECODE_SCRATCH), allocated once at boot for a
|
||||
// single square texture of side S (0x200 stock; 0x400 once VanillaHelpers has
|
||||
// patched the boot immediates) and written with no bounds check. So the rule
|
||||
// every client version upholds is: nothing may reach the decoder larger than
|
||||
// S on either axis. They just enforce it in different places:
|
||||
//
|
||||
// 1.12 allocator FUN_00448450: `< 1024` AND power-of-two, together => <= 512.
|
||||
// The create validator's device-caps max is raw hardware (thousands) and
|
||||
// protects nothing — a 2560x1080 sailed through it and faulted in the
|
||||
// decoder's pixel writer at 0x004492E5.
|
||||
// 3.3.5 scratch 1024; the allocator's size test is demoted to pool
|
||||
// eligibility ([32,512]) and the VALIDATOR carries the bound, because
|
||||
// 3.3.5 clamps its caps max to 1024. Verified in FUN_004b7f80 /
|
||||
// FUN_00681d90 / FUN_004b5bb0. Not grow-on-demand — the same fixed
|
||||
// buffer, one size up. VanillaHelpers' patch set is, in effect, this
|
||||
// exact backport (size 0x400, scratch 0x400, pool 6x6).
|
||||
//
|
||||
// Two consequences this module is built around. The POT rule was PART of 1.12's
|
||||
// size bound, so relaxing it alone exposes a 600x600 overrun on a stock client.
|
||||
// And the POT jumps sit ABOVE the size CMPs in the allocator, so retargeting a
|
||||
// POT failure straight to the pool-skip tail jumps OVER the bound.
|
||||
//
|
||||
// DESIGN — no numbers of our own. A fixed cap only exists because the buffer is
|
||||
// sized up front; size it on demand and the cap disappears. Every dimension
|
||||
// failure in the allocator — POT or size — is routed into a 24-byte code cave in
|
||||
// a page we own, which hands the texture's width and height to EnsureScratch:
|
||||
//
|
||||
// PUSHAD ; MOV ECX,EBX ; MOV EDX,EAX ; CALL EnsureScratch
|
||||
// TEST AL,AL ; POPAD ; JZ reject ; JMP pool-skip
|
||||
//
|
||||
// EnsureScratch refuses anything over the device's own max texture dimension —
|
||||
// the caps field the create validator compares against, so we never grow for a
|
||||
// texture the engine would refuse a moment later — and otherwise replaces the
|
||||
// scratch with one that fits, through the engine's own decode-buffer
|
||||
// constructor FUN_TEXTURE_DECODE_BUFFER_ALLOC (the routine both decoders use
|
||||
// when the scratch is NULL). The scratch is a high-water mark: someone who never
|
||||
// loads anything over 1024 pays nothing. The in-function size CMPs decide only
|
||||
// "poolable" (the smaller of what the recycle pool can index and the boot
|
||||
// scratch). That is 3.3.5's allocator/validator split, with the validator's
|
||||
// bound honoured up front instead of relying on a caps clamp 1.12 doesn't have.
|
||||
//
|
||||
// Layers:
|
||||
// 1. Pool eligibility follows the engine (always on). The pool limit is read
|
||||
// back from the boot immediates and the pool-count immediate, so it is
|
||||
// right on any DLL stack — the stock bucket table ends exactly at the
|
||||
// scratch pointer, so an index it can't hold reads the pointer as a list.
|
||||
// 2. VanillaHelpers' pool-index stride (auto, when its 6x6 table is
|
||||
// detected). Their table is 6 wide but the index arithmetic stayed at
|
||||
// stride 5, so a 1024 dimension collides with a 32-high bucket and the
|
||||
// wrong-size texture is recycled: 32x1024 rendered nothing, 64x1024
|
||||
// worked by luck. Both index sites are rewritten to stride 6.
|
||||
// 3. Grow on demand (always on) — the cave above. This is enough for TGA,
|
||||
// which allocates first and decodes later at upload.
|
||||
// 4. BLP grows BEFORE it decompresses (always on). The .blp loader writes
|
||||
// the decoded image into the scratch and only then calls the allocator, so
|
||||
// the cave is too late for it — a 2048x2048 BLP has always overrun the
|
||||
// scratch on this client. A pre-hook on the decompressor FUN_BLP_DECOMPRESS,
|
||||
// which carries the dimensions itself, grows the scratch first — and
|
||||
// repoints the caller's already-copied slot at the new buffer, since the
|
||||
// decompressor writes through that copy — or reports "decompression
|
||||
// failed" for a texture the GPU can't hold. It acts only when the slot IS
|
||||
// the scratch: the IMG path decompresses into a private, right-sized
|
||||
// buffer of its own and is left untouched.
|
||||
// 6. The recycle-pool free follows the same bound (always on). The free path
|
||||
// files a texture into a bucket with no upper size check; the cave lets
|
||||
// textures larger than the pool can index exist, so freeing one overran
|
||||
// the bucket table (a 2048x2048 crashed at teardown). A hook routes any
|
||||
// texture the table can't index to the non-pooled free instead — the
|
||||
// mirror of the allocator's cave, and the missing half of the size gate.
|
||||
// 5. The async fallback guard (always on). FUN_TEXTURE_FORCE_LOAD shoves a
|
||||
// read that never fit the shared async buffer into a 512 KiB static
|
||||
// fallback with no size check — a pre-existing engine bug the stock size
|
||||
// gate merely hid, and the actual mechanism behind "big BLPs crash". The
|
||||
// hook refuses to force a request larger than the fallback; the texture
|
||||
// stays blank. So a BLP FILE larger than the shared buffer (2 MiB stock,
|
||||
// 32 MiB with VanillaHelpers) does not load — an uncompressed BLP only;
|
||||
// DXT stays far under, and TGA is synchronous. Growing the fallback to fit
|
||||
// was tried and reverted (a reader-thread fault, not yet isolated).
|
||||
// NPOT (always on). POT failures go to the cave (fit, then fresh allocation
|
||||
// — never the pool, whose log2 index is meaningless for NPOT), and the
|
||||
// validator's own POT re-test is forced true. Verified in-game for the
|
||||
// TGA/UI path (clamped UVs, single mip); NPOT BLPs with DXT blocks or mip
|
||||
// chains are untested.
|
||||
//
|
||||
// Applied from module registration — glue boot and every FrameXML init — which
|
||||
// is after every other DLL's load-time patching, so the values read back are
|
||||
// the ones actually in force. The guard hook installs with the rest of the
|
||||
// DLL's hooks.
|
||||
|
||||
#include "Common.h"
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace Texture::DimensionGate {
|
||||
|
||||
namespace {
|
||||
|
||||
// ---- byte helpers ------------------------------------------------------------
|
||||
|
||||
uint32_t ReadImm32(uintptr_t addr) { return *reinterpret_cast<const uint32_t *>(addr); }
|
||||
|
||||
void Put32(uint8_t *dst, uint32_t v) { std::memcpy(dst, &v, sizeof v); }
|
||||
|
||||
// rel32 for a jump/call whose NEXT instruction begins at `next`.
|
||||
uint32_t Rel32(uintptr_t next, uintptr_t target) {
|
||||
return static_cast<uint32_t>(static_cast<int32_t>(target - next));
|
||||
}
|
||||
|
||||
bool WriteImm32(uintptr_t site, uint32_t v) {
|
||||
return Common::PatchBytes(reinterpret_cast<void *>(site), &v, sizeof v);
|
||||
}
|
||||
|
||||
// Rewrite a 6-byte `0F <cond> rel32` near jump in engine code. The condition is
|
||||
// written explicitly, never preserved — VanillaHelpers may have flipped it.
|
||||
bool WriteNearJump(uintptr_t site, uint8_t cond, uintptr_t target) {
|
||||
uint8_t bytes[6] = {0x0F, cond, 0, 0, 0, 0};
|
||||
Put32(bytes + 2, Rel32(site + 6, target));
|
||||
return Common::PatchBytes(reinterpret_cast<void *>(site), bytes, sizeof bytes);
|
||||
}
|
||||
|
||||
// ---- what the engine was actually configured with ----------------------------
|
||||
|
||||
// Side of the decode scratch. Read back from the two immediates FUN_00448BD0
|
||||
// feeds FUN_GX_FORMAT_IMAGE_BYTES, so this is what the buffer is REALLY sized
|
||||
// to, whoever last set them — the engine at boot, VanillaHelpers, or
|
||||
// GrowScratch below. A mismatch is someone else's bug; the smaller is the only
|
||||
// safe answer.
|
||||
uint32_t ScratchDimension() {
|
||||
const uint32_t w = ReadImm32(Offsets::PATCH_TEXDECODE_SCRATCH_DIM_W + 1);
|
||||
const uint32_t h = ReadImm32(Offsets::PATCH_TEXDECODE_SCRATCH_DIM_H + 1);
|
||||
return w < h ? w : h;
|
||||
}
|
||||
|
||||
// Recycle-pool side, from its init-count immediate: 25 = stock 5x5, 36 =
|
||||
// VanillaHelpers' 6x6. 0 = a layout we don't recognise.
|
||||
int PoolSide() {
|
||||
switch (ReadImm32(Offsets::PATCH_TEXPOOL_INIT_COUNT + 1)) {
|
||||
case 25:
|
||||
return 5;
|
||||
case 36:
|
||||
return 6;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Largest dimension the pool can index: buckets are keyed by log2(dim >> 5)
|
||||
// over 0..side-1. An unknown layout is treated as stock.
|
||||
uint32_t PoolMaxDimension(int side) { return 32u << ((side > 0 ? side : 5) - 1); }
|
||||
|
||||
// The GPU's own maximum texture dimension for 2D textures — the field the
|
||||
// create validator compares against. 0 if the device isn't up.
|
||||
uint32_t DeviceMaxDimension() {
|
||||
const auto *device = *reinterpret_cast<const uint8_t *const *>(Offsets::VAR_GX_DEVICE);
|
||||
if (device == nullptr)
|
||||
return 0;
|
||||
return *reinterpret_cast<const uint32_t *>(device + Offsets::OFF_GXDEV_CAPS_MAX_TEX_DIM);
|
||||
}
|
||||
|
||||
// ---- layer 3: the scratch grows to fit ----------------------------------------
|
||||
|
||||
constexpr const char *kAllocTag = "ClassicAPI/texture/DimensionGate.cpp";
|
||||
|
||||
using DecodeBufferAlloc_t = void *(__fastcall *)(int format, uint32_t width, uint32_t height,
|
||||
const char *file, uint32_t line);
|
||||
using SMemFree_t = void(__stdcall *)(void *buf, const char *file, int line, int flags);
|
||||
|
||||
void *g_retiredScratch = nullptr; // the previous scratch, freed at the next growth
|
||||
|
||||
// Replace the scratch with one sized for `side` x `side` (format 2, what the
|
||||
// boot alloc uses), through the engine's own decode-buffer constructor. Safe
|
||||
// from any main-thread context that is not itself a texture decode: every
|
||||
// reader re-reads VAR_TEXTURE_DECODE_SCRATCH per decode, and the old buffer's
|
||||
// only other reference is the exit teardown, which SMemFree's whatever the slot
|
||||
// holds. Never shrinks. Records the new side in the boot immediates so
|
||||
// ScratchDimension() — and every later registration — reads it back.
|
||||
bool GrowScratch(uint32_t side) {
|
||||
if (side <= ScratchDimension())
|
||||
return true;
|
||||
auto alloc = reinterpret_cast<DecodeBufferAlloc_t>(Offsets::FUN_TEXTURE_DECODE_BUFFER_ALLOC);
|
||||
void *fresh = alloc(2, side, side, kAllocTag, __LINE__);
|
||||
if (fresh == nullptr)
|
||||
return false;
|
||||
auto **slot = reinterpret_cast<void **>(Offsets::VAR_TEXTURE_DECODE_SCRATCH);
|
||||
void *old = *slot;
|
||||
*slot = fresh;
|
||||
// Retire the old buffer one generation late. Every consumer re-reads the
|
||||
// global per decode, and BlpDecompress_h repoints the one slot that was
|
||||
// copied before the growth — but keeping exactly one retired buffer alive
|
||||
// means any holder not accounted for reads stale bytes instead of unmapped
|
||||
// memory. Bounded: one buffer, freed at the next growth.
|
||||
if (g_retiredScratch != nullptr)
|
||||
reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE)(g_retiredScratch, kAllocTag,
|
||||
__LINE__, 0);
|
||||
g_retiredScratch = old;
|
||||
WriteImm32(Offsets::PATCH_TEXDECODE_SCRATCH_DIM_W + 1, side);
|
||||
WriteImm32(Offsets::PATCH_TEXDECODE_SCRATCH_DIM_H + 1, side);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called from the cave for every texture the allocator's own tests turned away
|
||||
// (not a power of two, or larger than the pool can index). Returns false to
|
||||
// reject — only for what the GPU itself can't hold — otherwise makes sure the
|
||||
// scratch can take it and lets it through to a fresh allocation. Runs on the
|
||||
// main thread inside FUN_00448450, which is never a decode in progress.
|
||||
bool __fastcall EnsureScratch(uint32_t width, uint32_t height) {
|
||||
const uint32_t deviceMax = DeviceMaxDimension();
|
||||
if (deviceMax == 0 || width > deviceMax || height > deviceMax)
|
||||
return false;
|
||||
return GrowScratch(width > height ? width : height);
|
||||
}
|
||||
|
||||
// ---- the code cave -----------------------------------------------------------
|
||||
//
|
||||
// +0 60 PUSHAD
|
||||
// +1 8B CB MOV ECX,EBX ; EBX = width at every entry site
|
||||
// +3 8B D0 MOV EDX,EAX ; EAX = height
|
||||
// +5 E8 rel32 CALL EnsureScratch
|
||||
// +10 84 C0 TEST AL,AL
|
||||
// +12 61 POPAD ; does not touch EFLAGS
|
||||
// +13 0F 84 rel32 JZ reject
|
||||
// +19 E9 rel32 JMP pool-skip ; fresh allocation, descriptor is complete
|
||||
//
|
||||
// Entered only by jumps from inside FUN_00448450, where EBX/EAX still hold the
|
||||
// dimensions, EDI is 0 and [EBP-0x2C] is the finished descriptor — everything
|
||||
// the pool-skip tail expects. PUSHAD/POPAD keep every register the engine still
|
||||
// needs; flags are clobbered and nothing downstream reads them. Lives for the
|
||||
// process: engine code points into it.
|
||||
|
||||
uint8_t *g_cave = nullptr;
|
||||
constexpr size_t kCaveSize = 24;
|
||||
|
||||
bool BuildCave() {
|
||||
if (g_cave == nullptr) {
|
||||
g_cave = static_cast<uint8_t *>(
|
||||
VirtualAlloc(nullptr, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
|
||||
if (g_cave == nullptr)
|
||||
return false;
|
||||
}
|
||||
const auto base = reinterpret_cast<uintptr_t>(g_cave);
|
||||
uint8_t c[kCaveSize];
|
||||
c[0] = 0x60;
|
||||
c[1] = 0x8B, c[2] = 0xCB;
|
||||
c[3] = 0x8B, c[4] = 0xD0;
|
||||
c[5] = 0xE8;
|
||||
Put32(c + 6, Rel32(base + 10, reinterpret_cast<uintptr_t>(&EnsureScratch)));
|
||||
c[10] = 0x84, c[11] = 0xC0;
|
||||
c[12] = 0x61;
|
||||
c[13] = 0x0F, c[14] = 0x84;
|
||||
Put32(c + 15, Rel32(base + 19, Offsets::VA_TEXALLOC_REJECT));
|
||||
c[19] = 0xE9;
|
||||
Put32(c + 20, Rel32(base + 24, Offsets::VA_TEXALLOC_POOL_SKIP));
|
||||
std::memcpy(g_cave, c, kCaveSize);
|
||||
FlushInstructionCache(GetCurrentProcess(), g_cave, kCaveSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
uintptr_t Cave() { return reinterpret_cast<uintptr_t>(g_cave); }
|
||||
|
||||
// ---- state -------------------------------------------------------------------
|
||||
|
||||
uint32_t g_poolMax = 0; // what the in-function CMPs admit to the pool
|
||||
uint32_t g_poolIndexMax = 0; // the largest dimension the bucket table can index
|
||||
int g_poolSide = 0;
|
||||
bool g_strideFixed = false;
|
||||
|
||||
// ---- layer 1: pool eligibility follows the engine -----------------------------
|
||||
|
||||
bool ApplySizeGate() {
|
||||
g_poolSide = PoolSide();
|
||||
const uint32_t indexable = PoolMaxDimension(g_poolSide);
|
||||
g_poolIndexMax = indexable; // the free-path guard's bound (layer 6)
|
||||
const uint32_t scratch = ScratchDimension();
|
||||
// A texture that enters the pool never reaches the cave, so it must also
|
||||
// fit the scratch as it stands. (A VanillaHelpers install whose scratch
|
||||
// patch failed but whose pool patch didn't would otherwise let 1024 into a
|
||||
// 512 buffer.)
|
||||
g_poolMax = scratch < indexable ? scratch : indexable;
|
||||
if (!BuildCave())
|
||||
return false;
|
||||
bool ok = WriteImm32(Offsets::PATCH_TEXALLOC_SIZE_W_IMM, g_poolMax);
|
||||
ok = WriteImm32(Offsets::PATCH_TEXALLOC_SIZE_H_IMM, g_poolMax) && ok;
|
||||
// JA: strictly greater than the pool max leaves the pool path — so exactly
|
||||
// poolMax (1024 on a VanillaHelpers stack) remains poolable.
|
||||
ok = WriteNearJump(Offsets::PATCH_TEXALLOC_SIZE_W_JMP, 0x87, Cave()) && ok;
|
||||
ok = WriteNearJump(Offsets::PATCH_TEXALLOC_SIZE_H_JMP, 0x87, Cave()) && ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---- layer 2: VanillaHelpers' pool-index stride -------------------------------
|
||||
|
||||
// `IMUL r,r,6 ; ADD r,other` in place of `LEA t,[other + r*4] ; ADD r,t`:
|
||||
// same length, same operand roles, stride 5 -> 6. See Offsets.h.
|
||||
constexpr uint8_t kStride6Alloc[5] = {0x6B, 0xF6, 0x06, 0x03, 0xF3}; // IMUL ESI,ESI,6; ADD ESI,EBX
|
||||
constexpr uint8_t kStride6Free[5] = {0x6B, 0xDB, 0x06, 0x03, 0xD8}; // IMUL EBX,EBX,6; ADD EBX,EAX
|
||||
|
||||
bool ApplyStrideFix() {
|
||||
if (g_poolSide != 6) {
|
||||
g_strideFixed = false;
|
||||
return true; // stock 5x5 (or unknown): stride 5 is correct, leave it
|
||||
}
|
||||
bool ok = Common::PatchBytes(reinterpret_cast<void *>(Offsets::PATCH_TEXPOOL_INDEX_STRIDE_ALLOC),
|
||||
kStride6Alloc, sizeof kStride6Alloc);
|
||||
ok = Common::PatchBytes(reinterpret_cast<void *>(Offsets::PATCH_TEXPOOL_INDEX_STRIDE_FREE),
|
||||
kStride6Free, sizeof kStride6Free) &&
|
||||
ok;
|
||||
g_strideFixed = ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ---- layer 4: BLP grows the scratch before it decompresses ---------------------
|
||||
|
||||
using BlpDecompress_t = int(__fastcall *)(void *reader, void *edxUnused, int format,
|
||||
int *scratchSlot, uint32_t mipBase, int flag);
|
||||
BlpDecompress_t BlpDecompress_o = nullptr;
|
||||
|
||||
// __thiscall wired as __fastcall with the ignored EDX slot. The reader holds the
|
||||
// level-0 dimensions; `mipBase` is how many levels the BLP pre-scale skipped, so
|
||||
// the level actually written is `>> mipBase` (floored at 1, as the engine does).
|
||||
// Returning 0 is the decompressor's own failure code: the loader logs
|
||||
// "decompression failed" and the texture stays blank.
|
||||
int __fastcall BlpDecompress_h(void *reader, void *edxUnused, int format, int *scratchSlot,
|
||||
uint32_t mipBase, int flag) {
|
||||
// Only a caller decompressing into the SHARED scratch concerns us. The .blp
|
||||
// path (FUN_0044A560) copies VAR_TEXTURE_DECODE_SCRATCH into its slot before
|
||||
// calling in; the IMG path (FUN_00449840) hands in a private buffer it already
|
||||
// sized for the image through FUN_0044AF90, and must be left alone — its
|
||||
// release path frees whatever the slot holds.
|
||||
const auto *scratchVar = reinterpret_cast<const int *>(Offsets::VAR_TEXTURE_DECODE_SCRATCH);
|
||||
if (scratchSlot != nullptr && *scratchSlot != 0 && *scratchSlot == *scratchVar) {
|
||||
const auto *r = static_cast<const uint8_t *>(reader);
|
||||
uint32_t w = *reinterpret_cast<const uint32_t *>(r + Offsets::OFF_BLPREADER_WIDTH) >>
|
||||
(mipBase & 31);
|
||||
uint32_t h = *reinterpret_cast<const uint32_t *>(r + Offsets::OFF_BLPREADER_HEIGHT) >>
|
||||
(mipBase & 31);
|
||||
if (w == 0)
|
||||
w = 1;
|
||||
if (h == 0)
|
||||
h = 1;
|
||||
if (!EnsureScratch(w, h))
|
||||
return 0;
|
||||
// The decompressor — and the upload after it — work through the caller's
|
||||
// copy, not the global. If the scratch just grew, the copy still names
|
||||
// the retired buffer; repoint it.
|
||||
*scratchSlot = *scratchVar;
|
||||
}
|
||||
return BlpDecompress_o(reader, edxUnused, format, scratchSlot, mipBase, flag);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _blpDecompressHook{Offsets::FUN_BLP_DECOMPRESS, &BlpDecompress_h,
|
||||
reinterpret_cast<void **>(&BlpDecompress_o)};
|
||||
|
||||
// ---- layer 5: the async fallback guard ----------------------------------------
|
||||
|
||||
using ForceLoad_t = void(__fastcall *)(void *hTexture);
|
||||
ForceLoad_t ForceLoad_o = nullptr;
|
||||
|
||||
// A request that never fit the shared async buffer is about to be forced into
|
||||
// the 512 KiB static fallback with no size check (see Offsets.h) — the engine
|
||||
// bug behind "big BLPs crash". Refuse when it cannot fit: the request stays
|
||||
// pending and the texture stays blank, the normal not-loaded state every caller
|
||||
// already handles, instead of overrunning the pool table.
|
||||
//
|
||||
// Growing the fallback to fit — the symmetric move to the scratch — was tried
|
||||
// and reverted: the read runs on the engine's async reader thread, which has no
|
||||
// crash handler, and a read into a right-sized heap buffer froze then killed the
|
||||
// process where the same read into the oversized shared buffer does not. The
|
||||
// fault is on that thread and not yet isolated, so this stops at the safe
|
||||
// refusal. Consequence: a BLP FILE larger than the shared async buffer (2 MiB
|
||||
// stock, 32 MiB with VanillaHelpers) does not load. That is only an
|
||||
// UNCOMPRESSED BLP over ~1.4 MB / ~22 MB of image; DXT stays far under the
|
||||
// buffer, and TGA is synchronous and never on this path — so it costs only a
|
||||
// pathological input.
|
||||
void __fastcall ForceLoad_h(void *hTexture) {
|
||||
const auto *req = *reinterpret_cast<const uint8_t *const *>(
|
||||
static_cast<const uint8_t *>(hTexture) + Offsets::OFF_HTEXTURE_ASYNC_REQUEST);
|
||||
if (req != nullptr && req[Offsets::OFF_ASYNCREQ_IN_SHARED] == 0 &&
|
||||
*reinterpret_cast<const uint32_t *>(req + Offsets::OFF_ASYNCREQ_SIZE) >
|
||||
Offsets::ASYNC_FALLBACK_BUFFER_SIZE)
|
||||
return;
|
||||
ForceLoad_o(hTexture);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _forceLoadHook{Offsets::FUN_TEXTURE_FORCE_LOAD, &ForceLoad_h,
|
||||
reinterpret_cast<void **>(&ForceLoad_o)};
|
||||
|
||||
// ---- layer 6: the recycle-pool free follows the same bound ---------------------
|
||||
|
||||
using PoolFree_t = void(__fastcall *)(void *tex);
|
||||
PoolFree_t PoolFree_o = nullptr;
|
||||
using TexGetDesc_t = void(__fastcall *)(void *tex, uint32_t *out);
|
||||
using TexFreeRaw_t = void(__fastcall *)(void *tex);
|
||||
|
||||
// The pool free files a texture into a bucket keyed by each dimension's
|
||||
// power-of-two factor, with no upper bound (see Offsets.h). The allocator's cave
|
||||
// lets textures larger than the pool can index exist; freeing one files it past
|
||||
// the bucket table and writes through garbage. Exclude the same textures here:
|
||||
// route anything the table can't index to the engine's own non-pooled free —
|
||||
// exactly the guard's else-branch for too-small / too-many-mip textures. Cold
|
||||
// path (texture teardown), so a MinHook here carries no per-frame concern, and
|
||||
// nothing else patches this function's code.
|
||||
void __fastcall PoolFree_h(void *tex) {
|
||||
if (g_poolIndexMax != 0 && tex != nullptr) {
|
||||
uint32_t desc[16] = {0};
|
||||
reinterpret_cast<TexGetDesc_t>(Offsets::FUN_TEXTURE_GET_DESC)(tex, desc);
|
||||
const uint32_t w = desc[Offsets::OFF_TEXDESC_WIDTH / 4];
|
||||
const uint32_t h = desc[Offsets::OFF_TEXDESC_HEIGHT / 4];
|
||||
if (w > g_poolIndexMax || h > g_poolIndexMax) {
|
||||
reinterpret_cast<TexFreeRaw_t>(Offsets::FUN_TEXTURE_FREE_RAW)(tex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
PoolFree_o(tex);
|
||||
}
|
||||
|
||||
const Game::HookAutoRegister _poolFreeHook{Offsets::FUN_TEXTURE_POOL_FREE, &PoolFree_h,
|
||||
reinterpret_cast<void **>(&PoolFree_o)};
|
||||
|
||||
// ---- non-power-of-two ----------------------------------------------------------
|
||||
|
||||
// The validator computes "is a power of two" as
|
||||
// EAX = x & (x-1); NEG EAX; SBB EAX,EAX; INC EAX; TEST EAX,EAX; JZ fail
|
||||
// so replacing NEG/SBB with `XOR EAX,EAX` leaves the INC to produce 1 and the
|
||||
// test always passes. Patching the arithmetic rather than the shared JZ keeps
|
||||
// the type-2 branch — which reaches the same TEST through its own SBB — intact.
|
||||
constexpr uint8_t kPotTestForced[4] = {0x31, 0xC0, 0x90, 0x90}; // XOR EAX,EAX; NOP; NOP
|
||||
|
||||
bool ApplyNonPowerOfTwo() {
|
||||
// JNZ (not a power of two) -> the cave: fit the scratch, then a fresh
|
||||
// allocation — never the pool, whose log2 index cannot describe NPOT.
|
||||
bool ok = WriteNearJump(Offsets::PATCH_TEXALLOC_POT_W_JMP, 0x85, Cave());
|
||||
ok = WriteNearJump(Offsets::PATCH_TEXALLOC_POT_H_JMP, 0x85, Cave()) && ok;
|
||||
ok = Common::PatchBytes(reinterpret_cast<void *>(Offsets::PATCH_TEXVALIDATE_POT_W),
|
||||
kPotTestForced, sizeof kPotTestForced) &&
|
||||
ok;
|
||||
ok = Common::PatchBytes(reinterpret_cast<void *>(Offsets::PATCH_TEXVALIDATE_POT_H),
|
||||
kPotTestForced, sizeof kPotTestForced) &&
|
||||
ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Re-asserted on every registration so the code segment and our recorded state
|
||||
// cannot disagree. Order matters: the cave must exist before anything is
|
||||
// pointed at it.
|
||||
void ApplyGates() {
|
||||
if (!ApplySizeGate())
|
||||
return;
|
||||
ApplyStrideFix();
|
||||
ApplyNonPowerOfTwo();
|
||||
}
|
||||
|
||||
// ---- Lua ---------------------------------------------------------------------
|
||||
|
||||
// `_classicapi_TextureLimits()` -> scratchSide, poolMax, poolSide, deviceMax, strideFixed
|
||||
// What the gate is actually working with, for /dump. scratchSide is the
|
||||
// high-water mark of what has been loaded; deviceMax is the GPU's own ceiling
|
||||
// and the only one enforced.
|
||||
int __fastcall Script_TextureLimits(void *L) {
|
||||
Game::Lua::PushNumber(L, static_cast<double>(ScratchDimension()));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(g_poolMax));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(g_poolSide));
|
||||
Game::Lua::PushNumber(L, static_cast<double>(DeviceMaxDimension()));
|
||||
Game::Lua::PushBool(L, g_strideFixed);
|
||||
return 5;
|
||||
}
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
ApplyGates();
|
||||
Game::Lua::RegisterGlobalFunction("_classicapi_TextureLimits", &Script_TextureLimits);
|
||||
}
|
||||
|
||||
// The glue screen loads textures too, and this is the first registration that
|
||||
// runs after every other DLL's load-time patching.
|
||||
void RegisterGlue() { ApplyGates(); }
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
const Game::GlueModuleAutoRegister _glueAutoreg{&RegisterGlue};
|
||||
|
||||
} // namespace
|
||||
|
||||
} // namespace Texture::DimensionGate
|
||||
@@ -0,0 +1,86 @@
|
||||
// This file is part of ClassicAPI.
|
||||
//
|
||||
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
|
||||
// of the GNU General Public License as published by the Free Software Foundation, either
|
||||
// version 3 of the License, or (at your option) any later version.
|
||||
//
|
||||
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
||||
// PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
// `texture:SetSpriteSheetCell(cell, numRows, numColumns [, cellWidth, cellHeight])`
|
||||
// — crops the texture to one cell of an evenly divided grid.
|
||||
//
|
||||
// A sprite sheet holds several images in one file, so drawing one of them means
|
||||
// working out its share of the texture and calling SetTexCoord. This does that
|
||||
// arithmetic, which is otherwise repeated at every call site. It is the primitive
|
||||
// behind raid target markers, whose eight icons share a single 4x4 sheet.
|
||||
//
|
||||
// `cell` is 1-based (Blizzard types the argument `luaIndex`) and counts left to
|
||||
// right, then top to bottom, so cell 1 is the top-left image.
|
||||
//
|
||||
// `cellWidth` and `cellHeight` are accepted and ignored. Every caller in
|
||||
// Blizzard's own UI passes the three-argument form, and the grid alone already
|
||||
// fixes each cell's coordinates, so there is nothing this module can do with them
|
||||
// that would not be a guess at their meaning.
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace Texture::SpriteSheet {
|
||||
|
||||
namespace {
|
||||
|
||||
using ScriptFn_t = int(__fastcall *)(void *L);
|
||||
|
||||
int __fastcall Script_SetSpriteSheetCell(void *L) {
|
||||
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
|
||||
!Game::Lua::IsNumber(L, 4)) {
|
||||
Game::Lua::Error(L, "Usage: texture:SetSpriteSheetCell(cell, numRows, numColumns)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int cell = static_cast<int>(Game::Lua::ToNumber(L, 2));
|
||||
const int rows = static_cast<int>(Game::Lua::ToNumber(L, 3));
|
||||
const int columns = static_cast<int>(Game::Lua::ToNumber(L, 4));
|
||||
if (rows <= 0 || columns <= 0)
|
||||
return 0;
|
||||
if (cell < 1 || cell > rows * columns)
|
||||
return 0; // outside the grid — leave the texture as it is
|
||||
|
||||
const int index = cell - 1;
|
||||
const double cellW = 1.0 / columns;
|
||||
const double cellH = 1.0 / rows;
|
||||
const double left = (index % columns) * cellW;
|
||||
const double top = (index / columns) * cellH;
|
||||
|
||||
// Delegate to the engine's own SetTexCoord handler so its argument handling
|
||||
// and both coordinate forms stay in force — this module only supplies the
|
||||
// grid arithmetic.
|
||||
Game::Lua::SetTop(L, 1); // (self)
|
||||
Game::Lua::PushNumber(L, left);
|
||||
Game::Lua::PushNumber(L, left + cellW);
|
||||
Game::Lua::PushNumber(L, top);
|
||||
Game::Lua::PushNumber(L, top + cellH);
|
||||
reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_TEXTURE_SET_TEXCOORD)(L);
|
||||
|
||||
Game::Lua::SetTop(L, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Game::Lua::FrameMethodEntry g_methods[] = {
|
||||
{"SetSpriteSheetCell", &Script_SetSpriteSheetCell},
|
||||
};
|
||||
|
||||
void RegisterLuaFunctions() {
|
||||
Game::Lua::RegisterFrameMethods(
|
||||
reinterpret_cast<void *>(Offsets::VAR_TEXTURE_METHOD_REGISTRY), g_methods,
|
||||
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
|
||||
}
|
||||
|
||||
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
|
||||
|
||||
} // namespace
|
||||
} // namespace Texture::SpriteSheet
|
||||
@@ -18,21 +18,24 @@
|
||||
// target-if-a-player, never the observing caster). We can't hear the change,
|
||||
// but we see the triggering cast's SMSG_SPELL_GO, so Aura::Source mirrors the
|
||||
// edit on the cached entry. A rule is a { exact trigger spellID -> affected
|
||||
// aura by SpellFamilyName + family-flag overlap } pair, applied by
|
||||
// Aura::Source::ApplyDurationModifiers.
|
||||
// aura by SpellFamilyName plus a family-flag overlap and/or a SpellIconID }
|
||||
// pair, applied by Aura::Source::ApplyDurationModifiers.
|
||||
//
|
||||
// Conflagrate -> Immolate: shave 3s (Turtle keeps Immolate ticking, -3s;
|
||||
// stock's full-consume removes it, which
|
||||
// OnAuraRemoved evicts, so the rule is
|
||||
// harmless there)
|
||||
// Molten Blast -> Flame Shock: refresh (RefreshHolder -> reset to full)
|
||||
// Conflagrate -> Immolate: shave 3s (Turtle keeps Immolate ticking, -3s;
|
||||
// stock's full-consume removes it, which
|
||||
// OnAuraRemoved evicts, so the rule is
|
||||
// harmless there)
|
||||
// Molten Blast -> Flame Shock: refresh (RefreshHolder -> reset to full)
|
||||
// Bestial Wrath -> Scent of Blood: set to Bestial Wrath's own duration
|
||||
//
|
||||
// Gated on Turtle::Detected() and registered once (a WorldTick latch, since the
|
||||
// gate reads a FrameXML global only present in-world). Stock's Conflagrate
|
||||
// still exists, so keeping the gate preserves the previous Lua behaviour
|
||||
// exactly; Molten Blast's spell IDs don't exist off Turtle anyway.
|
||||
|
||||
#include "Offsets.h"
|
||||
#include "aura/Source.h"
|
||||
#include "spell/Lookup.h"
|
||||
#include "turtle/Detect.h"
|
||||
|
||||
#include "tick/WorldTick.h"
|
||||
@@ -51,6 +54,50 @@ constexpr uint32_t kShaman = 11;
|
||||
constexpr uint64_t kImmolateFlag = 0x4;
|
||||
constexpr uint64_t kFlameShockFlag = 0x10000000;
|
||||
|
||||
// Bestial Wrath -> Scent of Blood (hunter). The talent's proc puts Scent of
|
||||
// Blood on the pet for its own 8s; casting Bestial Wrath then stretches that
|
||||
// SAME holder to Bestial Wrath's length. Server-side (tortoise-wow
|
||||
// spell_hunter_bestial_wrath) it is a bare
|
||||
// `holder->SetAuraMaxDuration/SetAuraDuration` on the pet's existing holder —
|
||||
// both are plain field writes that send nothing, and the one duration packet
|
||||
// 1.12 has is scoped to an aura-bearer that is a PLAYER, which a pet is not.
|
||||
// So the client keeps showing the 8s it computed at application and the timer
|
||||
// simply runs out under a buff that is still up.
|
||||
//
|
||||
// The trigger is visible, though, and Bestial Wrath's implicit target is
|
||||
// TARGET_UNIT_CASTER_PET, so the pet is in its SMSG_SPELL_GO hit list and the
|
||||
// standard rule machinery reaches the right unit. If the pet has no Scent of
|
||||
// Blood the server returns without doing anything; we mirror that for free,
|
||||
// since no cache entry means no match.
|
||||
//
|
||||
// Selected by ICON, not by family flags: every Turtle custom spell carries
|
||||
// SpellFamilyFlags of 0, so no mask can name this aura, while icon 2245 picks
|
||||
// out exactly the four Scent of Blood records in the hunter family and nothing
|
||||
// else (client DBC verified) — and it keeps picking them if Turtle adds a rank.
|
||||
constexpr uint32_t kHunter = 9;
|
||||
constexpr uint32_t kBestialWrath = 19574;
|
||||
constexpr uint32_t kScentOfBloodIcon = 2245;
|
||||
// The server hardcodes 18s; Bestial Wrath's own DBC duration agrees, so we
|
||||
// read the DBC and keep this only for a record we can't resolve.
|
||||
constexpr int32_t kBestialWrathFallbackMs = 18000;
|
||||
|
||||
// Bestial Wrath's own base duration, straight from the engine's duration
|
||||
// helper (`skipMod` = base: the server applies no caster mods to the value it
|
||||
// writes). Reading it instead of hardcoding means a Turtle rebalance that
|
||||
// patches the client's spell data carries over on its own.
|
||||
int32_t BestialWrathDurationMs() {
|
||||
const uint8_t *rec =
|
||||
Spell::Lookup::RecordForID(static_cast<int>(kBestialWrath));
|
||||
if (rec == nullptr)
|
||||
return kBestialWrathFallbackMs;
|
||||
using GetDuration_t = int(__fastcall *)(const uint8_t *spellRecord,
|
||||
int unit, char skipMod);
|
||||
const int ms = reinterpret_cast<GetDuration_t>(
|
||||
static_cast<uintptr_t>(Offsets::FUN_GET_SPELL_DURATION))(
|
||||
rec, 0, /*skipMod*/ 1);
|
||||
return ms > 0 ? ms : kBestialWrathFallbackMs;
|
||||
}
|
||||
|
||||
bool g_registered = false;
|
||||
|
||||
void RegisterAll() {
|
||||
@@ -66,6 +113,11 @@ void RegisterAll() {
|
||||
Aura::Source::AddDurationMod(id, kShaman, kFlameShockFlag, /*icon*/ 0,
|
||||
Aura::Source::DURATION_MOD_REFRESH,
|
||||
/*valueMs*/ 0);
|
||||
|
||||
Aura::Source::AddDurationMod(kBestialWrath, kHunter, /*mask*/ 0,
|
||||
kScentOfBloodIcon,
|
||||
Aura::Source::DURATION_MOD_SET,
|
||||
BestialWrathDurationMs());
|
||||
}
|
||||
|
||||
void OnTick() {
|
||||
|
||||
+53
-10
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "Game.h"
|
||||
#include "Offsets.h"
|
||||
#include "aura/Data.h"
|
||||
#include "guid/Guid.h"
|
||||
#include "object/Resolve.h"
|
||||
|
||||
@@ -20,13 +21,38 @@
|
||||
|
||||
namespace Unit::Tooltip {
|
||||
|
||||
// The 1-based index the engine's `SetUnitBuff` / `SetUnitDebuff` would need to
|
||||
// land on `slot`: one plus the slots before it in ITS range (0..31 or 32..47)
|
||||
// that pass the engine's tooltip visibility gate — the exact walk
|
||||
// `FUN_00534AC0` / `FUN_00534E30` perform (verified by decompile).
|
||||
static int EngineIndexForSlot(const uint8_t *unit, int slot) {
|
||||
const int home =
|
||||
slot < Offsets::UNIT_AURA_BUFF_COUNT ? 0 : Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
int index = 1;
|
||||
for (int s = home; s < slot; ++s) {
|
||||
if (Aura::Data::IsSlotTooltipVisible(unit, s))
|
||||
++index;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// `GameTooltip:SetUnitAura(unit, index, [filter])` — modern unified-aura
|
||||
// method. 1.12 splits this into `SetUnitBuff` (slot 32) and
|
||||
// `SetUnitDebuff` (slot 33); we dispatch to the right one based on the
|
||||
// `filter` string ("HELPFUL" → SetUnitBuff, "HARMFUL" → SetUnitDebuff).
|
||||
// method. 1.12 has `SetUnitBuff` (slot 32) and `SetUnitDebuff` (slot 33), each
|
||||
// indexing its own slot RANGE (0..31 / 32..47) under the engine's tooltip
|
||||
// gate. `C_UnitAuras` indexes by the aura's polarity nibble instead (see
|
||||
// Aura::Data), so the two index spaces diverge once the server has parked a
|
||||
// debuff in a buff slot — and an index an addon got from `C_UnitAuras` must
|
||||
// still open the right tooltip. So: resolve (index, filter) to the absolute
|
||||
// slot in OUR space, then hand the engine the index THAT slot has within its
|
||||
// own range under ITS gate, choosing SetUnitBuff or SetUnitDebuff by where the
|
||||
// slot lives. The aura tooltip builder only ever receives spellId / level /
|
||||
// stacks, so a debuff in a buff slot renders identically through SetUnitBuff.
|
||||
//
|
||||
// `filter` defaults to "HELPFUL" when omitted, matching modern
|
||||
// behavior. Anything other than a HARMFUL match goes to SetUnitBuff.
|
||||
// `filter` defaults to "HELPFUL" when omitted, matching modern behavior; only
|
||||
// its HELPFUL/HARMFUL half is read (a PLAYER-filtered index is not an index
|
||||
// into the plain list — pass an index from the same plain list, as FrameXML
|
||||
// does). A unit with no live CGUnit (out-of-range groupmate) passes straight
|
||||
// through: the engine reads the group array by range there, exactly as we do.
|
||||
static int __fastcall Script_GameTooltipSetUnitAura(void *L) {
|
||||
// Args: self (table), unit (string), index (number), filter (optional string)
|
||||
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
|
||||
@@ -51,14 +77,31 @@ static int __fastcall Script_GameTooltipSetUnitAura(void *L) {
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the filter arg before calling — the existing
|
||||
// SetUnitBuff/SetUnitDebuff take (self, unit, index) only.
|
||||
Game::Lua::SetTop(L, 3);
|
||||
int index = static_cast<int>(Game::Lua::ToNumber(L, 3));
|
||||
bool useDebuffMethod = isHarmful;
|
||||
const auto *unit = static_cast<const uint8_t *>(
|
||||
Game::ResolveUnitToken(Game::Lua::ToString(L, 2)));
|
||||
if (unit != nullptr) {
|
||||
const int slot = Aura::Data::FindNthSlot(
|
||||
unit, index,
|
||||
isHarmful ? Aura::Data::Filter::Harmful : Aura::Data::Filter::Helpful);
|
||||
if (slot >= 0) {
|
||||
index = EngineIndexForSlot(unit, slot);
|
||||
useDebuffMethod = slot >= Offsets::UNIT_AURA_BUFF_COUNT;
|
||||
}
|
||||
// slot < 0: nothing at that index in our space; the raw index goes
|
||||
// through and the engine's own walk shows nothing either.
|
||||
}
|
||||
|
||||
// Rebuild the args as (self, unit, engineIndex) — the engine methods take
|
||||
// exactly those three; the filter string is consumed here.
|
||||
Game::Lua::SetTop(L, 2);
|
||||
Game::Lua::PushNumber(L, static_cast<double>(index));
|
||||
|
||||
using Script_t = int(__fastcall *)(void *L);
|
||||
auto fn = reinterpret_cast<Script_t>(
|
||||
isHarmful ? Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_DEBUFF
|
||||
: Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_BUFF);
|
||||
useDebuffMethod ? Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_DEBUFF
|
||||
: Offsets::FUN_SCRIPT_GAMETOOLTIP_SET_UNIT_BUFF);
|
||||
return fn(L);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user