the combat log's wire completes, and PlayerModel is its own class

- the combat log's last eight wire bodies decode — PARTYKILLLOG, SPELLINSTAKILLLOG, PROCRESIST, SPELLORDAMAGE_IMMUNE, SPELLDISPELLOG, DISPEL_FAILED, ENCHANTMENTLOG, SPELLLOGEXECUTE — each layout read off vmangos's own writer and pinned by a byte-exact golden (DISPEL_FAILED carries no count, the list runs to the end of the body; SPELLLOGEXECUTE's POWER_DRAIN row is (amount, power, multiplier), verified at the client's bytes as well as the server's); with the eleven chat types that were waiting on them, 44 chat types become 55 — 29 sentence families, the death pair and the aura rows riding descriptor diffs because 1.12 has no "unit died" opcode, plus the six trailers ((glancing), (30 blocked), (12 absorbed) and kin) that had been missing from every family; also corrected: the durability-death line was going to the red error frame with hard-coded English, and SMSG_SPELLNONMELEEDAMAGELOG lacked its split-damage branch and its blocked wording
- PlayerModel is its own frame kind: SetUnit, RefreshUnit and SetRotation were published on Model, but they belong to the <PlayerModel> method table (0x84f1fc), and the client's lookup chains derived → base only, so a plain <Model> can never reach them — they got there from a strings scan read as ownership, and a strings hit answers whether a name EXISTS, not who owns it (SetUnit's one pooled string is referenced by the PlayerModel and GameTooltip tables and by no Model entry); the new PlayerModel → Model → Frame → Region chain unblocks CreateFrame("PlayerModel"), which is how pfUI builds every unit-frame portrait — and through pfUI, pfQuest and ShaguDPS
- a nil where a number goes is four shapes, not one law: a census of all 408 widget-registrar entries found 62 numeric positions that validate-or-raise, 43 that gate then stage an alternate, 64 that silently coerce to 0.0 and 5 that gate then store nothing — so SetMaxLetters(nil) completes and stores 0, "no limit" (the argument COUNT is checked exactly, the type is not), and the backdrop colour setters are asymmetric on purpose: a nil r/g/b channel is 0.0 but a nil alpha is OPAQUE, and reading every nil as 0.0 would have turned those borders transparent; both setters now also quantize ×255 into the packed 0xAARRGGBB byte quad the client actually stores, so 0.09 reads back as 23/255
- two more addon-killers: LIGHTYELLOW_FONT_COLOR_CODE was the one missing line of Fonts.xml's seven-line colour block (aux-addon concatenates it at file scope and died on its own first line), and the guard now enumerates the whole block l.4-20 instead of the names an earlier round had noticed absent; and the unit frames publish the reference's names — 1.12 has no *PowerBar anywhere, every bar is ManaBar and the colour table is ManaBarColor (8 of the top-20 addons read it by name), so ManaBarColor is the published table our own bars index into, with 22 more regions renamed to the reference's own

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 19:54:58 +07:00
co-authored by Claude Fable 5
parent 850f7df3b0
commit 31706821cd
45 changed files with 4074 additions and 522 deletions
@@ -366,7 +366,7 @@
getglobal("CharacterNameText"):SetText(UnitName("player"))
if ShowTextStatusBarText then -- engine tests load file subsets
ShowTextStatusBarText(PlayerFrameHealthBar)
ShowTextStatusBarText(PlayerFramePowerBar)
ShowTextStatusBarText(PlayerFrameManaBar)
ShowTextStatusBarText(MainMenuExpBar)
ShowTextStatusBarText(PetFrameHealthBar)
ShowTextStatusBarText(PetFrameManaBar)
@@ -394,7 +394,7 @@
PlaySound("igCharacterInfoClose")
if HideTextStatusBarText then -- engine tests load file subsets
HideTextStatusBarText(PlayerFrameHealthBar)
HideTextStatusBarText(PlayerFramePowerBar)
HideTextStatusBarText(PlayerFrameManaBar)
HideTextStatusBarText(MainMenuExpBar)
HideTextStatusBarText(PetFrameHealthBar)
HideTextStatusBarText(PetFrameManaBar)
+5
View File
@@ -50,6 +50,11 @@
RED_FONT_COLOR_CODE = "|cffff2020" -- l.10
GREEN_FONT_COLOR_CODE = "|cff20ff20" -- l.11
GRAY_FONT_COLOR_CODE = "|cff808080" -- l.12
-- l.13, and it was the ONE line of this block that was missing — the comments beside it
-- even skipped from `l.12` to `l.14`. `aux-addon.lua:7` concatenates it at file scope
-- (`LIGHTYELLOW_FONT_COLOR_CODE .. "aux: " ..`), so its absence was not a wrong colour, it
-- was the addon dying on its own first line.
LIGHTYELLOW_FONT_COLOR_CODE = "|cffffff9a" -- l.13
FONT_COLOR_CODE_CLOSE = "|r" -- l.14
NORMAL_FONT_COLOR = { r = 1.0, g = 0.82, b = 0 } -- l.15
HIGHLIGHT_FONT_COLOR = { r = 1.0, g = 1.0, b = 1.0 } -- l.16
+15 -15
View File
@@ -120,15 +120,15 @@
local tf = name .. "TextureFrame"
frame.unit = "party" .. id
frame.portrait = getglobal(name .. "Portrait")
frame.healthBar = getglobal(name .. "HealthBar")
frame.manaBar = getglobal(name .. "ManaBar")
frame.healthbar = getglobal(name .. "HealthBar")
frame.manabar = getglobal(name .. "ManaBar")
-- The recessed-bar look, the reference's own mechanism (ref-TargetFrame.lua l.32-34):
-- push the bars one frame level BELOW the art's TextureFrame. Frame level is the only
-- key term above the draw layer (decision 0884), so this is the only thing that can hold
-- the BACKGROUND party art over the ARTWORK bar fills. See the LAYERING note below.
local textureFrameLevel = getglobal(tf):GetFrameLevel()
frame.healthBar:SetFrameLevel(textureFrameLevel - 1)
frame.manaBar:SetFrameLevel(textureFrameLevel - 1)
frame.healthbar:SetFrameLevel(textureFrameLevel - 1)
frame.manabar:SetFrameLevel(textureFrameLevel - 1)
frame.nameText = getglobal(tf .. "Name")
frame.leaderIcon = getglobal(tf .. "LeaderIcon")
frame.masterIcon = getglobal(tf .. "MasterIcon")
@@ -164,16 +164,16 @@
local unit = frame.unit
if not UnitIsConnected(unit) then
-- Disconnected: bar forced full and grey, portrait desaturated, icon on.
frame.healthBar:SetMinMaxValues(0, 1)
frame.healthBar:SetValue(1)
frame.healthBar:SetStatusBarColor(0.5, 0.5, 0.5)
frame.healthbar:SetMinMaxValues(0, 1)
frame.healthbar:SetValue(1)
frame.healthbar:SetStatusBarColor(0.5, 0.5, 0.5)
SetDesaturation(frame.portrait, 1)
frame.disconnect:Show()
return
end
SetDesaturation(frame.portrait, nil)
frame.disconnect:Hide()
frame.healthBar:SetStatusBarColor(0.0, 1.0, 0.0)
frame.healthbar:SetStatusBarColor(0.0, 1.0, 0.0)
local hp, hpMax = UnitHealth(unit), UnitHealthMax(unit)
if UnitIsDead(unit) then
frame.portrait:SetVertexColor(0.35, 0.35, 0.35, 1.0)
@@ -196,19 +196,19 @@
frame.nameText:SetText(UnitName(unit) or "Unknown")
frame.healthBar:SetMinMaxValues(0, UnitHealthMax(unit))
frame.healthBar:SetValue(UnitHealth(unit))
frame.healthbar:SetMinMaxValues(0, UnitHealthMax(unit))
frame.healthbar:SetValue(UnitHealth(unit))
local maxPower = UnitManaMax(unit)
if maxPower > 0 then
frame.manaBar:Show()
frame.manabar:Show()
local _, token = UnitPowerType(unit)
local c = BENILLA_POWER_COLORS[token] or BENILLA_POWER_COLORS.MANA
frame.manaBar:SetStatusBarColor(c.r, c.g, c.b)
frame.manaBar:SetMinMaxValues(0, maxPower)
frame.manaBar:SetValue(UnitMana(unit))
frame.manabar:SetStatusBarColor(c.r, c.g, c.b)
frame.manabar:SetMinMaxValues(0, maxPower)
frame.manabar:SetValue(UnitMana(unit))
else
frame.manaBar:Hide()
frame.manabar:Hide()
end
-- Leader + master-looter icons (ref: GetPartyLeaderIndex / GetLootMethod vs own id).
+166 -132
View File
@@ -14,7 +14,7 @@
The ref gets this with a child `TargetFrameTextureFrame` (ring art) and `SetFrameLevel(bar,
textureFrame-1)` to push the bars beneath it, and we do the same, in BenillaUnitFrame_OnLoad:
the parent holds trough + portrait (+ target's name plate) in BACKGROUND; the
`$parentHealthBar`/`$parentPowerBar` StatusBars sit at the PARENT's frame level; and
`$parentHealthBar`/`$parentManaBar` StatusBars sit at the PARENT's frame level; and
`$parentTextureFrame` (232×100, filling the parent) holds the ring art (BACKGROUND) + name/level/dead text
(OVERLAY) one level ABOVE them, so it draws over the bar edges and the portrait.
@@ -29,7 +29,8 @@
band with transparent corners (not an opaque surround), so the square is hidden by the portrait
itself — set via SetPortraitToTexture, masked to its inscribed circle (the client's portraits are
always round: a model render or a circular-cropped icon), so no corner reaches the ring's gap. The
text lives under the TextureFrame, so its globals carry the `…TextureFrame…` name prefix (OnLoad).
text lives under the TextureFrame, but its GLOBAL names are the reference's flat ones
(`PlayerName`, `TargetDeadText`, …), not `$parent`-derived — see OnLoad's `short` prefix.
The shared black `…Background` trough sits behind the unfilled bar portion.
DATA FLOW UNCHANGED (pin: re-anchor + re-skin, don't rewrite the eventing): BenillaUnitFrame_*
@@ -138,13 +139,31 @@
NEWBIE_TOOLTIP_PARTYOPTIONS = NEWBIE_TOOLTIP_PARTYOPTIONS or "If you belong to a party, you can right-click your portrait to bring up the Party Options menu. This menu will allow you to quit the party or see what looting rules the party is using. A party leader can add or remove party members, as well as change the party's looting rules."
NEWBIE_TOOLTIP_PLAYEROPTIONS = NEWBIE_TOOLTIP_PLAYEROPTIONS or "Right-click to bring up special commands for interacting with another player. You can inspect their equipment, issue a party invite, initiate a trade, or challenge a player to a duel. A group leader can promote or remove that player from the group."
-- The client's PowerBarColor values, keyed by the UnitPowerType token.
-- ── ManaBarColor (ref UnitFrame.lua l.1-6, verbatim) ────────────────────────────────
-- The reference's own table: keyed by the NUMERIC power type `UnitPowerType` returns, each
-- row carrying the `prefix` its bar's text is labelled with. **8 addons in the top-20 root
-- read `ManaBarColor` by name**, which is why it is the published one rather than a
-- benilla-private table — we used to keep the same five colours under
-- `BENILLA_POWER_COLORS`, keyed by token string and carrying no prefix, so every one of
-- those reads found nil.
--
-- One table, not two: the token map below is DERIVED from it rather than restated, so a
-- colour can never differ between what an addon reads and what our own bars paint.
ManaBarColor = {}
ManaBarColor[0] = { r = 0.00, g = 0.00, b = 1.00, prefix = MANA or "Mana" }
ManaBarColor[1] = { r = 1.00, g = 0.00, b = 0.00, prefix = RAGE_POINTS or "Rage" }
ManaBarColor[2] = { r = 1.00, g = 0.50, b = 0.25, prefix = FOCUS_POINTS or "Focus" }
ManaBarColor[3] = { r = 1.00, g = 1.00, b = 0.00, prefix = ENERGY_POINTS or "Energy" }
ManaBarColor[4] = { r = 0.00, g = 1.00, b = 1.00, prefix = HAPPINESS_POINTS or "Happiness" }
-- Our own bars look colours up by the TOKEN `UnitPowerType` also returns (its 2nd value),
-- so this maps token → the row above. Same objects, so `ManaBarColor` is the only source.
BENILLA_POWER_COLORS = {
MANA = { r = 0.0, g = 0.0, b = 1.0 },
RAGE = { r = 1.0, g = 0.0, b = 0.0 },
FOCUS = { r = 1.0, g = 0.5, b = 0.25 },
ENERGY = { r = 1.0, g = 1.0, b = 0.0 },
HAPPINESS = { r = 0.0, g = 1.0, b = 1.0 },
MANA = ManaBarColor[0],
RAGE = ManaBarColor[1],
FOCUS = ManaBarColor[2],
ENERGY = ManaBarColor[3],
HAPPINESS = ManaBarColor[4],
}
-- ── The raid-target mark atlas (ref TargetFrame.lua l.460-463 + l.475-484) ──────────────
@@ -202,18 +221,25 @@
function BenillaUnitFrame_OnLoad(frame, unit)
frame.unit = unit
local name = frame:GetName()
frame.healthBar = getglobal(name .. "HealthBar")
frame.powerBar = getglobal(name .. "PowerBar")
-- The name/level/dead text live inside $parentTextureFrame (so they draw over the ring art
-- that draws over the bars), so their global names carry the TextureFrame prefix.
frame.healthbar = getglobal(name .. "HealthBar")
frame.manabar = getglobal(name .. "ManaBar")
-- TWO prefixes, both the reference's own. The bars are named off the FRAME
-- (`PlayerFrameHealthBar`, ref-PlayerFrame.xml l.288) but the art, text and icons are
-- named off the UNIT (`PlayerName` l.58, `PlayerPortrait` l.35, `TargetDeadText`
-- ref-TargetFrame.xml l.173) — flat globals, which is what an addon reaches for.
-- `short` is the frame name minus its trailing "Frame".
local short = string.sub(name, 1, string.len(name) - 5)
-- Our ring art rides a $parentTextureFrame child (the reference nests two ANONYMOUS
-- frames there instead, ref-PlayerFrame.xml l.49-51), so the frame whose level the bars
-- are pushed under is still named off the frame.
local tf = name .. "TextureFrame"
-- The recessed-bar look, the reference's own mechanism (ref-TargetFrame.lua l.32-34):
-- push the bars one frame level BELOW the ring art's TextureFrame. Frame level is the
-- only key term above the draw layer (decision 0884), so this is the only thing that can
-- hold a BACKGROUND ring texture over an ARTWORK bar fill. See the LAYERING note up top.
local textureFrameLevel = getglobal(tf):GetFrameLevel()
frame.healthBar:SetFrameLevel(textureFrameLevel - 1)
frame.powerBar:SetFrameLevel(textureFrameLevel - 1)
frame.healthbar:SetFrameLevel(textureFrameLevel - 1)
frame.manabar:SetFrameLevel(textureFrameLevel - 1)
-- The on-bar numerals (TextStatusBar.xml, decision 1082; the switch that shows them
-- is 1140's Status Bar Text). Wiring is unconditional — a frame with no text regions
-- gets a no-op, since SetTextStatusBarText drops a nil text and UpdateTextString skips
@@ -223,13 +249,13 @@
-- and the XP bar, and on nothing else, which is exactly why Status Bar Text pins your
-- own numbers and your pet's but never the target's (decision 1143).
if TextStatusBar_Initialize then -- engine tests load file subsets
TextStatusBar_Initialize(frame.healthBar)
TextStatusBar_Initialize(frame.powerBar)
SetTextStatusBarText(frame.healthBar, getglobal(tf .. "HealthBarText"))
SetTextStatusBarText(frame.powerBar, getglobal(tf .. "PowerBarText"))
TextStatusBar_Initialize(frame.healthbar)
TextStatusBar_Initialize(frame.manabar)
SetTextStatusBarText(frame.healthbar, getglobal(name .. "HealthBarText"))
SetTextStatusBarText(frame.manabar, getglobal(name .. "ManaBarText"))
if unit == "player" then
frame.healthBar.textLockable = 1
frame.powerBar.textLockable = 1
frame.healthbar.textLockable = 1
frame.manabar.textLockable = 1
end
end
-- The hover seam (1146): who this bar belongs to, and what its click means.
@@ -237,16 +263,16 @@
if unit == "player" then
click = PlayerFrame_OnClick
end
frame.healthBar.owner = frame
frame.powerBar.owner = frame
frame.healthBar.ownerClick = click
frame.powerBar.ownerClick = click
frame.nameText = getglobal(tf .. "NameText")
frame.levelText = getglobal(tf .. "LevelText")
frame.deadText = getglobal(tf .. "DeadText")
frame.healthbar.owner = frame
frame.manabar.owner = frame
frame.healthbar.ownerClick = click
frame.manabar.ownerClick = click
frame.nameText = getglobal(short .. "Name")
frame.levelText = getglobal(short .. "LevelText")
frame.deadText = getglobal(short .. "DeadText")
-- Target-only (the "??" skull on the level slot); getglobal returns nil for the
-- player, guarded at use.
frame.highLevel = getglobal(tf .. "HighLevelTexture")
frame.highLevel = getglobal(short .. "HighLevelTexture")
-- Target-only (the level/name plate); getglobal returns nil for the player, guarded at use.
frame.nameBackground = getglobal(name .. "NameBackground")
-- The live model portrait (the real API's global, ref-UnitFrame.lua): bind the portrait
@@ -256,11 +282,11 @@
-- model change), which is why the ref's UNIT_PORTRAIT_UPDATE re-call isn't needed here (the
-- event joins the surface when addon parity wants it). Until a bake lands (no unit / model
-- still loading) the circle is simply empty.
frame.portrait = getglobal(name .. "Portrait")
frame.portrait = getglobal(short .. "Portrait")
SetPortraitTexture(frame.portrait, unit)
-- The PvP flag icon (decision 0646), under the TextureFrame like the ring art it
-- draws over — see BenillaUnitFrame_UpdatePvP.
frame.pvpIcon = getglobal(tf .. "PVPIcon")
frame.pvpIcon = getglobal(short .. "PVPIcon")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("UNIT_HEALTH")
frame:RegisterEvent("UNIT_MAXHEALTH")
@@ -283,26 +309,26 @@
if unit == "target" then
frame:RegisterEvent("UNIT_AURA")
frame:RegisterEvent("UNIT_CLASSIFICATION_CHANGED")
frame.frameTexture = getglobal(tf .. "Texture")
frame.frameTexture = getglobal(name .. "Texture")
end
-- The portrait hit indicator (decision 0576): only the player frame registers
-- UNIT_COMBAT in 1.12 (ref PlayerFrame_OnLoad — the target frame has no feedback
-- text). Ref: CombatFeedback_Initialize(PlayerHitIndicator, 30).
if unit == "player" then
frame.feedbackText = getglobal(tf .. "HitIndicator")
frame.feedbackText = getglobal(short .. "HitIndicator")
frame.feedbackFontHeight = 30
frame:RegisterEvent("UNIT_COMBAT")
-- The status flash (ref PlayerFrame_OnLoad, decision 1082): the resting zzz /
-- auto-attack swords under the portrait, the gold/red ring, and the pulse
-- counters. Handles cached like pvpIcon above; their presence is what gates
-- PlayerFrame_UpdateStatus in the shared Update.
frame.statusTexture = getglobal(tf .. "StatusTexture")
frame.attackBackground = getglobal(tf .. "AttackBackground")
frame.restIcon = getglobal(tf .. "RestIcon")
frame.attackIcon = getglobal(tf .. "AttackIcon")
frame.statusGlow = getglobal(name .. "StatusGlow")
frame.restGlow = getglobal(name .. "StatusGlowRestGlow")
frame.attackGlow = getglobal(name .. "StatusGlowAttackGlow")
frame.statusTexture = getglobal(short .. "StatusTexture")
frame.attackBackground = getglobal(short .. "AttackBackground")
frame.restIcon = getglobal(short .. "RestIcon")
frame.attackIcon = getglobal(short .. "AttackIcon")
frame.statusGlow = getglobal(short .. "StatusGlow")
frame.restGlow = getglobal(short .. "RestGlow")
frame.attackGlow = getglobal(short .. "AttackGlow")
frame.statusCounter = 0
frame.statusSign = -1
frame.attackBackground:SetVertexColor(0.8, 0.1, 0.1)
@@ -320,8 +346,8 @@
-- shared Update exactly like statusTexture above — the target frame declares
-- neither texture, so it never runs the law, and the party member frames run
-- their own (PartyFrame.xml, a different predicate: an INDEX comparison).
frame.leaderIcon = getglobal(tf .. "LeaderIcon")
frame.masterIcon = getglobal(tf .. "MasterIcon")
frame.leaderIcon = getglobal(short .. "LeaderIcon")
frame.masterIcon = getglobal(short .. "MasterIcon")
frame:RegisterEvent("PARTY_MEMBERS_CHANGED")
frame:RegisterEvent("PARTY_LEADER_CHANGED")
frame:RegisterEvent("PARTY_LOOT_METHOD_CHANGED")
@@ -694,7 +720,7 @@
-- plate on (the reference has no such art for the others and leaves an empty rail drawn).
--
-- The bars are ARGUMENTS rather than fields: the player and target frames call theirs
-- `powerBar` and the pet frame calls its `manaBar`, following the reference's own two names
-- `manabar` and the pet frame calls its `manabar`, following the reference's own two names
-- for one rail, and a helper that reached for a field would have to pick a side.
--
-- NO label on the power bar. 1.12 names the resource there (ref UnitFrame_UpdateManaType
@@ -703,20 +729,20 @@
-- day: bare numbers read better on these bars (decision 1147). The mechanism stays —
-- SetTextStatusBarTextPrefix is live and one call brings any label back — only the calls
-- are gone.
function BenillaUnitFrame_PaintBars(healthBar, powerBar, unit)
healthBar:SetMinMaxValues(0, UnitHealthMax(unit))
healthBar:SetValue(UnitHealth(unit))
function BenillaUnitFrame_PaintBars(healthbar, manabar, unit)
healthbar:SetMinMaxValues(0, UnitHealthMax(unit))
healthbar:SetValue(UnitHealth(unit))
local maxPower = UnitManaMax(unit)
if maxPower <= 0 then
powerBar:Hide()
manabar:Hide()
return false
end
powerBar:Show()
manabar:Show()
local _, powerToken = UnitPowerType(unit)
local swatch = BENILLA_POWER_COLORS[powerToken] or BENILLA_POWER_COLORS.MANA
powerBar:SetStatusBarColor(swatch.r, swatch.g, swatch.b)
powerBar:SetMinMaxValues(0, maxPower)
powerBar:SetValue(UnitMana(unit))
manabar:SetStatusBarColor(swatch.r, swatch.g, swatch.b)
manabar:SetMinMaxValues(0, maxPower)
manabar:SetValue(UnitMana(unit))
return true
end
@@ -729,7 +755,7 @@
end
frame:Show()
BenillaUnitFrame_PaintBars(frame.healthBar, frame.powerBar, unit)
BenillaUnitFrame_PaintBars(frame.healthbar, frame.manabar, unit)
frame.nameText:SetText(UnitName(unit) or "Unknown")
@@ -1173,7 +1199,7 @@
frame LEVEL, and nothing else in this layer overlaps the portrait's square, so the
two are indistinguishable here — left alone rather than churned inside a bug fix).
Texture set in OnLoad via SetPortraitToTexture (circle-masked). -->
<Texture name="$parentPortrait">
<Texture name="PlayerPortrait">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="42" y="-12"/></Offset></Anchor>
@@ -1202,7 +1228,7 @@
</Scripts>
</StatusBar>
<!-- Power bar 119×12 TOPLEFT (106,-52), UI-StatusBar; color set at runtime (ref l.316-341). -->
<StatusBar name="$parentPowerBar" enableMouse="true">
<StatusBar name="$parentManaBar" enableMouse="true">
<Size><AbsDimension x="119" y="12"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="106" y="-52"/></Offset></Anchor>
@@ -1229,7 +1255,7 @@
<Layers>
<Layer level="BACKGROUND">
<!-- Ring art, MIRRORED: TexCoords left=1.0 right=0.09375 top=0 bottom=0.78125 (ref l.55-57). -->
<Texture name="$parentTexture" file="Interface\TargetingFrame\UI-TargetingFrame">
<Texture name="PlayerFrameTexture" file="Interface\TargetingFrame\UI-TargetingFrame">
<Size><AbsDimension x="232" y="100"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
@@ -1247,35 +1273,43 @@
number rode ON the badge (director report, 2026-08-07); decision 1093. -->
<!-- Name 100×10 CENTER (50,19) (ref l.58-69).
A DIVERGENCE, measured and deliberately kept. The reference names the
three unit-frame name FontStrings LITERALLY — `PlayerName`
(PlayerFrame.xml:58), `TargetName` (TargetFrame.xml:152), `PetName`
(PetFrame.xml:69) — where ours derive from `$parent`. That is 1222's
shadowed-global class on a region, and it was tried: the rename is
NOT free and it buys nothing.
· Not free — `UnitFrame_OnLoad` binds the region by COMPOSING the
name (`getglobal(tf .. "NameText")`, l.174), so a text search for
the derived global finds zero hits and the rename still breaks
every unit frame. It broke 40 tests. A name built inside
`getglobal` is invisible to a grep — the same trap that once left
CleanMinimap in the tail for three rounds.
· Buys nothing — the survey's "why" read-back matches ZERO
error rows for `TargetName` across
the 218-addon corpus. The one syntactic caller,
`CT_UnitFrames/CT_TargetFrame.xml:181`'s
`<Anchor relativeTo="TargetName">`, is not what stops that addon:
it dies at its own `CT_PlayerFrame.lua:206`.
So: 1222's rejected clause applies (a shadowed name with no observable
caller is building against a guess), and the cost lands on the frames
the director looks at every session. Revisit if a row ever names it. -->
<FontString name="$parentNameText" inherits="GameFontNormalSmall" text="Unknown">
The NAME is the reference's own, literally — and so is every other
art/text/icon region in the three unit frames. They used to derive
from `$parent`, which published `PlayerFrameTextureFrameNameText` and
left `PlayerName` (PlayerFrame.xml:58), `TargetName`
(TargetFrame.xml:152) and `PetName` (PetFrame.xml:69) — the names an
addon actually reaches for — nil. See BenillaUnitFrame_OnLoad's
`short` prefix for how the shared binder composes them now.
**This was tried once and REJECTED, and the rejection was right on
its evidence** — the block that stood here argued: (i) not free, since
`UnitFrame_OnLoad` composes these names inside `getglobal`, so a text
search finds nothing and the rename breaks every unit frame (it broke
40 tests); and (ii) buys nothing, since a "why" read-back over the
218-addon corpus matched ZERO error rows for `TargetName`, its one
syntactic caller being a `<Anchor relativeTo=>` in an addon that dies
earlier anyway. It closed "revisit if a row ever names it" (1222's
clause: a shadowed name with no observable caller is building against
a guess).
What changed (1719): the condition it named was met, and its cost
premise expired. A grep of the top-20 root gives `ManaBarColor` 8
readers, `PlayerFrameManaBar` 3, `PlayerFrameTexture`/
`TargetFrameTexture`/`PlayerStatusTexture`/`TargetFrameManaBar` 2
each, and `PlayerName`/`TargetName`/`PetName` 1 each. ShaguTweaks
dies on `TargetFrameManaBar` at `health-numbers.lua:22`. **Fifteen of
the twenty-two renames still have zero measured readers** and were
kept for consistency with the reference and because the cost was
already paid, NOT because demand was shown — say the word and they go
back. -->
<FontString name="PlayerName" inherits="GameFontNormalSmall" text="Unknown">
<Size><AbsDimension x="100" y="10"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="50" y="19"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- Level number CENTER (-63,-16) (ref l.70-78). -->
<FontString name="$parentLevelText" inherits="GameFontNormalSmall">
<FontString name="PlayerLevelText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-63" y="-16"/></Offset></Anchor>
</Anchors>
@@ -1295,12 +1329,12 @@
They draw over the bar FILL because the whole TextureFrame outranks the
bars by a frame level. Empty until Status Bar Text is on or (later) the
bar is hovered — TextStatusBar.xml owns that decision, not this file. -->
<FontString name="$parentHealthBarText" inherits="TextStatusBarText">
<FontString name="PlayerFrameHealthBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="50" y="3"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentPowerBarText" inherits="TextStatusBarText">
<FontString name="PlayerFrameManaBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="50" y="-9"/></Offset></Anchor>
</Anchors>
@@ -1311,7 +1345,7 @@
ref-PlayerFrame.xml l.124-135) — ARTWORK over the BACKGROUND ring art,
like the ref's. Texture chosen at runtime (decision 0646 §4); the ref's
PlayerPVPIconHitArea is deliberately OUT (see the header). -->
<Texture name="$parentPVPIcon" hidden="true">
<Texture name="PlayerPVPIcon" hidden="true">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="18" y="-20"/></Offset></Anchor>
@@ -1321,7 +1355,7 @@
the ADD-blend glow ring around the whole frame, tinted gold while
resting / red while auto-attacking, pulsed by the OnUpdate flash
(decision 1082). 190×66 TOPLEFT (35,-8), the ring art's own recess. -->
<Texture name="$parentStatusTexture" file="Interface\CharacterFrame\UI-Player-Status" alphaMode="ADD" hidden="true">
<Texture name="PlayerStatusTexture" file="Interface\CharacterFrame\UI-Player-Status" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="190" y="66"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="35" y="-8"/></Offset></Anchor>
@@ -1330,7 +1364,7 @@
</Texture>
<!-- ref PlayerAttackBackground (l.112-123): the dark red disc behind the
attack state icon; OnLoad tints it (0.8,0.1,0.1) at alpha 0.4. -->
<Texture name="$parentAttackBackground" file="Interface\TargetingFrame\UI-TargetingFrame-AttackBackground" hidden="true">
<Texture name="PlayerAttackBackground" file="Interface\TargetingFrame\UI-TargetingFrame-AttackBackground" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="37" y="-50"/></Offset></Anchor>
@@ -1342,7 +1376,7 @@
CENTER at (73,-42) from the frame's TOPLEFT — the portrait circle's
center. Hidden until a UNIT_COMBAT event lands; CombatFeedback drives
text/size/color and the fade. -->
<FontString name="$parentHitIndicator" hidden="true" inherits="NumberFontNormalHuge">
<FontString name="PlayerHitIndicator" hidden="true" inherits="NumberFontNormalHuge">
<Anchors>
<Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="73" y="-42"/></Offset></Anchor>
</Anchors>
@@ -1355,13 +1389,13 @@
$parentRestIcon's below. Shown by BenillaPlayerFrame_UpdatePartyLeader.
Report B317: the member frames have carried these since 0434 phase 2;
the player frame was built before the party wire and listed them OUT. -->
<Texture name="$parentLeaderIcon" file="Interface\GroupFrame\UI-Group-LeaderIcon" hidden="true">
<Texture name="PlayerLeaderIcon" file="Interface\GroupFrame\UI-Group-LeaderIcon" hidden="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="44" y="-10"/></Offset></Anchor>
</Anchors>
</Texture>
<Texture name="$parentMasterIcon" file="Interface\GroupFrame\UI-Group-MasterLooter" hidden="true">
<Texture name="PlayerMasterIcon" file="Interface\GroupFrame\UI-Group-MasterLooter" hidden="true">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="80" y="-10"/></Offset></Anchor>
@@ -1370,17 +1404,17 @@
<!-- The state icon pair under the portrait (ref PlayerRestIcon /
PlayerAttackIcon, ref-PlayerFrame.xml l.171-201): the two halves of
UI-StateIcon — zzz left, crossed swords right (decision 1082). -->
<Texture name="$parentRestIcon" file="Interface\CharacterFrame\UI-StateIcon" hidden="true">
<Texture name="PlayerRestIcon" file="Interface\CharacterFrame\UI-StateIcon" hidden="true">
<Size><AbsDimension x="31" y="33"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="37" y="-49"/></Offset></Anchor>
</Anchors>
<TexCoords left="0" right="0.5" top="0" bottom="0.421875"/>
</Texture>
<Texture name="$parentAttackIcon" file="Interface\CharacterFrame\UI-StateIcon" hidden="true">
<Texture name="PlayerAttackIcon" file="Interface\CharacterFrame\UI-StateIcon" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRestIcon"><Offset><AbsDimension x="1" y="1"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="PlayerRestIcon"><Offset><AbsDimension x="1" y="1"/></Offset></Anchor>
</Anchors>
<TexCoords left="0.5" right="1.0" top="0" bottom="0.5"/>
</Texture>
@@ -1390,21 +1424,21 @@
<!-- The state icon's own ADD-blend glow (ref PlayerStatusGlow, ref-PlayerFrame.xml
l.216-248): a sibling FRAME over the icon so the whole thing can pulse as one
SetAlpha group with the status ring (the OnUpdate flash, decision 1082). -->
<Frame name="$parentStatusGlow" hidden="true">
<Frame name="PlayerStatusGlow" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentTextureFrameRestIcon"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
<Anchor point="TOPLEFT" relativeTo="PlayerRestIcon"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentRestGlow" file="Interface\CharacterFrame\UI-StateIcon" alphaMode="ADD">
<Texture name="PlayerRestGlow" file="Interface\CharacterFrame\UI-StateIcon" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
</Anchors>
<TexCoords left="0" right="0.5" top="0.5" bottom="1.0"/>
</Texture>
<Texture name="$parentAttackGlow" file="Interface\CharacterFrame\UI-StateIcon" alphaMode="ADD">
<Texture name="PlayerAttackGlow" file="Interface\CharacterFrame\UI-StateIcon" alphaMode="ADD">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"/>
@@ -1510,7 +1544,7 @@
</Texture>
<!-- Portrait 64×64 TOPRIGHT (-42,-12) (ref l.131-142, BORDER→BACKGROUND for the same
ring-over-portrait layering as the player). -->
<Texture name="$parentPortrait">
<Texture name="TargetPortrait">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-42" y="-12"/></Offset></Anchor>
@@ -1538,7 +1572,7 @@
</Scripts>
</StatusBar>
<!-- Power bar 119×12 TOPRIGHT (-106,-52), UI-StatusBar; color set at runtime (ref l.274-292). -->
<StatusBar name="$parentPowerBar" enableMouse="true">
<StatusBar name="$parentManaBar" enableMouse="true">
<Size><AbsDimension x="119" y="12"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-106" y="-52"/></Offset></Anchor>
@@ -1565,7 +1599,7 @@
<Layers>
<Layer level="BACKGROUND">
<!-- Ring art, UN-mirrored: TexCoords left=0.09375 right=1.0 (ref l.146-151). -->
<Texture name="$parentTexture" file="Interface\TargetingFrame\UI-TargetingFrame">
<Texture name="TargetFrameTexture" file="Interface\TargetingFrame\UI-TargetingFrame">
<Size><AbsDimension x="232" y="100"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
@@ -1577,7 +1611,7 @@
<!-- The PvP flag icon, 64×64 TOPRIGHT (3,-20) (ref TargetPVPIcon,
ref-TargetFrame.xml l.208-219) — the player's icon mirrored to the
frame's other edge, same law (decision 0646 §4). -->
<Texture name="$parentPVPIcon" hidden="true">
<Texture name="TargetPVPIcon" hidden="true">
<Size><AbsDimension x="64" y="64"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="3" y="-20"/></Offset></Anchor>
@@ -1586,14 +1620,14 @@
</Layer>
<Layer level="OVERLAY">
<!-- Name 100×10 CENTER (-50,19) (ref l.152-163). -->
<FontString name="$parentNameText" inherits="GameFontNormalSmall" text="Unknown">
<FontString name="TargetName" inherits="GameFontNormalSmall" text="Unknown">
<Size><AbsDimension x="100" y="10"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-50" y="19"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- Level number CENTER (63,-16) (ref l.164-172). -->
<FontString name="$parentLevelText" inherits="GameFontNormalSmall">
<FontString name="TargetLevelText" inherits="GameFontNormalSmall">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="63" y="-16"/></Offset></Anchor>
</Anchors>
@@ -1601,7 +1635,7 @@
<!-- The "too high to tell" skull, 16×16 on the level slot (ref
TargetHighLevelTexture, l.184-195): shown for a corpse target or
UnitLevel ≤ 0, replacing the number (TargetFrame_CheckLevel). -->
<Texture name="$parentHighLevelTexture" file="Interface\TargetingFrame\UI-TargetingFrame-Skull">
<Texture name="TargetHighLevelTexture" file="Interface\TargetingFrame\UI-TargetingFrame-Skull">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="63" y="-16"/></Offset></Anchor>
@@ -1613,7 +1647,7 @@
key never reaches the screen. `hidden` like the ToT twin below, a
deliberate divergence: the reference leaves the region shown and
relies on CheckDead landing before the frame does. -->
<FontString name="$parentDeadText" inherits="GameFontNormalSmall" text="DEAD" hidden="true">
<FontString name="TargetDeadText" inherits="GameFontNormalSmall" text="DEAD" hidden="true">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-50" y="3"/></Offset></Anchor>
</Anchors>
@@ -1625,12 +1659,12 @@
bars textLockable, so Status Bar Text does not pin them. The HOVER is
what they were waiting for, and it is the reference's behaviour whole:
on the target, the numbers are something you go and look at. -->
<FontString name="$parentHealthBarText" inherits="TextStatusBarText">
<FontString name="TargetFrameHealthBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-50" y="3"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentPowerBarText" inherits="TextStatusBarText">
<FontString name="TargetFrameManaBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-50" y="-9"/></Offset></Anchor>
</Anchors>
@@ -1855,7 +1889,7 @@
and TargetofTarget_ModeAllows()
if show then
frame:Show()
BenillaUnitFrame_PaintBars(frame.healthBar, frame.manaBar, "targettarget")
BenillaUnitFrame_PaintBars(frame.healthbar, frame.manabar, "targettarget")
TargetofTargetName:SetText(UnitName("targettarget") or "")
TargetofTarget_CheckDead()
-- The reference's own line (l.536), and it is not redundant with the health
@@ -1946,16 +1980,16 @@
function TargetofTarget_OnLoad()
local frame = this
frame.unit = "targettarget"
frame.healthBar = TargetofTargetHealthBar
frame.manaBar = TargetofTargetManaBar
frame.healthbar = TargetofTargetHealthBar
frame.manabar = TargetofTargetManaBar
-- The recessed-bar look, the same mechanism every frame in this file uses (decision
-- 0884): the bars one frame level BELOW the art's TextureFrame, because the draw layer
-- is bucket-wide and the frame level is the one key term above it. The reference
-- spends its level the other way round (RaiseFrameLevel on the TextureFrame); same
-- ordering, and ours matches its siblings here.
local level = TargetofTargetTextureFrame:GetFrameLevel()
frame.healthBar:SetFrameLevel(level - 1)
frame.manaBar:SetFrameLevel(level - 1)
frame.healthbar:SetFrameLevel(level - 1)
frame.manabar:SetFrameLevel(level - 1)
-- The live model portrait, bound once by token (see BenillaUnitFrame_OnLoad's note):
-- the app keeps this slot's bake current, and it only bakes at all while this frame is
-- on screen (portrait/mod.rs's `"targettarget"` arm).
@@ -2175,29 +2209,29 @@
<Script><![CDATA[
function PetFrame_OnLoad()
local frame = this
frame.portrait = PetFrameTextureFramePortrait
frame.healthBar = PetFrameHealthBar
frame.manaBar = PetFrameManaBar
frame.nameText = PetFrameTextureFrameName
frame.portrait = PetPortrait
frame.healthbar = PetFrameHealthBar
frame.manabar = PetFrameManaBar
frame.nameText = PetName
-- The recessed-bar look, the same mechanism the player/target/party frames use
-- (decision 0884): push the bars one frame level BELOW the art's TextureFrame, because
-- the draw layer is bucket-wide and the frame level is the one key term above it.
local level = PetFrameTextureFrame:GetFrameLevel()
frame.healthBar:SetFrameLevel(level - 1)
frame.manaBar:SetFrameLevel(level - 1)
frame.healthbar:SetFrameLevel(level - 1)
frame.manabar:SetFrameLevel(level - 1)
-- The pet's numerals, textLockable like the ref's (PetFrame.xml l.139/164; 1143).
if TextStatusBar_Initialize then -- engine tests load file subsets
TextStatusBar_Initialize(frame.healthBar)
TextStatusBar_Initialize(frame.manaBar)
SetTextStatusBarText(frame.healthBar, PetFrameTextureFrameHealthBarText)
SetTextStatusBarText(frame.manaBar, PetFrameTextureFrameManaBarText)
frame.healthBar.textLockable = 1
frame.manaBar.textLockable = 1
TextStatusBar_Initialize(frame.healthbar)
TextStatusBar_Initialize(frame.manabar)
SetTextStatusBarText(frame.healthbar, PetFrameHealthBarText)
SetTextStatusBarText(frame.manabar, PetFrameManaBarText)
frame.healthbar.textLockable = 1
frame.manabar.textLockable = 1
end
-- The pet's bars hover for their numbers, but hand nothing on: the pet frame has no
-- OnEnter of its own, and its click is its own handler (1146).
frame.healthBar.ownerClick = PetFrame_OnClick
frame.manaBar.ownerClick = PetFrame_OnClick
frame.healthbar.ownerClick = PetFrame_OnClick
frame.manabar.ownerClick = PetFrame_OnClick
frame.attackModeCounter = 0
frame.attackModeSign = -1
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
@@ -2222,11 +2256,11 @@
function PetFrame_OnEvent(event)
local frame = this
if event == "PET_ATTACK_START" then
PetFrameAttackMode:SetVertexColor(1.0, 1.0, 1.0, 1.0)
PetFrameAttackMode:Show()
PetAttackModeTexture:SetVertexColor(1.0, 1.0, 1.0, 1.0)
PetAttackModeTexture:Show()
return
elseif event == "PET_ATTACK_STOP" then
PetFrameAttackMode:Hide()
PetAttackModeTexture:Hide()
return
elseif event == "UNIT_HAPPINESS" then
-- The ref's own last arm: repaint the icon alone, not the whole frame.
@@ -2253,7 +2287,7 @@
-- The ref's art swap (PetFrame.lua's Update): a powerless pet wears the -NoMana plate,
-- which has no mana rail to leave empty. Which plate is exactly the shared paint's
-- return — the pet is the only frame here whose art carries that distinction.
if BenillaUnitFrame_PaintBars(frame.healthBar, frame.manaBar, "pet") then
if BenillaUnitFrame_PaintBars(frame.healthbar, frame.manabar, "pet") then
PetFrameTexture:SetTexture("Interface\\TargetingFrame\\UI-SmallTargetingFrame")
else
PetFrameTexture:SetTexture("Interface\\TargetingFrame\\UI-SmallTargetingFrame-NoMana")
@@ -2360,7 +2394,7 @@
-- 55/255 and 1, sign-flipped at each end (the ref's own counter shape, transcribed).
function PetFrame_OnUpdate(elapsed)
local frame = this
if not PetFrameAttackMode:IsVisible() then return end
if not PetAttackModeTexture:IsVisible() then return end
local counter = frame.attackModeCounter + elapsed
local sign = frame.attackModeSign
if counter > 0.5 then
@@ -2375,7 +2409,7 @@
else
alpha = (255 - (counter * 400)) / 255
end
PetFrameAttackMode:SetVertexColor(1.0, 1.0, 1.0, alpha)
PetAttackModeTexture:SetVertexColor(1.0, 1.0, 1.0, alpha)
end
-- Clicking the pet frame (decision 1066 built the right-hand half). The LEFT button hands
@@ -2522,7 +2556,7 @@
<Layer level="BACKGROUND">
<!-- Portrait 37×37 TOPLEFT (7,-6), under the art's round window (ref
PetPortrait, PetFrame.xml l.16-27). -->
<Texture name="$parentPortrait">
<Texture name="PetPortrait">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
@@ -2538,14 +2572,14 @@
<Layer level="ARTWORK">
<!-- The attacking overlay (ref PetAttackModeTexture, l.104-118): the right
29.7% of UI-Player-AttackStatus, ADD-blended, pulsed by OnUpdate. -->
<Texture name="PetFrameAttackMode" file="Interface\TargetingFrame\UI-Player-AttackStatus" alphaMode="ADD" hidden="true">
<Texture name="PetAttackModeTexture" file="Interface\TargetingFrame\UI-Player-AttackStatus" alphaMode="ADD" hidden="true">
<Size><AbsDimension x="76" y="64"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="-9"/></Offset></Anchor></Anchors>
<TexCoords left="0.703125" right="1.0" top="0" bottom="1.0"/>
</Texture>
</Layer>
<Layer level="OVERLAY">
<FontString name="$parentName" inherits="GameFontNormalSmall" justifyH="LEFT">
<FontString name="PetName" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="50" y="33"/></Offset></Anchor></Anchors>
</FontString>
<!-- The pet's numerals: the reference's LITERAL seats, CENTER-from-TOPLEFT
@@ -2561,10 +2595,10 @@
under it) rather than centring it. Centring both is what collided.
No 1145 nudge here: that pixel was for a string centred in a bar, and
the reference has already placed these deliberately off-centre. -->
<FontString name="$parentHealthBarText" inherits="TextStatusBarText">
<FontString name="PetFrameHealthBarText" inherits="TextStatusBarText">
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="82" y="-27"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentManaBarText" inherits="TextStatusBarText">
<FontString name="PetFrameManaBarText" inherits="TextStatusBarText">
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="82" y="-38"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
@@ -1947,6 +1947,7 @@ const PROBE_FRAME_KINDS: &[&str] = &[
"Slider",
"ScrollFrame",
"Model",
"PlayerModel",
"MessageFrame",
"ScrollingMessageFrame",
"ColorSelect",
+70 -10
View File
@@ -755,6 +755,14 @@ pub(super) fn apply_net_updates(
session::reputations(standings, &mut reputations)
}
SessionEvent::ReputationDelta { standings } => {
// The chat line reads the deltas against the store, so it runs BEFORE the
// overwrite — after it, every delta is zero.
combat_chat::faction_standing(
&standings,
&reputations,
ui_actions.1 .2.as_deref(),
&mut chat_log,
);
session::reputation_delta(standings, &mut reputations, &mut quest)
}
SessionEvent::ReputationVisible { list_id } => {
@@ -1246,9 +1254,7 @@ pub(super) fn apply_net_updates(
SessionEvent::SpiritHealerConfirm { npc } => {
death::spirit_healer_confirm(npc, &mut death_net)
}
SessionEvent::DurabilityDamageDeath => {
death::durability_damage_death(&mut ui_actions.14)
}
SessionEvent::DurabilityDamageDeath => death::durability_damage_death(&mut chat_log),
SessionEvent::MoveMode {
guid,
counter,
@@ -1372,6 +1378,51 @@ pub(super) fn apply_net_updates(
&mut audio.15 .1,
)
}
// ── the combat log's completeness block (1703) ────────────────────────────────
// Every one of these is chat-only: they carry no damage number, so unlike their
// neighbours above they have no floating-text twin to call.
SessionEvent::PartyKillLog(k) => {
combat_chat::party_kill_log(k, &chat_ctx!(), &stores, &transforms, &mut chat_log)
}
SessionEvent::SpellInstaKillLog(k) => combat_chat::spell_insta_kill_log(
k,
&chat_ctx!(),
&stores,
&transforms,
&mut chat_log,
),
SessionEvent::ProcResist(o) => combat_chat::spell_outcome_log(
o,
false,
&chat_ctx!(),
&stores,
&transforms,
&mut chat_log,
),
SessionEvent::SpellOrDamageImmune(o) => combat_chat::spell_outcome_log(
o,
true,
&chat_ctx!(),
&stores,
&transforms,
&mut chat_log,
),
SessionEvent::SpellDispelLog(d) => {
combat_chat::spell_dispel_log(&d, &chat_ctx!(), &stores, &transforms, &mut chat_log)
}
SessionEvent::DispelFailed(d) => {
combat_chat::dispel_failed(&d, &chat_ctx!(), &stores, &transforms, &mut chat_log)
}
SessionEvent::EnchantmentLog(e) => {
combat_chat::enchantment_log(e, &chat_ctx!(), &stores, &transforms, &mut chat_log)
}
SessionEvent::SpellLogExecute(x) => combat_chat::spell_log_execute(
&x,
&chat_ctx!(),
&stores,
&transforms,
&mut chat_log,
),
SessionEvent::XpGain(x) => {
combat_log::xp_gain(x, &index, &self_guid, &mut audio.7, &mut chat_log)
}
@@ -1554,13 +1605,22 @@ pub(super) fn apply_net_updates(
SessionEvent::PlaySpellVisual { unit, kit_id } => {
anim::play_spell_visual(unit, kit_id, &index, play_seq, &mut audio.12)
}
SessionEvent::EnvironmentalDamageLog(e) => anim::environmental_damage_log(
e,
&index,
aura.5.as_deref(),
play_seq,
&mut audio.12,
),
SessionEvent::EnvironmentalDamageLog(e) => {
combat_chat::environmental_damage_log(
e,
&chat_ctx!(),
&stores,
&transforms,
&mut chat_log,
);
anim::environmental_damage_log(
e,
&index,
aura.5.as_deref(),
play_seq,
&mut audio.12,
)
}
// The gossip/vendor/trainer NPC-interaction family — arm bodies in `npc`.
SessionEvent::GossipMenu {
npc,
+727 -51
View File
@@ -16,8 +16,9 @@
use bevy::prelude::*;
use benilla_protocol::messages::{
AttackerState, DamageShield, PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellEnergizeLog,
SpellHealLog, SpellLogMiss,
AttackerState, DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog,
PartyKillLog, PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellDispelLog, SpellEnergizeLog,
SpellHealLog, SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog,
};
use crate::ui_chat::combat::{self, Family, Fills, UnitClass};
@@ -49,44 +50,10 @@ impl ChatCtx<'_> {
)
}
/// One endpoint's half of the reference's display-range gate (`0x626630` → the per-class range
/// getter `0x626810`): a 3-D squared distance from the ACTIVE PLAYER to the endpoint, against
/// the class's range squared.
///
/// **The comparison is strictly `<`** — `fcomp; fnstsw ax; test ah,5; jnp`, so `dist² == range²`
/// is OUT and a NaN is out. 1571 used `<=`; the boundary is exact in the binary and there is no
/// reason for us to be looser.
///
/// **The ranges are the compiled-in defaults, not live CVars** — the same standing shape as
/// [`crate::combat_text`]'s `COMBAT_DAMAGE`/`PET_*` gates, and for the same reason: the values
/// are byte-read and correct, and the CVars (`CombatLogRangeParty` and its six siblings) can be
/// wired to the live table without changing anything here. `UnitClass::default_range` carries
/// them, including the two sentinels that make the gate a no-op for you and your pet
/// (`100000.0`) and unconditional for an unresolvable unit (`0.0`).
///
/// A pose we do not hold is treated as **in** range: dropping a line because a unit's transform
/// had not landed yet would silently lose the killing blow on a mob that despawns, which is a
/// worse failure than logging one fight too far away.
/// One endpoint's half of the display-range gate — the law is
/// [`combat::in_range`]; this only supplies the context it reads.
fn in_range(&self, guid: u64, class: UnitClass, poses: &Query<&mut Transform>) -> bool {
let range = class.default_range();
if range >= 100_000.0 {
return true;
}
if range <= 0.0 {
return false;
}
let pose = |g: u64| {
self.index
.0
.get(&g)
.copied()
.and_then(|e| poses.get(e).ok())
.map(|t| t.translation)
};
let (Some(me), Some(them)) = (self.self_guid.0.and_then(pose), pose(guid)) else {
return true;
};
me.distance_squared(them) < range * range
combat::in_range(guid, class, self.self_guid, self.index, poses)
}
/// A spell's display name, or `None` when the reference would emit **no line at all** for this
@@ -140,6 +107,16 @@ pub(super) fn attacker_state(
spell: String::new(),
school: (s.school != 0).then_some(s.school),
amount: i64::from(s.damage),
// **Only the landed-hit family grows a trailer**, and it is the only family in the whole
// block that can show GLANCING/CRUSHING/BLOCK: `0x628410`'s four call sites, and this is
// the one that passes a real `blocked` and a real `HitInfo` (§4.2). A dodge or a full
// absorb prints no amount at all — the reference does not append to those lines.
trailers: landed.then_some(combat::Trailers {
absorbed: s.absorb,
resisted: s.resist,
blocked: s.blocked,
hit_info: s.hit_info,
}),
..Default::default()
};
queue(
@@ -175,6 +152,30 @@ pub(super) fn spell_damage_log(
return;
};
// **Split damage takes the packet before anything else does** (`hit_info & 8` → `0x62de60`,
// the handler's FIRST test at `0x5e8...`, before the periodic split): a Soul Link-style share reads "%s's
// %s causes %s %d damage.", never "hits %s for %d". It has no `…SELFSELF` key and it grows no
// trailers.
const SPELL_HIT_TYPE_SPLIT: u32 = 0x8;
if s.hit_info & SPELL_HIT_TYPE_SPLIT != 0 {
let Some(kind) = combat::spell_kind(attacker, victim, false) else {
return;
};
return queue(
log,
ctx,
poses,
kind,
combat::SPELLSPLITDAMAGE,
(s.attacker, attacker),
(s.target, victim),
Fills {
spell: spell.clone(),
amount: i64::from(s.damage),
..Default::default()
},
);
}
if s.periodic {
let Some(kind) = combat::periodic_kind(attacker, false) else {
return;
@@ -183,6 +184,15 @@ pub(super) fn spell_damage_log(
spell,
school: Some(s.school),
amount: i64::from(s.damage),
// `0x628341`, the periodic call site: absorb and resist only — it passes zero for
// blocked and for HitInfo, so a periodic line can never say "(blocked)" or
// "(crushing)".
trailers: Some(combat::Trailers {
absorbed: s.absorb,
resisted: s.resist,
blocked: 0,
hit_info: 0,
}),
..Default::default()
};
return queue(
@@ -200,9 +210,12 @@ pub(super) fn spell_damage_log(
let Some(kind) = combat::spell_kind(attacker, victim, false) else {
return;
};
// Nothing through: the reference words it as the reason rather than as a zero.
// Nothing through: the reference words it as the reason rather than as a zero, and the order
// of the three tests is its own — absorb, then BLOCK, then resist (§4.3).
let family = if s.damage == 0 && s.absorb > 0 {
combat::SPELLLOGABSORB
} else if s.damage == 0 && s.blocked > 0 {
combat::SPELLBLOCKED
} else if s.damage == 0 && s.resist > 0 {
combat::SPELLRESIST
} else {
@@ -213,10 +226,21 @@ pub(super) fn spell_damage_log(
(true, true) => combat::SPELLLOGCRITSCHOOL,
}
};
// `0x62d03c`, the spell call site: it passes a real `blocked` but **HitInfo 0**, so a spell
// line can show RESIST/VULNERABLE/BLOCK/ABSORB and never GLANCING or CRUSHING. Only the
// families that actually print a damage number take one — the reference appends nothing to
// the "was absorbed / was blocked / was resisted" wordings, which name the reason instead.
let landed = family.stem.starts_with("SPELLLOG") && family.stem != "SPELLLOGABSORB";
let fills = Fills {
spell,
school: Some(s.school),
amount: i64::from(s.damage),
trailers: landed.then_some(combat::Trailers {
absorbed: s.absorb,
resisted: s.resist,
blocked: s.blocked,
hit_info: 0,
}),
..Default::default()
};
queue(
@@ -257,11 +281,7 @@ pub(super) fn spell_log_miss(
// chat type has to see what the reference shows; the discriminator, if anyone wants it, is a
// live cast that misses and a look at which ChatFrame filter catches the line. 1571 sent these
// through `spell_kind` and had them land in `SPELL_*_DAMAGE`.
let kind = if matches!(attacker, UnitClass::Me | UnitClass::MyPet) {
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnSelf
} else {
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnOthers
};
let kind = combat::damage_shield_kind(attacker);
for &(target, miss_info) in &s.misses {
let family = combat::miss_family(miss_info);
let victim = ctx.classify(target, stores);
@@ -459,11 +479,7 @@ pub(super) fn damage_shield(
) {
let bearer = ctx.classify(s.victim, stores);
let struck = ctx.classify(s.attacker, stores);
let kind = if matches!(bearer, UnitClass::Me | UnitClass::MyPet) {
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnSelf
} else {
crate::ui_chat::ChatEventKind::SpellDamageShieldsOnOthers
};
let kind = combat::damage_shield_kind(bearer);
let fills = Fills {
school: u8::try_from(s.school).ok(),
amount: i64::from(s.damage),
@@ -481,6 +497,639 @@ pub(super) fn damage_shield(
);
}
/// `SMSG_PARTYKILLLOG` → "You have slain %s!" / "%s is slain by %s!".
///
/// **Only two killer classes produce a line at all** (`0x628890`, which has no selector): class 0
/// is `SELFKILLOTHER`, class 2 (a party member) is `PARTYKILLOTHER`, and **everything else —
/// including your own pet at class 1 — emits nothing**. That is the reference's own shape, not a
/// simplification: a pet's kill is announced by the plain `UNITDIES*` line instead.
///
/// The chat type comes off the **victim**, through the death selector.
pub(super) fn party_kill_log(
s: PartyKillLog,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
let killer = ctx.classify(s.killer, stores);
let victim = ctx.classify(s.victim, stores);
let family = match killer {
UnitClass::Me => combat::SELFKILLOTHER,
UnitClass::Party => combat::PARTYKILLOTHER,
_ => return,
};
queue(
log,
ctx,
poses,
combat::death_kind(victim),
family,
(s.victim, victim),
(s.killer, killer),
Fills::default(),
);
}
/// `SMSG_SPELLINSTAKILLLOG` → "You are killed by %s." / "%s is killed by %s."
///
/// `0x62cbe0` calls the spell-damage selector with the victim's class in **both** positions
/// (`0x626be0(class, class)`), so the type never splits on a second endpoint — there isn't one.
pub(super) fn spell_insta_kill_log(
s: SpellInstaKillLog,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
let victim = ctx.classify(s.victim, stores);
let Some(spell) = ctx.spell_name(s.spell_id) else {
return;
};
let Some(kind) = combat::spell_kind(victim, victim, false) else {
return;
};
queue(
log,
ctx,
poses,
kind,
combat::INSTAKILL,
(s.victim, victim),
(s.victim, victim),
Fills {
spell,
..Default::default()
},
);
}
/// `SMSG_PROCRESIST` → "%s resists %s's %s." and `SMSG_SPELLORDAMAGE_IMMUNE` → "%s is immune to
/// %s's %s." — one body, two sentences.
///
/// Both word the TARGET first (the reference's convention B), and both take their chat type from
/// the (caster, target) pair — `PROCRESIST` always through the damage/buff stub `0x627d30`, and
/// `IMMUNESPELL` through the plain damage selector `0x626be0`. The difference is real: an immunity
/// to a *helpful* spell still files under `…_DAMAGE`.
///
/// `IMMUNESPELL`'s `log_format` byte is the reference's "is periodic" flag, and it is the only
/// thing `CombatLogPeriodicSpells` would gate here — a CVar we do not read live yet, so the flag
/// changes nothing today and is named rather than dropped.
pub(super) fn spell_outcome_log(
s: SpellOutcomeLog,
immune: bool,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
let caster = ctx.classify(s.caster, stores);
let target = ctx.classify(s.target, stores);
let Some(spell) = ctx.spell_name(s.spell_id) else {
return;
};
let Some(kind) = combat::spell_kind(caster, target, false) else {
return;
};
queue(
log,
ctx,
poses,
kind,
if immune {
combat::IMMUNESPELL
} else {
combat::PROCRESIST
},
(s.caster, caster),
(s.target, target),
Fills {
spell,
..Default::default()
},
);
}
/// `SMSG_SPELLDISPELLOG` → "Your %s is removed." / "%s's %s is removed.", one line per aura.
///
/// The chat type is **hard-coded `0x45` `SPELL_BREAK_AURA`** (`0x62d480`) — it consults no class at
/// all, which is why this is the one arm that never calls a selector. The dispeller is classified
/// only for the range gate; the sentence names the bearer and the aura and nothing else.
pub(super) fn spell_dispel_log(
s: &SpellDispelLog,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
let bearer = ctx.classify(s.victim, stores);
let caster = ctx.classify(s.caster, stores);
for &spell_id in &s.spell_ids {
let Some(spell) = ctx.spell_name(spell_id) else {
continue;
};
queue(
log,
ctx,
poses,
crate::ui_chat::ChatEventKind::SpellBreakAura,
combat::AURADISPEL,
(s.victim, bearer),
(s.caster, caster),
Fills {
spell,
..Default::default()
},
);
}
}
/// `SMSG_DISPEL_FAILED` → "You fail to dispel %s's %s.", one line per aura that would not come off.
///
/// **The reference picks the format-string variant ONCE, before the loop** (`0x628c20`), so every
/// line in a packet shares it — which is the same answer ours reaches, since both endpoints are the
/// same for the whole packet. The chat type is re-derived per line only because the msg-id stub
/// `0x627d30` consults the spell, and the spells differ.
pub(super) fn dispel_failed(
s: &DispelFailed,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
let caster = ctx.classify(s.caster, stores);
let victim = ctx.classify(s.victim, stores);
let Some(kind) = combat::spell_kind(caster, victim, false) else {
return;
};
for &spell_id in &s.spell_ids {
let Some(spell) = ctx.spell_name(spell_id) else {
continue;
};
queue(
log,
ctx,
poses,
kind,
combat::DISPELFAILED,
(s.caster, caster),
(s.victim, victim),
Fills {
spell,
..Default::default()
},
);
}
}
/// `SMSG_ENCHANTMENTLOG` → "You cast %s on your %s." / "%s has faded from your %s."
///
/// **An empty caster guid is how the server says the enchant FADED** (vmangos's own comment on the
/// field, and the reference's two-way at `0x628f40`); the fade names only the owner, which is why
/// it drops from four keys to two. The item name is the last `%s` in every variant and comes from
/// the item cache, so a line whose entry is not cached yet waits rather than printing a hole.
///
/// The chat type is the literal `0x44` `SPELL_ITEM_ENCHANTMENTS` on the fade leg and on the ADD leg
/// when `show_affiliation` is clear — the copy the server sends to the item's own owner. The
/// broadcast copy (affiliation set) takes the BUFF selector instead, which is what puts a
/// bystander's enchant into their `SPELL_*_BUFF` bucket rather than the item block.
pub(super) fn enchantment_log(
s: EnchantmentLog,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
let owner = ctx.classify(s.owner, stores);
let caster = ctx.classify(s.caster, stores);
let Some(spell) = ctx.spell_name(s.spell_id) else {
return;
};
let fills = Fills {
spell,
..Default::default()
};
let enchantments = crate::ui_chat::ChatEventKind::SpellItemEnchantments;
if s.caster == 0 {
// A fade: the owner is the only endpoint, in both the key and the range gate.
return queue_named(
log,
ctx,
poses,
enchantments,
combat::ITEMENCHANTMENTREMOVE,
(s.owner, owner),
(s.owner, owner),
fills,
combat::Named::Item(s.item_entry),
);
}
let kind = if s.show_affiliation {
match combat::spell_kind(caster, owner, true) {
Some(k) => k,
None => return,
}
} else {
enchantments
};
queue_named(
log,
ctx,
poses,
kind,
combat::ITEMENCHANTMENTADD,
(s.caster, caster),
(s.owner, owner),
fills,
combat::Named::Item(s.item_entry),
);
}
/// `SMSG_SPELLLOGEXECUTE` → the lines a cast's *effects* produce, as opposed to the damage it
/// dealt: what it created, fed, interrupted, drained, dismissed or damaged.
///
/// **One packet, many formatters.** The wire is a list of groups keyed by spell-effect id, and the
/// reference's own per-effect jump table (`0x5e8074`) sends each to a different formatter with a
/// different family, a different chat type and a different argument convention. So this arm is a
/// switch, not a sentence — the seven effects below are the ones that word themselves.
///
/// **Deliberately not wired, and named rather than dropped** (decision 1703): effects 33/59
/// `OPEN_LOCK` (`OPEN_LOCK_{SELF,OTHER}` — its trailing `%s` is a *gameobject* name, and benilla
/// has no GO-name cache to resolve one from a guid), and the `SIMPLECAST*`/`SIMPLEPERFORM*`/
/// `SPELLTERSE_*` catch-all the reference falls back to for the guid-only tail of the switch (its
/// bail conditions read `AttributesEx4` and the spell's three `Effect` columns, and `Spell.dbc`
/// column 10 is not parsed into `SpellDisplay` yet). Both are additions, not corrections: nothing
/// below changes when they land.
pub(super) fn spell_log_execute(
s: &SpellLogExecute,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
use benilla_protocol::messages::ExecuteLog as E;
let caster = ctx.classify(s.caster, stores);
let Some(spell) = ctx.spell_name(s.spell_id) else {
return;
};
let spell_fill = || Fills {
spell: spell.clone(),
..Default::default()
};
for (_effect, rows) in &s.effects {
for row in rows {
match *row {
// Effect 8 POWER_DRAIN. Three families come out of this one row: happiness has no
// `…_POINTS` noun so it gets its own sentence at the misc-info type, a real leech
// multiplier says the caster GAINED what the target lost, and a zero one is a plain
// drain. `drained == 0` drops the line — the reference's own test.
E::PowerDrain {
target,
amount,
power,
multiplier,
} => {
let victim = ctx.classify(target, stores);
let div = power_divisor(power);
let drained = i64::from(amount) / div;
if drained == 0 {
continue;
}
if power == POWER_HAPPINESS {
// `0x627de0`: the subject is the pet's OWNER and the named thing is the
// pet, at the literal misc-info type.
let Some(owner) = unit_owner(target, ctx, stores) else {
continue;
};
let owner_class = ctx.classify(owner, stores);
queue_named(
log,
ctx,
poses,
crate::ui_chat::ChatEventKind::CombatMiscInfo,
combat::SPELLHAPPINESSDRAIN,
(owner, owner_class),
(target, victim),
Fills {
amount: drained,
..Default::default()
},
combat::Named::Unit(target),
);
continue;
}
let Some(kind) = combat::spell_kind(caster, victim, false) else {
continue;
};
// `|multiplier| >= 2^-22` is the reference's own epsilon (`[0x8029d4]`), not a
// round number of ours.
let leech = multiplier.abs() >= LEECH_EPSILON;
let gained = ((f64::from(amount) * f64::from(multiplier)) as i64) / div;
queue(
log,
ctx,
poses,
kind,
if leech {
combat::SPELLPOWERLEECH
} else {
combat::SPELLPOWERDRAIN
},
(s.caster, caster),
(target, victim),
Fills {
spell: spell.clone(),
power: Some(power),
power2: Some(power),
amount: drained,
amount2: gained,
..Default::default()
},
);
}
// Effect 19 ADD_EXTRA_ATTACKS. **The caster guid is never passed** (`0x62d9f0`):
// the sentence's only endpoint is the unit that gained the attacks, and the
// singular form is the same key with `_SINGULAR` appended.
E::ExtraAttacks { target, count } => {
let victim = ctx.classify(target, stores);
let Some(kind) = combat::spell_kind(victim, victim, false) else {
continue;
};
queue(
log,
ctx,
poses,
kind,
if count == 1 {
combat::SPELLEXTRAATTACKS_SINGULAR
} else {
combat::SPELLEXTRAATTACKS
},
(target, victim),
(target, victim),
Fills {
spell: spell.clone(),
amount: i64::from(count),
..Default::default()
},
);
}
// Effect 24 CREATE_ITEM — the tradeskill line, at the literal `0x3e`. No target
// guid on the wire: the two-way is on the CASTER being you.
E::CreateItem { item_entry } => queue_named(
log,
ctx,
poses,
crate::ui_chat::ChatEventKind::SpellTradeskills,
combat::TRADESKILL_LOG,
(s.caster, caster),
(s.caster, caster),
Fills::default(),
combat::Named::Item(item_entry),
),
// Effect 101 FEED_PET — the same shape as CREATE_ITEM, a different sentence.
E::FeedPet { item_entry } => queue_named(
log,
ctx,
poses,
crate::ui_chat::ChatEventKind::SpellTradeskills,
combat::FEEDPET_LOG,
(s.caster, caster),
(s.caster, caster),
Fills::default(),
combat::Named::Item(item_entry),
),
// Effect 68 INTERRUPT_CAST. **The spell the sentence names is the INTERRUPTED
// one**, off the row, not the interrupting cast off the packet header — which is
// the whole point of the line.
E::InterruptCast { target, spell_id } => {
let victim = ctx.classify(target, stores);
let Some(interrupted) = ctx.spell_name(spell_id) else {
continue;
};
let Some(kind) = combat::spell_kind(caster, victim, false) else {
continue;
};
queue(
log,
ctx,
poses,
kind,
combat::SPELLINTERRUPT,
(s.caster, caster),
(target, victim),
Fills {
spell: interrupted,
..Default::default()
},
);
}
// Effect 111 DURABILITY_DAMAGE. Both fields `-1` is the "all items" form, which
// has its own family and names no item at all.
E::DurabilityDamage {
target,
item_entry,
slot,
} => {
let victim = ctx.classify(target, stores);
let Some(kind) = combat::spell_kind(caster, victim, false) else {
continue;
};
if item_entry < 0 && slot < 0 {
queue(
log,
ctx,
poses,
kind,
combat::SPELLDURABILITYDAMAGEALL,
(s.caster, caster),
(target, victim),
spell_fill(),
);
continue;
}
let Ok(entry) = u32::try_from(item_entry) else {
continue;
};
queue_named(
log,
ctx,
poses,
kind,
combat::SPELLDURABILITYDAMAGE,
(s.caster, caster),
(target, victim),
spell_fill(),
combat::Named::Item(entry),
);
}
// Effect 102 DISMISS_PET arrives in the guid-only tail; the reference's two-way is
// on the caster being you, at the literal misc-info type, and the named thing is
// the pet.
E::Target { target } if *_effect == EFFECT_DISMISS_PET => {
let pet = ctx.classify(target, stores);
queue_named(
log,
ctx,
poses,
crate::ui_chat::ChatEventKind::CombatMiscInfo,
combat::SPELLDISMISSPET,
(s.caster, caster),
(target, pet),
Fills::default(),
combat::Named::Unit(target),
);
}
// Heals and energizes off this packet are the floating text's business, not the
// chat log's: the reference words them from SMSG_SPELLHEALLOG / SPELLENERGIZELOG,
// which arrive separately and already have arms. The rest of the guid-only tail is
// the SIMPLECAST catch-all named in this function's docs.
E::Heal { .. } | E::Energize { .. } | E::Target { .. } => {}
}
}
}
}
/// vmangos `Powers`: happiness, the one power with no `…_POINTS` GlobalString — `0x6278f0` returns
/// NULL for it, which is why it takes its own family instead of a `POWERGAIN` row.
const POWER_HAPPINESS: u32 = 4;
/// vmangos `SpellEffects::SPELL_EFFECT_DISMISS_PET`.
const EFFECT_DISMISS_PET: u32 = 102;
/// `|multiplier| >= 2^-22` — the reference's own leech/drain discriminator (`[0x8029d4]`).
const LEECH_EPSILON: f32 = 1.0 / 4_194_304.0;
/// `0x6e7130(powerType)` — the divisor a power's log amounts are reported in
/// (`[powerType*4 + 0x86f978]` = `{1, 10, 1, 1, 1000}`). Rage is stored ×10 and happiness ×1000.
fn power_divisor(power: u32) -> i64 {
match power {
1 => 10,
POWER_HAPPINESS => 1000,
_ => 1,
}
}
/// A unit's owner guid — `CHARMEDBY` first, then `CREATEDBY`, the same pair
/// [`combat::classify`] reads. `None` when the unit is not streamed or owns itself.
fn unit_owner(guid: u64, ctx: &ChatCtx, stores: &Query<&mut ObjectStore>) -> Option<u64> {
let entity = ctx.index.0.get(&guid).copied()?;
let store = stores.get(entity).ok()?;
store
.0
.unit_charmed_by()
.or_else(|| store.0.unit_created_by())
}
/// `SMSG_ENVIRONMENTALDAMAGELOG` → "You fall and lose %d health." and its five siblings.
///
/// **The only family with no selector at all.** `0x62aac0` builds the key by `snprintf` over a
/// 6-entry damage-type table and a plain SELF/OTHER, and calls the melee HITS msg-id selector with
/// **`ecx = edx = victimClass`** — so a fall on a party member types as `HOSTILEPLAYER_HITS`
/// through that selector's tgt<=3 override, which looks wrong and is what the reference does.
///
/// It grows trailers (absorb/resist/vulnerability), which is how a resisted fire tick reads.
pub(super) fn environmental_damage_log(
e: EnvironmentalDamageLog,
ctx: &ChatCtx,
stores: &Query<&mut ObjectStore>,
poses: &Query<&mut Transform>,
log: &mut ChatLog,
) {
let victim = ctx.classify(e.victim, stores);
let Some(family) = combat::env_family(e.damage_type) else {
return;
};
let Some(kind) = combat::combat_kind(victim, victim, false) else {
return;
};
queue(
log,
ctx,
poses,
kind,
family,
(e.victim, victim),
(e.victim, victim),
Fills {
amount: i64::from(e.damage),
trailers: Some(combat::Trailers {
absorbed: e.absorb,
resisted: e.resist,
// The environmental call site passes zero for both — it has no wire source for a
// block or a HitInfo, so GLANCING/CRUSHING/BLOCK can never appear on these lines.
blocked: 0,
hit_info: 0,
}),
..Default::default()
},
);
}
/// `SMSG_SET_FACTION_STANDING` → "Your %s reputation has increased by %d."
///
/// **The wire carries the new TOTAL, and the sentence wants the DELTA** — so this runs before the
/// store is overwritten, and a slot whose value did not actually move prints nothing (the
/// reference's own `0x62c5f0` guard: "only when the stored value actually changed").
///
/// It is one of the twelve formatters with **no range gate and no classifier** — the chat type is
/// the literal `0x55` `COMBAT_FACTION_CHANGE`, and the only participant is you — so it builds its
/// line directly rather than going through [`queue`], which exists to gate on two endpoints.
///
/// The wire's `reputationListId` is `Faction.dbc`'s `rep_index`, not a faction id; the name comes
/// off the row that carries that index.
pub(super) fn faction_standing(
deltas: &[(u32, i32)],
reputations: &Reputations,
factions: Option<&crate::target::ring::Factions>,
log: &mut ChatLog,
) {
let Some(catalog) = factions.map(|f| f.catalog()) else {
return;
};
for &(list_id, standing) in deltas {
let old = reputations
.0
.get(list_id as usize)
.map_or(0, |(_, standing)| *standing);
let delta = standing - old;
if delta == 0 {
continue;
}
let Ok(index) = i32::try_from(list_id) else {
continue;
};
let Some(name) = catalog
.reputation_factions()
.find(|(_, f)| f.rep_index == index)
.and_then(|(id, _)| catalog.faction_name(id))
else {
continue;
};
log.push_combat(combat::PendingCombat {
kind: crate::ui_chat::ChatEventKind::CombatFactionChange,
family: if delta > 0 {
combat::FACTION_STANDING_INCREASED
} else {
combat::FACTION_STANDING_DECREASED
},
// A `Single` family reads no variant, but the field is not optional; `OtherOther` is
// the one `tests::variants_of` sweeps such a family with.
variant: combat::Variant::OtherOther,
subject: 0,
object: 0,
fills: Fills {
named: name.to_string(),
amount: i64::from(delta.abs()),
..Default::default()
},
named: combat::Named::Ready,
tries: 0,
});
}
}
/// The one tail every arm ends in: build the queued line (dropping a class-9 endpoint) and park it
/// for its names.
#[allow(clippy::too_many_arguments)] // the tail's args ARE the line: sink, context, and the five
@@ -494,6 +1143,33 @@ fn queue(
subject: (u64, UnitClass),
object: (u64, UnitClass),
fills: Fills,
) {
queue_named(
log,
ctx,
poses,
kind,
family,
subject,
object,
fills,
combat::Named::Ready,
);
}
/// [`queue`] for a family whose `Named` slot still has to be looked up — an item entry or a unit
/// guid rides along and the drain resolves it, holding the line until it lands (§5.7).
#[allow(clippy::too_many_arguments)] // as [`queue`], plus the one key that defers the line
fn queue_named(
log: &mut ChatLog,
ctx: &ChatCtx,
poses: &Query<&mut Transform>,
kind: crate::ui_chat::ChatEventKind,
family: Family,
subject: (u64, UnitClass),
object: (u64, UnitClass),
fills: Fills,
named: combat::Named,
) {
// **The gate is an OR over the two endpoints, not an AND** — the §5 verdict's own wording:
// `dist²(player, src) < range(srcClass)²` **OR** `dist²(player, tgt) < range(tgtClass)²`. 1571
@@ -502,7 +1178,7 @@ fn queue(
if !ctx.in_range(subject.0, subject.1, poses) && !ctx.in_range(object.0, object.1, poses) {
return;
}
if let Some(line) = combat::queue(kind, family, subject, object, fills) {
if let Some(line) = combat::queue(kind, family, subject, object, fills, named) {
log.push_combat(line);
}
}
+19 -5
View File
@@ -10,7 +10,6 @@ use bevy::prelude::*;
use crate::death::{CorpsePoint, DeathNet, ResurrectOffer};
use crate::net::MoveModeMessage;
use crate::ui_action::UiErrorTexts;
use super::super::SelfGuid;
@@ -88,10 +87,25 @@ pub(super) fn spirit_healer_confirm(npc: u64, death_net: &mut DeathNet) {
death_net.confirm_generation = death_net.confirm_generation.wrapping_add(1);
}
/// `SMSG_DURABILITY_DAMAGE_DEATH` — the red line, verbatim GlobalStrings `DURABILITYDAMAGE_DEATH`
/// (the `%%` unescaped).
pub(super) fn durability_damage_death(errors: &mut UiErrorTexts) {
errors.error("Your equipped items suffer a 10% durability loss.".to_string());
/// `SMSG_DURABILITY_DAMAGE_DEATH` — the 10% death durability loss.
///
/// **It is a combat-log line, not a red error** (1703, correcting the shape 0308 shipped):
/// `0x628e60` is itself the packet's handler and it emits at the literal chat type `0x19`
/// `COMBAT_MISC_INFO`, with **zero arguments** — the packet's body is empty and both of vmangos's
/// fields are ignored. Routing it to `UIErrorsFrame` put it in the wrong frame and, worse, meant
/// hard-coding Blizzard's English sentence in our source; as a combat-log family it resolves
/// `DURABILITYDAMAGE_DEATH` out of the player's own `GlobalStrings.lua` like every other line.
pub(super) fn durability_damage_death(log: &mut crate::ui_chat::ChatLog) {
log.push_combat(crate::ui_chat::combat::PendingCombat {
kind: crate::ui_chat::ChatEventKind::CombatMiscInfo,
family: crate::ui_chat::combat::DURABILITYDAMAGE_DEATH,
variant: crate::ui_chat::combat::Variant::OtherOther,
subject: 0,
object: 0,
fills: crate::ui_chat::combat::Fills::default(),
named: crate::ui_chat::combat::Named::Ready,
tries: 0,
});
}
/// **The ack'd movement-mode family** (decision 0866) — root, water-walk, feather-fall, hover. The
+50
View File
@@ -94,6 +94,9 @@ pub(super) fn feed_actions(
areas: Option<Res<crate::area::AreaTableRes>>,
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
// The combat log's own record of a failed cast (1703) — a different frame, a different
// sentence, and the reference emits both from the one routine (`0x6e1a00`).
mut chat_log: ResMut<crate::ui_chat::ChatLog>,
) {
let Some(mut script) = script else {
return;
@@ -114,6 +117,11 @@ pub(super) fn feed_actions(
// are filled inside [`cast_fail`] itself, off the wire's argument word.
let self_store = self_q.iter().next();
let mut await_template: Vec<crate::ui_action::CastFail> = Vec::new();
// The same failures, worded for the combat log. Collected beside the red line rather than
// instead of it: `0x6e1a00` calls `0x62c360` AND `DisplayError`, and they say different
// things — "Not enough mana." on the screen, "You fail to cast Frostbolt: Not enough mana."
// in the log.
let mut fail_lines: Vec<crate::ui_chat::combat::PendingCombat> = Vec::new();
let fail_args = cast_fail::FailArgs {
arg: None,
focus: spell_focus.as_deref().map(|f| &f.catalog),
@@ -185,6 +193,45 @@ pub(super) fn feed_actions(
.filter(|s| !s.is_empty())
.map(|t| t.replace("%s", &name));
}
// The combat-log twin. **Only the `…SELF` half is reachable here**: `SMSG_CAST_FAILED`
// is addressed to the caster alone, so benilla never learns that somebody *else's*
// cast failed — the `…OTHER` keys exist and stay unproduced, exactly as the reference
// leaves its own two unreachable `…SELFSTART` keys.
//
// The reason `%s` is the FIRST-layer string — `GetText("SPELL_FAILED_<name>")`, which
// is what `0x6e1a00` holds when it calls the formatter — not the errorId-substituted
// message the red line shows. An empty one drops the line rather than printing a
// sentence with a hole, the same rule every other family here follows.
if let Some(display) = d {
// `0x62aff0`: `Attributes` bit 4 marks an ABILITY, which "performs" rather than
// "casts".
const ATTR_IS_ABILITY: u32 = 0x10;
let family = if display.attributes & ATTR_IS_ABILITY != 0 {
crate::ui_chat::combat::SPELLFAILPERFORM
} else {
crate::ui_chat::combat::SPELLFAILCAST
};
let why = cast_fail::CAST_FAIL_KEYS
.get(usize::from(reason))
.and_then(|k| get(k))
.filter(|t| !t.is_empty());
if let (Some(why), false) = (why, display.name.is_empty()) {
fail_lines.push(crate::ui_chat::combat::PendingCombat {
kind: crate::ui_chat::ChatEventKind::SpellFailedLocalPlayer,
family,
variant: crate::ui_chat::combat::Variant::SelfOther,
subject: 0,
object: 0,
fills: crate::ui_chat::combat::Fills {
spell: display.name.clone(),
named: why,
..Default::default()
},
named: crate::ui_chat::combat::Named::Ready,
tries: 0,
});
}
}
let text = cast_fail::cast_fail_text(
reason,
d,
@@ -207,6 +254,9 @@ pub(super) fn feed_actions(
})
.collect();
cast_errors.0.extend(await_template);
for line in fail_lines {
chat_log.push_combat(line);
}
for text in texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
@@ -62,6 +62,8 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver<ClientCommand
.init_resource::<MountErrors>()
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
// The cast-failure combat-log line (1703) rides the same drain.
.init_resource::<crate::ui_chat::ChatLog>()
.insert_resource(ItemDisplays::icons_for_tests(
ItemDisplayCatalog::from_displays(displays),
))
@@ -310,6 +312,8 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() {
.init_resource::<MountErrors>()
.init_resource::<UiErrorKeys>()
.init_resource::<UiErrorTexts>()
// The cast-failure combat-log line (1703) rides the same drain.
.init_resource::<crate::ui_chat::ChatLog>()
.insert_resource(NetCommands(tx));
let mut script = UiScript::new().unwrap();
script.set_macros(MacroState {
+13
View File
@@ -85,6 +85,19 @@ impl Plugin for UiChatPlugin {
)
.before(feed::feed_chat),
)
// The three combat-log families that are NOT packet-driven (1703): the death reflex,
// the aura arrival/departure/stack callbacks, and the pet-loyalty byte. Each is a
// descriptor diff, so each runs before the drain that would otherwise show its line a
// frame late.
.add_systems(
Update,
(
combat::watch::death_lines,
combat::watch::aura_lines,
combat::watch::pet_loyalty_lines,
)
.before(feed::feed_chat),
)
.add_systems(Update, feed::feed_chat.before(UiInput))
// A fresh VM gets the joined-channel mirror re-pushed once (decision 1291) — before
// the feed, so the reload frame's first routed line already renders numbered.
+707 -7
View File
@@ -42,6 +42,84 @@ use crate::net::{GuidIndex, NetCommands, ObjectStore, Reputations, SelfGuid};
use crate::target::ring::Factions;
use crate::ui_party::GroupState;
/// Read-only `ObjectStore` lookup by entity — the only thing [`classify`] needs from the world.
///
/// It exists because the two callers hold different query shapes and neither is wrong: the net
/// drain already has a `&mut` store query in hand (whose `get` is read-only anyway), while the
/// watcher systems in [`watch`] hold the `(Entity, &ObjectStore)` pair they sweep. One trait beats
/// either duplicating the classifier or forcing a query shape on a system that does not want it.
pub(crate) trait Stores {
fn store(&self, entity: Entity) -> Option<&ObjectStore>;
}
impl Stores for Query<'_, '_, &mut ObjectStore> {
fn store(&self, entity: Entity) -> Option<&ObjectStore> {
self.get(entity).ok()
}
}
impl Stores for Query<'_, '_, (Entity, &ObjectStore)> {
fn store(&self, entity: Entity) -> Option<&ObjectStore> {
self.get(entity).ok().map(|(_, s)| s)
}
}
/// [`Stores`]' twin for the range gate: a world position by entity.
pub(crate) trait Poses {
fn pose(&self, entity: Entity) -> Option<Vec3>;
}
impl Poses for Query<'_, '_, &mut Transform> {
fn pose(&self, entity: Entity) -> Option<Vec3> {
self.get(entity).ok().map(|t| t.translation)
}
}
impl Poses for Query<'_, '_, &Transform> {
fn pose(&self, entity: Entity) -> Option<Vec3> {
self.get(entity).ok().map(|t| t.translation)
}
}
/// One endpoint's half of the reference's display-range gate (`0x626630` → the per-class range
/// getter `0x626810`): a 3-D squared distance from the ACTIVE PLAYER to the endpoint, against the
/// class's range squared.
///
/// **The comparison is strictly `<`** — `fcomp; fnstsw ax; test ah,5; jnp`, so `dist² == range²` is
/// OUT and a NaN is out. 1571 used `<=`; the boundary is exact in the binary and there is no reason
/// for us to be looser.
///
/// **The ranges are the compiled-in defaults, not live CVars** — the same standing shape as
/// [`crate::combat_text`]'s `COMBAT_DAMAGE`/`PET_*` gates, and for the same reason: the values are
/// byte-read and correct, and the CVars (`CombatLogRangeParty` and its six siblings) can be wired to
/// the live table without changing anything here. [`UnitClass::default_range`] carries them,
/// including the two sentinels that make the gate a no-op for you and your pet (`100000.0`) and
/// unconditional for an unresolvable unit (`0.0`).
///
/// A pose we do not hold is treated as **in** range: dropping a line because a unit's transform had
/// not landed yet would silently lose the killing blow on a mob that despawns, which is a worse
/// failure than logging one fight too far away.
pub(crate) fn in_range(
guid: u64,
class: UnitClass,
self_guid: &SelfGuid,
index: &GuidIndex,
poses: &impl Poses,
) -> bool {
let range = class.default_range();
if range >= 100_000.0 {
return true;
}
if range <= 0.0 {
return false;
}
let pose = |g: u64| index.0.get(&g).copied().and_then(|e| poses.pose(e));
let (Some(me), Some(them)) = (self_guid.0.and_then(pose), pose(guid)) else {
return true;
};
me.distance_squared(them) < range * range
}
/// A unit's standing relative to the active player — the `0..9` index every combat-log selector in
/// the reference takes, in both parameter positions.
///
@@ -154,7 +232,7 @@ pub(crate) fn classify(
self_guid: &SelfGuid,
group: Option<&GroupState>,
index: &GuidIndex,
stores: &Query<&mut ObjectStore>,
stores: &impl Stores,
factions: Option<&Factions>,
reputations: &Reputations,
) -> UnitClass {
@@ -167,7 +245,7 @@ pub(crate) fn classify(
let Some(entity) = index.0.get(&guid).copied() else {
return UnitClass::Unknown;
};
let Ok(store) = stores.get(entity) else {
let Some(store) = stores.store(entity) else {
return UnitClass::Unknown;
};
// The owner: CHARMEDBY first, then CREATEDBY (§2). A pet/guardian/totem/charmed unit is
@@ -203,7 +281,7 @@ pub(crate) fn classify(
// Friend or foe — `CanAttack`, judged on **this unit**, never on its owner (§2). The owner
// hop was ours and it was wrong: the reference asks the pet itself.
let hostile = {
let me_store = index.0.get(&me).copied().and_then(|e| stores.get(e).ok());
let me_store = index.0.get(&me).copied().and_then(|e| stores.store(e));
crate::target::ring::can_attack_from_player(
factions,
reputations,
@@ -323,6 +401,49 @@ pub(crate) fn periodic_kind(attacker: UnitClass, buff: bool) -> Option<ChatEvent
Some(if buff { buff_twin(damage) } else { damage })
}
/// `0x628980` — the death pair's selector, and it takes the **victim's** class alone.
///
/// The comparison is SIGNED (`jl` then `cmp ecx,5; jle`), which is why the note spells the negative
/// arm out: `0 <= c <= 5` is FRIENDLY_DEATH, everything else — a hostile player, their pet, any
/// creature, an unresolvable unit — is HOSTILE_DEATH. Our `UnitClass` cannot be negative, so the
/// sign only matters as a statement of what the arm is.
pub(crate) fn death_kind(victim: UnitClass) -> ChatEventKind {
if (victim as u8) <= 5 {
ChatEventKind::CombatFriendlyDeath
} else {
ChatEventKind::CombatHostileDeath
}
}
/// `0x62b7d0` — where an aura's DEPARTURE is logged, off the bearer's class alone. Three rows, not
/// ten: `{0,1}` SELF · `{2,3}` PARTY · everything else OTHER.
///
/// The arrival is not this selector's — an aura landing rides the two PERIODIC selectors instead
/// (harmful → [`periodic_kind`]'s damage row, helpful → its buff row), which is the asymmetry §4.4
/// records and not a simplification of ours.
pub(crate) fn aura_gone_kind(bearer: UnitClass) -> ChatEventKind {
use ChatEventKind as K;
use UnitClass as C;
match bearer {
C::Me | C::MyPet => K::SpellAuraGoneSelf,
C::Party | C::PartyPet => K::SpellAuraGoneParty,
_ => K::SpellAuraGoneOther,
}
}
/// `0x62c140` — the damage-shield two-way, off one class: `{0,1}` ON_SELF, else ON_OTHERS.
///
/// It has **two** users, which is the surprising half: the shield formatter itself, and *every*
/// `SMSG_SPELLLOGMISS` line (`0x5e7f31 push 1` routes `0x62bab0` here instead of the eight-row
/// spell matrix — §4.4, a byte fact wow-re states without claiming to know whether it is deliberate).
pub(crate) fn damage_shield_kind(subject: UnitClass) -> ChatEventKind {
if matches!(subject, UnitClass::Me | UnitClass::MyPet) {
ChatEventKind::SpellDamageShieldsOnSelf
} else {
ChatEventKind::SpellDamageShieldsOnOthers
}
}
/// The HITS → MISSES step. Every combat pair is adjacent in the type table, so the reference's two
/// selectors are one table apart; this is that `+1`, spelled as the pairing it encodes.
fn miss_twin(hits: ChatEventKind) -> ChatEventKind {
@@ -412,6 +533,35 @@ impl Variant {
fn names_object(self) -> bool {
matches!(self, Self::SelfOther | Self::OtherOther)
}
/// Whether the SUBJECT is the local player — the single bit a [`Keying::Duo`] family keys on,
/// since such a family has only one endpoint in its sentence.
fn subject_is_me(self) -> bool {
matches!(self, Self::SelfSelf | Self::SelfOther)
}
}
/// How a family builds its GlobalString KEY out of the stem and the variant — the reference's three
/// shapes, and they are not interchangeable.
///
/// The 4-way `…SELFSELF`/`…SELFOTHER`/`…OTHERSELF`/`…OTHEROTHER` selector (`0x62a290` and its 44
/// siblings) is only the *commonest* one. A family whose sentence has a single participant —
/// a death, an aura landing or leaving, a tradeskill create — keys on that one endpoint with a
/// two-way `…SELF`/`…OTHER` (`0x62c160`, `0x62b480`, `0x629610`, and the other inline 2-ways), and
/// a handful of families have no variant at all (`DURABILITYDAMAGE_DEATH`, `PET_LOYALTY_GAIN`,
/// `SELFKILLOTHER`). Modelling all three here is what lets one composer serve the whole block.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Keying {
/// `…SELFSELF` / `…SELFOTHER` / `…OTHERSELF` / `…OTHEROTHER`, off both endpoints.
Quad,
/// Two keys off the SUBJECT alone. The words are the family's own — most spell `SELF`/`OTHER`,
/// but `TRADESKILL_LOG`/`FEEDPET_LOG` say `_FIRSTPERSON`/`_THIRDPERSON`.
Duo {
me: &'static str,
other: &'static str,
},
/// One key: the stem (plus any tail) IS the whole name.
Single,
}
/// One value slot in a family's format string, in the order the shipped template consumes it.
@@ -439,6 +589,11 @@ pub(crate) enum Slot {
Amount2,
/// `%s` — a second power word (the leech family's gained power).
Power2,
/// `%s` — a name the ARM already resolved and that the template always spells out: an item
/// ("You create %s."), a gameobject ("You perform %s on %s."), a pet, a faction, or a cast
/// failure's reason. Unconditional — unlike [`Self::Attacker`]/[`Self::Victim`] it is never
/// dropped by the variant, because it names something that is never "you".
Named,
}
/// A combat-log message family: the stem its four keys are built from, and the ordered slots its
@@ -446,9 +601,112 @@ pub(crate) enum Slot {
#[derive(Clone, Copy, Debug)]
pub(crate) struct Family {
pub stem: &'static str,
/// How the stem becomes a key (see [`Keying`]).
pub keying: Keying,
/// Appended AFTER the variant word — `AURAADDED` + `SELF` + `HARMFUL`, `SPELLEXTRAATTACKS` +
/// `SELF` + `_SINGULAR`. Empty for every other family.
pub tail: &'static str,
pub slots: &'static [Slot],
}
impl Family {
/// The GlobalString key this family resolves for `variant`.
pub(crate) fn key(&self, variant: Variant) -> String {
let mid = match self.keying {
Keying::Quad => variant.suffix(),
Keying::Duo { me, other } => {
if variant.subject_is_me() {
me
} else {
other
}
}
Keying::Single => "",
};
format!("{}{mid}{}", self.stem, self.tail)
}
/// Whether this family's template spells the SUBJECT out for `variant`.
fn names_subject(&self, variant: Variant) -> bool {
match self.keying {
Keying::Quad => variant.names_subject(),
// The two-way key IS the test: the `…OTHER` half is the one that carries a name.
Keying::Duo { .. } => !variant.subject_is_me(),
// A fixed key like `PARTYKILLOTHER` already says "other" in its name.
Keying::Single => true,
}
}
/// Whether this family's template spells the OBJECT out for `variant`.
fn names_object(&self, variant: Variant) -> bool {
match self.keying {
Keying::Quad => variant.names_object(),
// A Duo family's sentence has one participant; a `Victim` slot in one would be a
// declaration bug, and `tests` is what catches it.
Keying::Duo { .. } => false,
Keying::Single => true,
}
}
}
/// The six suffixes `0x628410` can append to a finished sentence, in its fixed order.
///
/// **This is a separate pass over the already-formatted line, not more `%s` slots** — the reference
/// builds the sentence, appends what applies, and only then hands the whole thing to `0x626850` as
/// a single `%s`. Each trailer is skipped silently when its GlobalString resolves empty, so a
/// locale that ships one blank simply loses that clause.
///
/// Only four call sites can grow one, and what each can show differs because it passes zeroes for
/// the fields it has no wire source for (§4.2): melee `COMBATHIT*` is the only family that can ever
/// show GLANCING/CRUSHING/BLOCK; `SPELLLOG*` can show RESIST/VULNERABLE/BLOCK/ABSORB;
/// `PERIODICAURADAMAGE*` and `VSENVIRONMENTALDAMAGE_*` RESIST/VULNERABLE/ABSORB.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Trailers {
pub absorbed: u32,
/// **Signed on purpose**: a negative resist is a *vulnerability* bonus and words itself as one.
pub resisted: i32,
pub blocked: u32,
/// The swing's raw `HitInfo` — only `0x4000` GLANCING and `0x8000` CRUSHING are read, and only
/// the melee call site passes a real one.
pub hit_info: u32,
}
/// Append the trailers `0x628410` would, in its order, skipping each whose GlobalString is empty.
fn append_trailers(lua: &benilla_ui::script::UiScript, line: &mut String, t: Trailers) {
const GLANCING: u32 = 0x4000;
const CRUSHING: u32 = 0x8000;
let mut push = |key: &str, arg: Option<i64>| {
let Some(template) = global_string(lua, key) else {
return;
};
let args = match arg {
Some(n) => vec![Arg::Num(n)],
None => Vec::new(),
};
match fill(&template, &args) {
Some(text) => line.push_str(&text),
None => warn!("combat log: {key} does not match its trailer shape"),
}
};
if t.hit_info & GLANCING != 0 {
push("GLANCING_TRAILER", None);
}
if t.hit_info & CRUSHING != 0 {
push("CRUSHING_TRAILER", None);
}
if t.resisted > 0 {
push("RESIST_TRAILER", Some(i64::from(t.resisted)));
} else if t.resisted < 0 {
push("VULNERABLE_TRAILER", Some(-i64::from(t.resisted)));
}
if t.blocked != 0 {
push("BLOCK_TRAILER", Some(i64::from(t.blocked)));
}
if t.absorbed != 0 {
push("ABSORB_TRAILER", Some(i64::from(t.absorbed)));
}
}
/// The value bound to each [`Slot`] for one line. Built by the caller from the packet; the family's
/// slot list decides which of these are read and in what order.
///
@@ -469,6 +727,11 @@ pub(crate) struct Fills {
pub amount2: i64,
/// The leech family's *gained* power index — usually the same as [`Self::power`].
pub power2: Option<u32>,
/// The [`Slot::Named`] text — an item, gameobject, pet, faction or failure reason, already
/// localized by the arm that built this.
pub named: String,
/// The `0x628410` suffixes, for the four families that can grow one. `None` everywhere else.
pub trailers: Option<Trailers>,
}
/// Resolve a family + variant to a finished line, or `None` when the key is absent from the
@@ -483,7 +746,7 @@ pub(crate) fn compose_line(
variant: Variant,
fills: &Fills,
) -> Option<String> {
let key = format!("{}{}", family.stem, variant.suffix());
let key = family.key(variant);
let template = global_string(lua, &key)?;
// Resolved before the walk so the borrows outlive it.
let school = fills.school.and_then(|s| school_word(lua, s));
@@ -492,9 +755,10 @@ pub(crate) fn compose_line(
let mut args: Vec<Arg> = Vec::with_capacity(family.slots.len());
for slot in family.slots {
match slot {
Slot::Attacker if variant.names_subject() => args.push(Arg::Str(&fills.attacker)),
Slot::Victim if variant.names_object() => args.push(Arg::Str(&fills.victim)),
Slot::Attacker if family.names_subject(variant) => args.push(Arg::Str(&fills.attacker)),
Slot::Victim if family.names_object(variant) => args.push(Arg::Str(&fills.victim)),
Slot::Attacker | Slot::Victim => {}
Slot::Named => args.push(Arg::Str(&fills.named)),
Slot::Spell => args.push(Arg::Str(&fills.spell)),
Slot::School => args.push(Arg::Str(school.as_deref()?)),
Slot::Power => args.push(Arg::Str(power.as_deref()?)),
@@ -504,7 +768,12 @@ pub(crate) fn compose_line(
}
}
match fill(&template, &args) {
Some(line) => Some(line),
Some(mut line) => {
if let Some(t) = fills.trailers {
append_trailers(lua, &mut line, t);
}
Some(line)
}
None => {
// A mismatch means our declared slot order disagrees with the shipped template — a
// real defect, not a data condition, so it is loud. `tests` proves it cannot happen for
@@ -617,6 +886,40 @@ macro_rules! family {
#[doc = $doc]
pub(crate) const $name: Family = Family {
stem: $stem,
keying: Keying::Quad,
tail: "",
slots: &[$(Slot::$slot),*],
};
};
}
/// Declare a **two-way** family — one keyed on the subject alone (`Keying::Duo`). `$me`/`$other`
/// are the family's own words, which are `"SELF"`/`"OTHER"` for most and `"_FIRSTPERSON"`/
/// `"_THIRDPERSON"` for the two tradeskill ones. The optional `tail` is what `AURAADDED` and
/// `SPELLEXTRAATTACKS` append after it.
macro_rules! duo {
($name:ident, $stem:literal, $me:literal, $other:literal, [$($slot:ident),* $(,)?], $doc:literal) => {
duo!($name, $stem, $me, $other, tail "", [$($slot),*], $doc);
};
($name:ident, $stem:literal, $me:literal, $other:literal, tail $tail:literal, [$($slot:ident),* $(,)?], $doc:literal) => {
#[doc = $doc]
pub(crate) const $name: Family = Family {
stem: $stem,
keying: Keying::Duo { me: $me, other: $other },
tail: $tail,
slots: &[$(Slot::$slot),*],
};
};
}
/// Declare a family with **no variant at all** — the key is the whole name.
macro_rules! single {
($name:ident, $key:literal, [$($slot:ident),* $(,)?], $doc:literal) => {
#[doc = $doc]
pub(crate) const $name: Family = Family {
stem: $key,
keying: Keying::Single,
tail: "",
slots: &[$(Slot::$slot),*],
};
};
@@ -839,6 +1142,340 @@ family!(
object is whoever struck them, which is the reverse of the packet's own field names."
);
// ── a unit dying ───────────────────────────────────────────────────────────────────────
duo!(
UNITDIES,
"UNITDIES",
"SELF",
"OTHER",
[Attacker],
"`\"You die.\"` (:4426) / `\"%s dies.\"` (:4425) — the death reflex's ordinary line. The SUBJECT \
is the unit that died; there is no second endpoint."
);
single!(
UNITDESTROYEDOTHER,
"UNITDESTROYEDOTHER",
[Attacker],
"`\"%s is destroyed.\"` (:4424) — what a SUMMONED thing does instead of dying. There is no \
`…SELF` twin: you are never a summon."
);
single!(
SELFKILLOTHER,
"SELFKILLOTHER",
[Attacker],
"`\"You have slain %s!\"` (:3408) — `SMSG_PARTYKILLLOG` when the killer is YOU. The reference \
pushes (victim, killer) and the string consumes only the first, so the subject is the VICTIM."
);
single!(
PARTYKILLOTHER,
"PARTYKILLOTHER",
[Attacker, Victim],
"`\"%s is slain by %s!\"` (:2988) — the same packet when the killer is a party member. \
(victim, killer), in that order."
);
// ── an aura landing, stacking, leaving, being dispelled or stolen ──────────────────────
duo!(
AURAADDED_HARMFUL,
"AURAADDED",
"SELF",
"OTHER",
tail "HARMFUL",
[Attacker, Spell],
"`\"You are afflicted by %s.\"` (:102) / `\"%s is afflicted by %s.\"` (:100) — UNIT first."
);
duo!(
AURAADDED_HELPFUL,
"AURAADDED",
"SELF",
"OTHER",
tail "HELPFUL",
[Attacker, Spell],
"`\"You gain %s.\"` (:103) / `\"%s gains %s.\"` (:101)"
);
duo!(
AURAAPPLICATIONADDED_HARMFUL,
"AURAAPPLICATIONADDED",
"SELF",
"OTHER",
tail "HARMFUL",
[Attacker, Spell, Amount],
"`\"You are afflicted by %s (%d).\"` (:106) / `\"%s is afflicted by %s (%d).\"` (:104)"
);
duo!(
AURAAPPLICATIONADDED_HELPFUL,
"AURAAPPLICATIONADDED",
"SELF",
"OTHER",
tail "HELPFUL",
[Attacker, Spell, Amount],
"`\"You gain %s (%d).\"` (:107) / `\"%s gains %s (%d).\"` (:105)"
);
duo!(
AURAREMOVED,
"AURAREMOVED",
"SELF",
"OTHER",
[Spell, Attacker],
"`\"%s fades from you.\"` (:113) / `\"%s fades from %s.\"` (:112) — AURA first, which is the \
other way round from `AURAADDED*`. The flip is the reference's own (§4.4) and is exactly the \
kind of thing a single ordered slot list per family exists to pin."
);
duo!(
AURADISPEL,
"AURADISPEL",
"SELF",
"OTHER",
[Attacker, Spell],
"`\"Your %s is removed.\"` (:111) / `\"%s's %s is removed.\"` (:110) — the subject is the aura's \
BEARER, not the dispeller, who the sentence never names."
);
// ── an enchant landing on or fading from an item ───────────────────────────────────────
family!(
ITEMENCHANTMENTADD,
"ITEMENCHANTMENTADD",
[Attacker, Spell, Victim, Named],
"`\"%s casts %s on %s's %s.\"` (:2378) — caster, enchant, owner, ITEM. The item name is always \
last and always spelled."
);
duo!(
ITEMENCHANTMENTREMOVE,
"ITEMENCHANTMENTREMOVE",
"SELF",
"OTHER",
[Spell, Attacker, Named],
"`\"%s has faded from your %s.\"` (:2383) / `\"%s has faded from %s's %s.\"` (:2382) — enchant, \
owner, item. A fade names no caster, which is why it drops to two keys."
);
// ── what a cast made, fed or opened ────────────────────────────────────────────────────
duo!(
TRADESKILL_LOG,
"TRADESKILL_LOG",
"_FIRSTPERSON",
"_THIRDPERSON",
[Attacker, Named],
"`\"You create %s.\"` (:4273) / `\"%s creates %s.\"` (:4274) — the one family pair whose two-way \
words are not SELF/OTHER."
);
duo!(
FEEDPET_LOG,
"FEEDPET_LOG",
"_FIRSTPERSON",
"_THIRDPERSON",
[Attacker, Named],
"`\"Your pet begins eating the %s.\"` (:1965) / `\"%s's pet begins eating a %s.\"` (:1966)"
);
// ── the spell-outcome leaves ───────────────────────────────────────────────────────────
family!(
PROCRESIST,
"PROCRESIST",
[Victim, Attacker, Spell],
"`\"%s resists %s's %s.\"` (:3100) — TARGET first, then caster (the reference's convention B)."
);
family!(
IMMUNESPELL,
"IMMUNESPELL",
[Victim, Attacker, Spell],
"`\"%s is immune to %s's %s.\"` (:2317) — convention B."
);
family!(
DISPELFAILED,
"DISPELFAILED",
[Attacker, Victim, Spell],
"`\"%s fails to dispel %s's %s.\"` (:933) — convention C: caster, target, spell."
);
family!(
SPELLINTERRUPT,
"SPELLINTERRUPT",
[Attacker, Victim, Spell],
"`\"%s interrupts %s's %s.\"` (:3863) — convention C. No `…SELFSELF`."
);
duo!(
INSTAKILL,
"INSTAKILL",
"SELF",
"OTHER",
[Attacker, Spell],
"`\"You are killed by %s.\"` (:2330) / `\"%s is killed by %s.\"` (:2329) — subject is the unit \
killed."
);
family!(
SPELLSPLITDAMAGE,
"SPELLSPLITDAMAGE",
[Attacker, Spell, Victim, Amount],
"`\"%s's %s causes %s %d damage.\"` (:3916) — the `hit_info & 8` split-damage form of \
`SMSG_SPELLNONMELEEDAMAGELOG`. No `…SELFSELF`."
);
family!(
SPELLPOWERDRAIN,
"SPELLPOWERDRAIN",
[Attacker, Spell, Amount, Power, Victim],
"`\"%s's %s drains %d %s from %s.\"` (:3900) — [`SPELLPOWERLEECH`]'s twin, chosen when the \
leech multiplier is effectively zero: the same push block, three slots shorter."
);
duo!(
SPELLEXTRAATTACKS,
"SPELLEXTRAATTACKS",
"SELF",
"OTHER",
[Attacker, Amount, Spell],
"`\"You gain %d extra attacks through %s.\"` (:3851) / `\"%s gains %d extra attacks through \
%s.\"` (:3849)"
);
duo!(
SPELLEXTRAATTACKS_SINGULAR,
"SPELLEXTRAATTACKS",
"SELF",
"OTHER",
tail "_SINGULAR",
[Attacker, Amount, Spell],
"`\"You gain %d extra attack through %s.\"` (:3852) — the reference `SStrCat`s `_SINGULAR` onto \
the same key when the count is 1, so this is the same family with a tail rather than a fifth \
name."
);
family!(
SPELLDURABILITYDAMAGE,
"SPELLDURABILITYDAMAGE",
[Attacker, Spell, Victim, Named],
"`\"%s casts %s on %s: %s damaged.\"` (:3842) — the trailing name is the ITEM. No `…SELFSELF`."
);
family!(
SPELLDURABILITYDAMAGEALL,
"SPELLDURABILITYDAMAGEALL",
[Attacker, Spell, Victim],
"`\"%s casts %s on %s: all items damaged.\"` (:3839) — what the same effect says when its slot \
and item id are both `-1`. No `…SELFSELF`."
);
duo!(
SPELLDISMISSPET,
"SPELLDISMISSPET",
"SELF",
"OTHER",
[Attacker, Named],
"`\"Your %s is dismissed.\"` (:3834) / `\"%s's %s is dismissed.\"` (:3833) — the trailing name \
is the PET."
);
duo!(
SPELLHAPPINESSDRAIN,
"SPELLHAPPINESSDRAIN",
"SELF",
"OTHER",
[Attacker, Named, Amount],
"`\"Your %s loses %d happiness.\"` (:3858) / `\"%s's %s loses %d happiness.\"` (:3857) — power \
type 4 has no `…_POINTS` noun, so happiness gets its own family instead of a `POWERGAIN` row."
);
// ── a cast that failed ─────────────────────────────────────────────────────────────────
duo!(
SPELLFAILCAST,
"SPELLFAILCAST",
"SELF",
"OTHER",
[Attacker, Spell, Named],
"`\"You fail to cast %s: %s.\"` (:3854) / `\"%s fails to cast %s: %s.\"` (:3853) — the trailing \
name is the REASON, already localized."
);
duo!(
SPELLFAILPERFORM,
"SPELLFAILPERFORM",
"SELF",
"OTHER",
[Attacker, Spell, Named],
"`\"You fail to perform %s: %s.\"` (:3856) / `\"%s fails to perform %s: %s.\"` (:3855)"
);
// ── the miscellaneous 0x19 leaves and the reputation line ──────────────────────────────
single!(
DURABILITYDAMAGE_DEATH,
"DURABILITYDAMAGE_DEATH",
[],
"`\"Your equipped items suffer a 10%% durability loss.\"` (:963) — `SMSG_DURABILITY_DAMAGE_DEATH` \
carries an EMPTY body, so the line has no arguments at all."
);
single!(
PET_LOYALTY_GAIN,
"PET_LOYALTY_GAIN",
[],
"`\"Your pet's loyalty has increased.\"` (:3043). The reference emits it as `(\"%s\", GetText(key))`; \
a no-slot family is the same sentence by a shorter road."
);
single!(
PET_LOYALTY_LOSS,
"PET_LOYALTY_LOSS",
[],
"`\"Your pet's loyalty has decreased.\"` (:3044)"
);
single!(
FACTION_STANDING_INCREASED,
"FACTION_STANDING_INCREASED",
[Named, Amount],
"`\"Your %s reputation has increased by %d.\"` (:1946) — the name is the FACTION's."
);
single!(
FACTION_STANDING_DECREASED,
"FACTION_STANDING_DECREASED",
[Named, Amount],
"`\"Your %s reputation has decreased by %d.\"` (:1945)"
);
// ── environmental damage ───────────────────────────────────────────────────────────────
/// Declare one `VSENVIRONMENTALDAMAGE_<TYPE>_{SELF,OTHER}` pair. The reference builds the key by
/// `snprintf("VSENVIRONMENTALDAMAGE_%s_%s", envTypeName, self ? "SELF" : "OTHER")` over the 6-entry
/// table `0x80dcac`; six declared families are that `snprintf` spelled out, which is what lets the
/// sweep check all twelve keys against the shipped file.
macro_rules! env_family {
($name:ident, $stem:literal, $doc:literal) => {
duo!($name, $stem, "SELF", "OTHER", [Attacker, Amount], $doc);
};
}
env_family!(
VSENV_FATIGUE,
"VSENVIRONMENTALDAMAGE_FATIGUE_",
"`\"You are exhausted and lose %d health.\"` (:5408) — damage type 0."
);
env_family!(
VSENV_DROWNING,
"VSENVIRONMENTALDAMAGE_DROWNING_",
"`\"You are drowning and lose %d health.\"` (:5404) — type 1."
);
env_family!(
VSENV_FALLING,
"VSENVIRONMENTALDAMAGE_FALLING_",
"`\"You fall and lose %d health.\"` (:5406) — type 2."
);
env_family!(
VSENV_LAVA,
"VSENVIRONMENTALDAMAGE_LAVA_",
"`\"You lose %d health for swimming in lava.\"` (:5412) — type 3."
);
env_family!(
VSENV_SLIME,
"VSENVIRONMENTALDAMAGE_SLIME_",
"`\"You lose %d health for swimming in slime.\"` (:5414) — type 4."
);
env_family!(
VSENV_FIRE,
"VSENVIRONMENTALDAMAGE_FIRE_",
"`\"You suffer %d points of fire damage.\"` (:5410) — type 5."
);
/// The environmental family for a `SMSG_ENVIRONMENTALDAMAGELOG` damage type, `None` for a byte
/// outside the six the table holds (`0x62abc5` indexes it unguarded; we decline instead).
pub(crate) fn env_family(damage_type: u8) -> Option<Family> {
Some(match damage_type {
0 => VSENV_FATIGUE,
1 => VSENV_DROWNING,
2 => VSENV_FALLING,
3 => VSENV_LAVA,
4 => VSENV_SLIME,
5 => VSENV_FIRE,
_ => return None,
})
}
/// Every family this module can emit — the sweep [`tests`] uses to check each one against the
/// shipped `GlobalStrings.lua`. Adding a family without adding it here is the only way to get an
/// unchecked slot order, so the list is the gate.
@@ -878,6 +1515,46 @@ pub(crate) const ALL_FAMILIES: &[Family] = &[
PERIODICAURADAMAGE,
PERIODICAURAHEAL,
DAMAGESHIELD,
UNITDIES,
UNITDESTROYEDOTHER,
SELFKILLOTHER,
PARTYKILLOTHER,
AURAADDED_HARMFUL,
AURAADDED_HELPFUL,
AURAAPPLICATIONADDED_HARMFUL,
AURAAPPLICATIONADDED_HELPFUL,
AURAREMOVED,
AURADISPEL,
ITEMENCHANTMENTADD,
ITEMENCHANTMENTREMOVE,
TRADESKILL_LOG,
FEEDPET_LOG,
PROCRESIST,
IMMUNESPELL,
DISPELFAILED,
SPELLINTERRUPT,
INSTAKILL,
SPELLSPLITDAMAGE,
SPELLPOWERDRAIN,
SPELLEXTRAATTACKS,
SPELLEXTRAATTACKS_SINGULAR,
SPELLDURABILITYDAMAGE,
SPELLDURABILITYDAMAGEALL,
SPELLDISMISSPET,
SPELLHAPPINESSDRAIN,
SPELLFAILCAST,
SPELLFAILPERFORM,
DURABILITYDAMAGE_DEATH,
PET_LOYALTY_GAIN,
PET_LOYALTY_LOSS,
FACTION_STANDING_INCREASED,
FACTION_STANDING_DECREASED,
VSENV_FATIGUE,
VSENV_DROWNING,
VSENV_FALLING,
VSENV_LAVA,
VSENV_SLIME,
VSENV_FIRE,
];
/// The `SpellMissInfo` byte `SMSG_SPELLLOGMISS` (and `SpellGo`'s own list) carries → the family
@@ -984,9 +1661,28 @@ pub(crate) struct PendingCombat {
pub object: u64,
/// Everything the template needs that is not a name; the two name fields are filled at drain.
pub fills: Fills,
/// Where [`Slot::Named`]'s text comes from — see [`Named`].
pub named: Named,
pub tries: u16,
}
/// What still has to be looked up before a line's [`Slot::Named`] can be filled.
///
/// The reference has both hops and keeps them apart for the same reason: an item name comes from
/// the **item cache** (`0x55ba30`), whose miss parks the whole formatter on the deferred queue and
/// re-runs it when the server answers (§5.7); a unit name comes from `GetObjectName` (`0x6264e0`),
/// the same resolve the two endpoint slots already use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Named {
/// Nothing to do — the arm already put the text in [`Fills::named`], or the family has no
/// `Named` slot at all.
Ready,
/// An item entry, through the ask-once item cache.
Item(u32),
/// A unit or object guid, through the name cache.
Unit(u64),
}
/// Build a queued line from an already-classified pair, or `None` when the reference would emit
/// nothing: an unroutable type (`0x5e`), or an endpoint whose class is out of the range gate.
///
@@ -998,6 +1694,7 @@ pub(crate) fn queue(
subject: (u64, UnitClass),
object: (u64, UnitClass),
fills: Fills,
named: Named,
) -> Option<PendingCombat> {
// **A class-9 endpoint alone does NOT drop the line**, and 1571 had this wrong. Its range of
// `0.0` means it can never satisfy *its own half* of the gate — but the gate is an OR over the
@@ -1014,9 +1711,12 @@ pub(crate) fn queue(
subject: subject.0,
object: object.0,
fills,
named,
tries: 0,
})
}
pub(crate) mod watch;
#[cfg(test)]
mod tests;
+36 -16
View File
@@ -30,14 +30,32 @@ fn declared_signature(family: Family, variant: Variant) -> Vec<char> {
.slots
.iter()
.filter_map(|slot| match slot {
Slot::Attacker => variant.names_subject().then_some('s'),
Slot::Victim => variant.names_object().then_some('s'),
Slot::Spell | Slot::School | Slot::Power | Slot::Power2 => Some('s'),
Slot::Attacker => family.names_subject(variant).then_some('s'),
Slot::Victim => family.names_object(variant).then_some('s'),
Slot::Spell | Slot::School | Slot::Power | Slot::Power2 | Slot::Named => Some('s'),
Slot::Amount | Slot::Amount2 => Some('d'),
})
.collect()
}
/// The variants a family can actually be asked for. A [`Keying::Quad`] family answers all four;
/// a `Duo` one collapses to two distinct keys (both `…SELF*` variants give the `me` word), and a
/// `Single` family has exactly one. Sweeping the quad list over a Duo family would check the same
/// key twice and prove nothing extra — worse, it would let `every_family_has_the_three_core_keys`
/// demand keys that do not exist.
fn variants_of(family: Family) -> &'static [Variant] {
match family.keying {
Keying::Quad => &[
Variant::SelfOther,
Variant::OtherSelf,
Variant::OtherOther,
Variant::SelfSelf,
],
Keying::Duo { .. } => &[Variant::SelfOther, Variant::OtherOther],
Keying::Single => &[Variant::OtherOther],
}
}
/// **The gate on the one thing this module authors.** Every family × every variant: if the install
/// defines the key, our declared slot order must produce exactly the template's own `%s`/`%d`
/// sequence.
@@ -66,16 +84,10 @@ fn every_family_matches_the_shipped_template() {
.run(&String::from_utf8_lossy(&src))
.expect("GlobalStrings runs clean");
let variants = [
Variant::SelfOther,
Variant::OtherSelf,
Variant::OtherOther,
Variant::SelfSelf,
];
let mut checked = 0usize;
for &family in ALL_FAMILIES {
for variant in variants {
let key = format!("{}{}", family.stem, variant.suffix());
for &variant in variants_of(family) {
let key = family.key(variant);
let Some(template) = global_string(&script, &key) else {
continue;
};
@@ -91,13 +103,14 @@ fn every_family_matches_the_shipped_template() {
// A stem typo'd across the board would make every lookup miss and leave this at zero, which
// would otherwise pass silently.
assert!(
checked >= ALL_FAMILIES.len() * 3,
checked >= ALL_FAMILIES.len(),
"only {checked} keys resolved"
);
}
/// Every family defines the three variants that are not `…SELFSELF`. Those three are the ones the
/// reference's selectors always return a key for, so an absent one is a wrong stem, not a data
/// Every family defines the variants that are not `…SELFSELF` — for a `Quad` family the other
/// three, for a `Duo` one both of its keys, for a `Single` one its single key. Those are the ones
/// the reference's selectors always return a key for, so an absent one is a wrong stem, not a data
/// condition — the failure mode [`every_family_matches_the_shipped_template`] cannot see because
/// it skips what it cannot find. Skips without client data.
#[test]
@@ -113,8 +126,12 @@ fn every_family_has_the_three_core_keys() {
.expect("GlobalStrings runs clean");
for &family in ALL_FAMILIES {
for variant in [Variant::SelfOther, Variant::OtherSelf, Variant::OtherOther] {
let key = format!("{}{}", family.stem, variant.suffix());
let core: Vec<Variant> = match family.keying {
Keying::Quad => vec![Variant::SelfOther, Variant::OtherSelf, Variant::OtherOther],
_ => variants_of(family).to_vec(),
};
for variant in core {
let key = family.key(variant);
assert!(
global_string(&script, &key).is_some(),
"{key} is not a GlobalString — wrong stem?"
@@ -180,6 +197,8 @@ fn the_reported_sentences_read_correctly() {
amount: 120,
amount2: 40,
power2: Some(0),
named: "Copper Bar".into(),
trailers: None,
};
let line = |family, variant| compose_line(&script, family, variant, &fills).expect("composes");
@@ -382,6 +401,7 @@ fn an_unknown_endpoint_alone_does_not_drop_the_line() {
(1, a),
(2, b),
f.clone(),
Named::Ready,
)
};
assert!(
@@ -0,0 +1,355 @@
//! The combat-log lines that are **not packet-driven at all** — the two families §3 of wow-re's
//! `combat-log-chat-law.md` singles out, plus the pet-loyalty leaf.
//!
//! Everything else in [`super`] hangs off an SMSG arm. These three hang off *descriptor changes*,
//! exactly as the reference does:
//!
//! - **the death line** (`UNITDIES*` / `UNITDESTROYEDOTHER`) rides the unit-death reflex
//! `0x625190`, not a packet — there is no "X died" opcode in 1.12;
//! - **the aura lines** (`AURAADDED*`, `AURAREMOVED*`, `AURAAPPLICATIONADDED*`) ride the
//! `UNIT_FIELD_AURA` / `UNIT_FIELD_AURAAPPLICATIONS` change callbacks `0x604d00` / `0x604ea0`
//! through the bridges `0x612320`/`0x6123f0`/`0x612450`;
//! - **the loyalty line** rides the `UNIT_FIELD_BYTES_1` byte-1 callback (`0x5ff860` → `0x62d440`).
//!
//! Each is a slot diff against the last frame's snapshot, the same idiom
//! [`crate::creature_anim::arm_aura_state_fx`] uses for the aura state kits — and for the same
//! reason: the *edge* is the event, and a descriptor snapshot is all the wire gives us.
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use super::{
aura_gone_kind, classify, death_kind, in_range, periodic_kind, Family, Fills, Named,
PendingCombat, UnitClass, Variant,
};
use crate::net::{GuidIndex, ObjectStore, Reputations, SelfGuid};
use crate::target::ring::Factions;
use crate::ui_chat::{ChatEventKind, ChatLog};
use crate::ui_party::GroupState;
/// The classification inputs all three watchers share — the same six facts
/// [`crate::net::apply::combat_chat::ChatCtx`] carries, as a `SystemParam` so a watcher's own
/// parameter list stays about *its* diff.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct WatchCtx<'w> {
pub self_guid: Res<'w, SelfGuid>,
pub group: Res<'w, GroupState>,
pub index: Res<'w, GuidIndex>,
pub factions: Option<Res<'w, Factions>>,
pub reputations: Res<'w, Reputations>,
pub spells: Option<Res<'w, crate::ui_action::Spells>>,
}
impl WatchCtx<'_> {
fn classify(&self, guid: u64, stores: &Query<(Entity, &ObjectStore)>) -> UnitClass {
classify(
guid,
&self.self_guid,
Some(&self.group),
&self.index,
stores,
self.factions.as_deref(),
&self.reputations,
)
}
/// [`crate::net::apply::combat_chat::ChatCtx::spell_name`]'s law, shared: the per-spell gates
/// (`Attributes & 0x180`, an empty localized name) apply to every spell-driven line, and an
/// aura line is one.
fn spell_name(&self, spell_id: u32) -> Option<String> {
let Some(display) = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id)) else {
return Some(String::new());
};
const DO_NOT_DISPLAY_OR_LOG: u32 = 0x180;
if display.attributes & DO_NOT_DISPLAY_OR_LOG != 0 || display.name.is_empty() {
return None;
}
Some(display.name.clone())
}
/// The reference's ONE-SIDED range test — the shape 15 formatters run instead of the two-ended
/// gate (§5.2), the death and aura lines among them. One participant, one distance.
fn in_range(&self, guid: u64, class: UnitClass, poses: &Query<&Transform>) -> bool {
in_range(guid, class, &self.self_guid, &self.index, poses)
}
}
/// Queue one single-endpoint line, gated the way its formatter gates.
#[allow(clippy::too_many_arguments)] // the line's own facts, plus the two contexts the gate reads
fn queue_one(
log: &mut ChatLog,
ctx: &WatchCtx,
poses: &Query<&Transform>,
kind: ChatEventKind,
family: Family,
subject: (u64, UnitClass),
fills: Fills,
named: Named,
) {
if !ctx.in_range(subject.0, subject.1, poses) {
return;
}
log.push_combat(PendingCombat {
kind,
family,
variant: Variant::of(subject.1, UnitClass::Creature),
subject: subject.0,
object: 0,
fills,
named,
tries: 0,
});
}
/// `Spell.dbc` `Effect[0]` values that make a unit "destroyed" rather than "dead" — `0x62c320`'s
/// byte table, read through `UNIT_CREATED_BY_SPELL`. A totem, a summoned object, a guardian: the
/// things the world *made*, which are unmade rather than killed.
const DESTROYED_EFFECTS: [u32; 10] = [50, 74, 87, 88, 89, 90, 104, 105, 106, 107];
/// The unit-death reflex's chat line (`0x625190` → `0x62c160`).
///
/// **The edge is the event.** The wire never says "this unit died"; it says the unit's health is
/// zero, or sets its dead flag, in an ordinary descriptor update — so the line comes off a diff
/// against the previous frame, and a unit that streams in *already* dead produces none (which is
/// what the reference does too: its reflex fires on the transition, not on the state).
///
/// **The XP-award suppression is deliberately not modelled**, and is named rather than dropped:
/// `[CGUnit+0xc58] bit 3`, set only in the `SMSG_LOG_XPGAIN` chain, makes the death reflex emit the
/// award line *instead of* the plain death line (§5.9). wow-re found no clearing site for the bit,
/// so whether it survives a second death is UNSETTLED there — and modelling an unsettled latch is
/// how you get a line that silently stops appearing. The visible consequence today is one extra
/// "%s dies." beside the XP line on a kill that awards experience.
pub(crate) fn death_lines(
ctx: WatchCtx,
stores: Query<(Entity, &ObjectStore)>,
guids: Query<&crate::net::Guid>,
changed: Query<Entity, Changed<ObjectStore>>,
poses: Query<&Transform>,
mut log: ResMut<ChatLog>,
mut was_dead: Local<EntityHashMap<bool>>,
) {
for entity in changed.iter() {
let Ok((_, store)) = stores.get(entity) else {
continue;
};
let dead = store.0.unit_is_dead();
let seen = was_dead.insert(entity, dead);
// No previous frame for this entity: it streamed in at whatever state it is in, which is
// not a death.
let Some(false) = seen else { continue };
if !dead {
continue;
}
let Ok(&crate::net::Guid(guid)) = guids.get(entity) else {
continue;
};
let class = ctx.classify(guid, &stores);
// A summoned thing is *destroyed*; a living one *dies*. The test is the creating spell's
// first effect, which is why it needs the spell catalog at all.
let destroyed = store
.0
.unit_created_by_spell()
.filter(|&id| id != 0)
.and_then(|id| ctx.spells.as_ref()?.catalog.get(id))
.is_some_and(|d| DESTROYED_EFFECTS.contains(&d.effects[0]));
let family = match (class, destroyed) {
(UnitClass::Me, _) => super::UNITDIES,
(_, true) => super::UNITDESTROYEDOTHER,
(_, false) => super::UNITDIES,
};
queue_one(
&mut log,
&ctx,
&poses,
death_kind(class),
family,
(guid, class),
Fills::default(),
Named::Ready,
);
}
was_dead.retain(|e, _| stores.contains(*e));
}
/// The aura lines (`0x62b480` / `0x62b800`).
///
/// **Three edges out of one diff**, because the reference reads two descriptor arrays through two
/// callbacks and words three sentences:
///
/// - a slot that filled → `AURAADDED{SELF,OTHER}{HARMFUL,HELPFUL}`, at the PERIODIC chat types
/// (harmful takes the `…_DAMAGE` row, helpful the `…_BUFFS` one) — not the `AURA_GONE` block,
/// which is the departure's alone;
/// - a slot that emptied → `AURAREMOVED{SELF,OTHER}`, at `0x41`/`0x42`/`0x43`;
/// - a slot whose stack count ROSE → `AURAAPPLICATIONADDED*`, and only when the spell can stack at
/// all. We approximate the reference's `SpellRec->StackAmount > 1` gate with the observed count:
/// a stack that rose past one is a spell that stacks. It differs only for a first application of
/// a stacking aura, which the reference words as a plain `AURAADDED*` — and so do we, because a
/// 0→1 step is a *fill*, not an increase.
///
/// **HARMFUL is the SLOT INDEX, not a flag** (§4.4, three byte sites): slots `0x20`–`0x2f` are
/// harmful, every other slot helpful. `UNIT_FIELD_AURAFLAGS` is only an occupancy predicate — using
/// it here would be the wrong mechanism that happens to agree most of the time.
pub(crate) fn aura_lines(
ctx: WatchCtx,
stores: Query<(Entity, &ObjectStore)>,
guids: Query<&crate::net::Guid>,
changed: Query<Entity, Changed<ObjectStore>>,
poses: Query<&Transform>,
mut log: ResMut<ChatLog>,
// Per entity, per slot: `(spell id, stacks)` as of the last frame. Slot-keyed rather than
// spell-keyed because the sentence's harmful/helpful half IS the slot.
mut seen: Local<EntityHashMap<Vec<(u8, u32, u8)>>>,
) {
/// The first harmful aura slot — `0x20`. Slots below it are helpful (§4.4).
const FIRST_HARMFUL_SLOT: u8 = 0x20;
for entity in changed.iter() {
let Ok((_, store)) = stores.get(entity) else {
continue;
};
let now: Vec<(u8, u32, u8)> = store
.0
.unit_auras()
.map(|a| (a.slot, a.spell_id, a.stacks))
.collect();
let prev = seen.insert(entity, now.clone());
// First sight of this unit: its standing auras are not arrivals.
let Some(prev) = prev else { continue };
let Ok(&crate::net::Guid(guid)) = guids.get(entity) else {
continue;
};
let class = ctx.classify(guid, &stores);
let harmful = |slot: u8| slot >= FIRST_HARMFUL_SLOT;
let find =
|list: &[(u8, u32, u8)], slot: u8| list.iter().find(|(s, _, _)| *s == slot).copied();
for &(slot, spell_id, stacks) in &now {
let Some(spell) = ctx.spell_name(spell_id) else {
continue;
};
let fills = |amount: i64| Fills {
spell: spell.clone(),
amount,
..Default::default()
};
match find(&prev, slot) {
// The slot held a DIFFERENT spell, or nothing: this aura arrived.
Some((_, was, _)) if was == spell_id => {
let Some((_, _, before)) = find(&prev, slot) else {
continue;
};
if stacks <= before || stacks <= 1 {
continue;
}
let Some(kind) = periodic_kind(class, !harmful(slot)) else {
continue;
};
queue_one(
&mut log,
&ctx,
&poses,
kind,
if harmful(slot) {
super::AURAAPPLICATIONADDED_HARMFUL
} else {
super::AURAAPPLICATIONADDED_HELPFUL
},
(guid, class),
fills(i64::from(stacks)),
Named::Ready,
);
}
_ => {
let Some(kind) = periodic_kind(class, !harmful(slot)) else {
continue;
};
queue_one(
&mut log,
&ctx,
&poses,
kind,
if harmful(slot) {
super::AURAADDED_HARMFUL
} else {
super::AURAADDED_HELPFUL
},
(guid, class),
fills(0),
Named::Ready,
);
}
}
}
// Departures: a slot that held a spell and no longer holds THAT spell.
for &(slot, spell_id, _) in &prev {
if find(&now, slot).is_some_and(|(_, id, _)| id == spell_id) {
continue;
}
let Some(spell) = ctx.spell_name(spell_id) else {
continue;
};
queue_one(
&mut log,
&ctx,
&poses,
aura_gone_kind(class),
super::AURAREMOVED,
(guid, class),
Fills {
spell,
..Default::default()
},
Named::Ready,
);
}
}
seen.retain(|e, _| stores.contains(*e));
}
/// The pet-loyalty line (`0x5ff860` → `0x62d440`) — `UNIT_FIELD_BYTES_1` byte 1 moving.
///
/// **Both legs require the pet's owner to be you**, which is the reference's own guard, and the
/// chat type is the literal `0x19` `COMBAT_MISC_INFO`. The reference emits the localized text as
/// the single `%s` argument of a bare `"%s"`; a no-slot family reaches the same sentence.
pub(crate) fn pet_loyalty_lines(
ctx: WatchCtx,
stores: Query<(Entity, &ObjectStore)>,
guids: Query<&crate::net::Guid>,
changed: Query<Entity, Changed<ObjectStore>>,
mut log: ResMut<ChatLog>,
mut seen: Local<EntityHashMap<u8>>,
) {
for entity in changed.iter() {
let Ok((_, store)) = stores.get(entity) else {
continue;
};
let level = store.0.unit_loyalty_level();
let Some(before) = seen.insert(entity, level) else {
continue;
};
if level == before || level == 0 || before == 0 {
continue;
}
let Ok(&crate::net::Guid(guid)) = guids.get(entity) else {
continue;
};
if ctx.classify(guid, &stores) != UnitClass::MyPet {
continue;
}
log.push_combat(PendingCombat {
kind: ChatEventKind::CombatMiscInfo,
family: if level > before {
super::PET_LOYALTY_GAIN
} else {
super::PET_LOYALTY_LOSS
},
variant: Variant::OtherOther,
subject: 0,
object: 0,
fills: Fills::default(),
named: Named::Ready,
tries: 0,
});
}
seen.retain(|e, _| stores.contains(*e));
}
+77 -7
View File
@@ -69,11 +69,16 @@ pub(crate) enum ChatEventKind {
BgSystemNeutral,
BgSystemAlliance,
BgSystemHorde,
// ── the combat log (B297) ──────────────────────────────────────────────────────────────
// The `COMBAT_*`/`SPELL_*` block 0288 §3 held back as "the combat-log content arc". All 44
// kinds are produced by [`super::combat`]; the rest of the reference's block (the death,
// dispel, enchant, tradeskill and cast-failure leaves) has no decoded wire source yet and is
// deliberately still absent — a kind with no producer is dead weight the sweeps cannot police.
// ── the combat log (B297; completed by 1703) ───────────────────────────────────────────
// The `COMBAT_*`/`SPELL_*` block 0288 §3 held back as "the combat-log content arc". 1571 shipped
// the damage/heal/power 44; 1703 added the eleven leaves it named as "deliberately out because
// their wire sources are undecoded" — the death pair, the three AURA_GONE rows, MISC_INFO,
// TRADESKILLS, ITEM_ENCHANTMENTS, BREAK_AURA, FACTION_CHANGE and FAILED_LOCALPLAYER. Every kind
// here has a producer in [`super::combat`]; a kind with none is dead weight the sweeps cannot
// police, which is why the enum grew only as the arms did.
/// `0x19` — the reference's catch-all combat line: `DURABILITYDAMAGE_DEATH`, `SPELLDISMISSPET*`,
/// `PET_LOYALTY_*`, `SPELLHAPPINESSDRAIN*`, and the "string not found" warning.
CombatMiscInfo,
CombatSelfHits,
CombatSelfMisses,
CombatPetHits,
@@ -90,6 +95,10 @@ pub(crate) enum ChatEventKind {
CombatCreatureVsPartyMisses,
CombatCreatureVsCreatureHits,
CombatCreatureVsCreatureMisses,
/// `0x2b` — a death whose victim classifies 0..5 (you, yours, a friendly player or their pet).
CombatFriendlyDeath,
/// `0x2c` — every other death: a hostile player, a creature, an unresolvable victim.
CombatHostileDeath,
SpellSelfDamage,
SpellSelfBuff,
SpellPetDamage,
@@ -106,8 +115,20 @@ pub(crate) enum ChatEventKind {
SpellCreatureVsPartyBuff,
SpellCreatureVsCreatureDamage,
SpellCreatureVsCreatureBuff,
/// `0x3e` — the item a cast produced: `TRADESKILL_LOG_*` and `FEEDPET_LOG_*`.
SpellTradeskills,
SpellDamageShieldsOnSelf,
SpellDamageShieldsOnOthers,
/// `0x41` — an aura leaving you or your pet (`AURAREMOVED*`).
SpellAuraGoneSelf,
/// `0x42` — an aura leaving a party member or their pet.
SpellAuraGoneParty,
/// `0x43` — an aura leaving anyone else.
SpellAuraGoneOther,
/// `0x44` — an enchant landing on or fading from an item (`ITEMENCHANTMENT*`).
SpellItemEnchantments,
/// `0x45` — an aura dispelled or stolen (`AURADISPEL*`, `AURASTOLEN*`).
SpellBreakAura,
SpellPeriodicSelfDamage,
SpellPeriodicSelfBuffs,
SpellPeriodicPartyDamage,
@@ -118,6 +139,10 @@ pub(crate) enum ChatEventKind {
SpellPeriodicHostilePlayerBuffs,
SpellPeriodicCreatureDamage,
SpellPeriodicCreatureBuffs,
/// `0x50` — your own cast that failed (`SPELLFAIL{CAST,PERFORM}SELF`).
SpellFailedLocalPlayer,
/// `0x55` — a reputation delta (`FACTION_STANDING_INCREASED`/`_DECREASED`).
CombatFactionChange,
}
impl ChatEventKind {
@@ -134,7 +159,8 @@ impl ChatEventKind {
use ChatEventKind as K;
matches!(
self,
K::CombatSelfHits
K::CombatMiscInfo
| K::CombatSelfHits
| K::CombatSelfMisses
| K::CombatPetHits
| K::CombatPetMisses
@@ -150,6 +176,8 @@ impl ChatEventKind {
| K::CombatCreatureVsPartyMisses
| K::CombatCreatureVsCreatureHits
| K::CombatCreatureVsCreatureMisses
| K::CombatFriendlyDeath
| K::CombatHostileDeath
| K::SpellSelfDamage
| K::SpellSelfBuff
| K::SpellPetDamage
@@ -166,8 +194,14 @@ impl ChatEventKind {
| K::SpellCreatureVsPartyBuff
| K::SpellCreatureVsCreatureDamage
| K::SpellCreatureVsCreatureBuff
| K::SpellTradeskills
| K::SpellDamageShieldsOnSelf
| K::SpellDamageShieldsOnOthers
| K::SpellAuraGoneSelf
| K::SpellAuraGoneParty
| K::SpellAuraGoneOther
| K::SpellItemEnchantments
| K::SpellBreakAura
| K::SpellPeriodicSelfDamage
| K::SpellPeriodicSelfBuffs
| K::SpellPeriodicPartyDamage
@@ -178,6 +212,8 @@ impl ChatEventKind {
| K::SpellPeriodicHostilePlayerBuffs
| K::SpellPeriodicCreatureDamage
| K::SpellPeriodicCreatureBuffs
| K::SpellFailedLocalPlayer
| K::CombatFactionChange
)
}
@@ -229,6 +265,7 @@ impl ChatEventKind {
K::BgSystemNeutral,
K::BgSystemAlliance,
K::BgSystemHorde,
K::CombatMiscInfo,
K::CombatSelfHits,
K::CombatSelfMisses,
K::CombatPetHits,
@@ -245,6 +282,8 @@ impl ChatEventKind {
K::CombatCreatureVsPartyMisses,
K::CombatCreatureVsCreatureHits,
K::CombatCreatureVsCreatureMisses,
K::CombatFriendlyDeath,
K::CombatHostileDeath,
K::SpellSelfDamage,
K::SpellSelfBuff,
K::SpellPetDamage,
@@ -261,8 +300,14 @@ impl ChatEventKind {
K::SpellCreatureVsPartyBuff,
K::SpellCreatureVsCreatureDamage,
K::SpellCreatureVsCreatureBuff,
K::SpellTradeskills,
K::SpellDamageShieldsOnSelf,
K::SpellDamageShieldsOnOthers,
K::SpellAuraGoneSelf,
K::SpellAuraGoneParty,
K::SpellAuraGoneOther,
K::SpellItemEnchantments,
K::SpellBreakAura,
K::SpellPeriodicSelfDamage,
K::SpellPeriodicSelfBuffs,
K::SpellPeriodicPartyDamage,
@@ -273,6 +318,8 @@ impl ChatEventKind {
K::SpellPeriodicHostilePlayerBuffs,
K::SpellPeriodicCreatureDamage,
K::SpellPeriodicCreatureBuffs,
K::SpellFailedLocalPlayer,
K::CombatFactionChange,
]
};
}
@@ -448,6 +495,7 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str {
K::BgSystemNeutral => "CHAT_MSG_BG_SYSTEM_NEUTRAL",
K::BgSystemAlliance => "CHAT_MSG_BG_SYSTEM_ALLIANCE",
K::BgSystemHorde => "CHAT_MSG_BG_SYSTEM_HORDE",
K::CombatMiscInfo => "CHAT_MSG_COMBAT_MISC_INFO",
K::CombatSelfHits => "CHAT_MSG_COMBAT_SELF_HITS",
K::CombatSelfMisses => "CHAT_MSG_COMBAT_SELF_MISSES",
K::CombatPetHits => "CHAT_MSG_COMBAT_PET_HITS",
@@ -464,6 +512,8 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str {
K::CombatCreatureVsPartyMisses => "CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES",
K::CombatCreatureVsCreatureHits => "CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS",
K::CombatCreatureVsCreatureMisses => "CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_MISSES",
K::CombatFriendlyDeath => "CHAT_MSG_COMBAT_FRIENDLY_DEATH",
K::CombatHostileDeath => "CHAT_MSG_COMBAT_HOSTILE_DEATH",
K::SpellSelfDamage => "CHAT_MSG_SPELL_SELF_DAMAGE",
K::SpellSelfBuff => "CHAT_MSG_SPELL_SELF_BUFF",
K::SpellPetDamage => "CHAT_MSG_SPELL_PET_DAMAGE",
@@ -480,8 +530,14 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str {
K::SpellCreatureVsPartyBuff => "CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF",
K::SpellCreatureVsCreatureDamage => "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
K::SpellCreatureVsCreatureBuff => "CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF",
K::SpellTradeskills => "CHAT_MSG_SPELL_TRADESKILLS",
K::SpellDamageShieldsOnSelf => "CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF",
K::SpellDamageShieldsOnOthers => "CHAT_MSG_SPELL_DAMAGESHIELDS_ON_OTHERS",
K::SpellAuraGoneSelf => "CHAT_MSG_SPELL_AURA_GONE_SELF",
K::SpellAuraGoneParty => "CHAT_MSG_SPELL_AURA_GONE_PARTY",
K::SpellAuraGoneOther => "CHAT_MSG_SPELL_AURA_GONE_OTHER",
K::SpellItemEnchantments => "CHAT_MSG_SPELL_ITEM_ENCHANTMENTS",
K::SpellBreakAura => "CHAT_MSG_SPELL_BREAK_AURA",
K::SpellPeriodicSelfDamage => "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
K::SpellPeriodicSelfBuffs => "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
K::SpellPeriodicPartyDamage => "CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE",
@@ -492,6 +548,8 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str {
K::SpellPeriodicHostilePlayerBuffs => "CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS",
K::SpellPeriodicCreatureDamage => "CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE",
K::SpellPeriodicCreatureBuffs => "CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS",
K::SpellFailedLocalPlayer => "CHAT_MSG_SPELL_FAILED_LOCALPLAYER",
K::CombatFactionChange => "CHAT_MSG_COMBAT_FACTION_CHANGE",
}
}
@@ -680,6 +738,9 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
K::BgSystemNeutral => [255, 120, 10],
K::BgSystemAlliance => [0, 174, 239],
K::BgSystemHorde => [255, 0, 0],
// The two `8080ff` rows, both from the reference's table: the catch-all combat line and
// the reputation line share a colour and nothing else.
K::CombatMiscInfo | K::CombatFactionChange => [128, 128, 255],
// ── the combat log ───────────────────────────────────────────────────────────────
// The shipped defaults are overwhelmingly plain white; the three that are not are the
// ones about YOU being hit, and they are the reason the block is grouped by colour
@@ -729,7 +790,16 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] {
| K::SpellPeriodicHostilePlayerDamage
| K::SpellPeriodicHostilePlayerBuffs
| K::SpellPeriodicCreatureDamage
| K::SpellPeriodicCreatureBuffs => [255, 255, 255],
| K::SpellPeriodicCreatureBuffs
| K::CombatFriendlyDeath
| K::CombatHostileDeath
| K::SpellTradeskills
| K::SpellAuraGoneSelf
| K::SpellAuraGoneParty
| K::SpellAuraGoneOther
| K::SpellItemEnchantments
| K::SpellBreakAura
| K::SpellFailedLocalPlayer => [255, 255, 255],
}
}
+25
View File
@@ -636,6 +636,10 @@ pub(super) fn feed_chat(
states: Res<crate::world_state::WorldStates>,
// The language gate (B262): the word pool + this character's fluency + the GM bit.
langs: Res<super::language::ChatLanguages>,
// The combat log's item-name seam (1703): the four families whose sentence names an ITEM
// (`TRADESKILL_LOG`, `FEEDPET_LOG`, `ITEMENCHANTMENT*`, `SPELLDURABILITYDAMAGE`) resolve it
// here, through the same ask-once cache the reference's own deferred queue re-runs against.
mut items: ResMut<crate::items::Items>,
) {
let Some(mut script) = script else {
return;
@@ -1053,6 +1057,27 @@ pub(super) fn feed_chat(
None => wait = true,
}
}
// The `Named` slot, on the same terms as the endpoints. A cached NEGATIVE on an
// item (the server does not know the entry) is not something to wait on — it would
// pin the line for the whole `tries` budget and then drop it — so it composes with
// whatever the arm left in `named`, one level down the reference's own
// `"UKNOWNOBJECT"` degrade.
match line.named {
super::combat::Named::Ready => {}
super::combat::Named::Item(entry) => {
match items.template(entry, 0, &commands).map(|t| t.name.clone()) {
Some(name) => line.fills.named = name,
None if !items.template_answered_unknown(entry) => wait = true,
None => {}
}
}
super::combat::Named::Unit(guid) => {
match super::combat::object_name(guid, &mut names, &commands) {
Some(name) => line.fills.named = name,
None => wait = true,
}
}
}
if wait {
if line.tries < NAME_MAX_TRIES {
line.tries += 1;
+14 -5
View File
@@ -70,12 +70,14 @@ impl Default for ChatWindows {
// they simply land in no window until a player registers them. A damage meter
// reads them either way.
//
// Five names in that block have no producer yet and so no group here:
// COMBAT_MISC_INFO, COMBAT_FRIENDLY_DEATH, COMBAT_HOSTILE_DEATH,
// COMBAT_FACTION_CHANGE, SPELL_TRADESKILLS, SPELL_AURA_GONE_SELF,
// SPELL_ITEM_ENCHANTMENTS and SPELL_BREAK_AURA — the leaves of the block whose
// wire sources are still undecoded.
// The block is now registered WHOLE (1703): the eight names 1571 had to leave
// out — COMBAT_MISC_INFO, the death pair, SPELL_TRADESKILLS, SPELL_AURA_GONE_SELF,
// SPELL_ITEM_ENCHANTMENTS, SPELL_BREAK_AURA and COMBAT_FACTION_CHANGE — all have
// producers now and sit in the shipped file's own order. `SPELL_FAILED_LOCALPLAYER`
// is deliberately absent: the reference's own chat-cache registers it in NO window,
// so a failed cast lands in the log only for a player who adds it.
vec![
ChatGroup::Own(ChatEventKind::CombatMiscInfo),
ChatGroup::Own(ChatEventKind::CombatSelfHits),
ChatGroup::Own(ChatEventKind::CombatSelfMisses),
ChatGroup::Own(ChatEventKind::CombatPetHits),
@@ -84,6 +86,8 @@ impl Default for ChatWindows {
ChatGroup::Own(ChatEventKind::CombatHostilePlayerMisses),
ChatGroup::Own(ChatEventKind::CombatCreatureVsSelfHits),
ChatGroup::Own(ChatEventKind::CombatCreatureVsSelfMisses),
ChatGroup::Own(ChatEventKind::CombatFriendlyDeath),
ChatGroup::Own(ChatEventKind::CombatHostileDeath),
// XP joined with the ding arc (0304) and honour with the honor arc (1512);
// the reference registers the two one line apart (ChatFrame.lua l.2428-2429).
ChatGroup::Own(ChatEventKind::CombatXpGain),
@@ -96,13 +100,18 @@ impl Default for ChatWindows {
ChatGroup::Own(ChatEventKind::SpellHostilePlayerBuff),
ChatGroup::Own(ChatEventKind::SpellCreatureVsSelfDamage),
ChatGroup::Own(ChatEventKind::SpellCreatureVsSelfBuff),
ChatGroup::Own(ChatEventKind::SpellTradeskills),
ChatGroup::Own(ChatEventKind::SpellDamageShieldsOnSelf),
ChatGroup::Own(ChatEventKind::SpellAuraGoneSelf),
ChatGroup::Own(ChatEventKind::SpellItemEnchantments),
ChatGroup::Own(ChatEventKind::SpellBreakAura),
ChatGroup::Own(ChatEventKind::SpellPeriodicSelfDamage),
ChatGroup::Own(ChatEventKind::SpellPeriodicSelfBuffs),
ChatGroup::Own(ChatEventKind::SpellPeriodicHostilePlayerDamage),
ChatGroup::Own(ChatEventKind::SpellPeriodicHostilePlayerBuffs),
ChatGroup::Own(ChatEventKind::SpellPeriodicCreatureDamage),
ChatGroup::Own(ChatEventKind::SpellPeriodicCreatureBuffs),
ChatGroup::Own(ChatEventKind::CombatFactionChange),
ChatGroup::Money,
],
],
+138
View File
@@ -1066,6 +1066,8 @@ fn combat_log_battery(log: &mut super::feed::ChatLog) {
amount,
amount2: amount / 3,
power2: power,
named: "Copper Bar".into(),
trailers: None,
};
let line = |kind: K, family: Family, variant: Variant, f: Fills| PendingCombat {
kind,
@@ -1073,6 +1075,7 @@ fn combat_log_battery(log: &mut super::feed::ChatLog) {
variant,
subject: 0,
object: 0,
named: super::combat::Named::Ready,
fills: f,
tries: 0,
};
@@ -1201,6 +1204,141 @@ fn combat_log_battery(log: &mut super::feed::ChatLog) {
Variant::SelfOther,
fills(150, None, Some(0)),
),
// ── the completeness block (1703) ────────────────────────────────────────────────
// One line per family that was absent until now, in the order a player meets them.
// The `Named` slot's text is already in the fills, so these drive the same drain the
// wire does with the same composer — only the resolve hops are short-circuited.
line(
K::CombatHostileDeath,
combat::UNITDIES,
Variant::OtherOther,
fills(0, None, None),
),
line(
K::CombatHostileDeath,
combat::SELFKILLOTHER,
Variant::OtherOther,
fills(0, None, None),
),
line(
K::CombatFriendlyDeath,
combat::UNITDESTROYEDOTHER,
Variant::OtherOther,
fills(0, None, None),
),
line(
K::SpellPeriodicSelfDamage,
combat::AURAADDED_HARMFUL,
Variant::SelfOther,
fills(0, None, None),
),
line(
K::SpellPeriodicSelfBuffs,
combat::AURAADDED_HELPFUL,
Variant::SelfOther,
fills(0, None, None),
),
line(
K::SpellPeriodicSelfDamage,
combat::AURAAPPLICATIONADDED_HARMFUL,
Variant::SelfOther,
fills(3, None, None),
),
line(
K::SpellAuraGoneSelf,
combat::AURAREMOVED,
Variant::SelfOther,
fills(0, None, None),
),
line(
K::SpellBreakAura,
combat::AURADISPEL,
Variant::SelfOther,
fills(0, None, None),
),
line(
K::SpellItemEnchantments,
combat::ITEMENCHANTMENTADD,
Variant::SelfSelf,
fills(0, None, None),
),
line(
K::SpellTradeskills,
combat::TRADESKILL_LOG,
Variant::SelfOther,
fills(0, None, None),
),
line(
K::SpellSelfDamage,
combat::SPELLINTERRUPT,
Variant::SelfOther,
fills(0, None, None),
),
line(
K::SpellSelfDamage,
combat::SPELLEXTRAATTACKS_SINGULAR,
Variant::SelfOther,
fills(1, None, None),
),
line(
K::SpellSelfDamage,
combat::SPELLSPLITDAMAGE,
Variant::SelfOther,
fills(66, None, None),
),
line(
K::SpellSelfDamage,
combat::PROCRESIST,
Variant::SelfOther,
fills(0, None, None),
),
line(
K::CombatCreatureVsSelfHits,
combat::VSENV_FALLING,
Variant::SelfOther,
fills(94, None, None),
),
line(
K::CombatMiscInfo,
combat::DURABILITYDAMAGE_DEATH,
Variant::OtherOther,
fills(0, None, None),
),
// The two families whose `Named` slot is not an item name: a faction and a failure
// reason. Overridden here so the battery reads as the sentences a player will see.
line(
K::CombatFactionChange,
combat::FACTION_STANDING_INCREASED,
Variant::OtherOther,
Fills {
named: "Stormwind".into(),
..fills(250, None, None)
},
),
line(
K::SpellFailedLocalPlayer,
combat::SPELLFAILCAST,
Variant::SelfOther,
Fills {
named: "Not enough mana".into(),
..fills(0, None, None)
},
),
// The trailer pass, on the one family that can show all six.
line(
K::CombatSelfHits,
combat::COMBATHIT,
Variant::SelfOther,
Fills {
trailers: Some(combat::Trailers {
absorbed: 12,
resisted: 7,
blocked: 30,
hit_info: 0x4000,
}),
..fills(210, None, None)
},
),
];
for l in battery {
log.push_combat(l);
+47 -1
View File
@@ -942,7 +942,7 @@ fn every_kind_is_in_all() {
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), before, "a kind is listed twice in ALL");
assert_eq!(before, 81, "81 kinds — update this when the kind set grows");
assert_eq!(before, 92, "92 kinds — update this when the kind set grows");
}
#[test]
@@ -1815,6 +1815,52 @@ fn an_addon_sees_the_combat_log_line_it_registers_for() {
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// **Every combat-log kind reaches an addon that registers it**, not just the one B297 named.
///
/// [`an_addon_sees_the_combat_log_line_it_registers_for`] pins the shape on `SPELL_SELF_DAMAGE`;
/// this sweeps the whole block, which is what makes 1703's eleven new types a *fact* rather than a
/// hope — a kind whose name is misspelled in `event_name`, or that the router drops, fires nothing
/// and would otherwise be found by a player's damage meter months later.
#[test]
fn every_combat_log_kind_reaches_an_addon() {
for kind in K::ALL.iter().copied().filter(|k| k.is_combat_log()) {
let name = super::event::event_name(kind);
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(SPY).unwrap();
s.run(&format!(r#"BenillaChatSpy:RegisterEvent("{name}")"#))
.unwrap();
let line = format!("a line for {name}");
super::frames::route(
&mut s,
&mut windows,
&ChatEvent::text_only(kind, line.clone()),
);
assert_eq!(
s.eval::<String>("return SpyEvent").unwrap(),
name,
"{name} did not fire under its own name"
);
assert_eq!(
s.eval::<i64>("return SpyN").unwrap(),
1,
"{name} fired more than once"
);
let seen: String = s.eval("return SpyLine").unwrap();
assert!(
seen.starts_with(&format!("{line}|")),
"{name}: arg1 must be the whole sentence — got {seen:?}"
);
assert!(
s.errors().is_empty(),
"{name} handler errors: {:?}",
s.errors()
);
}
}
/// The combat block is **verbatim**: the composer adds `arg1` and nothing else — no `[Name]` link,
/// no `<AFK>` prefix, no language header. The reference says so by prefix
/// (`ChatFrame_OnEvent` l.1397/1399, two arms that only `AddMessage(arg1, …)`), and the failure
@@ -144,7 +144,7 @@ fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
local _, hmax = hb:GetMinMaxValues()
local _, mmax = mb:GetMinMaxValues()
return PetFrame:IsVisible()
and PetFrameTextureFrameName:GetText() == "Grimjaw"
and PetName:GetText() == "Grimjaw"
and hb:GetValue() == 72 and hmax == 100
and mb:GetValue() == 45 and mmax == 80 and mb:IsVisible()
"#,
@@ -242,12 +242,12 @@ fn the_attack_overlay_follows_its_own_two_events() {
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s
.eval::<bool>("return PetFrameAttackMode:IsVisible()")
.eval::<bool>("return PetAttackModeTexture:IsVisible()")
.unwrap());
s.fire_event("PET_ATTACK_START", vec![]);
assert!(s
.eval::<bool>("return PetFrameAttackMode:IsVisible()")
.eval::<bool>("return PetAttackModeTexture:IsVisible()")
.unwrap());
// The ref's OnUpdate ramps the overlay's tint alpha down from 1 on the opening (sign −1) leg.
@@ -269,7 +269,7 @@ fn the_attack_overlay_follows_its_own_two_events() {
s.fire_event("PET_ATTACK_STOP", vec![]);
assert!(!s
.eval::<bool>("return PetFrameAttackMode:IsVisible()")
.eval::<bool>("return PetAttackModeTexture:IsVisible()")
.unwrap());
}
@@ -795,7 +795,7 @@ fn every_texture_frame_outranks_its_status_bars() {
let Some(base) = texture_frame.strip_suffix("TextureFrame") else {
continue;
};
for suffix in ["HealthBar", "PowerBar", "ManaBar"] {
for suffix in ["HealthBar", "ManaBar", "ManaBar"] {
let bar = format!("{base}{suffix}");
let Some(&(bar_strata, bar_level)) = level_of.get(&bar) else {
continue;
@@ -245,14 +245,18 @@ fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The tint the bare call was there to apply (ref-GameTooltip.lua l.79-82's two colours).
// Compared as f32: the colours make a round trip through the arena's f32 storage.
// The tint the bare call was there to apply (ref-GameTooltip.lua l.79-82's two colours),
// QUANTIZED: the reference's backdrop colour field is a packed `0xAARRGGBB` byte quad and the
// setter converts `×255 + 0.5` through `__ftol` (wow-re `numeric-arg-coercion-law.md` Q4), so
// `0.09` stores as 23 and reads back as `23/255`. This used to compare against `0.09` exactly,
// which was our lossless `[f32; 4]` showing through a store the client cannot make.
let q = |x: f32| f32::from((x * 255.0 + 0.5) as u8) / 255.0;
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return MyTip:GetBackdropColor()")
.unwrap();
assert_eq!(
(r, g, b),
(0.09, 0.09, 0.19),
(q(0.09), q(0.09), q(0.19)),
"GameTooltip_OnLoad tinted the plate — TOOLTIP_DEFAULT_BACKGROUND_COLOR"
);
@@ -209,12 +209,19 @@ fn raid_class_colors_is_the_references_own_nine() {
);
}
/// **The four font-PATH globals** (ref `Fonts.xml` l.4-7), which this file quoted around for a long
/// time — it took the colour block at l.8-19 and skipped the four lines above it.
/// **Every global `Fonts.xml` l.4-20 assigns** — the whole block, not the names someone noticed
/// were missing (1718).
///
/// `STANDARD_TEXT_FONT` is why they matter: `Dewdrop-2.0` calls
/// `button.text:SetFont(STANDARD_TEXT_FONT, height)` **unguarded** at two of its three sites, so a
/// nil global is a failed `SetFont` on every menu button the Ace2 ecosystem draws.
/// The four font-PATH globals came first (l.4-7): this file had taken the colour block at l.8-19
/// and skipped the four lines above it. `STANDARD_TEXT_FONT` is why they matter — `Dewdrop-2.0`
/// calls `button.text:SetFont(STANDARD_TEXT_FONT, height)` **unguarded** at two of its three
/// sites, so a nil global is a failed `SetFont` on every menu button the Ace2 ecosystem draws.
///
/// Then the same class struck INSIDE the block this test was standing next to:
/// `LIGHTYELLOW_FONT_COLOR_CODE` (l.13) was absent, with `Fonts.xml`'s own comments skipping from
/// `-- l.12` to `-- l.14`, and aux-addon — which concatenates it at file scope — died on its first
/// line. This test could not catch it, because it enumerated the four names the previous round had
/// found rather than the block those four came out of. It now enumerates the block.
#[test]
fn the_font_path_globals_are_the_references_own_four() {
let mut s = UiScript::new().unwrap();
@@ -250,4 +257,46 @@ fn the_font_path_globals_are_the_references_own_four() {
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
// ── The WHOLE block, not the names someone noticed were missing (1718) ──────────────────
//
// This test was written because the colour block was transcribed and the four font-path lines
// above it were dropped. It then enumerated those four — and `LIGHTYELLOW_FONT_COLOR_CODE`,
// l.13, sat missing INSIDE the colour block the whole time, with the comments in `Fonts.xml`
// skipping from `-- l.12` to `-- l.14` where it should have been. aux-addon concatenates it at
// file scope and died on its own first line.
//
// A list of "the names we found absent" cannot catch the next absent name. This is the
// reference's `Fonts.xml` l.4-20 in full, so the next dropped line fails here instead of in an
// addon: every global that block assigns, and nothing else.
for name in [
"STANDARD_TEXT_FONT", // l.4
"UNIT_NAME_FONT", // l.5
"DAMAGE_TEXT_FONT", // l.6
"NAMEPLATE_FONT", // l.7
"NORMAL_FONT_COLOR_CODE", // l.8
"HIGHLIGHT_FONT_COLOR_CODE", // l.9
"RED_FONT_COLOR_CODE", // l.10
"GREEN_FONT_COLOR_CODE", // l.11
"GRAY_FONT_COLOR_CODE", // l.12
"LIGHTYELLOW_FONT_COLOR_CODE", // l.13
"FONT_COLOR_CODE_CLOSE", // l.14
"NORMAL_FONT_COLOR", // l.15
"HIGHLIGHT_FONT_COLOR", // l.16
"GRAY_FONT_COLOR", // l.17
"GREEN_FONT_COLOR", // l.18
"RED_FONT_COLOR", // l.19
"PASSIVE_SPELL_FONT_COLOR", // l.20
] {
assert!(
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
"{name} — ref Fonts.xml assigns it; a dropped line from a transcribed block is how LIGHTYELLOW_FONT_COLOR_CODE went missing"
);
}
// The one whose value the addons splice into a string, spelled out (ref l.13).
assert_eq!(
s.eval::<String>("return LIGHTYELLOW_FONT_COLOR_CODE")
.unwrap(),
"|cffffff9a"
);
}
@@ -77,14 +77,14 @@ fn shipped_unit_frames_drive_end_to_end() {
let ok: bool = s
.eval(
r#"
local hb, pb = PlayerFrameHealthBar, PlayerFramePowerBar
local hb, pb = PlayerFrameHealthBar, PlayerFrameManaBar
local _, hmax = hb:GetMinMaxValues()
local _, pmax = pb:GetMinMaxValues()
local r, g, b = pb:GetStatusBarColor()
return hb:GetValue() == 72 and hmax == 100
and pb:GetValue() == 45 and pmax == 80 and pb:IsVisible()
and b == 1 and r == 0 -- mana blue
and PlayerFrameTextureFrameNameText:GetText() == "Unknown"
and PlayerName:GetText() == "Unknown"
"#,
)
.unwrap();
@@ -109,8 +109,7 @@ fn shipped_unit_frames_drive_end_to_end() {
);
s.fire_event("UNIT_NAME_UPDATE", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<String>("return PlayerFrameTextureFrameNameText:GetText()")
.unwrap(),
s.eval::<String>("return PlayerName:GetText()").unwrap(),
"Benilla"
);
@@ -137,10 +136,10 @@ fn shipped_unit_frames_drive_end_to_end() {
.eval(
r#"
return TargetFrame:IsVisible()
and not TargetFramePowerBar:IsVisible()
and TargetFrameTextureFrameNameText:GetText() == "Young Wolf"
and TargetFrameTextureFrameLevelText:GetText() == "3"
and not TargetFrameTextureFrameDeadText:IsShown() -- living target: no dead word
and not TargetFrameManaBar:IsVisible()
and TargetName:GetText() == "Young Wolf"
and TargetLevelText:GetText() == "3"
and not TargetDeadText:IsShown() -- living target: no dead word
"#,
)
.unwrap();
@@ -280,7 +279,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
let ok: bool = s
.eval(
r#"
local ind = PlayerFrameTextureFrameHitIndicator
local ind = PlayerHitIndicator
return ind:IsShown() ~= nil and tostring(ind:GetText()) == "17"
"#,
)
@@ -295,7 +294,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
// A spell crit: ×1.5 height (the CRITICAL arm), and the type>0 yellow.
s.fire_event("UNIT_COMBAT", ev("player", "WOUND", "CRITICAL", 64, 4));
let ok: bool = s
.eval("return tostring(PlayerFrameTextureFrameHitIndicator:GetText()) == \"64\"")
.eval("return tostring(PlayerHitIndicator:GetText()) == \"64\"")
.unwrap();
assert!(ok, "spell crit paints ({:?})", s.errors());
assert_eq!(
@@ -307,7 +306,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
// A full absorb: the word at ×0.75.
s.fire_event("UNIT_COMBAT", ev("player", "WOUND", "ABSORB", 0, 0));
let ok: bool = s
.eval("return PlayerFrameTextureFrameHitIndicator:GetText() == \"Absorb\"")
.eval("return PlayerHitIndicator:GetText() == \"Absorb\"")
.unwrap();
assert!(ok, "full absorb paints the word ({:?})", s.errors());
assert_eq!(
@@ -321,7 +320,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
let ok: bool = s
.eval(
r#"
local ind = PlayerFrameTextureFrameHitIndicator
local ind = PlayerHitIndicator
return ind:IsShown() ~= nil and ind:GetAlpha() == 1.0
"#,
)
@@ -329,7 +328,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
assert!(ok, "mid-hold: opaque ({:?})", s.errors());
s.tick(0.8); // past fade-in + hold + fade-out (1.2 s total)
assert!(
s.eval::<bool>("return PlayerFrameTextureFrameHitIndicator:IsShown() == nil")
s.eval::<bool>("return PlayerHitIndicator:IsShown() == nil")
.unwrap(),
"the envelope ends in a Hide ({:?})",
s.errors()
@@ -338,7 +337,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
// A target-token event leaves the (hidden) indicator alone.
s.fire_event("UNIT_COMBAT", ev("target", "WOUND", "", 99, 0));
assert!(
s.eval::<bool>("return PlayerFrameTextureFrameHitIndicator:IsShown() == nil")
s.eval::<bool>("return PlayerHitIndicator:IsShown() == nil")
.unwrap(),
"a target event never touches the player indicator"
);
@@ -643,8 +642,8 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local lvl = getglobal("TargetFrameTextureFrameLevelText")
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
local lvl = getglobal("TargetLevelText")
local skull = getglobal("TargetHighLevelTexture")
local c = GetDifficultyColor(8)
return lvl:IsShown() ~= nil and skull:IsShown() == nil
and tostring(lvl:GetText()) == "8"
@@ -672,8 +671,8 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local lvl = getglobal("TargetFrameTextureFrameLevelText")
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
local lvl = getglobal("TargetLevelText")
local skull = getglobal("TargetHighLevelTexture")
return UnitLevel("target") == -1
and skull:IsShown() ~= nil and lvl:IsShown() == nil
"#,
@@ -700,8 +699,8 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local lvl = getglobal("TargetFrameTextureFrameLevelText")
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
local lvl = getglobal("TargetLevelText")
local skull = getglobal("TargetHighLevelTexture")
return UnitIsCorpse("target") == nil
and lvl:IsShown() ~= nil and skull:IsShown() == nil
and tostring(lvl:GetText()) == "8"
@@ -729,7 +728,7 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
local skull = getglobal("TargetHighLevelTexture")
return UnitIsCorpse("target") == 1 and skull:IsShown() ~= nil
"#,
)
@@ -778,9 +777,9 @@ fn pvp_icon_follows_the_three_branch_law() {
load_unit_frames(&s);
let _ = s.take_sounds(); // the frames' own load-time kits (target hide) aren't ours
let icon_shown = |s: &UiScript, frame: &str| -> bool {
let icon_shown = |s: &UiScript, unit: &str| -> bool {
s.eval(&format!(
"return {frame}TextureFramePVPIcon:IsVisible() and true or false"
"return {unit}PVPIcon:IsVisible() and true or false"
))
.unwrap()
};
@@ -808,13 +807,13 @@ fn pvp_icon_follows_the_three_branch_law() {
// Unflagged: no icon, no sound.
s.set_unit("player", Some(alliance_player(false, false)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("player".into())]);
assert!(!icon_shown(&s, "PlayerFrame"), "unflagged shows none");
assert!(!icon_shown(&s, "Player"), "unflagged shows none");
assert!(s.take_sounds().is_empty(), "no sound while unflagged");
// Flagged: the Alliance icon, and one igPVPUpdate for the flag change.
s.set_unit("player", Some(alliance_player(true, false)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("player".into())]);
assert!(icon_shown(&s, "PlayerFrame"));
assert!(icon_shown(&s, "Player"));
assert!(icon_path(&mut s, "UI-PVP-Alliance"), "faction leg art");
assert_eq!(
s.take_sounds(),
@@ -851,7 +850,7 @@ fn pvp_icon_follows_the_three_branch_law() {
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(
!icon_shown(&s, "TargetFrame"),
!icon_shown(&s, "Target"),
"flagged but sideless draws no icon"
);
@@ -871,7 +870,7 @@ fn pvp_icon_follows_the_three_branch_law() {
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(icon_shown(&s, "TargetFrame"));
assert!(icon_shown(&s, "Target"));
assert!(icon_path(&mut s, "UI-PVP-Horde"), "the target's own side");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1046,7 +1045,7 @@ fn target_frame_border_follows_the_classification_law() {
let plain_on_player: bool = s
.eval(
r#"
local p = getglobal("PlayerFrameTextureFrameTexture")
local p = getglobal("PlayerFrameTexture")
return p ~= nil and PlayerFrame.frameTexture == nil
"#,
)
@@ -1235,9 +1234,9 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
let alive: bool = s
.eval(
r#"
local hb, pb = TargetFrameHealthBar, TargetFramePowerBar
local hb, pb = TargetFrameHealthBar, TargetFrameManaBar
return hb:GetValue() == 1200 and pb:GetValue() == 300
and not TargetFrameTextureFrameDeadText:IsShown()
and not TargetDeadText:IsShown()
"#,
)
.unwrap();
@@ -1250,25 +1249,24 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
s.eval("return TargetFrameHealthBar:GetValue()").unwrap(),
s.eval("local _, m = TargetFrameHealthBar:GetMinMaxValues() return m")
.unwrap(),
s.eval("return TargetFramePowerBar:GetValue()").unwrap(),
s.eval("local _, m = TargetFramePowerBar:GetMinMaxValues() return m")
s.eval("return TargetFrameManaBar:GetValue()").unwrap(),
s.eval("local _, m = TargetFrameManaBar:GetMinMaxValues() return m")
.unwrap(),
);
assert_eq!((hp, hmax), (0.0, 1500.0), "empty health bar, real track");
assert_eq!((mana, mmax), (0.0, 900.0), "empty mana bar, real track");
assert!(
s.eval::<bool>("return TargetFramePowerBar:IsVisible()")
s.eval::<bool>("return TargetFrameManaBar:IsVisible()")
.unwrap(),
"the mana bar empties, it does not disappear — UnitManaMax 0x5177e0 is ungated"
);
assert!(
s.eval::<bool>("return TargetFrameTextureFrameDeadText:IsShown() and true or false")
s.eval::<bool>("return TargetDeadText:IsShown() and true or false")
.unwrap(),
"TargetFrame_CheckDead's UnitHealth(unit) <= 0 test, tripped by the flag"
);
assert_eq!(
s.eval::<String>("return TargetFrameTextureFrameDeadText:GetText()")
.unwrap(),
s.eval::<String>("return TargetDeadText:GetText()").unwrap(),
"Dead",
"the WORD is the GlobalString `DEAD` (l.898), never the key: a literal \"DEAD\" here \
is the caps bug the director caught on Onyxia"
@@ -1285,8 +1283,8 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
.eval(
r#"
return TargetFrameHealthBar:GetValue() == 1200
and TargetFramePowerBar:GetValue() == 300
and not TargetFrameTextureFrameDeadText:IsShown()
and TargetFrameManaBar:GetValue() == 300
and not TargetDeadText:IsShown()
and not UnitIsDead("target")
"#,
)
@@ -1326,14 +1324,14 @@ fn the_player_frame_flashes_zzz_while_resting() {
let resting: bool = s
.eval(
r#"
local tf = "PlayerFrameTextureFrame"
return getglobal(tf .. "StatusTexture"):IsShown()
and getglobal(tf .. "RestIcon"):IsShown()
and not getglobal(tf .. "AttackIcon"):IsShown()
and PlayerFrameStatusGlow:IsShown()
and PlayerFrameStatusGlowRestGlow:IsShown()
and not PlayerFrameStatusGlowAttackGlow:IsShown()
and not getglobal(tf .. "AttackBackground"):IsShown()
local u = "Player"
return getglobal(u .. "StatusTexture"):IsShown()
and getglobal(u .. "RestIcon"):IsShown()
and not getglobal(u .. "AttackIcon"):IsShown()
and PlayerStatusGlow:IsShown()
and PlayerRestGlow:IsShown()
and not PlayerAttackGlow:IsShown()
and not getglobal(u .. "AttackBackground"):IsShown()
"#,
)
.unwrap();
@@ -1343,14 +1341,10 @@ fn the_player_frame_flashes_zzz_while_resting() {
);
// The pulse: two OnUpdate ticks move the ring's alpha (the 0.5 s triangle wave).
let a0: f64 = s
.eval("return PlayerFrameTextureFrameStatusTexture:GetAlpha()")
.unwrap();
let a0: f64 = s.eval("return PlayerStatusTexture:GetAlpha()").unwrap();
s.run("this = PlayerFrame; PlayerFrame_OnUpdate(0.25)")
.unwrap();
let a1: f64 = s
.eval("return PlayerFrameTextureFrameStatusTexture:GetAlpha()")
.unwrap();
let a1: f64 = s.eval("return PlayerStatusTexture:GetAlpha()").unwrap();
assert!(
(a0 - a1).abs() > 0.1,
"the flash moves the status alpha ({a0} → {a1})"
@@ -1359,8 +1353,7 @@ fn the_player_frame_flashes_zzz_while_resting() {
// Swinging while resting: resting still wins (the ref's branch order).
s.fire_event("PLAYER_ENTER_COMBAT", vec![]);
assert!(
s.eval::<bool>("return PlayerFrameTextureFrameRestIcon:IsShown()")
.unwrap(),
s.eval::<bool>("return PlayerRestIcon:IsShown()").unwrap(),
"resting outranks auto-attack"
);
@@ -1370,12 +1363,12 @@ fn the_player_frame_flashes_zzz_while_resting() {
let attacking: bool = s
.eval(
r#"
local tf = "PlayerFrameTextureFrame"
return getglobal(tf .. "StatusTexture"):IsShown()
and getglobal(tf .. "AttackIcon"):IsShown()
and not getglobal(tf .. "RestIcon"):IsShown()
and PlayerFrameStatusGlowAttackGlow:IsShown()
and getglobal(tf .. "AttackBackground"):IsShown()
local u = "Player"
return getglobal(u .. "StatusTexture"):IsShown()
and getglobal(u .. "AttackIcon"):IsShown()
and not getglobal(u .. "RestIcon"):IsShown()
and PlayerAttackGlow:IsShown()
and getglobal(u .. "AttackBackground"):IsShown()
"#,
)
.unwrap();
@@ -1386,12 +1379,12 @@ fn the_player_frame_flashes_zzz_while_resting() {
let clear: bool = s
.eval(
r#"
local tf = "PlayerFrameTextureFrame"
return not getglobal(tf .. "StatusTexture"):IsShown()
and not getglobal(tf .. "RestIcon"):IsShown()
and not getglobal(tf .. "AttackIcon"):IsShown()
and not PlayerFrameStatusGlow:IsShown()
and not getglobal(tf .. "AttackBackground"):IsShown()
local u = "Player"
return not getglobal(u .. "StatusTexture"):IsShown()
and not getglobal(u .. "RestIcon"):IsShown()
and not getglobal(u .. "AttackIcon"):IsShown()
and not PlayerStatusGlow:IsShown()
and not getglobal(u .. "AttackBackground"):IsShown()
"#,
)
.unwrap();
@@ -1497,8 +1490,8 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
};
// Off (the shipped default): the strings carry the numbers, and nothing paints.
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
assert!(!shown(&s, "PlayerFrameTextureFramePowerBarText"));
assert!(!shown(&s, "PlayerFrameHealthBarText"));
assert!(!shown(&s, "PlayerFrameManaBarText"));
// On, through the switch's own event — no repaint, no damage taken.
s.register_cvars([("statusBarText", "0")]);
@@ -1506,17 +1499,17 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
.unwrap();
s.tick(0.0);
assert!(
shown(&s, "PlayerFrameTextureFrameHealthBarText"),
shown(&s, "PlayerFrameHealthBarText"),
"your own health numerals pin on"
);
assert_eq!(
text(&s, "PlayerFrameTextureFrameHealthBarText"),
text(&s, "PlayerFrameHealthBarText"),
"72 / 100",
"bare numbers: the reference's \"Health\"/\"Rage\" labels were cut on the \
director's call (1147)"
);
assert_eq!(
text(&s, "PlayerFrameTextureFramePowerBarText"),
text(&s, "PlayerFrameManaBarText"),
"45 / 80",
"the power bar shows the number alone, whatever resource it is"
);
@@ -1524,19 +1517,19 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
// The target's stay dark with the switch ON: 1.12 never makes its bars textLockable, so the
// option does not reach them.
assert!(
!shown(&s, "TargetFrameTextureFrameHealthBarText"),
!shown(&s, "TargetFrameHealthBarText"),
"the switch pins your own numbers, never the target's"
);
// ...but the HOVER reveals them, which is how you read a target's health in 1.12 (1146).
s.run("BenillaUnitFrameBar_OnEnter(TargetFrameHealthBar)")
.unwrap();
assert!(shown(&s, "TargetFrameTextureFrameHealthBarText"));
assert_eq!(text(&s, "TargetFrameTextureFrameHealthBarText"), "50 / 100");
assert!(shown(&s, "TargetFrameHealthBarText"));
assert_eq!(text(&s, "TargetFrameHealthBarText"), "50 / 100");
s.run("BenillaUnitFrameBar_OnLeave(TargetFrameHealthBar)")
.unwrap();
assert!(
!shown(&s, "TargetFrameTextureFrameHealthBarText"),
!shown(&s, "TargetFrameHealthBarText"),
"and go away again — the lockShow refcount balances"
);
@@ -1545,16 +1538,16 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
s.run("SetCVar(\"statusBarText\", \"0\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
assert!(!shown(&s, "PlayerFrameHealthBarText"));
s.run("BenillaUnitFrameBar_OnEnter(PlayerFrameHealthBar)")
.unwrap();
assert!(
shown(&s, "PlayerFrameTextureFrameHealthBarText"),
shown(&s, "PlayerFrameHealthBarText"),
"hover shows them even with the option off"
);
s.run("BenillaUnitFrameBar_OnLeave(PlayerFrameHealthBar)")
.unwrap();
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
assert!(!shown(&s, "PlayerFrameHealthBarText"));
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
@@ -1562,12 +1555,12 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
// A health change repaints through the bar's own OnValueChanged, like the reference's.
s.set_unit("player", Some(alive(31, 45, 1)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert_eq!(text(&s, "PlayerFrameTextureFrameHealthBarText"), "31 / 100");
assert_eq!(text(&s, "PlayerFrameHealthBarText"), "31 / 100");
// A power-type change repaints the same way (it used to re-LABEL the bar too — 1147 cut that).
s.set_unit("player", Some(alive(31, 60, 3)));
s.fire_event("UNIT_DISPLAYPOWER", vec![ScriptValue::Str("player".into())]);
assert_eq!(text(&s, "PlayerFrameTextureFramePowerBarText"), "60 / 80");
assert_eq!(text(&s, "PlayerFrameManaBarText"), "60 / 80");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1636,16 +1629,11 @@ fn no_two_numeral_strings_overlap_on_any_frame() {
for (frame, upper, lower, want) in [
(
"player",
"PlayerFrameTextureFrameHealthBarText",
"PlayerFrameTextureFramePowerBarText",
"PlayerFrameHealthBarText",
"PlayerFrameManaBarText",
12.0,
),
(
"pet",
"PetFrameTextureFrameHealthBarText",
"PetFrameTextureFrameManaBarText",
11.0,
),
("pet", "PetFrameHealthBarText", "PetFrameManaBarText", 11.0),
] {
let mid = |s: &UiScript, n: &str| -> f64 {
s.eval::<f64>(&format!("return ({n}:GetTop() + {n}:GetBottom()) / 2"))
@@ -1790,8 +1778,8 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
s.eval::<bool>(&format!("return {region}:IsVisible()"))
.unwrap()
};
let leader = "PlayerFrameTextureFrameLeaderIcon";
let master = "PlayerFrameTextureFrameMasterIcon";
let leader = "PlayerLeaderIcon";
let master = "PlayerMasterIcon";
// Solo: neither. A solo player "leads" nothing — `IsPartyLeader()` is nil without a group.
assert!(!shown(&s, leader), "solo: no leader icon");
@@ -1841,3 +1829,140 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Every unit-frame global the reference declares** — the whole block, per decision 1718.
///
/// benilla named the player's and target's power bar `…PowerBar`, which is the LATER client's
/// vocabulary: 1.12 has no `*PowerBar` anywhere. Every one is `ManaBar` — `PlayerFrameManaBar`,
/// `TargetFrameManaBar`, `PetFrameManaBar`, plus the `ManaBarColor` table and the `frame.manabar`
/// field `UnitFrame_Initialize` sets (`UnitFrame.lua:14`). So we published four names the
/// reference lacks and were missing four it has, which is decision 1189's error in both
/// directions at once. ShaguTweaks reads `TargetFrameManaBar` unguarded (`health-numbers.lua:22`)
/// and died on it; our own party frames already used the right name, so the tree disagreed with
/// itself as well.
///
/// The guard is the reference's own list, not the names that turned up missing — 1718's rule,
/// which exists because the two previous guards of this class each enumerated the instances that
/// had already broken and so could not fire on the next one. Deliberate ADDITIONS are fine and are
/// not asserted against; what is asserted is that nothing the reference declares is absent.
#[test]
fn the_unit_frames_publish_every_name_the_reference_declares() {
let s = UiScript::new().unwrap();
load_unit_frames(&s);
// ref PlayerFrame.xml / TargetFrame.xml / PetFrame.xml — every `name=` those three files
// declare, minus the `virtual="true"` templates (which are not globals) and the buff/debuff
// button instances (a separate arc: TargetFrameBuff1..5, TargetFrameDebuff1..16).
//
// The list is taken from the reference files, not written from what turned up missing — and
// the first draft of THIS test still got that wrong, by filtering the reference's own list to
// the names starting `PlayerFrame`/`TargetFrame`/`PetFrame` and so dropping `PlayerName`,
// `PetPortrait`, `PetAttackModeTexture` and two dozen more. 1718's rule is easy to state and
// easy to re-break one level down.
//
// Collected rather than asserted one at a time: the point of a whole-block guard is to report
// the whole gap, and a first-failure assert would have hidden everything after it.
let mut missing = Vec::new();
for name in [
// PlayerFrame.xml
"PlayerAttackBackground",
"PlayerAttackGlow",
"PlayerAttackIcon",
"PlayerFrame",
"PlayerFrameBackground",
"PlayerFrameGroupIndicator",
"PlayerFrameGroupIndicatorLeft",
"PlayerFrameGroupIndicatorMiddle",
"PlayerFrameGroupIndicatorRight",
"PlayerFrameGroupIndicatorText",
"PlayerFrameHealthBar",
"PlayerFrameHealthBarText",
"PlayerFrameManaBar",
"PlayerFrameManaBarText",
"PlayerFrameTexture",
"PlayerHitIndicator",
"PlayerLeaderIcon",
"PlayerLevelText",
"PlayerMasterIcon",
"PlayerName",
"PlayerPortrait",
"PlayerPVPIcon",
"PlayerRestGlow",
"PlayerRestIcon",
"PlayerStatusGlow",
"PlayerStatusTexture",
// TargetFrame.xml
"TargetDeadText",
"TargetFrame",
"TargetFrameBackground",
"TargetFrameHealthBar",
"TargetFrameManaBar",
"TargetFrameNameBackground",
"TargetFrameTexture",
"TargetFrameTextureFrame",
"TargetHighLevelTexture",
"TargetLevelText",
"TargetName",
"TargetPortrait",
"TargetPVPIcon",
// PetFrame.xml
"PetAttackModeTexture",
"PetFrame",
"PetFrameHappiness",
"PetFrameHappinessTexture",
"PetFrameHealthBar",
"PetFrameHealthBarText",
"PetFrameManaBar",
"PetFrameManaBarText",
"PetFrameTexture",
"PetName",
"PetPortrait",
] {
if !s
.eval::<bool>(&format!("return getglobal('{name}') ~= nil"))
.unwrap()
{
missing.push(name);
}
}
// The one genuine GAP, stated rather than quietly dropped from the list (1718's whole point
// is that the block stays visible): `PlayerFrameGroupIndicator` and its four children are the
// raid "Group N" tab above the player frame, driven by `PlayerFrame_UpdateGroupIndicator`
// (ref PlayerFrame.lua:214-229). We build no region, no texture and no handler for it — this
// is a feature we have not written, not a name we got wrong, and inventing five regions to
// satisfy a list would be the worse error. Zero corpus readers.
let unbuilt = [
"PlayerFrameGroupIndicator",
"PlayerFrameGroupIndicatorLeft",
"PlayerFrameGroupIndicatorMiddle",
"PlayerFrameGroupIndicatorRight",
"PlayerFrameGroupIndicatorText",
];
missing.retain(|m| !unbuilt.contains(m));
assert!(
missing.is_empty(),
"the reference declares these and we do not publish them — an addon reading any of them \
by name finds nil: {missing:?}"
);
// The colour table is `ManaBarColor` (ref UnitFrame.lua:2), not the later `PowerBarColor`,
// and addons index it directly for the power-type prefix and tint.
assert!(s.eval::<bool>("return ManaBarColor ~= nil").unwrap());
assert!(
s.eval::<bool>("return PowerBarColor == nil").unwrap(),
"PowerBarColor is the later client's name — 1.12 has no such global"
);
// And the frame FIELD `UnitFrame_Initialize` sets (ref UnitFrame.lua:13-14), which unit-frame
// addons read off the frame rather than by global name.
assert!(
s.eval::<bool>("return PlayerFrame.manabar ~= nil and PlayerFrame.healthbar ~= nil")
.unwrap(),
"the reference's field names are `manabar`/`healthbar`, both lowercase"
);
// ShaguTweaks' own line, in shape (health-numbers.lua:22).
s.run("TargetFrameManaBar:SetStatusBarColor(0, 0, 1)")
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+1 -1
View File
@@ -171,7 +171,7 @@ mod cap_tests {
///
/// We had the opposite: the paint pass recomputed the seam every frame for every overflowing
/// FontString, and the extract gate (decision 0740) is all-or-nothing over the whole render list,
/// so one flashing frame (`PlayerFrameStatusGlow`, which never stops while you are resting) kept
/// so one flashing frame (`PlayerStatusGlow`, which never stops while you are resting) kept
/// it open — measured live at the reported plaque, 0 of 240 frames skipped. That is what turned a
/// one-time 5.6 ms into +5.4 ms *per frame* and halved the framerate (B240, decision 1332).
///
+29 -10
View File
@@ -12,16 +12,18 @@
use crate::messages::{
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, ChannelNoticeTail, Character, CreateSpline, DamageShield,
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GmTicket, GossipOption,
GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo,
GuildQueryResponse, GuildRoster, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog,
PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList,
PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestDetails,
QuestGiverList, QuestOfferReward, QuestRequestItems, QuestTemplate, SpellDamageLog,
SpellEnergizeLog, SpellHealLog, SpellLogMiss, StabledPet, TaxiMask, TradeStatus,
TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
DispelFailed, EnchantmentLog, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GmTicket, GossipOption, GroupLootInfo, GroupMemberEntry,
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MonsterMoveFacing,
ObjectFields, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowSignatures,
PetitionSignResults, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
QuestRequestItems, QuestTemplate, SpellDamageLog, SpellDispelLog, SpellEnergizeLog,
SpellHealLog, SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog, StabledPet,
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
WhoResults, XpGain,
};
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
@@ -777,6 +779,23 @@ pub enum SessionEvent {
EnvironmentalDamageLog(EnvironmentalDamageLog),
/// A spell cast's per-target miss list (`SMSG_SPELLLOGMISS`) — decision 0137 phase 2.
SpellLogMiss(SpellLogMiss),
/// The killing blow (`SMSG_PARTYKILLLOG`) — the "You have slain %s!" line's only source
/// (decision 1703).
PartyKillLog(PartyKillLog),
/// An instant kill (`SMSG_SPELLINSTAKILLLOG`) — decision 1703.
SpellInstaKillLog(SpellInstaKillLog),
/// A proc the target resisted (`SMSG_PROCRESIST`) — decision 1703.
ProcResist(SpellOutcomeLog),
/// A target immune to the spell (`SMSG_SPELLORDAMAGE_IMMUNE`) — decision 1703.
SpellOrDamageImmune(SpellOutcomeLog),
/// The auras a dispel removed (`SMSG_SPELLDISPELLOG`) — decision 1703.
SpellDispelLog(SpellDispelLog),
/// The auras a dispel failed to remove (`SMSG_DISPEL_FAILED`) — decision 1703.
DispelFailed(DispelFailed),
/// An enchant landing on or fading from an item (`SMSG_ENCHANTMENTLOG`) — decision 1703.
EnchantmentLog(EnchantmentLog),
/// What a cast's effects did (`SMSG_SPELLLOGEXECUTE`) — decision 1703.
SpellLogExecute(SpellLogExecute),
/// An XP award, kill or non-kill (`SMSG_LOG_XPGAIN`) — decision 0137 phase 2.
XpGain(XpGain),
/// A first visit to an area (`SMSG_EXPLORATION_EXPERIENCE`) — the discovered area id + its
@@ -286,6 +286,14 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::DamageShield(s) => vec![SessionEvent::DamageShield(s)],
ServerPacket::EnvironmentalDamageLog(s) => vec![SessionEvent::EnvironmentalDamageLog(s)],
ServerPacket::SpellLogMiss(s) => vec![SessionEvent::SpellLogMiss(s)],
ServerPacket::PartyKillLog(s) => vec![SessionEvent::PartyKillLog(s)],
ServerPacket::SpellInstaKillLog(s) => vec![SessionEvent::SpellInstaKillLog(s)],
ServerPacket::ProcResist(s) => vec![SessionEvent::ProcResist(s)],
ServerPacket::SpellOrDamageImmune(s) => vec![SessionEvent::SpellOrDamageImmune(s)],
ServerPacket::SpellDispelLog(s) => vec![SessionEvent::SpellDispelLog(s)],
ServerPacket::DispelFailed(s) => vec![SessionEvent::DispelFailed(s)],
ServerPacket::EnchantmentLog(s) => vec![SessionEvent::EnchantmentLog(s)],
ServerPacket::SpellLogExecute(s) => vec![SessionEvent::SpellLogExecute(s)],
ServerPacket::XpGain(s) => vec![SessionEvent::XpGain(s)],
ServerPacket::ExplorationXp(s) => vec![SessionEvent::ExplorationXp(s)],
ServerPacket::LevelUp(l) => vec![SessionEvent::LevelUp(l)],
@@ -297,3 +297,342 @@ pub(super) fn read_spell_log_miss(r: &mut impl Read) -> io::Result<SpellLogMiss>
misses,
})
}
/// One decoded `SMSG_PARTYKILLLOG` — the killing blow, sent to the killer's party (vmangos
/// `WorldPackets::Combat::PartyKillLog::AppendBodyTo`, `Server/Packets/Combat.cpp:52-56`).
///
/// It is the **only** source of the "You have slain %s!" / "%s is slain by %s!" pair; the plain
/// "%s dies." line is not a packet at all — it rides the client's own unit-death reflex.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PartyKillLog {
pub killer: u64,
pub victim: u64,
}
/// Read `SMSG_PARTYKILLLOG`: killer raw `u64` guid · victim raw `u64` guid.
pub(super) fn read_party_kill_log(r: &mut impl Read) -> io::Result<PartyKillLog> {
Ok(PartyKillLog {
killer: read_u64_le(r)?,
victim: read_u64_le(r)?,
})
}
/// One decoded `SMSG_SPELLINSTAKILLLOG` — an instant kill (vmangos `Spell::EffectInstaKill`,
/// `Spells/SpellEffects.cpp:274-279`; the packet exists only for `> 1.11.2` clients, which 5875 is).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpellInstaKillLog {
pub victim: u64,
pub spell_id: u32,
}
/// Read `SMSG_SPELLINSTAKILLLOG`: victim raw `u64` guid · spellId u32.
pub(super) fn read_spell_insta_kill_log(r: &mut impl Read) -> io::Result<SpellInstaKillLog> {
Ok(SpellInstaKillLog {
victim: read_u64_le(r)?,
spell_id: read_u32_le(r)?,
})
}
/// One decoded `SMSG_PROCRESIST` **or** `SMSG_SPELLORDAMAGE_IMMUNE` — the two share a body byte for
/// byte (vmangos `WorldPackets::Spell::ProcResist` / `SpellOrDamageImmune`,
/// `Server/Packets/Spell.cpp:88-102`) and differ only in which sentence they word.
///
/// `log_format` is vmangos's `logFormat` (`0` default, `1` debug); the reference reads it as the
/// "is periodic" flag that decides whether `CombatLogPeriodicSpells` gates the line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SpellOutcomeLog {
pub caster: u64,
pub target: u64,
pub spell_id: u32,
pub log_format: u8,
}
/// Read `SMSG_PROCRESIST` / `SMSG_SPELLORDAMAGE_IMMUNE`: caster raw `u64` · target raw `u64` ·
/// spellId u32 · logFormat u8.
pub(super) fn read_spell_outcome_log(r: &mut impl Read) -> io::Result<SpellOutcomeLog> {
Ok(SpellOutcomeLog {
caster: read_u64_le(r)?,
target: read_u64_le(r)?,
spell_id: read_u32_le(r)?,
log_format: read_u8(r)?,
})
}
/// One decoded `SMSG_SPELLDISPELLOG` — auras a dispel actually removed (vmangos
/// `Spell::EffectDispel`, `Spells/SpellEffects.cpp:2524-2539`; the guid pair is **packed** on the
/// `>= 1.12.1` branch our build takes).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpellDispelLog {
/// The unit the auras were on.
pub victim: u64,
/// The dispeller. The sentence never names them — `AURADISPEL*` words the bearer and the aura.
pub caster: u64,
pub spell_ids: Vec<u32>,
}
/// Read `SMSG_SPELLDISPELLOG`: victim PackedGuid · caster PackedGuid · count u32 · `count` × u32.
pub(super) fn read_spell_dispel_log(r: &mut impl Read) -> io::Result<SpellDispelLog> {
let victim = read_packed_guid(r)?;
let caster = read_packed_guid(r)?;
let count = read_u32_le(r)?;
let mut spell_ids = Vec::with_capacity(count.min(64) as usize);
for _ in 0..count {
spell_ids.push(read_u32_le(r)?);
}
Ok(SpellDispelLog {
victim,
caster,
spell_ids,
})
}
/// One decoded `SMSG_DISPEL_FAILED` — auras a dispel tried and failed to remove (vmangos
/// `Spell::EffectDispel`, `Spells/SpellEffects.cpp:2549-2555`).
///
/// **The list runs to the end of the packet** — vmangos writes no count, so the body's own length
/// is the terminator. The reference reads it the same way (`0x628c20` loops to end of buffer).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DispelFailed {
pub caster: u64,
pub victim: u64,
pub spell_ids: Vec<u32>,
}
/// Read `SMSG_DISPEL_FAILED`: caster raw `u64` · victim raw `u64` · u32 spell ids to end of body.
pub(super) fn read_dispel_failed(r: &mut impl Read) -> io::Result<DispelFailed> {
let caster = read_u64_le(r)?;
let victim = read_u64_le(r)?;
let mut spell_ids = Vec::new();
loop {
let mut b = [0u8; 4];
match r.read_exact(&mut b) {
Ok(()) => spell_ids.push(u32::from_le_bytes(b)),
// The body ended — the only terminator this packet has.
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e),
}
}
Ok(DispelFailed {
caster,
victim,
spell_ids,
})
}
/// One decoded `SMSG_ENCHANTMENTLOG` — an enchant landing on, or fading from, an item (vmangos
/// `WorldPackets::Item::EnchantmentLog::AppendBodyTo`, `Server/Packets/Item.cpp:235-242`;
/// filled by `Player::SendEnchantmentLog`, `Objects/Player.cpp:12049-12072`).
///
/// **An empty `caster` means the enchant FADED**, not that the server forgot who cast it — that is
/// vmangos's own comment on the field, and it is the two-way the reference switches on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EnchantmentLog {
pub caster: u64,
pub owner: u64,
pub item_entry: u32,
pub spell_id: u32,
/// vmangos `showAffiliation`: false on the copy sent to the item's owner, true on the broadcast
/// to everyone else. The reference lets it pick the msg-id selector on the ADD leg.
pub show_affiliation: bool,
}
/// Read `SMSG_ENCHANTMENTLOG`: caster raw `u64` · owner raw `u64` · itemEntry u32 · spellId u32 ·
/// showAffiliation u8.
pub(super) fn read_enchantment_log(r: &mut impl Read) -> io::Result<EnchantmentLog> {
Ok(EnchantmentLog {
caster: read_u64_le(r)?,
owner: read_u64_le(r)?,
item_entry: read_u32_le(r)?,
spell_id: read_u32_le(r)?,
show_affiliation: read_u8(r)? != 0,
})
}
/// One `SMSG_SPELLLOGEXECUTE` entry — a single (effect, target) row. The payload shape is decided
/// by the **effect id**, exactly as vmangos's `Spell::SendLogExecute` switch writes it
/// (`Spells/Spell.cpp:4694-4771`) and as the reference's own per-effect jump table reads it
/// (`0x5e8074 jmp [edx*4+0x5e8430]`, byte remap `0x5e845c`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExecuteLog {
/// Effect 8 `POWER_DRAIN`. **Field order VERIFIED at the client's bytes, not just the server's**:
/// the arm at `0x5e807b` reads guid → u32 → u32 → f32 and hands them to `0x62dbf0` →
/// `0x627930(…, spellId, powerType, amount, multiplier)` — where `0x62793c cmp edi,4` and
/// `0x62795d call 0x6278f0` (GetPowerTypeNoun) identify the SECOND u32 as the power type. So
/// the wire is (amount, power), which is what vmangos writes, and the two agree.
PowerDrain {
target: u64,
amount: u32,
power: u32,
multiplier: f32,
},
/// Effects 10 `HEAL` / 62 `HEAL_MAX_HEALTH`.
Heal {
target: u64,
amount: u32,
critical: bool,
},
/// Effect 30 `ENERGIZE`.
Energize {
target: u64,
amount: u32,
power: u32,
},
/// Effect 19 `ADD_EXTRA_ATTACKS` — "You gain %d extra attacks through %s."
ExtraAttacks { target: u64, count: u32 },
/// Effect 24 `CREATE_ITEM` — the tradeskill line. **No target guid**: the item entry is the
/// whole payload.
CreateItem { item_entry: u32 },
/// Effect 68 `INTERRUPT_CAST` — the interrupted target and the spell that was interrupted.
InterruptCast { target: u64, spell_id: u32 },
/// Effect 101 `FEED_PET` — like [`Self::CreateItem`], an item entry alone.
FeedPet { item_entry: u32 },
/// Effect 111 `DURABILITY_DAMAGE`. Both fields are **signed**: `-1`/`-1` is the "all items"
/// form, which the reference words with its own `SPELLDURABILITYDAMAGEALL*` family. vmangos
/// calls the second field `unk`; the reference calls it the item SLOT and tests it with the
/// entry for exactly that `-1` pair.
DurabilityDamage {
target: u64,
item_entry: i32,
slot: i32,
},
/// Every other effect vmangos logs — a bare target guid. The effect id is kept because it is
/// what the reference's formatter switch keys on (open lock, dismiss pet, summon, dispel …).
Target { target: u64 },
}
/// One decoded `SMSG_SPELLLOGEXECUTE` — "this cast's effects did these things" (vmangos
/// `Spell::SendLogExecute`, `Spells/Spell.cpp:4662-4778`).
///
/// The packet is a *list of lists*: one group per spell effect that logged anything, each group a
/// run of per-target rows. Groups keep their effect id because the whole formatter choice hangs off
/// it — one packet can carry a tradeskill create, an interrupt and a power drain at once.
#[derive(Debug, Clone, PartialEq)]
pub struct SpellLogExecute {
pub caster: u64,
pub spell_id: u32,
/// `(effect id, the rows that effect logged)`, in wire order.
pub effects: Vec<(u32, Vec<ExecuteLog>)>,
}
// vmangos `SpellEffects.h` effect ids, named for the arms that read a payload wider than a guid.
const EFFECT_POWER_DRAIN: u32 = 8;
const EFFECT_HEAL: u32 = 10;
const EFFECT_ADD_EXTRA_ATTACKS: u32 = 19;
const EFFECT_CREATE_ITEM: u32 = 24;
const EFFECT_ENERGIZE: u32 = 30;
const EFFECT_HEAL_MAX_HEALTH: u32 = 62;
const EFFECT_INTERRUPT_CAST: u32 = 68;
const EFFECT_FEED_PET: u32 = 101;
const EFFECT_DURABILITY_DAMAGE: u32 = 111;
/// Read `SMSG_SPELLLOGEXECUTE`: caster PackedGuid · spellId u32 · effectCount u32 · per group
/// `{effect u32, count u32, count × payload}` — the payload per [`ExecuteLog`].
///
/// An effect id outside the set vmangos logs cannot be skipped without desyncing the body (the row
/// width is not on the wire), so it errors rather than guessing. vmangos never sends one: its own
/// switch `return`s before `SendMessageToSet` for anything it has no case for, so an unlisted
/// effect means no packet at all rather than a truncated one.
pub(super) fn read_spell_log_execute(r: &mut impl Read) -> io::Result<SpellLogExecute> {
let caster = read_packed_guid(r)?;
let spell_id = read_u32_le(r)?;
let group_count = read_u32_le(r)?;
let mut effects = Vec::with_capacity(group_count.min(8) as usize);
for _ in 0..group_count {
let effect = read_u32_le(r)?;
let rows = read_u32_le(r)?;
let mut out = Vec::with_capacity(rows.min(64) as usize);
for _ in 0..rows {
out.push(match effect {
EFFECT_POWER_DRAIN => ExecuteLog::PowerDrain {
target: read_u64_le(r)?,
amount: read_u32_le(r)?,
power: read_u32_le(r)?,
multiplier: read_f32_le(r)?,
},
EFFECT_HEAL | EFFECT_HEAL_MAX_HEALTH => ExecuteLog::Heal {
target: read_u64_le(r)?,
amount: read_u32_le(r)?,
critical: read_u8(r)? != 0,
},
EFFECT_ENERGIZE => ExecuteLog::Energize {
target: read_u64_le(r)?,
amount: read_u32_le(r)?,
power: read_u32_le(r)?,
},
EFFECT_ADD_EXTRA_ATTACKS => ExecuteLog::ExtraAttacks {
target: read_u64_le(r)?,
count: read_u32_le(r)?,
},
EFFECT_CREATE_ITEM => ExecuteLog::CreateItem {
item_entry: read_u32_le(r)?,
},
EFFECT_INTERRUPT_CAST => ExecuteLog::InterruptCast {
target: read_u64_le(r)?,
spell_id: read_u32_le(r)?,
},
EFFECT_FEED_PET => ExecuteLog::FeedPet {
item_entry: read_u32_le(r)?,
},
EFFECT_DURABILITY_DAMAGE => ExecuteLog::DurabilityDamage {
target: read_u64_le(r)?,
item_entry: read_i32_le(r)?,
slot: read_i32_le(r)?,
},
// The long tail of vmangos's switch: instakill, dispel, threat, summon, open lock,
// dismiss pet … all one guid wide.
_ if rows_are_guid_only(effect) => ExecuteLog::Target {
target: read_u64_le(r)?,
},
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("SMSG_SPELLLOGEXECUTE: unknown effect {other}"),
))
}
});
}
effects.push((effect, out));
}
Ok(SpellLogExecute {
caster,
spell_id,
effects,
})
}
/// The effects vmangos logs as a bare target guid — its `SendLogExecute` switch's long
/// fall-through case list (`Spells/Spell.cpp:4735-4770`), each name resolved against
/// `Spells/SpellDefines.h`'s `SpellEffects` enum rather than transcribed from memory.
fn rows_are_guid_only(effect: u32) -> bool {
matches!(
effect,
1 // INSTAKILL
| 18 // RESURRECT
| 28 // SUMMON
| 33 // OPEN_LOCK
| 38 // DISPEL
| 41 // SUMMON_WILD
| 42 // SUMMON_GUARDIAN
| 50 // TRANS_DOOR
| 56 // SUMMON_PET
| 59 // OPEN_LOCK_ITEM
| 63 // THREAT
| 69 // DISTRACT
| 73 // SUMMON_POSSESSED
| 74 // SUMMON_TOTEM
| 76 // SUMMON_OBJECT_WILD
| 79 // SANCTUARY
| 87..=90 // SUMMON_TOTEM_SLOT1..4
| 91 // THREAT_ALL
| 97 // SUMMON_CRITTER
| 102 // DISMISS_PET
| 104..=107 // SUMMON_OBJECT_SLOT1..4
| 108 // DISPEL_MECHANIC
| 112 // SUMMON_DEMON
| 113 // RESURRECT_NEW
| 114 // ATTACK_ME
| 116 // SKIN_PLAYER_CORPSE
| 125 // MODIFY_THREAT_PERCENT
| 126 // SPELL_EFFECT_126
)
}
+3 -2
View File
@@ -101,8 +101,9 @@ pub use client::{
teleport_ack, text_emote,
};
pub use combat_log::{
DamageShield, EnvironmentalDamageLog, PeriodicAuraLog, PeriodicTick, SpellDamageLog,
SpellEnergizeLog, SpellHealLog, SpellLogMiss,
DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog, ExecuteLog, PartyKillLog,
PeriodicAuraLog, PeriodicTick, SpellDamageLog, SpellDispelLog, SpellEnergizeLog, SpellHealLog,
SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog,
};
pub use death::{
reclaim_corpse, resurrect_response, spirit_healer_activate, CorpseLocation,
@@ -280,6 +280,18 @@ pub const SMSG_PERIODICAURALOG: u16 = 0x024E; // 590
pub const SMSG_SPELLDAMAGESHIELD: u16 = 0x024F; // 591
pub const SMSG_SPELLNONMELEEDAMAGELOG: u16 = 0x0250; // 592
// The combat log's remaining wire (VERIFIED vmangos `Opcodes_1_12_1.h`; decision 1703 — the
// families 1571 §5 named as "deliberately out because their wire sources are undecoded"). Bodies
// in [`super::combat_log`].
pub const SMSG_ENCHANTMENTLOG: u16 = 0x01D7; // 471
pub const SMSG_PARTYKILLLOG: u16 = 0x01F5; // 501
pub const SMSG_SPELLLOGEXECUTE: u16 = 0x024C; // 588
pub const SMSG_PROCRESIST: u16 = 0x0260; // 608
pub const SMSG_DISPEL_FAILED: u16 = 0x0262; // 610
pub const SMSG_SPELLORDAMAGE_IMMUNE: u16 = 0x0263; // 611
pub const SMSG_SPELLDISPELLOG: u16 = 0x027B; // 635
pub const SMSG_SPELLINSTAKILLLOG: u16 = 0x032F; // 815
// The heal/energize pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 336/337) — the center combat
// text's HEAL / power-gain feed (decision 0578). Bodies in [`super::spells`].
pub const SMSG_SPELLHEALLOG: u16 = 0x0150; // 336
+46 -12
View File
@@ -7,18 +7,19 @@ use crate::wire::Vector3d;
use super::{
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, CastOutcome, ChannelNotify, Character, ChatMessage, CorpseLocation,
DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate,
GameObjectQueryInfo, GmTicket, GossipOption, GossipPoi, GroupLootInfo, GroupMemberEntry,
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowSignatures,
PetitionSignResults, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind,
SpellChainTargets, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog,
SpellLogMiss, SpellStart, StabledPet, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell,
TransportPose, VendorItem, WhoResults, XpGain,
DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GameObjectQueryInfo, GmTicket, GossipOption, GossipPoi, GroupLootInfo,
GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse,
GuildRoster, InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MoveMode, Object, PartyKillLog, PartyMemberStatsInfo, PeriodicAuraLog,
PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList,
PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestDetails,
QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate,
ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog,
SpellDispelLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellInstaKillLog, SpellLogExecute,
SpellLogMiss, SpellOutcomeLog, SpellStart, StabledPet, TaxiMask, TradeStatus,
TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -671,6 +672,31 @@ pub enum ServerPacket {
/// `SMSG_SPELLLOGMISS` — a spell cast's per-target miss list (decision 0137 phase 2; layout in
/// [`super::combat_log::read_spell_log_miss`]).
SpellLogMiss(SpellLogMiss),
/// `SMSG_PARTYKILLLOG` — the killing blow (decision 1703; layout in
/// [`super::combat_log::read_party_kill_log`]).
PartyKillLog(PartyKillLog),
/// `SMSG_SPELLINSTAKILLLOG` — an instant kill (decision 1703; layout in
/// [`super::combat_log::read_spell_insta_kill_log`]).
SpellInstaKillLog(SpellInstaKillLog),
/// `SMSG_PROCRESIST` — a proc the target resisted (decision 1703; layout in
/// [`super::combat_log::read_spell_outcome_log`]).
ProcResist(SpellOutcomeLog),
/// `SMSG_SPELLORDAMAGE_IMMUNE` — a target immune to the spell (decision 1703; the same body as
/// [`Self::ProcResist`], a different sentence).
SpellOrDamageImmune(SpellOutcomeLog),
/// `SMSG_SPELLDISPELLOG` — the auras a dispel removed (decision 1703; layout in
/// [`super::combat_log::read_spell_dispel_log`]).
SpellDispelLog(SpellDispelLog),
/// `SMSG_DISPEL_FAILED` — the auras a dispel failed to remove (decision 1703; layout in
/// [`super::combat_log::read_dispel_failed`]).
DispelFailed(DispelFailed),
/// `SMSG_ENCHANTMENTLOG` — an enchant landing on or fading from an item (decision 1703; layout
/// in [`super::combat_log::read_enchantment_log`]).
EnchantmentLog(EnchantmentLog),
/// `SMSG_SPELLLOGEXECUTE` — what a cast's effects did: created items, interrupts, extra
/// attacks, power drains, durability damage (decision 1703; layout in
/// [`super::combat_log::read_spell_log_execute`]).
SpellLogExecute(SpellLogExecute),
/// `SMSG_LOG_XPGAIN` — an XP award, kill or non-kill (decision 0137 phase 2; layout in
/// [`super::progression::read_xp_gain`]).
XpGain(XpGain),
@@ -1469,6 +1495,14 @@ impl ServerPacket {
ServerPacket::DamageShield(_) => "SMSG_SPELLDAMAGESHIELD".into(),
ServerPacket::EnvironmentalDamageLog(_) => "SMSG_ENVIRONMENTALDAMAGELOG".into(),
ServerPacket::SpellLogMiss(_) => "SMSG_SPELLLOGMISS".into(),
ServerPacket::PartyKillLog(_) => "SMSG_PARTYKILLLOG".into(),
ServerPacket::SpellInstaKillLog(_) => "SMSG_SPELLINSTAKILLLOG".into(),
ServerPacket::ProcResist(_) => "SMSG_PROCRESIST".into(),
ServerPacket::SpellOrDamageImmune(_) => "SMSG_SPELLORDAMAGE_IMMUNE".into(),
ServerPacket::SpellDispelLog(_) => "SMSG_SPELLDISPELLOG".into(),
ServerPacket::DispelFailed(_) => "SMSG_DISPEL_FAILED".into(),
ServerPacket::EnchantmentLog(_) => "SMSG_ENCHANTMENTLOG".into(),
ServerPacket::SpellLogExecute(_) => "SMSG_SPELLLOGEXECUTE".into(),
ServerPacket::XpGain(_) => "SMSG_LOG_XPGAIN".into(),
ServerPacket::ExplorationXp(_) => "SMSG_EXPLORATION_EXPERIENCE".into(),
ServerPacket::LevelUp(_) => "SMSG_LEVELUP_INFO".into(),
@@ -603,6 +603,31 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_SPELLLOGMISS => {
ServerPacket::SpellLogMiss(combat_log::read_spell_log_miss(&mut r)?)
}
opcode::SMSG_PARTYKILLLOG => {
ServerPacket::PartyKillLog(combat_log::read_party_kill_log(&mut r)?)
}
opcode::SMSG_SPELLINSTAKILLLOG => {
ServerPacket::SpellInstaKillLog(combat_log::read_spell_insta_kill_log(&mut r)?)
}
// One body, two sentences — the arm is the only thing that tells them apart.
opcode::SMSG_PROCRESIST => {
ServerPacket::ProcResist(combat_log::read_spell_outcome_log(&mut r)?)
}
opcode::SMSG_SPELLORDAMAGE_IMMUNE => {
ServerPacket::SpellOrDamageImmune(combat_log::read_spell_outcome_log(&mut r)?)
}
opcode::SMSG_SPELLDISPELLOG => {
ServerPacket::SpellDispelLog(combat_log::read_spell_dispel_log(&mut r)?)
}
opcode::SMSG_DISPEL_FAILED => {
ServerPacket::DispelFailed(combat_log::read_dispel_failed(&mut r)?)
}
opcode::SMSG_ENCHANTMENTLOG => {
ServerPacket::EnchantmentLog(combat_log::read_enchantment_log(&mut r)?)
}
opcode::SMSG_SPELLLOGEXECUTE => {
ServerPacket::SpellLogExecute(combat_log::read_spell_log_execute(&mut r)?)
}
opcode::SMSG_LOG_XPGAIN => ServerPacket::XpGain(progression::read_xp_gain(&mut r)?),
opcode::SMSG_EXPLORATION_EXPERIENCE => {
ServerPacket::ExplorationXp(progression::read_exploration_xp(&mut r)?)
+194
View File
@@ -1108,3 +1108,197 @@ fn spell_chain_targets_wire() {
messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).is_err()
);
}
/// **The combat-log completeness wire** (decision 1703) — the eight bodies 1571 §5 named as
/// "deliberately out because their wire sources are undecoded". Byte-exact against vmangos's own
/// writers, cited per packet, because a wrong field order here is a wrong *sentence* in the chat
/// log and no gate downstream can see it.
#[test]
fn combat_log_completeness_wire() {
use benilla_protocol::messages::{
DispelFailed, EnchantmentLog, ExecuteLog, PartyKillLog, SpellDispelLog, SpellInstaKillLog,
SpellLogExecute, SpellOutcomeLog,
};
// SMSG_PARTYKILLLOG: killer raw guid, victim raw guid
// (`WorldPackets::Combat::PartyKillLog::AppendBodyTo`, Combat.cpp:52-56).
let body = hx(concat!(
"0700000000000000", // killer 7
"2a00000000000000", // victim 42
));
match messages::parse_server(messages::opcode::SMSG_PARTYKILLLOG, &body).unwrap() {
ServerPacket::PartyKillLog(p) => assert_eq!(
p,
PartyKillLog {
killer: 7,
victim: 42
}
),
other => panic!("party kill log, got {}", other.name()),
}
// SMSG_SPELLINSTAKILLLOG: victim raw guid, spellId (`Spell::EffectInstaKill`,
// SpellEffects.cpp:274-279).
let body = hx(concat!("0900000000000000", "0d270000"));
match messages::parse_server(messages::opcode::SMSG_SPELLINSTAKILLLOG, &body).unwrap() {
ServerPacket::SpellInstaKillLog(p) => assert_eq!(
p,
SpellInstaKillLog {
victim: 9,
spell_id: 9997
}
),
other => panic!("instakill log, got {}", other.name()),
}
// SMSG_PROCRESIST / SMSG_SPELLORDAMAGE_IMMUNE: one body, two opcodes
// (`WorldPackets::Spell::ProcResist`/`SpellOrDamageImmune`, Spell.cpp:88-102).
let body = hx(concat!(
"0100000000000000", // caster 1
"0200000000000000", // target 2
"39300000", // spellId 12345
"01", // logFormat 1 (the reference's "is periodic" flag)
));
let expect = SpellOutcomeLog {
caster: 1,
target: 2,
spell_id: 12345,
log_format: 1,
};
match messages::parse_server(messages::opcode::SMSG_PROCRESIST, &body).unwrap() {
ServerPacket::ProcResist(p) => assert_eq!(p, expect),
other => panic!("proc resist, got {}", other.name()),
}
match messages::parse_server(messages::opcode::SMSG_SPELLORDAMAGE_IMMUNE, &body).unwrap() {
ServerPacket::SpellOrDamageImmune(p) => assert_eq!(p, expect),
other => panic!("spell-or-damage immune, got {}", other.name()),
}
// SMSG_SPELLDISPELLOG: victim PACKED guid, caster PACKED guid, count, count x spellId
// (`Spell::EffectDispel`, SpellEffects.cpp:2524-2539 — the >= 1.12.1 branch, which is ours).
let body = hx(concat!(
"0105", // packed victim: mask 0x01, byte 0x05 -> 5
"0206", // packed caster: mask 0x02, byte 0x06 -> 0x600
"02000000", // count 2
"0a000000", // 10
"14000000", // 20
));
match messages::parse_server(messages::opcode::SMSG_SPELLDISPELLOG, &body).unwrap() {
ServerPacket::SpellDispelLog(p) => assert_eq!(
p,
SpellDispelLog {
victim: 5,
caster: 0x600,
spell_ids: vec![10, 20],
}
),
other => panic!("spell dispel log, got {}", other.name()),
}
// SMSG_DISPEL_FAILED: caster raw guid, victim raw guid, then spell ids TO THE END OF THE BODY —
// vmangos writes no count (`Spell::EffectDispel`, SpellEffects.cpp:2549-2555).
let body = hx(concat!(
"0300000000000000",
"0400000000000000",
"63000000",
"64000000",
));
match messages::parse_server(messages::opcode::SMSG_DISPEL_FAILED, &body).unwrap() {
ServerPacket::DispelFailed(p) => assert_eq!(
p,
DispelFailed {
caster: 3,
victim: 4,
spell_ids: vec![99, 100],
}
),
other => panic!("dispel failed, got {}", other.name()),
}
// The same packet with no failures at all is a legal two-guid body, not a parse error.
let body = hx(concat!("0300000000000000", "0400000000000000"));
match messages::parse_server(messages::opcode::SMSG_DISPEL_FAILED, &body).unwrap() {
ServerPacket::DispelFailed(p) => assert!(p.spell_ids.is_empty()),
other => panic!("dispel failed, got {}", other.name()),
}
// SMSG_ENCHANTMENTLOG: caster raw guid, owner raw guid, itemEntry, spellId, showAffiliation
// (`WorldPackets::Item::EnchantmentLog::AppendBodyTo`, Item.cpp:235-242). An EMPTY caster is
// how the server says the enchant FADED (`Player::SendEnchantmentLog`'s own comment).
let body = hx(concat!(
"0000000000000000", // caster 0 -> a fade
"0b00000000000000", // owner 11
"d2040000", // itemEntry 1234
"39050000", // spellId 1337
"00", // showAffiliation false
));
match messages::parse_server(messages::opcode::SMSG_ENCHANTMENTLOG, &body).unwrap() {
ServerPacket::EnchantmentLog(p) => assert_eq!(
p,
EnchantmentLog {
caster: 0,
owner: 11,
item_entry: 1234,
spell_id: 1337,
show_affiliation: false,
}
),
other => panic!("enchantment log, got {}", other.name()),
}
// SMSG_SPELLLOGEXECUTE: caster PACKED guid, spellId, groupCount, then per group
// {effect, rowCount, rows} (`Spell::SendLogExecute`, Spell.cpp:4662-4778). Three groups here
// cover the three payload shapes that are not a bare guid, including POWER_DRAIN's
// (amount, power, multiplier) — the order verified at the client's own bytes, not only at
// vmangos's (see `ExecuteLog::PowerDrain`).
let body = hx(concat!(
"0107", // packed caster 7
"e8030000", // spellId 1000
"03000000", // 3 groups
// group 1: effect 8 POWER_DRAIN, one row
"08000000",
"01000000",
"1400000000000000", // target 20
"2c010000", // amount 300
"00000000", // power 0 (mana)
"0000803f", // multiplier 1.0
// group 2: effect 24 CREATE_ITEM, one row — an item entry and nothing else
"18000000",
"01000000",
"b80b0000", // itemEntry 3000
// group 3: effect 102 DISMISS_PET, one row — the guid-only tail of the switch
"66000000",
"01000000",
"1e00000000000000", // target 30
));
match messages::parse_server(messages::opcode::SMSG_SPELLLOGEXECUTE, &body).unwrap() {
ServerPacket::SpellLogExecute(p) => assert_eq!(
p,
SpellLogExecute {
caster: 7,
spell_id: 1000,
effects: vec![
(
8,
vec![ExecuteLog::PowerDrain {
target: 20,
amount: 300,
power: 0,
multiplier: 1.0,
}]
),
(24, vec![ExecuteLog::CreateItem { item_entry: 3000 }]),
(102, vec![ExecuteLog::Target { target: 30 }]),
],
}
),
other => panic!("spell log execute, got {}", other.name()),
}
// An effect id vmangos has no case for cannot be skipped — its row width is not on the wire —
// so the body errors rather than desyncing. vmangos never sends one: its own switch returns
// before `SendMessageToSet`, so an unlisted effect means no packet, never a truncated one.
let body = hx(concat!(
"0107", "e8030000", "01000000", "77000000", "01000000"
));
assert!(messages::parse_server(messages::opcode::SMSG_SPELLLOGEXECUTE, &body).is_err());
}
@@ -63,6 +63,25 @@ pub(crate) fn number_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Resu
}
}
/// **Shape C** — a numeric argument the binding reads with a bare `lua_tonumber 0x6f3620` and NO
/// `lua_isnumber` guard, so it cannot fail: absent, `nil`, `true`, a table, a function, or an
/// unparseable string all land on **`0.0`** and the binding completes.
///
/// This is the counterpart to [`number_arg`] (shape A, which raises `Usage:`), and which shape a
/// given argument takes is **per binding, not a global law** — wow-re
/// `scratch/numeric-arg-coercion-law.md`, which censused all 408 widget-registrar entries and
/// found 110 gated positions against 64 ungated ones. The clustering is the useful part: C is the
/// colour/coordinate tuples (`Set*Color`'s r/g/b, `SetTexCoord`, `SetPosition`), A is the single
/// scalar setters (`SetAlpha`, `SetWidth`, `SetValue`, `SetID`). Do not reach for this one because
/// an argument "looks optional" — check the census.
///
/// One instruction settles why nil and a table are not distinguished: `0x6f7c32 cmp ecx,4;
/// jne 0x6f7c6a` refuses every non-string tag without inspecting the value.
pub(crate) fn coerced_number(lua: &Lua, v: Option<Value>) -> f64 {
v.and_then(|v| lua.coerce_number(v).ok().flatten())
.unwrap_or(0.0)
}
/// `is-number-or-string` (`0x6f3510`) → `arg-as-C-string` (`0x6f3690`), with the binding's own
/// `Usage:` string on the failure edge.
///
@@ -100,9 +100,51 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
m.set(
// `GetMaxLetters 0x79929f` — the read half, which we were missing. Not published off the
// `strings` hit alone (that is what put `SetUnit` on the wrong widget earlier today):
// wow-re's numeric-arg round identified `0x79929f` as the GETTER of `[widget+0x340]`,
// the same field the setter below writes, which is table-level evidence rather than a
// name that happens to be in the image.
//
// Its sibling `GetMaxBytes` is in the image too and is NOT added: we do not model
// `maxBytes` at all (a separate field with a `-1` sentinel), and a getter for a field we
// do not have would answer confidently with a number that means nothing.
"GetMaxLetters",
lua.create_function(|lua, this: Table| {
with_editbox(lua, &this, |eb| eb.max_letters as i64)
})?,
)?;
m.set(
// `SetMaxLetters 0x799110` — one of only FOUR widget bindings in the whole registrar that
// calls `lua_gettop` (wow-re `numeric-arg-coercion-law.md` Q3), and its gate is EXACT:
// `cmp eax,2`. So the count is checked and the type is not, which is the opposite of the
// usual pairing and the reason this needs its own body:
//
// SetMaxLetters() -> RAISES `Usage:` (too few)
// SetMaxLetters(50, 60) -> RAISES `Usage:` (too MANY — an exact gate, not a minimum)
// SetMaxLetters(nil) -> completes, stores 0
// SetMaxLetters("12") -> completes, stores 12 (a numeric string coerces)
// SetMaxLetters({}) -> completes, stores 0
//
// and **0 is "no limit"**, not "no letters": the insert path's trim block is skipped
// whole on zero (`0x77c085 test edi,edi; je`), which is what `max_letters == 0` already
// means here. So `SetMaxLetters(nil)` is `SetMaxLetters(0)` is unlimited — aux-addon's
// `gui/core.lua:288` writes exactly that, and benilla raised on it and killed the addon at
// load. (Its neighbour `SetMaxBytes` uses **-1** for the same idea; two adjacent fields,
// two different sentinels.)
"SetMaxLetters",
lua.create_function(|lua, (this, n): (Table, i64)| {
with_editbox(lua, &this, |eb| eb.max_letters = n.max(0) as usize)
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let args: Vec<Value> = args.into_iter().collect();
if args.len() != 1 {
return Err(mlua::Error::runtime(
"Usage: <unnamed>:SetMaxLetters(maxLetters)",
));
}
let n = crate::script::binding_abi::coerced_number(lua, args.first().cloned());
// `__ftol` truncates toward zero; a negative stores as itself there, but our field is
// a `usize` and the trim only ever tests `> 0`, so the two agree on every value that
// can change behaviour.
with_editbox(lua, &this, |eb| eb.max_letters = (n as i64).max(0) as usize)
})?,
)?;
// The submitted-line history (`historyLines`): FrameXML pushes each sent line
@@ -1320,3 +1320,52 @@ fn creating_a_box_does_not_focus_it_the_way_showing_one_does() {
assert!(!s.eval::<bool>("return E:HasFocus()").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **`SetMaxLetters` gates the COUNT and not the type** — one of four widget bindings in the whole
/// registrar that calls `lua_gettop`, and its gate is exact (`cmp eax,2`), while the value goes
/// through a bare `lua_tonumber` with no `isnumber` guard (wow-re `numeric-arg-coercion-law.md`
/// Q1/Q3, VERIFIED).
///
/// That pairing is the opposite of the usual one, which is why it earns a test: benilla typed the
/// argument `i64` and so raised on `SetMaxLetters(nil)` — aux-addon's `gui/core.lua:288` writes
/// exactly that, and died at load on it — while accepting the wrong *number* of arguments
/// silently.
///
/// And `0` is **no limit**, not "no letters".
#[test]
fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E") E:SetFocus()"#)
.unwrap();
let max = |s: &UiScript| s.eval::<i64>("return E:GetMaxLetters()").unwrap();
s.run("E:SetMaxLetters(12)").unwrap();
assert_eq!(max(&s), 12);
// nil is 0 is UNLIMITED — the call completes, which is the whole point.
s.run("E:SetMaxLetters(nil)").unwrap();
assert_eq!(max(&s), 0);
s.run(r#"E:SetMaxLetters(5) E:SetText("abcdefgh")"#)
.unwrap();
assert_eq!(s.eval::<String>("return E:GetText()").unwrap().len(), 5);
s.run(r#"E:SetMaxLetters(nil) E:SetText("abcdefgh")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"abcdefgh",
"0 skips the trim block whole (0x77c085) — it is no limit, not no letters"
);
// A numeric string coerces; anything else is 0.
s.run(r#"E:SetMaxLetters("12")"#).unwrap();
assert_eq!(max(&s), 12);
s.run("E:SetMaxLetters({})").unwrap();
assert_eq!(max(&s), 0);
// The COUNT is exact — too few AND too many both raise.
assert!(s.run("E:SetMaxLetters()").is_err(), "too few raises");
assert!(
s.run("E:SetMaxLetters(50, 60)").is_err(),
"too many raises too"
);
}
+1
View File
@@ -1364,6 +1364,7 @@ impl UiScript {
crate::widget::FrameKind::Slider => "Slider",
crate::widget::FrameKind::ScrollFrame => "ScrollFrame",
crate::widget::FrameKind::Model => "Model",
crate::widget::FrameKind::PlayerModel => "PlayerModel",
crate::widget::FrameKind::MessageFrame => "MessageFrame",
crate::widget::FrameKind::ScrollingMessageFrame => "ScrollingMessageFrame",
crate::widget::FrameKind::ColorSelect => "ColorSelect",
+135 -64
View File
@@ -1,10 +1,33 @@
//! The `Model` widget's method surface — the 3D pane an addon or a FrameXML frame parks a model in.
//! The model-pane family's method surfaces — `Model` and `PlayerModel`, the 3D panes an addon or a
//! FrameXML frame parks a model in.
//!
//! **The same split as [`super::minimap`] and [`super::cooldown`], and for the same reason.** The
//! engine core holds exactly the scene the Lua API reads and writes ([`ModelState`]); the pixels
//! are the app renderer's job. That is the posture the `<Minimap>` and cooldown widgets already
//! run under — a sized hole the game layer draws into — and this widget is the third of that
//! shape, not a new compromise.
//! run under — a sized hole the game layer draws into.
//!
//! ## Two tables, chained — not one table with everything in it
//!
//! 1.12 registers **four** model-pane types, and each has its own Lua method table that **never
//! repeats its base's entries**; a derived pane reaches its base's verbs through the miss leg of
//! `vtable+0x8` (wow-re `ui/scratch/model-pane-method-tables.md`, byte-enumerated 2026-08-30):
//!
//! ```text
//! CSimpleFrame 0x778590
//! └─ CSimpleModel 0x76f870 table 0x878948 (23) <Model> ← built here
//! └─ CGCharacterModelBase 0x506260 table 0x84f1fc (3) <PlayerModel> ← built here
//! ├─ DressUpModelFrame 0x5050d0 table 0x84f190 (3) <DressUpModel> ← not built
//! └─ TabardModel 0x503bd0 table 0x84ee40 (10) <TabardModel> ← not built
//! ```
//!
//! Our `__index` dispatcher already walks a *slice* of registry keys per kind, so the chain is
//! `&[REG_PLAYERMODEL_METHODS, REG_MODEL_METHODS]` and neither table duplicates the other. The
//! direction is derived → base **only**: a plain `<Model>` does not acquire `SetUnit`.
//!
//! `DressUpModel` (`Undress`/`Dress`/`TryOn`) and `TabardModel` (10 tabard verbs) are deliberately
//! **not built**: zero callers in the corpus, and our dress-up window already models the intents
//! host-side (`super::dressup`), so wiring them is a design change to that subsystem rather than a
//! missing verb. Named, not stubbed (decision 1134 §4).
//!
//! ## Why this surface, in this order
//!
@@ -17,31 +40,41 @@
//! f.autocast:SetSequence(0)
//! ```
//!
//! and pfUI is embedded in pfQuest, pfQuest-turtle and ShaguDPS as well — so one missing verb
//! stopped all four dead, each of them *after* the whole rest of the UI had built.
//! and its unit frames build portraits as `CreateFrame("PlayerModel", …)` driven by `SetUnit` +
//! `SetCamera`. pfUI is embedded in pfQuest, pfQuest-turtle and ShaguDPS as well, so one missing
//! type stopped all four dead, each of them *after* the whole rest of the UI had built.
//!
//! ## The names are read off the binary, not assumed
//! ## Ownership is read off the registrar, never off a string scan
//!
//! wow-re's registered-binding scan lists the **scene** half of this widget and not the rest —
//! `SetPosition 0x76dc00`, `SetLight 0x76e1e0`, `GetLight 0x76e7d0`, `GetPosition 0x76ea40`,
//! `SetFogColor 0x76ee60`, `GetFogColor 0x76f080`. It has no row for `SetModel` or its siblings,
//! which would ordinarily mean "not a 1.12 verb" and would make publishing one a fidelity error of
//! exactly the kind decision 1189 warns about (a name we have and the reference lacks routes an
//! addon down a path the real client never takes).
//! This module published `SetUnit`, `RefreshUnit` and `SetRotation` on `Model` until 2026-08-30,
//! on the strength of an isolated-string scan of `WoW.exe` finding one occurrence of each. **A
//! `strings` hit cannot settle ownership** — it answers *whether a name exists*, and `SetUnit`'s
//! single pooled string `0x84f22c` is referenced by **two** method-table entries in two different
//! tables (`PlayerModel 0x84f1fc[0]` and `GameTooltip 0x854290`). All three are `PlayerModel`'s.
//!
//! So it was checked against the shipped image rather than inferred either way: an isolated-string
//! scan of `WoW.exe` finds **one occurrence each** of `SetModel`, `ClearModel`, `GetModel`,
//! `SetSequence`, `SetSequenceTime`, `SetRotation`, `SetFacing`, `SetModelScale`, `SetCamera`,
//! `SetUnit` and `RefreshUnit`. The reference's own FrameXML corroborates the ones it uses —
//! `SetRotation` ×10 (TabardFrame, UIParent), `SetSequence` ×7, `SetSequenceTime` ×5, `SetUnit`
//! ×9, `RefreshUnit` ×3. **These are 1.12 verbs; wow-re's scan is incomplete for this widget**,
//! and that is reported back to wow-re rather than quietly worked around here.
//! The question a `strings` hit *does* settle, and the reason it was reached for: wow-re's
//! registrar-dump tool silently missed six of the 23 widget tables — including this whole family —
//! so the "not in wow-re's scan" half of the old header was a tooling defect, not a fidelity fact.
//! The enumeration recipe that replaces both is §5.1 of the note above: census `(call|jmp)
//! 0x701d80`, read the count from the registering `mov edx`, read the pairs at
//! `base + 8*i`, and settle "which table owns method M" by counting image-wide dword references to
//! M's name VA.
//!
//! ## What is deliberately NOT here
//!
//! `SetFogFar`/`SetFogNear` (nothing in the shipped chain or the corpus calls them) and any
//! interpretation of `SetLight`'s numbers — the engine core has no lighting model, so the tuple is
//! stored verbatim rather than typed into a scene semantics nobody has verified.
//! Seven of `Model`'s own 23 — `AdvanceTime 0x76eca0`, `ReplaceIconTexture 0x76ed70`,
//! `SetFogNear 0x76f1e0`, `GetFogNear 0x76f2d0`, `SetFogFar 0x76f390`, `GetFogFar 0x76f480`,
//! `ClearFog 0x76f540`. No corpus caller, and their bodies are uncarved: `ClearFog`'s exact effect
//! on the colour/near/far triple is a guess until someone reads it, and a guessed clear reads as
//! knowledge. Named, not stubbed.
//!
//! Also absent, and correctly so: `SetCreature` and `SetCustomRace`. Neither string exists in
//! 5875 in any form (substring scan of the whole mapped image returns 0, against a positive
//! control of 27 hits for `Creature`) — they are later-expansion names, and publishing one would
//! be decision 1189's error.
//!
//! And any interpretation of `SetLight`'s numbers — the engine core has no lighting model, so the
//! tuple is stored verbatim rather than typed into a scene semantics nobody has verified.
use mlua::{Lua, MultiValue, Table, Value};
@@ -49,9 +82,14 @@ use super::object::frame_handle_of;
use super::Model;
use crate::widget::{KindState, ModelState};
/// Registry key of the Model method table (the MAXCSTACK discipline: Lua-side root, named key).
/// Registry key of the `Model` method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_MODEL_METHODS: &str = "__benilla_model_methods";
/// Registry key of the `PlayerModel` method table — its **own three** entries only. The other 23
/// names a `<PlayerModel>` answers come from [`REG_MODEL_METHODS`] through the dispatcher's chain,
/// exactly as the client's `0x506260` reaches `0x76f870` on a miss.
pub(super) const REG_PLAYERMODEL_METHODS: &str = "__benilla_playermodel_methods";
/// Run `f` over a frame's Model state under one short write borrow. Errors if `this` is not a live
/// Model (unreachable through the kind dispatcher, but the method table is a plain Lua value — a
/// caller can fish it out and misapply it).
@@ -90,12 +128,13 @@ fn int(v: &Value) -> i32 {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// ── Content: the model, the unit, and clearing ──────────────────────────────────────────
// ── Content: the model, and clearing ────────────────────────────────────────────────────
//
// `SetModel` and `SetUnit` are the two ways a pane gets content and they are alternatives, not
// layers: setting one clears the other, so `GetModel` after a `SetUnit` cannot answer a stale
// path from three frames ago. (The dress-up and paper-doll frames drive the `SetUnit` arm; every
// addon in the corpus drives the path arm.)
// `SetModel` and `PlayerModel:SetUnit` are the two ways a pane gets content and they are
// alternatives, not layers: setting one clears the other, so `GetModel` after a `SetUnit`
// cannot answer a stale path from three frames ago. Only the `SetModel` arm is a `Model` verb —
// the unit arm is `PlayerModel`'s (`playermodel_install` below), which is why the paper-doll
// and dress-up panes are `<PlayerModel>`s and every corpus `<Model>` drives the path arm.
m.set(
"SetModel",
lua.create_function(|lua, (this, path): (Table, Value)| {
@@ -125,28 +164,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})
})?,
)?;
m.set(
"SetUnit",
lua.create_function(|lua, (this, unit): (Table, Value)| {
let unit = match &unit {
Value::String(s) => Some(s.to_str()?.to_string()),
_ => None,
};
with_model(lua, &this, |m| {
m.unit = unit;
m.path = None;
})
})?,
)?;
// `RefreshUnit()` re-reads the unit the pane is already showing — for us a no-op with a live
// receiver check, because our pane holds the unit TOKEN and resolves it at render, so there is
// no cached appearance here to invalidate. Present because the reference's own
// `DressUpFrame`/`PaperDollFrame` call it (3 sites) and an addon that hooks them will too.
m.set(
"RefreshUnit",
lua.create_function(|lua, this: Table| with_model(lua, &this, |_| ()))?,
)?;
// ── Animation ───────────────────────────────────────────────────────────────────────────
m.set(
"SetSequence",
@@ -175,19 +192,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// ── The pane's view: yaw, scale, camera, position ───────────────────────────────────────
//
// `SetRotation` and `SetFacing` are TWO NAMES FOR ONE SLOT. Both are in the binary; the shipped
// FrameXML drives the tabard and character panes with `SetRotation` (10 sites) and calls
// `SetFacing` nowhere, while addons reach for either. A pane with two independent yaws would be
// a bug nobody could see until a frame used both.
for name in ["SetRotation", "SetFacing"] {
m.set(
name,
lua.create_function(|lua, (this, rad): (Table, Value)| {
let rad = num(&rad);
with_model(lua, &this, |m| m.facing = rad)
})?,
)?;
}
// `SetFacing` is `Model`'s yaw setter (`0x878948[4]` -> `0x76dce0`, writing `+0x39c`).
// `SetRotation` writes THE SAME FIELD but is `PlayerModel`'s, not a second name here — see
// `playermodel_install`. A `<Model>` that wants its pane turned calls this one.
m.set(
"SetFacing",
lua.create_function(|lua, (this, rad): (Table, Value)| {
let rad = num(&rad);
with_model(lua, &this, |m| m.facing = rad)
})?,
)?;
m.set(
"GetFacing",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.facing))?,
@@ -276,5 +290,62 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
lua.set_named_registry_value(REG_MODEL_METHODS, m)
lua.set_named_registry_value(REG_MODEL_METHODS, m)?;
playermodel_install(lua)
}
/// `PlayerModel`'s **own three** verbs (table `0x84f1fc`) — nothing else. Everything a
/// `<PlayerModel>` else answers is `Model`'s, reached through the dispatcher's chain.
fn playermodel_install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// `SetUnit(unit)` — `0x84f1fc[0]` -> `0x505d70`. The pane's other content arm, and `SetModel`'s
// alternative: each clears the other, so `GetModel` after a `SetUnit` cannot answer a stale
// path. Ours stores the unit TOKEN and resolves it at render.
m.set(
"SetUnit",
lua.create_function(|lua, (this, unit): (Table, Value)| {
let unit = match &unit {
Value::String(s) => Some(s.to_str()?.to_string()),
_ => None,
};
with_model(lua, &this, |m| {
m.unit = unit;
m.path = None;
})
})?,
)?;
// `RefreshUnit()` — `0x84f1fc[1]` -> `0x505e40`. Re-reads the unit the pane already shows; for
// us a no-op with a live receiver check, because the pane holds the TOKEN and resolves it at
// render, so there is no cached appearance here to invalidate. Present because the reference's
// own `DressUpFrame`/`PaperDollFrame` call it (3 sites) and addons that hook them will too.
m.set(
"RefreshUnit",
lua.create_function(|lua, this: Table| with_model(lua, &this, |_| ()))?,
)?;
// `SetRotation(rad)` — `0x84f1fc[2]` -> the Lua glue `0x505f00` -> the worker `0x505bb0`, whose
// final instruction is `0x505c44 mov [esi+0x39c], eax`: **the same yaw field `SetFacing`
// writes**, so the Lua-observable effect of the two verbs is identical and `GetFacing` reads
// either back. That equality is the whole reason this can be one line.
//
// What `0x505bb0` does BESIDES the yaw write is deliberately not modeled, and is worth naming
// because it is real: it picks a turn animation from the sign of the change (`0xc`
// ShuffleRight when the current facing is **<** the argument, `0xb` ShuffleLeft when **>**,
// `0` Stand on equality or NaN — the mapping wow-re CORRECTED on 2026-08-23 after publishing
// it inverted), plays it unless that id is already armed on bone slot 0, and then
// UNCONDITIONALLY sets `[+0x3e8] = 1` and `[+0x3ec] = now_ms + 100` — a 100 ms turn hold that
// the per-paint `0x505c50` expires. Every one of those is invisible to Lua (no getter reads
// them) and lands on a model renderer we have not built, so storing them here would be state
// nobody writes and nobody reads. The addresses are the pin for the day the renderer exists.
m.set(
"SetRotation",
lua.create_function(|lua, (this, rad): (Table, Value)| {
let rad = num(&rad);
with_model(lua, &this, |m| m.facing = rad)
})?,
)?;
lua.set_named_registry_value(REG_PLAYERMODEL_METHODS, m)
}
+8
View File
@@ -189,6 +189,7 @@ fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
"SLIDER" => FrameKind::Slider,
"SCROLLFRAME" => FrameKind::ScrollFrame,
"MODEL" => FrameKind::Model,
"PLAYERMODEL" => FrameKind::PlayerModel,
"MESSAGEFRAME" => FrameKind::MessageFrame,
"SCROLLINGMESSAGEFRAME" => FrameKind::ScrollingMessageFrame,
"COLORSELECT" => FrameKind::ColorSelect,
@@ -341,6 +342,13 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
// embedded copies, pfQuest, pfQuest-turtle and ShaguDPS — on `SetModel` after the whole
// rest of their UI had already built (`super::modelframe`'s header).
Some(FrameKind::Model) => &[super::modelframe::REG_MODEL_METHODS],
// The chain, not a repetition: `CGCharacterModelBase`'s lookup `0x506260` probes its own
// 3-entry map and on a miss tail-calls `CSimpleModel`'s `0x76f870`. Order is the miss
// order — three names of its own, then all 23 of `Model`'s.
Some(FrameKind::PlayerModel) => &[
super::modelframe::REG_PLAYERMODEL_METHODS,
super::modelframe::REG_MODEL_METHODS,
],
Some(FrameKind::Minimap) => &[super::minimap::REG_MINIMAP_METHODS],
Some(FrameKind::Cooldown) => &[super::cooldown::REG_COOLDOWN_METHODS],
Some(FrameKind::GameTooltip) => &[super::tooltip::REG_TOOLTIP_METHODS],
@@ -122,6 +122,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
FrameKind::Slider => &["Slider", "Frame", "Region"],
FrameKind::ScrollFrame => &["ScrollFrame", "Frame", "Region"],
FrameKind::Model => &["Model", "Frame", "Region"],
// 4 deep, and the ONLY frame chain in the roster that is: `PlayerModel` derives from
// `Model`, and `DressUpModel`/`TabardModel` (unbuilt) derive from it in turn, for a
// maximum depth of 5 (wow-re `ui/scratch/widget-type-identity.md` §6).
FrameKind::PlayerModel => &["PlayerModel", "Model", "Frame", "Region"],
FrameKind::MessageFrame => &["MessageFrame", "Frame", "Region"],
FrameKind::ScrollingMessageFrame => &["ScrollingMessageFrame", "Frame", "Region"],
FrameKind::ColorSelect => &["ColorSelect", "Frame", "Region"],
@@ -590,29 +594,27 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
)?;
m.set(
"SetBackdropColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.bg_color = [r, g, b, a.unwrap_or(1.0)];
}
Ok(())
},
)?,
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.bg_color = color;
}
Ok(())
})?,
)?;
m.set(
"SetBackdropBorderColor",
lua.create_function(
|lua, (this, r, g, b, a): (Table, f32, f32, f32, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.border_color = [r, g, b, a.unwrap_or(1.0)];
}
Ok(())
},
)?,
lua.create_function(|lua, (this, args): (Table, mlua::MultiValue)| {
let color = backdrop_color(lua, args);
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(bd) = model.backdrops.get_mut(&h) {
bd.border_color = color;
}
Ok(())
})?,
)?;
m.set(
"GetBackdropColor",
@@ -907,3 +909,49 @@ fn regions_of(lua: &Lua, this: &Table) -> mlua::Result<Vec<u32>> {
.collect();
Ok(live.into_iter().map(|r| model.region_id(r)).collect())
}
/// One backdrop colour channel, the reference's own conversion (`SetBackdropColor 0x777d30` /
/// `SetBackdropBorderColor 0x7780d0` — instruction-identical bar the delegate; wow-re
/// `scratch/numeric-arg-coercion-law.md` Q4, VERIFIED).
///
/// **r/g/b are UNGATED and alpha is not**, and that asymmetry is the whole of this function:
///
/// · r/g/b (`lua` indices 2/3/4) go through a bare `lua_tonumber`, so a `nil` channel is `0.0`
/// and the call completes — no raise. ShaguTweaks `helpers.lua:248` passes `color.r` from a
/// table that does not always have one, and benilla raised on it, killing the addon.
/// · alpha (index 5) is `lua_isnumber`-gated at `0x778227` with `1.0f` staged at `0x778220`, so a
/// **missing or nil** alpha is OPAQUE. Reading the asymmetry the other way — nil alpha as `0.0`
/// like its neighbours — would turn every one of those borders transparent, which is a worse
/// bug than the raise it replaced and would look like a rendering fault.
///
/// Then the reference clamps to `[0,1]` with **NaN landing on 1.0** (the compare's unordered arm
/// takes the high clamp), and quantizes `×255 + 0.5` through `__ftol` — round-half-up, not the
/// bare truncate `chat-window-record.md`'s colour path uses. The field is a packed `0xAARRGGBB`
/// byte quad, so nothing finer survives the store and we quantize on the way in rather than
/// pretend to a precision `GetBackdropColor` could not read back.
fn backdrop_channel(lua: &Lua, v: Option<Value>, gated: bool) -> f32 {
let x = if gated {
// `lua_isnumber` — a number or a numeric string; anything else takes the staged default.
v.and_then(|v| lua.coerce_number(v).ok().flatten())
.unwrap_or(1.0)
} else {
crate::script::binding_abi::coerced_number(lua, v)
};
let clamped = if x.is_nan() { 1.0 } else { x.clamp(0.0, 1.0) };
// `×255 + 0.5` then `__ftol` (truncate toward zero) = round-half-up over `[0,1]`.
let byte = (clamped * 255.0 + 0.5) as u8;
f32::from(byte) / 255.0
}
/// The four channels of a backdrop colour setter, in the reference's own gating (see
/// [`backdrop_channel`]). Takes the raw stack so a missing argument and an explicit `nil` reach
/// the same place they do there.
fn backdrop_color(lua: &Lua, args: mlua::MultiValue) -> [f32; 4] {
let a: Vec<Value> = args.into_iter().collect();
[
backdrop_channel(lua, a.first().cloned(), false),
backdrop_channel(lua, a.get(1).cloned(), false),
backdrop_channel(lua, a.get(2).cloned(), false),
backdrop_channel(lua, a.get(3).cloned(), true),
]
}
+64 -2
View File
@@ -35,9 +35,14 @@ fn backdrop_installs_and_extracts_pieces_with_colors() {
.collect();
// bg (1) + 8 border pieces.
assert_eq!(pieces.len(), 9);
// First is the bg, tinted the tooltip background color.
// First is the bg, tinted the tooltip background color — QUANTIZED. The reference's colour
// field is a packed `0xAARRGGBB` byte quad and the setter converts `×255 + 0.5` through
// `__ftol` (wow-re `numeric-arg-coercion-law.md` Q4), so `0.09` stores as 23 and reads back
// as `23/255`. This assertion used to hold `0.09` exactly, which was our lossless `[f32; 4]`
// showing through a store the client cannot make.
assert_eq!(pieces[0].0, "bg");
assert_eq!(pieces[0].1, [0.09, 0.09, 0.19, 1.0]);
let q = |x: f32| f32::from((x * 255.0 + 0.5) as u8) / 255.0;
assert_eq!(pieces[0].1, [q(0.09), q(0.09), q(0.19), 1.0]);
// The remaining 8 are the border, white, from the edge file.
assert!(pieces[1..]
.iter()
@@ -173,3 +178,60 @@ fn set_backdrop_nil_tears_down() {
.iter()
.all(|q| !matches!(q.content, QuadContent::Backdrop { .. })));
}
/// **The colour setters' argument gating is asymmetric, and getting it backwards is worse than
/// the bug it replaces** (wow-re `numeric-arg-coercion-law.md` Q4, VERIFIED at `0x777d30` /
/// `0x7780d0`).
///
/// r/g/b go through a bare `lua_tonumber` — a `nil` channel is `0.0` and the call COMPLETES.
/// benilla typed them `f32` and raised, which killed ShaguTweaks at `helpers.lua:248`, where
/// `color.r` comes off a table that does not always have one.
///
/// Alpha is different: `lua_isnumber`-gated at `0x778227` with `1.0f` staged at `0x778220`, so a
/// missing **or nil** alpha is OPAQUE. The tempting blanket fix — "treat every nil as 0.0, like
/// its neighbours" — would have turned every one of those borders transparent, which reads as a
/// rendering fault rather than an API bug and is the reason this test names both halves.
#[test]
fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
let s = script();
s.run(
r#"
f = CreateFrame("Frame", "Plate2")
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge", edgeSize = 16 })
"#,
)
.unwrap();
// A nil channel is 0.0 and the call completes — no raise.
s.run("f:SetBackdropBorderColor(nil, 1, 1, 1)").unwrap();
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return f:GetBackdropBorderColor()")
.unwrap(),
(0.0, 1.0, 1.0, 1.0)
);
// A missing alpha is 1.0, and so is an explicit nil one — NOT 0.0.
s.run("f:SetBackdropColor(0.2, 0.4, 0.6)").unwrap();
let (_, _, _, a) = s
.eval::<(f32, f32, f32, f32)>("return f:GetBackdropColor()")
.unwrap();
assert_eq!(a, 1.0, "a missing alpha is opaque");
s.run("f:SetBackdropColor(0.2, 0.4, 0.6, nil)").unwrap();
let (_, _, _, a) = s
.eval::<(f32, f32, f32, f32)>("return f:GetBackdropColor()")
.unwrap();
assert_eq!(
a, 1.0,
"an explicit nil alpha is opaque too — the isnumber gate"
);
// Out of range clamps; a table coerces to 0 like any non-number.
s.run("f:SetBackdropColor(5, -1, {}, 0.5)").unwrap();
let (r, g, b, a) = s
.eval::<(f32, f32, f32, f32)>("return f:GetBackdropColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.0, 0.0));
// Quantized `×255 + 0.5` through `__ftol`: 0.5 -> 128/255, not 0.5 exactly. The field is a
// packed 0xAARRGGBB byte quad and nothing finer survives the store.
assert_eq!(a, 128.0 / 255.0);
}
+207 -37
View File
@@ -1,9 +1,15 @@
//! **The `Model` widget's Lua surface** — the pane's scene, read back through the API that wrote it.
//! **The model-pane family's Lua surface** — `Model` and `PlayerModel`, read back through the API
//! that wrote them.
//!
//! The property under test throughout is that this widget is *state the app renders*, not state the
//! engine interprets: every setter's value must come back out unchanged, and the two places where
//! the widget does have an opinion (the two names sharing one yaw slot, and content being an
//! either/or) must hold.
//! The property under test throughout is that these widgets are *state the app renders*, not state
//! the engine interprets: every setter's value must come back out unchanged, and the places where
//! the widget does have an opinion (one yaw slot written by two verbs on two classes; content being
//! an either/or) must hold.
//!
//! Plus the one structural property, guarded as a whole block:
//! [`the_two_model_tables_are_the_references_own`] asserts our surfaces against the reference's
//! enumerated tables in **both** directions, so neither a missing verb nor an invented one can slip
//! past — and so `Model` can never re-acquire the three that are `PlayerModel`'s.
use super::common::script;
use crate::script::UiScript;
@@ -38,17 +44,10 @@ fn the_model_pane_holds_the_scene_it_was_given() {
r"Interface\Buttons\UI-AutoCastButton.mdx"
);
// ── ONE YAW SLOT, TWO NAMES. Both are in the binary; the shipped FrameXML drives the tabard
// and character panes with SetRotation and never calls SetFacing, while addons reach for
// either. Two independent yaws would be a bug invisible until one frame used both names.
s.run("MPane:SetRotation(1.5)").unwrap();
assert_eq!(s.eval::<f64>("return MPane:GetFacing()").unwrap(), 1.5);
// The yaw. `SetFacing` is the `Model` verb for it (`0x878948[4]`); `SetRotation` writes the
// same field but belongs to `PlayerModel` and is tested there.
s.run("MPane:SetFacing(-0.25)").unwrap();
assert_eq!(
s.eval::<f64>("return MPane:GetFacing()").unwrap(),
-0.25,
"SetFacing writes the same slot SetRotation does"
);
assert_eq!(s.eval::<f64>("return MPane:GetFacing()").unwrap(), -0.25);
// Scale, camera, position — plain storage, read back through their own getters.
s.run("MPane:SetModelScale(0.4) MPane:SetCamera(2) MPane:SetPosition(0.1, -0.2, 3)")
@@ -64,28 +63,8 @@ fn the_model_pane_holds_the_scene_it_was_given() {
"GetPosition returns the three numbers SetPosition took"
);
// ── CONTENT IS AN EITHER/OR. SetUnit and SetModel are two ways to fill one pane, not layers:
// after a SetUnit, GetModel must not answer a path from three frames ago.
s.run(r#"MPane:SetUnit("player")"#).unwrap();
assert_eq!(
s.eval::<Option<String>>("return MPane:GetModel()").unwrap(),
None,
"SetUnit displaces the model path"
);
s.run(r#"MPane:SetModel("Interface\\Buttons\\Other.mdx")"#)
.unwrap();
// ...and back the other way, which is the direction an addon reskinning the dress-up pane takes.
assert_eq!(
s.eval::<String>("return MPane:GetModel()").unwrap(),
r"Interface\Buttons\Other.mdx"
);
// RefreshUnit is a live no-op: our pane stores the unit TOKEN and resolves it at render, so
// there is no cached appearance to invalidate. It must still exist — the reference's own
// DressUp/PaperDoll frames call it, so an addon hooking them will too.
assert!(s.run("MPane:RefreshUnit()").is_ok());
// ClearModel empties BOTH content slots, not just the one that was last set.
// `SetModel(nil)` is the documented clear and reaches the same place `ClearModel` does — not
// everything in the corpus calls the dedicated verb.
s.run("MPane:ClearModel()").unwrap();
assert_eq!(
s.eval::<Option<String>>("return MPane:GetModel()").unwrap(),
@@ -93,6 +72,197 @@ fn the_model_pane_holds_the_scene_it_was_given() {
);
}
/// **A `<PlayerModel>` is a `<Model>` plus exactly three verbs — and the inheritance runs one way.**
///
/// The client registers four model-pane types and each has its own Lua method table that never
/// repeats its base's; a derived pane reaches its base through the miss leg of `vtable+0x8`
/// (wow-re `ui/scratch/model-pane-method-tables.md` §3). So `PlayerModel`'s three-entry table
/// `0x84f1fc` must sit *over* `Model`'s 23-entry `0x878948`, and nothing may chain the other way:
/// `CSimpleModel`'s lookup `0x76f870` has no leg into `CGCharacterModelBase`'s `0x506260`.
///
/// This is the shape pfUI's unit frames need — `CreateFrame("PlayerModel", ...)` driven by
/// `SetUnit` + `SetCamera`, one verb from each table on the same frame.
#[test]
fn a_player_model_is_a_model_plus_three_and_the_chain_runs_one_way() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
pm = CreateFrame("PlayerModel", "PMPane", UIParent)
m = CreateFrame("Model", "MOnly", UIParent)
"#,
)
.unwrap();
// Its own three resolve on the PlayerModel...
for verb in ["SetUnit", "RefreshUnit", "SetRotation"] {
assert_eq!(
s.eval::<String>(&format!("return type(PMPane.{verb})"))
.unwrap(),
"function",
"PlayerModel must answer its own {verb}"
);
// ...and on the plain Model they are ABSENT. This is the assertion that would have caught
// three years of `SetUnit` published on the wrong class.
assert_eq!(
s.eval::<String>(&format!("return type(MOnly.{verb})"))
.unwrap(),
"nil",
"a plain Model must NOT answer {verb} — the chain runs derived -> base only"
);
}
// ...and the base's verbs resolve through the chain, unrepeated. pfUI's portrait line.
s.run(r#"PMPane:SetUnit("player") PMPane:SetCamera(0)"#)
.unwrap();
assert_eq!(
s.eval::<Option<String>>("return PMPane:GetModel()")
.unwrap(),
None,
"SetUnit displaces the model path — content is an either/or, not layers"
);
s.run(r#"PMPane:SetModel("Interface\\Buttons\\Other.mdx")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return PMPane:GetModel()").unwrap(),
r"Interface\Buttons\Other.mdx",
"...and back the other way, the direction an addon reskinning a paper doll takes"
);
// `SetRotation` writes THE SAME yaw field `SetFacing` does — `0x505bb0`'s last instruction is
// `mov [esi+0x39c], eax`, and `+0x39c` is what `0x76dce0` writes. `GetFacing` reads either.
s.run("PMPane:SetRotation(1.5)").unwrap();
assert_eq!(s.eval::<f64>("return PMPane:GetFacing()").unwrap(), 1.5);
s.run("PMPane:SetFacing(-0.25)").unwrap();
assert_eq!(
s.eval::<f64>("return PMPane:GetFacing()").unwrap(),
-0.25,
"one slot: SetFacing overwrites what SetRotation wrote"
);
// RefreshUnit is a live no-op: the pane stores the unit TOKEN and resolves it at render, so
// there is no cached appearance to invalidate. It must still exist — the reference's own
// DressUp/PaperDoll frames call it, so an addon hooking them will too.
assert!(s.run("PMPane:RefreshUnit()").is_ok());
// ClearModel — a `Model` verb reached through the chain — empties BOTH content slots.
s.run(r#"PMPane:SetUnit("player") PMPane:ClearModel()"#)
.unwrap();
assert_eq!(
s.eval::<Option<String>>("return PMPane:GetModel()")
.unwrap(),
None
);
}
/// **The whole block, both directions** — decision 1718's rule applied to the two model tables.
///
/// The lists below are the reference's OWN enumerations, transcribed entry-for-entry from
/// wow-re's `ui/scratch/model-pane-method-tables.md` §2.1 and §2.2 (each table's count fixed twice:
/// the registering `mov edx, imm32`, and the dword at `base + 8*count` being the start of the
/// string pool). They are **not** the names anyone noticed were missing — that is exactly the
/// mistake 1718 records, and it was re-made inside the test written to enforce it.
///
/// Guarding both directions is the point:
///
/// - **no gap** — every reference name we claim to publish must resolve;
/// - **no superset** — a name the reference does not have on a table must not resolve there
/// (1189: a name we have and the reference lacks routes an addon down a path the real client
/// never takes);
/// - **the seven we deliberately do not build must stay absent**, so "unbuilt" cannot quietly
/// become "stubbed" without this test being edited to say so.
#[test]
fn the_two_model_tables_are_the_references_own() {
/// `Model` — `CSimpleModel`, table `0x878948`, 23 entries, in table order.
const MODEL_23: [&str; 23] = [
"SetModel",
"GetModel",
"ClearModel",
"SetPosition",
"SetFacing",
"SetModelScale",
"SetSequence",
"SetSequenceTime",
"SetCamera",
"SetLight",
"GetLight",
"GetPosition",
"GetFacing",
"GetModelScale",
"AdvanceTime",
"ReplaceIconTexture",
"SetFogColor",
"GetFogColor",
"SetFogNear",
"GetFogNear",
"SetFogFar",
"GetFogFar",
"ClearFog",
];
/// `PlayerModel` — `CGCharacterModelBase`, table `0x84f1fc`, 3 entries, in table order.
const PLAYERMODEL_3: [&str; 3] = ["SetUnit", "RefreshUnit", "SetRotation"];
/// The subset of [`MODEL_23`] this client does not build. Named, not stubbed (1134 §4) — their
/// bodies are uncarved and they have no corpus caller. Moving a name OUT of here means the
/// verb was actually implemented.
const UNBUILT: [&str; 7] = [
"AdvanceTime",
"ReplaceIconTexture",
"SetFogNear",
"GetFogNear",
"SetFogFar",
"GetFogFar",
"ClearFog",
];
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
CreateFrame("Model", "GuardM", UIParent)
CreateFrame("PlayerModel", "GuardPM", UIParent)
"#,
)
.unwrap();
let is_fn = |s: &UiScript, frame: &str, name: &str| {
s.eval::<String>(&format!("return type({frame}.{name})"))
.unwrap()
== "function"
};
for name in MODEL_23 {
let want = !UNBUILT.contains(&name);
// A `Model` verb resolves on BOTH panes — on the PlayerModel through the chain.
for frame in ["GuardM", "GuardPM"] {
assert_eq!(
is_fn(&s, frame, name),
want,
"{frame}.{name}: table 0x878948 has it; built = {want}"
);
}
}
for name in PLAYERMODEL_3 {
assert!(
is_fn(&s, "GuardPM", name),
"GuardPM.{name}: table 0x84f1fc entry, all three are built"
);
assert!(
!is_fn(&s, "GuardM", name),
"GuardM.{name}: 0x84f1fc is NOT reachable from CSimpleModel's lookup"
);
}
// The two names a `strings` scan of the model band would tempt anyone into, which do not exist
// in 5875 in ANY form (substring scan of the mapped image returns 0, positive control 27 hits
// for `Creature`). They are later-expansion verbs; publishing one is decision 1189's error.
for name in ["SetCreature", "SetCustomRace"] {
for frame in ["GuardM", "GuardPM"] {
assert!(
!is_fn(&s, frame, name),
"{frame}.{name} does not exist in 1.12.1.5875"
);
}
}
}
/// **`SetSequenceTime` is a scrub INTO the current sequence**, so changing the sequence drops it.
///
/// The cooldown indicator drives this pair every frame — `SetSequence(n)` then
@@ -1447,6 +1447,15 @@ fn a_frames_type_chain_matches_the_roster() {
&["GameTooltip", "Frame", "Region"],
),
("Minimap", "Minimap", &["Minimap", "Frame", "Region"]),
// **The roster's only 4-deep frame chain.** `PlayerModel` derives from `Model`, which is
// why `SetUnit` on a portrait pane finds `SetCamera` too (wow-re
// `ui/scratch/widget-type-identity.md` §6; the unbuilt `DressUpModel`/`TabardModel` hang
// off this one and make the roster's true maximum 5).
(
"PlayerModel",
"PlayerModel",
&["PlayerModel", "Model", "Frame", "Region"],
),
// **Our Era-shaped divergence reports what 1.12's cooldown IS**: a Model. 1.12.1 has no
// `Cooldown` type name at all (the roster is 23 and none is that).
("Cooldown", "Model", &["Model", "Frame", "Region"]),
+55 -17
View File
@@ -27,6 +27,31 @@ pub enum FrameKind {
Slider,
ScrollFrame,
Model,
/// `CGCharacterModelBase` (`0x505680`, size `0x3f8`) — the **unit-showing** model pane, and a
/// registered `CreateFrame` type in its own right: pair `("PlayerModel" @0x84343c, factory
/// 0x495bd0)` at registration site `0x49597f`, one of the eight the game client registers
/// through `0x495940` (wow-re `ui/scratch/model-pane-method-tables.md` §1).
///
/// **It extends [`FrameKind::Model`] and adds exactly three verbs** — `SetUnit`, `RefreshUnit`,
/// `SetRotation` (table `0x84f1fc`, 3 entries). Its Lua surface is those three *plus* all 23 of
/// `Model`'s, and it gets them by **chaining, not repetition**: `CGCharacterModelBase`'s method
/// lookup `0x506260` probes its own 3-entry map and, on a miss, tail-calls `0x506290 call
/// 0x76f870` — `CSimpleModel`'s. Our `__index` already walks a *slice* of registry keys, so the
/// chain is `&[PLAYERMODEL, MODEL]` and nothing is duplicated.
///
/// **The direction is derived → base only.** A plain `<Model>` does *not* acquire `SetUnit`/
/// `RefreshUnit`/`SetRotation`: nothing chains from `0x76f870` down into `0x506260`. benilla
/// published all three on `Model` until 2026-08-30 — a `strings WoW.exe` hit read as ownership,
/// which it cannot be (`SetUnit`'s pooled string `0x84f22c` is referenced by **two** table
/// entries, `PlayerModel 0x84f1fc` and `GameTooltip 0x854290`, and by no `Model` entry).
///
/// Same posture as its base: the engine core holds the scene the Lua API reads and writes
/// ([`KindState::Model`], shared with [`FrameKind::Model`]); the pixels are the app renderer's.
/// `CGCharacterModelBase` adds `0x1c` bytes of members over `CSimpleModel` — the turn-animation
/// flag/expiry at `+0x3e8`/`+0x3ec` that `SetRotation` arms — and those are **not modeled**:
/// no getter reads them, and they drive a shuffle animation on a renderer we have not built.
/// `script::modelframe`'s `SetRotation` carries the addresses.
PlayerModel,
/// `CSimpleMessageFrame` — the non-scrolling message frame (`UIErrorsFrame`'s class, and the
/// one `CreateFrame("MessageFrame")` makes). Its behaviour (the display lines, the per-line
/// fade, `insertMode`) is modeled in [`KindState::Message`]. Sibling of
@@ -294,7 +319,9 @@ impl CooldownState {
}
}
/// The `Model` widget's scene state (`0x6eecf0`'s frame type; the `<Model>` element).
/// The model-pane scene state — **shared by [`FrameKind::Model`] and [`FrameKind::PlayerModel`]**,
/// because the client's `CGCharacterModelBase` (`0x505680`) *extends* `CSimpleModel` (`0x76c8e0`)
/// rather than replacing it: every field below is a `CSimpleModel` member both classes carry.
///
/// **What this is and is not.** The 1.12 `Model` widget is a viewport that draws one M2 in a
/// little scene of its own — the character pane, the tabard designer, the minimap ping, the pet
@@ -303,23 +330,32 @@ impl CooldownState {
/// contract [`MinimapState`] and [`CooldownState`] already run under, and the reason both of those
/// exist as state-only kinds here.
///
/// **Every field is a 1.12 binding's storage, not a guess at one.** The names are in the shipped
/// binary as isolated strings (`SetModel`, `ClearModel`, `GetModel`, `SetSequence`,
/// `SetSequenceTime`, `SetRotation`, `SetFacing`, `SetModelScale`, `SetCamera`, `SetPosition`,
/// `SetLight`, `SetFogColor`, `SetUnit`, `RefreshUnit`), and wow-re's ledger already has the
/// scene half registered — `SetPosition 0x76dc00`, `SetLight 0x76e1e0`, `GetLight 0x76e7d0`,
/// `GetPosition 0x76ea40`, `SetFogColor 0x76ee60`, `GetFogColor 0x76f080`. (wow-re's
/// registration scan does *not* list the model/sequence half; the strings say otherwise, and that
/// gap is reported back rather than worked around.)
/// **Every field is a 1.12 binding's storage, and which binding is read off the registrar, not off
/// a string scan.** wow-re enumerated the whole family at the pair bytes on 2026-08-30
/// (`ui/scratch/model-pane-method-tables.md`): `Model`'s table is `0x878948` with **23** entries,
/// `PlayerModel`'s is `0x84f1fc` with **3**, and the derived table never repeats its base's. The
/// earlier justification for these fields — "the names are in the binary as isolated strings" — is
/// the mistake that round corrected: a `strings` hit answers *whether a name exists*, never *which
/// table owns it*, and `SetUnit`'s single pooled string `0x84f22c` is referenced by two entries in
/// two different tables. Ownership now comes from a dword-reference count over the name's VA.
///
/// **Seven of `Model`'s 23 are not published yet** — `AdvanceTime 0x76eca0`,
/// `ReplaceIconTexture 0x76ed70`, `SetFogNear 0x76f1e0`, `GetFogNear 0x76f2d0`,
/// `SetFogFar 0x76f390`, `GetFogFar 0x76f480`, `ClearFog 0x76f540` — named here rather than
/// stubbed (decision 1134 §4). They have no corpus caller and their bodies are uncarved.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelState {
/// The M2/MDX path last given to `SetModel`, or `None` after `ClearModel` / before any set.
/// Stored **as written** — the client's own path space is `Interface\...\Foo.mdx`, and the
/// app's loader is what maps `.mdx` to the `.m2` that actually ships.
pub path: Option<String>,
/// `SetUnit(unit)` — the *other* way a pane gets its content, used by the dress-up and
/// paper-doll frames. Mutually exclusive with [`Self::path`] in practice: whichever was set
/// `PlayerModel:SetUnit(unit)` — the *other* way a pane gets its content, used by the dress-up
/// and paper-doll frames. Mutually exclusive with [`Self::path`] in practice: whichever was set
/// last is what the pane shows, so each setter clears the other.
///
/// The field lives on every pane because it is a `CSimpleModel` member; the **setter** does
/// not — `SetUnit` is [`FrameKind::PlayerModel`]'s (`0x84f1fc[0]` → `0x505d70`), and a plain
/// `<Model>` has no way to reach it.
pub unit: Option<String>,
/// `SetSequence(n)` — the animation index the pane plays.
pub sequence: i32,
@@ -327,10 +363,11 @@ pub struct ModelState {
/// cooldown indicator's whole mechanism is this pair driven per frame, which is why it is
/// stored rather than folded into [`Self::sequence`].
pub sequence_time: Option<(i32, i32)>,
/// `SetRotation`/`SetFacing` — the model's yaw in radians. **Two names, one slot**: the
/// reference FrameXML drives the tabard and character panes with `SetRotation` (10 sites) and
/// nothing in the shipped chain calls `SetFacing`, but both are in the binary and both mean
/// the pane's yaw.
/// The pane's yaw in radians — `CSimpleModel+0x39c`. **One slot, written by two verbs on two
/// different classes**: `Model:SetFacing` (`0x76dce0`) and `PlayerModel:SetRotation`
/// (`0x505f00` → `0x505bb0`, whose last act is `0x505c44 mov [esi+0x39c], eax` — literally the
/// same field). That is why the reference's rotate buttons can drive a paper-doll pane through
/// `SetRotation` and `GetFacing` reads it back.
pub facing: f32,
/// `SetModelScale` — the model's own scale within the pane, default 1.
pub scale: f32,
@@ -344,8 +381,9 @@ pub struct ModelState {
/// no lighting model, and inventing a typed one would be asserting a scene semantics we have
/// not verified.
pub light: Option<Vec<f32>>,
/// `SetFogColor(r, g, b)` — `None` until set; the client also has a `SetFogFar`/`SetFogNear`
/// pair we do not model (nothing in the corpus or the shipped chain calls them).
/// `SetFogColor(r, g, b)` — `None` until set. The client's fog surface is seven verbs, not two:
/// this pair plus `SetFogNear`/`GetFogNear`/`SetFogFar`/`GetFogFar`/`ClearFog`, which the
/// struct doc names as unbuilt.
pub fog_color: Option<(f32, f32, f32)>,
}
+6 -1
View File
@@ -659,7 +659,12 @@ impl WidgetArena {
FrameKind::ColorSelect => {
KindState::ColorSelect(kinds::ColorSelectState::default())
}
FrameKind::Model => KindState::Model(kinds::ModelState::default()),
// Both model panes share one state: `CGCharacterModelBase` EXTENDS `CSimpleModel`,
// so a `<PlayerModel>` carries every `CSimpleModel` member and adds only the
// turn-animation pair we do not model (`ModelState`'s doc).
FrameKind::Model | FrameKind::PlayerModel => {
KindState::Model(kinds::ModelState::default())
}
FrameKind::Minimap => KindState::Minimap(kinds::MinimapState::default()),
FrameKind::Cooldown => KindState::Cooldown(kinds::CooldownState::default()),
FrameKind::GameTooltip => KindState::Tooltip(kinds::TooltipState::default()),