the guild window opens: roster, ranks, notes and the message of the day

- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them
- setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back
- unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used
- the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:38:54 +07:00
co-authored by Claude Opus 5
parent 04340a6893
commit 8d3d805ec8
78 changed files with 13155 additions and 271 deletions
+2 -1
View File
@@ -1174,7 +1174,8 @@
SetDesaturation(b.icon, nil)
end
-- The four slots' own paint decides their restored colour: an item locked on the cursor
-- wants 0.4 grey, not the flat white SetDesaturation(nil) just left.
-- wants its 0.4 grey back, which the un-desaturate above does not restore (it clears
-- the greyscale flag, not the tint).
for i = 1, 4 do
BenillaBagBarSlot_Update(getglobal("CharacterBag" .. (i - 1) .. "Slot"))
end
@@ -0,0 +1,69 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<!--
The innkeeper's "make this your home?" dialog (decision 1331): a registry entry on the
shared StaticPopup engine (UiPanels.xml) plus its UIParent event arm, written against the
patch-chain FrameXML (mpqx extraction, decision 0246) — StaticPopup.lua l.1308-1322 (the
entry) and UIParent.lua l.125 / l.547-548 (the registration and the show arm). The string
is GlobalStrings.lua l.840, verbatim.
It is NOT part of GossipFrame.xml, and the reason is the wire: selecting the innkeeper's
gossip line makes the server CLOSE the gossip menu and then ask. By the time this dialog
goes up the window that raised it is already gone, so it belongs with the other free-
standing confirms rather than with the panel.
The whole flow behind it is two packets — SMSG_BINDER_CONFIRM in, CMSG_BINDER_ACTIVATE out
— and only the second one binds anything (`crate::ui_binder`'s header carries the law).
Accepting is therefore the ONLY path to a moved hearthstone; there is no client-side bind
and nothing to undo on Cancel, which is why this entry has no OnCancel.
-->
<Script><![CDATA[
-- ── Strings (GlobalStrings.lua, verbatim) ───────────────────────────────────────────────
local CONFIRM_BINDER = "Do you want to make %s your new home?" -- l.840
local ACCEPT = "Accept" -- l.10
local CANCEL = "Cancel" -- l.464
-- ── The confirm — answering to StaticPopup.lua l.1308-1322 ─────────────────────────────
-- This is the dialog's ONLY teardown, and it is a poll rather than an event: walk out of
-- the innkeeper's range and the question leaves the screen by itself, with no packet
-- either way. `CheckBinderDist()` is the engine binding behind it
-- (`benilla-ui/src/script/binder.rs`), false as soon as the app's NPC-session guard has
-- closed the question. Named rather than inlined into the entry, because "the question
-- expires when you walk away" is the interesting half of this file.
local function hideOnceOutOfRange(_, dialog)
if not CheckBinderDist() then
dialog:Hide()
end
end
StaticPopupDialogs["CONFIRM_BINDER"] = {
text = CONFIRM_BINDER,
button1 = ACCEPT,
button2 = CANCEL,
OnAccept = function()
ConfirmBinder()
end,
OnUpdate = hideOnceOutOfRange,
timeout = 0,
hideOnEscape = 1,
}
-- ── The UIParent event arm (UIParent.lua l.547-548) ────────────────────────────────────
-- arg1 is the area name the "%s" takes.
function BenillaBinderConfirmDriver_OnEvent()
if event == "CONFIRM_BINDER" then
StaticPopup_Show("CONFIRM_BINDER", arg1)
end
end
]]></Script>
<Frame name="BenillaBinderConfirmDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("CONFIRM_BINDER")
</OnLoad>
<OnEvent>BenillaBinderConfirmDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
File diff suppressed because it is too large Load Diff
@@ -80,12 +80,28 @@
-- The Era icon-type string → the real Blizzard gossip-icon texture (resolves from the MPQ at
-- render). Unmapped types fall back to the chat bubble (the app already defaults unknowns to
-- "gossip", so this is belt-and-braces).
--
-- These are ALL TEN the 1.12 install ships under `Interface\GossipFrame\*GossipIcon.blp`.
-- The reference builds the path by pasting the type string straight in (ref
-- GossipFrame.lua:123), so the shipped art is the whole vocabulary a row can draw. The app
-- indexes the wire icon byte into the client's own name table (`ui_gossip::
-- GOSSIP_ICON_TYPES`, VERIFIED at 0x84b7ac — decisions 1331/1335); four of these
-- (healer/banker/petition/tabard) were unreachable before 1331, which is why the
-- innkeeper's bind line drew a speech bubble (B249).
--
-- The client's table has an ELEVENTH name, `auctioneer` at index 10, and 5875 ships no
-- texture for it — the reference draws nothing there. No key for it here on purpose: our
-- lookup then falls back to the chat bubble, which is the better wrong answer than a
-- missing texture, and `ui_gossip`'s test pins the absence so nobody invents a path.
BENILLA_GOSSIP_ICONS = {
gossip = "Interface\\GossipFrame\\GossipGossipIcon",
vendor = "Interface\\GossipFrame\\VendorGossipIcon",
taxi = "Interface\\GossipFrame\\TaxiGossipIcon",
trainer = "Interface\\GossipFrame\\TrainerGossipIcon",
healer = "Interface\\GossipFrame\\HealerGossipIcon",
binder = "Interface\\GossipFrame\\BinderGossipIcon",
banker = "Interface\\GossipFrame\\BankerGossipIcon",
petition = "Interface\\GossipFrame\\PetitionGossipIcon",
tabard = "Interface\\GossipFrame\\TabardGossipIcon",
battlemaster = "Interface\\GossipFrame\\BattleMasterGossipIcon",
}
@@ -164,10 +164,11 @@
-- The reference asks the texture itself whether the desaturating shader is available
-- (`local shaderSupported = icon:SetDesaturated(desaturated)`) and falls through to a
-- flat grey tint when it is not — 1.12 ran on fixed-function hardware where that was
-- the ordinary answer. benilla has no `Texture:SetDesaturated` at all (it is the top
-- row of the harness's missing-METHODS ranking, 61 corpus addons), so the feature test
-- is on the METHOD's existence and the no-shader branch is the one we take. When the
-- real verb lands this line starts using it with nothing else to change.
-- the ordinary answer. Since decision 1327 benilla's answer is YES: the icon greys to
-- luminance in the shader and wears the caller's own tint on top, which is what the
-- ref's own `(1, 0.65, 0.65, 0.65)` and `(locked, 0.5, 0.5, 0.5)` calls are asking
-- for. The `icon.SetDesaturated and` guard stays for a region leaf without the verb;
-- the flat-grey arm below is then the ref's own fallback, unchanged.
local shaderSupported = icon.SetDesaturated and icon:SetDesaturated(desaturated)
-- Undesaturated is full colour whatever the caller passed; desaturated is the caller's
-- own tint when they named one AND the shader took it, and the flat grey otherwise.
+10 -5
View File
@@ -233,15 +233,20 @@
BenillaMacroFrameText_Resize()
end
-- ref l.122-126. The engine calls nothing by this name; the spellbook's shift-click does
-- (SpellBookFrame.xml, ref SpellBookFrame.lua:272-280) — the "shift click a spell in your
-- spellbook with the macro UI open" line MACRO_HELP_TEXT_LINE5 advertises.
-- ref MacroFrame.lua:93-97. The engine calls nothing by this name; the spellbook's
-- SHIFT-click does (SpellBookFrame.xml, ref SpellBookFrame.lua:271-283) — the "Shift click
-- a spell in your spellbook with the macro UI open" line MACRO_HELP_TEXT_LINE5 advertises.
--
-- Three things here are the reference's, not oversights (B248 reported the first as a bug):
-- the line arrives WHOLE (`/cast <name>`, never the bare name — that insert is a later
-- client's `ChatEdit_InsertLink`), it is appended with NO separator, and the visibility
-- gate is the BODY BOX's, so a shift-click with the name/icon popup up writes nothing.
-- The dirty flag and the scroll refit are the box's own OnTextChanged, exactly as they are
-- for a typed character — this function is the ref's three lines and stays them.
function BenillaMacroFrame_AddMacroLine(line)
local box = getglobal("BenillaMacroFrameText")
if box:IsVisible() then
box:SetText((box:GetText() or "") .. line)
getglobal("BenillaMacroFrame").textChanged = 1
BenillaMacroFrameText_Resize()
end
end
+9 -5
View File
@@ -21,9 +21,12 @@
RENDER APPROXIMATIONS (the only geometry-affecting deviations; every other measurement in the
markup below is the reference's own):
· Unread/read row **desaturation**: the engine has no `SetDesaturation`, so the read icon +
slot ring grey via vertex color (0.5) — ref MailFrame.lua l.130-141's SetVertexColor legs
are exact, only the icon's desaturation is the vertex-grey stand-in.
· Unread/read row **desaturation**: the read icon + slot ring grey via vertex color (0.5)
— ref MailFrame.lua l.130-141's SetVertexColor legs are exact, only the icon's
desaturation is the vertex-grey stand-in. The engine HAS `SetDesaturation` since
decision 1327 (it did not when this was written); routing the read icon through it is a
known, unclaimed gap — it needs the ref's own l.130-141 legs read back first, so that
the restore side is the reference's and not an invention.
· The **body scroll**: `OpenMail/SendMailScrollFrame` (296×257 at 21,-97) is transcribed as a
plain Frame at that exact rect with the body FontString/EditBox INSIDE it (no live
scrollbar); a very long letter clips. The stationery backdrop + scrollbar-art geometry are
@@ -172,8 +175,9 @@
subjectText:SetText(subject)
local senderText = getglobal("MailItem"..i.."Sender")
senderText:SetText(sender)
-- Unread gold / read grey (ref l.130-141; the read icon's SetDesaturation is our
-- vertex-grey stand-in — the engine has no desaturation).
-- Unread gold / read grey (ref l.130-141; the read icon's SetDesaturation is
-- still our vertex-grey stand-in — see this file's header for why 1327's real
-- desaturation has not been wired in here yet).
if wasRead then
senderText:SetTextColor(0.75, 0.75, 0.75)
subjectText:SetTextColor(0.75, 0.75, 0.75)
@@ -50,9 +50,6 @@
slot glow while its form is up), but the trade-skill-window arm of the engine
predicate is unmodeled (the same named gap `ui_action`'s IsCurrentAction carries),
so those four events would re-read an arm that never fires — omitted with it.
- The MacroFrame branch (`SpellButton_OnClick`'s shift-click "already in a macro editor"
fork): no macro window exists — shift-click always falls to the ref's own "else"
(`PickupSpell`), exactly what the ref itself does whenever no MacroFrame is visible.
- The `this.updateTooltip`/`OnUpdate` re-poll loop around the hover: `GameTooltip:SetSpell`
itself is LIVE (the engine channel, decision 0276's line law; it reads whichever book
`bookType` names — 1050), but its per-frame re-poll is dropped, since nothing in the
@@ -361,9 +358,15 @@
elseif IsShiftKeyDown() then
-- ref SpellButton_OnClick l.271-283 (LIVE since decision 0983): with the macro
-- editor open a shift-click APPENDS `/cast <name>[(<rank>)]` to the body instead of
-- picking the spell up — the behaviour MACRO_HELP_TEXT_LINE5 advertises ("shift
-- picking the spell up — the behaviour MACRO_HELP_TEXT_LINE5 advertises ("Shift
-- click a spell in your spellbook with the macro UI open to add it to your macro").
-- The `/cast` alias comes from the shipped strings (SLASH_CAST1), never typed here.
--
-- DO NOT "FIX" THIS TO INSERT THE BARE NAME (B248, decision 1328). The whole
-- `/cast` line is what 1.12.1 writes; the bare-name-at-the-cursor insert people
-- remember is a LATER client's (`ChatEdit_InsertLink`). And the gate is SHIFT,
-- not the mouse button: a plain right click on this page casts, same as a left one
-- — pinned both ways in `ui_script::spellbook_tests`.
local macroFrame = getglobal("BenillaMacroFrame")
if macroFrame and macroFrame:IsVisible() then
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
+11 -9
View File
@@ -118,10 +118,15 @@
(Blizzard_TalentUITemplates.xml)'s own additions (the EmptySlot backdrop, RankBorder, Rank
text) — CharacterFrame.xml's own `BenillaPaperDollItemSlotTemplate` is the identical-shape
precedent for this whole reconstruction move.
- `SetItemButtonTexture`/`SetItemButtonDesaturated`: no engine equivalent (ENGINE API note).
The icon Texture child (`$parentIconTexture`) is `SetTexture`'d directly; desaturation is
approximated by `SetVertexColor(0.65, 0.65, 0.65)` / restored to `(1, 1, 1)`
(CharacterFrame.xml:873's identical gray-dim convention for its own equip slots).
- `SetItemButtonTexture`: no engine equivalent (ENGINE API note). The icon Texture child
(`$parentIconTexture`) is `SetTexture`'d directly.
- `SetItemButtonDesaturated` IS the ref's own verb here (ItemButtonTemplate.xml, loaded far
above this file in the .toc), called exactly as the ref calls it. It used to be open-coded
as `SetVertexColor(0.65, 0.65, 0.65)` / restored to `(1, 1, 1)`, because the engine had no
desaturation and a dim tint was the whole of what a grey-out could be. Decision 1327 gave
the engine `Texture:SetDesaturated`, so the ref's verb now lands as greyscale AND the 0.65
dim — B162: at 0 talent points every unlearned icon in the real client goes greyscale, and
a 0.65 multiply on colourful art does not read as greyed out at all.
Engine-gap notes (transcribed anyway per this file's own instructions, or worked around):
- `text=` XML attributes are a LITERAL, verbatim `SetText` in this engine — verified,
@@ -462,8 +467,7 @@
-- Talent must meet prereqs or the player must have no points to spend
if BenillaTalentFrame_SetPrereqs(tier, column, forceDesaturated, tierUnlocked,
GetTalentPrereqs(selectedTab, i)) and meetsPrereq then
-- SetItemButtonDesaturated(button, nil): restore full color.
getglobal(buttonName .. "IconTexture"):SetVertexColor(1, 1, 1)
SetItemButtonDesaturated(button, nil)
if rank < maxRank then
-- Rank is green if not maxed out
@@ -478,9 +482,7 @@
getglobal(buttonName .. "RankBorder"):Show()
getglobal(buttonName .. "Rank"):Show()
else
-- SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65): gray-dim the icon
-- (this file's header comment, CharacterFrame.xml:873's precedent).
getglobal(buttonName .. "IconTexture"):SetVertexColor(0.65, 0.65, 0.65)
SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)
getglobal(buttonName .. "Slot"):SetVertexColor(0.5, 0.5, 0.5)
if rank == 0 then
getglobal(buttonName .. "RankBorder"):Hide()
+25 -7
View File
@@ -538,18 +538,27 @@
(used for header rows, blanked per-row for services), the name, and the rank subtext beside it.
NO item icon (the list rows carry none; the spell icon is the DETAIL pane's). <OnLoad> wires the
child regions + the fold highlight onto `self` (the QuestLogFrame.xml row-template shape).
THE ROW IS A FLOW, NOT TWO COLUMNS (B253). The name carries **no width**, so it never wraps:
a FontString given an explicit width wraps at it, and a 16px row cannot grow — which is how
"Handstitched Leather Pants" came to draw two lines over the row beneath it. The reference's
own row (`ClassTrainerFrameTemplates.xml`, read off the player's chain, never transcribed)
makes the name the Button's `<ButtonText>` at width **0** and then SetPoints the subtext to
that text's RIGHT + 10 from Lua, per row — so a long name simply runs on and pushes its
"(Rank N)" along. We express the same law declaratively: no size on either string, the
subtext anchored to the name's right edge. The 160/188 fixed columns this replaces were
benilla's invention, and both halves of them were wrong — the cap wrapped, and the column
put the rank at a fixed x the reference never uses.
-->
<Button name="BenillaTrainerServiceTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="160" y="0"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="22" y="0"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentSubText" inherits="GameFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="100" y="0"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="188" y="0"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="LEFT" relativeTo="$parentName" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
@@ -644,21 +653,30 @@
</Texture>
</Layer>
<!-- The FIXED detail-pane FontStrings (ref DetailScrollChildFrame), flattened onto this
window's own Layers (the flat-layout gap). Blanked/filled by Hide/ShowDetails. -->
window's own Layers (the flat-layout gap). Blanked/filled by Hide/ShowDetails.
THE WIDTHS ARE THE REFERENCE'S, not ours (B253). A FontString wraps at whatever
width it is given, so an invented narrow one is a wrap bug waiting for a long name:
the detail name at 150 broke "Handstitched Leather Pants" over two lines. The
reference's detail child is 296 wide with its name/requirements at **244** and its
description at **290**; our flattened anchors put the name at the same window x (68)
the child's 46 resolves to, so the same numbers land in the same place — and, unlike
the 290/300 they replace, they stay inside the pane instead of running past its
right edge at 358/330. -->
<Layer level="OVERLAY">
<FontString name="BenillaTrainerSkillName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="150" y="0"/></Size>
<Size><AbsDimension x="244" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="68" y="-293"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaTrainerSkillRequirements" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="290" y="0"/></Size>
<Size><AbsDimension x="244" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerSkillName" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaTrainerCostLabel" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="-340"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="BenillaTrainerSkillDescription" inherits="GameFontHighlightSmall" justifyH="LEFT">
<Size><AbsDimension x="300" y="0"/></Size>
<Size><AbsDimension x="290" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaTrainerCostLabel" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
+87 -21
View File
@@ -740,15 +740,16 @@
-- SetDesaturation(texture, desaturation) — ref UIParent.lua l.1499-1510, plus two guards
-- it does not have: a nil texture, and a texture carrying no `SetDesaturated` method at
-- all (the reference calls it unconditionally, l.1501; ours cannot, see below). The
-- reference itself has a no-shader fallback, and OURS IS ALWAYS THAT PATH: this engine has
-- no SetDesaturated, so `shaderSupported` is false exactly as on a 1.12 card without the
-- desaturation shader, and the grey comes from the ref's own vertex-colour branch. Written
-- as the wrapper rather than open-coded at each call site so the day a real desaturation
-- lands, one function changes. First caller here is the game menu's bag-bar greying
-- (decision 0674); PartyFrame.xml's disconnected-member branch has been CALLING this
-- global since decision 0434 with nothing defining it — an error thrown every time a party
-- member went offline, which is why it went unnoticed.
-- all (the reference calls it unconditionally, l.1501; ours keeps the guard for a region
-- leaf that never grew the verb). Since decision 1327 the engine HAS a desaturating
-- shader, so `shaderSupported` is true and the greyscale arm is the one taken — the
-- vertex-colour branch below is now the ref's own no-shader fallback, kept because it is
-- the ref's and because a texture without the method still has to grey somehow. Written as
-- the wrapper rather than open-coded at each call site precisely so that flip was one
-- function. First caller here is the game menu's bag-bar greying (decision 0674);
-- PartyFrame.xml's disconnected-member branch has been CALLING this global since decision
-- 0434 with nothing defining it — an error thrown every time a party member went offline,
-- which is why it went unnoticed.
function SetDesaturation(texture, desaturation)
if not texture then return end
local shaderSupported = texture.SetDesaturated and texture:SetDesaturated(desaturation)
@@ -778,9 +779,12 @@
-- · hasEditBox + maxLetters + EditBoxOnEnterPressed are live (decision 0668's
-- ADD_FRIEND/ADD_IGNORE are the first customers): the box shows, caps its length,
-- opens empty, and re-anchors the buttons under itself.
-- · hasWideEditBox is live too (the guild arc's SET_GUILDMOTD / SET_GUILDPLAYERNOTE /
-- SET_GUILDOFFICERNOTE, registered in FriendsFrame.xml): the 350x64 centred box
-- replaces the narrow one, and the dialog widens to 420. See the widen arm below for
-- the reference's own oddity — the widen is keyed on `which`, not on hasWideEditBox.
-- · Accepted but v1-inert: exclusive, interruptCinematic (cinematics are acked
-- instantly — 0196), notClosableByLogout, preferredIndex, hasWideEditBox (guild
-- MOTD/notes — the guild arc), and the money variant.
-- instantly — 0196), notClosableByLogout, preferredIndex, and the money variant.
-- · text_arg1/2 stash on the DIALOG frame (the ref stashes them on the Text region;
-- our regions don't carry Lua fields).
-- · GetText("SECONDS"/"MINUTES") localization collapses to English singular/plural.
@@ -829,7 +833,11 @@
end
end
-- Ref l.1428-1438, the no-editbox/no-money leg only (v1 has neither variant).
-- Ref l.1428-1438, the no-money leg only (v1 has no money variant).
--
-- Note the ref measures the NARROW `EditBox` for the height even when the shown box is the
-- wide one — the wide box is 64 tall, anchored CENTER, and floats over a frame sized for a
-- 32-tall box. Transcribed as written: the guild MOTD dialog's look is that arithmetic.
function StaticPopup_Resize(dialog, which)
local text = getglobal(dialog:GetName() .. "Text")
local button1 = getglobal(dialog:GetName() .. "Button1")
@@ -925,28 +933,48 @@
text:SetText(format(info.text, text_arg1, text_arg2))
end
-- The alert dress (ref l.1579-1589): every Show resets width 320 / icon hidden, then
-- a showAlert entry widens to 420 and shows the icon. The ref's neighbouring arms —
-- the guild-MOTD 420 widen and HELP_TICKET's 350 — wait for their dialogs.
-- The width/alert dress (ref l.1576-1589), an if/elseif in the ref's own order: every
-- Show resets width 320 / icon hidden; a GUILD MESSAGE dialog widens to 420 with NO
-- icon; otherwise a showAlert entry widens to 420 and shows the icon. HELP_TICKET's
-- 350 arm waits for that dialog.
--
-- **The widen is keyed on `which`, not on `hasWideEditBox`** — that is the reference's
-- own shape, transcribed rather than improved: a fourth wide-box dialog would get the
-- 350x64 box inside an un-widened 320 frame until its name joins this list. (The ref's
-- list also names SET_GUILDPLAYERNOTE twice, l.1582; the duplicate is inert.)
local alertIcon = getglobal(dialog:GetName() .. "AlertIcon")
alertIcon:Hide()
dialog:SetWidth(320)
if info.showAlert then
if (which == "SET_GUILDMOTD") or (which == "SET_GUILDPLAYERNOTE")
or (which == "SET_GUILDOFFICERNOTE") then
dialog:SetWidth(420)
elseif info.showAlert then
dialog:SetWidth(420)
alertIcon:Show()
end
-- The edit box (ref l.1599-1624): shown only for a hasEditBox entry, capped at the
-- entry's maxLetters, and always empty on open — a name left over from the last time
-- the dialog ran would be sent by a reflexive Enter. The wide variant is guild-only
-- (MOTD/notes), so it waits for the guild arc.
-- the dialog ran would be sent by a reflexive Enter. `hasWideEditBox` picks the 350x64
-- centred box INSTEAD of the narrow one; exactly one of the two is ever shown, and
-- neither is when the entry has no box at all (the ref's three-way, verbatim).
local editBox = getglobal(dialog:GetName() .. "EditBox")
local wideEditBox = getglobal(dialog:GetName() .. "WideEditBox")
if info.hasEditBox then
editBox:SetMaxLetters(info.maxLetters or 0)
editBox:SetText("")
editBox:Show()
if info.hasWideEditBox then
wideEditBox:SetMaxLetters(info.maxLetters or 0)
wideEditBox:SetText("")
wideEditBox:Show()
editBox:Hide()
else
editBox:SetMaxLetters(info.maxLetters or 0)
editBox:SetText("")
editBox:Show()
wideEditBox:Hide()
end
else
editBox:Hide()
wideEditBox:Hide()
end
-- The buttons (ref l.1634-1676; fixed 120 widths — header note). Button2 shows only
@@ -1774,6 +1802,44 @@
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
<!-- The WIDE entry box (ref StaticPopup.xml l.138-202): 350x64 at CENTER, hidden
unless the entry sets hasWideEditBox. Its border is the same chat-input art
stretched over three slices — a 256 left cap, a 75 right cap, and a 5-wide middle
pinned between the two so it fills whatever is left. First customers: the guild
arc's SET_GUILDMOTD / SET_GUILDPLAYERNOTE / SET_GUILDOFFICERNOTE. It shares the
narrow box's two key handlers, which take the box as an argument for exactly this
reason. -->
<EditBox name="$parentWideEditBox" historyLines="1" hidden="true" autoFocus="false">
<Size><AbsDimension x="350" y="64"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeft" file="Interface\ChatFrame\UI-ChatInputBorder-Left">
<Size><AbsDimension x="256" y="32"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="1.0" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentRight" file="Interface\ChatFrame\UI-ChatInputBorder-Right">
<Size><AbsDimension x="75" y="32"/></Size>
<Anchors><Anchor point="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0.70703125" right="1.0" top="0" bottom="1.0"/>
</Texture>
<Texture file="Interface\ChatFrame\UI-ChatInputBorder-Left">
<Size><AbsDimension x="5" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/>
<Anchor point="RIGHT" relativeTo="$parentRight" relativePoint="LEFT"/>
</Anchors>
<TexCoords left="0.29296875" right="1.0" top="0" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnterPressed>StaticPopup_EditBoxOnEnterPressed(self)</OnEnterPressed>
<OnEscapePressed>StaticPopup_EditBoxOnEscapePressed(self)</OnEscapePressed>
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
</Frames>
<!-- The engine handlers (ref StaticPopup.xml template Scripts): open/close sounds ride
inside StaticPopup_OnShow/OnHide so every path sounds; OnUpdate drives the countdown/
+47 -3
View File
@@ -25,8 +25,10 @@
reference. `/pvp` is the toggle the client has.
- DEFERRED ENTRIES: rows whose backing system doesn't exist yet are HIDDEN outright (the
BENILLA_UNITPOPUP_DEFERRED set below) rather than shown-dead: TARGET (the friends
list), GUILD_PROMOTE/GUILD_LEAVE (the guild arc), RESET_INSTANCES (instances). TRADE came out
with decision 0592 P1, DUEL with 0633, INSPECT with 0631, FOLLOW with 0890's follow-up — and
list), RESET_INSTANCES (instances). TRADE came out
with decision 0592 P1, DUEL with 0633, INSPECT with 0631, GUILD_PROMOTE/GUILD_LEAVE with
the guild arc (1257 — their hide predicates and click rows are below), FOLLOW with 0890's
follow-up — and
that last entry's stated reason ("its CMSG family is its own slice") was simply WRONG: 0890
verified auto-follow sends nothing at all, it is pure client-side movement, so the row was
waiting on a wire slice that does not exist. It was the motion law it really needed.
@@ -225,7 +227,7 @@
-- The DEFERRED set (header): entries hidden until their backing arc lands.
BENILLA_UNITPOPUP_DEFERRED = {
TARGET = 1,
GUILD_PROMOTE = 1, GUILD_LEAVE = 1, RESET_INSTANCES = 1,
RESET_INSTANCES = 1,
};
-- ── Shared helpers ─────────────────────────────────────────────────────────────────
@@ -394,6 +396,26 @@
HIDE_ROW["RAID_DEMOTE"] = hideAlways;
HIDE_ROW["RAID_REMOVE"] = hideAlways;
-- The two GUILD rows, ref UnitPopup.lua l.323-330 (decision 1257's guild arc un-defers
-- them). Both gate on `GuildFrame:IsVisible()`, which is the whole point of them: these
-- rows exist for right-clicking a GUILD ROSTER row, not a unit, so they are hidden
-- everywhere else — a friend-list or /who row opens the same FRIEND menu and must not
-- offer them.
-- · GUILD_PROMOTE — hand over the guild. Guild master only, and never on yourself.
-- · GUILD_LEAVE — leave the guild. Only ever offered on YOURSELF.
-- `GuildFrame and` is a nil-guard, not a behaviour change: FriendsFrame.xml loads after
-- this file in the manifest, so in a real session the frame always exists and the
-- condition is exactly the reference's. It is here because this file must stay usable
-- when loaded on its own (the same reason FriendsFrame.xml guards `UpdateMicroButtons`).
HIDE_ROW["GUILD_PROMOTE"] = function(menu)
return not IsGuildLeader() or menu.name == UnitName("player")
or not (GuildFrame and GuildFrame:IsVisible());
end
HIDE_ROW["GUILD_LEAVE"] = function(menu)
return menu.name ~= UnitName("player")
or not (GuildFrame and GuildFrame:IsVisible());
end
-- Marking is leader/assistant business, and a mark on an enemy-faction player nobody in
-- the group can cooperate with is pointless — except on our OWN menu, which marks us.
local function hideRaidMarkRow(menu, party)
@@ -745,6 +767,28 @@
StaticPopup_Show("RENAME_PET");
end
-- The two GUILD arms, ref UnitPopup_OnClick l.571-575 (decision 1257). Both go behind a
-- confirm, and both are addressed by NAME — a guild roster row has no unit token behind
-- it, which is the reason this menu takes a name at all.
--
-- GUILD_PROMOTE stashes the name on the DIALOG (`dialog.data`), not on a global: the
-- popup engine hands `data` back to OnAccept, so the dialog acts on the name that was
-- right-clicked even if the selection moved while it was open. GUILD_LEAVE needs no
-- data — it is only ever offered on yourself — and formats the guild's own name into
-- "Really leave %s?". The `if dialog` is ours, and it is not decoration: the ref writes
-- `dialog.data` on the bare return because its engine runs FOUR dialog instances and
-- ours runs two (UiPanels.xml's header), so a refused Show returning nil is reachable
-- here and indexing it would raise inside a menu click.
CLICK_ROW["GUILD_PROMOTE"] = function(menu)
local dialog = StaticPopup_Show("CONFIRM_GUILD_PROMOTE", menu.name);
if ( dialog ) then
dialog.data = menu.name;
end
end
CLICK_ROW["GUILD_LEAVE"] = function()
StaticPopup_Show("CONFIRM_GUILD_LEAVE", GetGuildInfo("player"));
end
function UnitPopup_OnClick()
local clicked = this;
local key = clicked.value;
+4
View File
@@ -88,6 +88,10 @@ DuelFrame.xml
# registry again. Not CraftFrame's — the gate that raises them is the generic item-target
# bind, reached by a poison or a sharpening stone with no profession window open.
EnchantConfirm.xml
# The innkeeper's bind confirm + its event driver (decision 1331): the same StaticPopup registry.
# Not GossipFrame's, and the wire is why — the server closes the gossip menu BEFORE it asks, so
# this dialog is free-standing by the time it shows. Needs only UiPanels.xml, far above.
BinderConfirm.xml
# The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
# consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
Cooldown.xml
+4
View File
@@ -82,6 +82,8 @@ mod live_shot;
mod phase_probe;
mod pick_probe;
mod probe_bank;
mod probe_binder;
mod probe_book;
mod probe_castcancel;
mod probe_charcreate;
mod probe_crossing;
@@ -99,6 +101,8 @@ pub(crate) use live_shot::LiveShotPlugin;
pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
pub(crate) use probe_bank::ProbeBankPlugin;
pub(crate) use probe_binder::ProbeBinderPlugin;
pub(crate) use probe_book::ProbeBookPlugin;
pub(crate) use probe_castcancel::ProbeCastCancelPlugin;
pub(crate) use probe_charcreate::ProbeCharCreatePlugin;
pub(crate) use probe_crossing::ProbeCrossingPlugin;
@@ -0,0 +1,621 @@
//! The innkeeper-bind live probe (`WOW_PROBE_BINDER=1`) — decision 1331's end-to-end instrument and
//! the evidence that closes **B249** ("setting your Hearthstone shows a wrong icon and doesn't
//! take"): log in, GM-hop to the exact innkeeper in the bug's screenshot, open her gossip menu on
//! the real wire, assert the bind row's icon reads **binder** (half 1), select it, assert the
//! server's `SMSG_BINDER_CONFIRM` arrived and reached the Lua dialog (half 2's question), answer it
//! through the reference's own `ConfirmBinder()`, and assert the hearthstone actually moved (half
//! 2's answer). One `PROBE_BINDER: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
//! `PROBE_BINDER: DONE pass=<n> fail=<m>`. Modeled closely on [`super::probe_bank`] — same phase
//! machine, same trace style, same self-terminating exit ([`super::probes::ProbeExitPlugin`]'s
//! pattern), same live-VM observation idiom (a small Lua hook appending to a probe table, read back
//! with `script.eval`).
//!
//! Unit tests cannot close B249. Half 1's old mapping was self-consistent (it just described a
//! *later* client's icon art), and half 2's packet was never parsed at all — both defects are only
//! visible against a real server sending real bytes, which is what this probe puts in front of
//! them.
//!
//! ## The innkeeper (live-DB verified this session, `/Users/sam/dev/vmangos-deploy` → `mangos` DB)
//!
//! Innkeeper Keldamyr — `creature_template.entry = 6736`, spawn `creature.guid = 46343`, **map 1**
//! (Teldrassil, Dolanaar), position `(9802.21, 982.608, 1313.98)`,
//! `creature_template.npc_flags = 135` = `0x87` = GOSSIP|QUESTGIVER|VENDOR|**INNKEEPER**,
//! `gossip_menu_id = 1293`. She is the NPC in the bug's screenshot, which is why the probe hops to
//! her rather than to whichever innkeeper is nearest.
//!
//! **`UNIT_NPC_FLAG_INNKEEPER` is `0x80` (128) on 1.12** — vmangos `Objects/UnitDefines.h:610`,
//! read this session. `GossipDef.h:45`'s comment beside `GOSSIP_OPTION_INNKEEPER` says `(65536)`,
//! which is a *later* client's value for the same flag: exactly the stale-comment trap decision
//! 1331 is about (half 1 was a hand-written icon map that trusted that same header's enum *names*).
//! [`NPC_FLAG_INNKEEPER`] below is the verified `0x80`, and it is only the scan's fallback — the
//! primary identity check is the template entry.
//!
//! ## The bind row (live-DB verified this session)
//!
//! `gossip_menu_option` for menu 1293 carries five rows; the innkeeper's is
//! `option_icon = 5`, `option_id = 8` (`GOSSIP_OPTION_INNKEEPER`), `npc_option_npcflag = 128`,
//! text *"Make this inn my home."* — and **every one of the 21 `option_id = 8` rows in the whole
//! world DB uses `option_icon = 5`** (`SELECT option_icon, COUNT(*) … WHERE option_id = 8 GROUP BY
//! option_icon` → a single row, `5 | 21`). So icon 5 is *the* innkeeper icon, and 5 was precisely
//! the byte the pre-1331 map had no entry for: it fell through to the chat bubble. Step 3 FAILs
//! loudly on `"gossip"` for that reason — that string is the B249 regression itself.
//!
//! Two things a live reading shows that the DB row does not, both verified on the first run:
//!
//! - **The label on the wire is not `option_text`.** vmangos prefers the row's
//! `option_broadcast_text` (2822 -> *"Make this inn your home."*) over the `option_text` column
//! (*"Make this inn **my** home."*). Step 4's guard is therefore a lowercase `"home"` substring,
//! not an equality — the exact wording is the server's to choose.
//! - **A probe login sees five rows here, not the three in the bug's screenshot.** GM mode is the
//! probe default (decision 0679; the preflight banner says so every run), and vmangos does not
//! condition-filter a GM's menu — it appends `"(GM mode is ON)"` to the two holiday rows and
//! sends them anyway. So the bind row sits at list position 2 with wire index 1 on a probe, and
//! would sit at position 1 on a player. That is exactly why step 3 finds the row by its **wire
//! icon byte** and step 4 selects by its **wire index**, and why neither ever counts positions.
//!
//! ## What each step can and cannot conclude
//!
//! Step 5 is the load-bearing one. Before 1331 `SMSG_BINDER_CONFIRM` (0x2eb) had no const, no parse
//! arm and no dialog: it fell through `parse.rs`'s tail into `ServerPacket::Other` and the click
//! produced a closing gossip window and nothing else. A PASS there means the packet is parsed, the
//! session state took it, and the `CONFIRM_BINDER` event reached the live VM carrying a real area
//! name.
//!
//! Step 6 answers it the way a player does — `ConfirmBinder()` run in the live VM, so the whole
//! dialog→engine→drain→wire chain (`CMSG_BINDER_ACTIVATE`, 0x1b5) is exercised rather than a
//! synthesized packet. **A rebind to the place you are already bound is invisible unless you make
//! it visible**: vmangos's `EffectBind` (`SpellEffects.cpp`) writes the homebind and sends
//! `SMSG_BINDPOINTUPDATE` *unconditionally*, but our [`HomeBind`] would simply be re-set to the
//! value it already held. So the probe clears `HomeBind` to `None` immediately before answering
//! and waits for it to become `Some` again — that transition is the fresh packet, and it is the
//! only reading that survives a probe character who already hearths in Dolanaar from an earlier
//! run.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=WoW/Data WOW_USER=probe1 WOW_PASS=pprobe1 WOW_CHAR=Probeone \
//! WOW_PROBE_BINDER=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-1` → `probe1`/`pprobe1`/`Probeone`;
//! method.md "The local vmangos server"). Non-combat, and GM mode is left exactly as found. An
//! outer `timeout` + grep on `PROBE_BINDER:` is the whole harness; the probe self-exits once DONE.
//!
//! Every step SKIPs with a note rather than FAILing for an environmental problem (the NPC never
//! streamed, a GM command refused, no UI VM in this build). A genuine wrong value is a FAIL.
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::area::AreaTableRes;
use crate::net::SelfPlayer;
use crate::net::{ChatKind, ClientCommand, Guid, HomeBind, NetCommands, NetEntity, ObjectStore};
use crate::player::Player;
use crate::ui_binder::BinderState;
use crate::ui_gossip::GossipState;
use crate::ui_session::NpcSession;
/// Innkeeper Keldamyr's spawn (vmangos `creature` guid 46343, entry 6736) — the `.go xyz` target.
const INNKEEPER_AT: [f32; 3] = [9802.21, 982.608, 1313.98];
/// Her map — Teldrassil. `.go xyz` takes the map id as its fourth argument.
const INNKEEPER_MAP: u32 = 1;
/// Her creature template entry — the streamed-unit identity check (module doc).
const INNKEEPER_ENTRY: u32 = 6736;
/// `UNIT_NPC_FLAG_INNKEEPER` — `0x80` on 1.12, NOT the `65536` `GossipDef.h`'s comment names
/// (module doc). The scan's fallback when the entry read is unavailable.
const NPC_FLAG_INNKEEPER: u32 = 0x80;
/// The wire `GOSSIP_ICON` byte every `GOSSIP_OPTION_INNKEEPER` row in the world DB sends
/// (21/21, verified this session) — decision 1331's table indexes it to `"binder"`.
const ICON_INNKEEPER: u8 = 5;
/// The type string [`crate::ui_gossip`]'s table must produce for [`ICON_INNKEEPER`].
const ICON_TYPE_BINDER: &str = "binder";
/// What the pre-1331 map produced instead — the chat bubble. Seeing it back is B249, not a flake.
const ICON_TYPE_REGRESSION: &str = "gossip";
/// The substring the bind row's label must carry before the probe is willing to select it — a
/// guard against binding whatever else the menu happens to offer if the DB row ever moves.
const BIND_LABEL_HINT: &str = "home";
/// Scan radius around the `.go` landing, generously wide so a slightly-off hop still finds her
/// (the bank/mail probes' idiom).
const SCAN_RANGE: f32 = 12.0;
const SETTLE_SECS: f64 = 3.0;
/// The waits are deliberately generous, and the reason is measured rather than guessed: the `.go`
/// lands on a **different map**, so the whole leg after the hop runs inside Teldrassil's terrain
/// load — the first live run logged five consecutive `frame hitch: ~1050 ms` lines and a 5.0 s
/// loading screen across steps 4→5, i.e. the probe got roughly one frame per second to poll in.
/// The packets were prompt; the *observer* was starved. A timeout tight enough to trip on that
/// would report a FAIL about the wire, which is the one thing an instrument must never do.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
const MENU_TIMEOUT_SECS: f64 = 20.0;
const CONFIRM_TIMEOUT_SECS: f64 = 20.0;
const BIND_TIMEOUT_SECS: f64 = 20.0;
const LINE_TIMEOUT_SECS: f64 = 20.0;
/// `ERR_DEATHBIND_SUCCESS_S` (GlobalStrings.lua:1543, verbatim) — what step 7 expects to read back
/// off CHAT_MSG_SYSTEM, composed here rather than imported so the probe asserts against the
/// reference's own string and not against whatever `ui_binder` happens to hold.
fn bound_line(area_name: &str) -> String {
"%s is now your home.".replace("%s", area_name)
}
pub(crate) struct ProbeBinderPlugin;
impl Plugin for ProbeBinderPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BinderProbe>()
.add_systems(Update, binder_probe);
}
}
/// The probe's phase machine plus the identities discovered along the way (the bank probe's shape:
/// a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe` freely).
#[derive(Resource, Default)]
struct BinderProbe {
phase: Phase,
/// The innkeeper's guid, once streamed in.
innkeeper: Option<u64>,
/// The bind row's **wire** `index` — the value the packet carried and the value
/// `CMSG_GOSSIP_SELECT_OPTION` must echo back. vmangos numbers them `data << uint32(iI)` over
/// the rows it actually sends (`GossipDef.cpp:188`), so it is the row's **0-based** position in
/// *this* menu — neither the DB's `gossip_menu_option.id` nor the Lua menu's 1-based position,
/// and the probe assumes no relation between them for the same reason the real drain doesn't.
bind_index: Option<u32>,
/// The area id [`HomeBind`] held before step 6 cleared it — reported in the verdict so a
/// rebind-to-the-same-place reads as the deliberate no-visible-change case it is.
baseline_area: Option<u32>,
/// The area name the bind resolved to — step 7 composes its expected line from it.
bound_name: String,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
/// `.go` issued; settling before the world streams the innkeeper in (step 1).
Settling {
sent_at: f64,
},
/// `GossipHello` sent; waiting for the parsed menu AND its push into the VM (step 2).
Menu {
sent_at: f64,
},
/// The bind row selected; waiting for `SMSG_BINDER_CONFIRM` to reach both [`BinderState`] and
/// the `CONFIRM_BINDER` Lua event (step 5).
Confirm {
since: f64,
events_baseline: i64,
},
/// `ConfirmBinder()` run in the live VM (after clearing [`HomeBind`]); waiting for a fresh
/// `SMSG_BINDPOINTUPDATE` and the VM's `GetBindLocation()` to agree with it (step 6).
Accept {
since: f64,
sent: bool,
},
/// Step 7 — the bind landed; waiting for `SMSG_PLAYERBOUND`'s
/// `ERR_DEATHBIND_SUCCESS_S` line to reach the VM as CHAT_MSG_SYSTEM.
Line {
since: f64,
},
Done,
}
/// Read the Lua-side `ProbeBinderEvents` log length (the `CONFIRM_BINDER` hook) — `0` on any eval
/// hiccup, treated as "nothing observed yet", never a panic (the bank probe's idiom).
fn events_len(script: &UiScript) -> i64 {
script
.eval::<i64>("return table.getn(ProbeBinderEvents or {})")
.unwrap_or(0)
}
/// The newest `ProbeBinderEvents` entry — `CONFIRM_BINDER`'s `arg1`, the area name that fills the
/// dialog's `"Do you want to make %s your new home?"`.
fn last_event(script: &UiScript) -> String {
script
.eval::<String>("return ProbeBinderEvents[table.getn(ProbeBinderEvents)] or \"\"")
.unwrap_or_default()
}
/// The `CHAT_MSG_SYSTEM` lines seen since the hook went in, newest last — step 6's second
/// observation. `SMSG_PLAYERBOUND` prints `ERR_DEATHBIND_SUCCESS_S` here (`0x5e3d3f` →
/// `DisplayError(0x138)`, chat type 238 = CHAT_MSG_SYSTEM; wow-re, decision 1335).
fn system_lines(script: &UiScript) -> Vec<String> {
script
.eval::<Vec<String>>("return ProbeBinderSystemLines or {}")
.unwrap_or_default()
}
/// How many values the live `GetGossipOptions()` returns — flat `(label, type)` pairs, so twice the
/// row count. The probe waits on this rather than assuming the feed already ran this frame.
fn vm_gossip_values(script: &UiScript) -> i64 {
script
.eval::<i64>("local t = { GetGossipOptions() } return table.getn(t)")
.unwrap_or(0)
}
/// The icon **type string** the app mapped for the 1-based menu row `pos` — read exactly where the
/// FrameXML reads it, out of the pushed [`benilla_ui::script::GossipMenu`] snapshot through the
/// Era `GetGossipOptions()` vararg. (`ui_gossip::gossip_icon_type` is private to its module; the
/// snapshot is the same value the real menu draws from, which is the stronger assert anyway.)
fn vm_icon_type(script: &UiScript, pos: usize) -> String {
script
.eval::<String>(&format!(
"local t = {{ GetGossipOptions() }} return t[{}] or \"\"",
pos * 2
))
.unwrap_or_default()
}
/// The texture path `BENILLA_GOSSIP_ICONS.binder` resolves to in the live VM — `""` if the table or
/// the key is missing, which would mean the row draws the fallback bubble whatever the app mapped.
fn vm_binder_texture(script: &UiScript) -> String {
script
.eval::<String>("return (BENILLA_GOSSIP_ICONS and BENILLA_GOSSIP_ICONS.binder) or \"\"")
.unwrap_or_default()
}
/// The live VM's `GetBindLocation()` — the hearthstone's own answer for where you are bound.
fn vm_bind_location(script: &UiScript) -> String {
script
.eval::<String>("return GetBindLocation()")
.unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
fn binder_probe(
time: ProbeClock,
mut probe: ResMut<BinderProbe>,
gossip: Res<GossipState>,
binder: Res<BinderState>,
mut home: ResMut<HomeBind>,
areas: Option<Res<AreaTableRes>>,
script: Option<NonSendMut<UiScript>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
};
let now = time.elapsed_secs_f64();
let phase = probe.phase;
match phase {
Phase::Wait => {
// The CONFIRM_BINDER hook (step 5's observation channel — the bank probe's exact
// pattern), installed up front so it is live long before the question can arrive.
if let Err(e) = script.run(
r#"
if not ProbeBinderHooked then
ProbeBinderHooked = true
ProbeBinderEvents = {}
ProbeBinderSystemLines = {}
local f = CreateFrame("Frame")
f:RegisterEvent("CONFIRM_BINDER")
f:RegisterEvent("CHAT_MSG_SYSTEM")
f:SetScript("OnEvent", function()
if event == "CONFIRM_BINDER" then
table.insert(ProbeBinderEvents, arg1 or "")
else
table.insert(ProbeBinderSystemLines, arg1 or "")
end
end)
end
"#,
) {
error!("PROBE_BINDER: installing the CONFIRM_BINDER hook: {e}");
}
let [x, y, z] = INNKEEPER_AT;
info!(
"PROBE_BINDER: hopping to Innkeeper Keldamyr (entry {INNKEEPER_ENTRY}) at \
({x}, {y}, {z}) map {INNKEEPER_MAP}"
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {INNKEEPER_MAP}"),
});
probe.phase = Phase::Settling { sent_at: now };
}
Phase::Settling { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let found = units.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::Unit
&& (store.0.object_entry() == Some(INNKEEPER_ENTRY)
|| store.0.unit_npc_flags() & NPC_FLAG_INNKEEPER != 0)
&& tf.translation.distance(me) < SCAN_RANGE
});
if let Some((guid, ..)) = found {
info!(
"PROBE_BINDER: PASS (1 hop) — innkeeper {:#x} streamed within {SCAN_RANGE}yd \
of the landing",
guid.0
);
probe.passes += 1;
probe.innkeeper = Some(guid.0);
let _ = net.0.send(ClientCommand::GossipHello { guid: guid.0 });
info!("PROBE_BINDER: (2 menu) GossipHello({:#x}) sent", guid.0);
probe.phase = Phase::Menu { sent_at: now };
} else if now - sent_at > SCAN_TIMEOUT_SECS {
warn!(
"PROBE_BINDER: SKIP (1 hop) — no entry {INNKEEPER_ENTRY}/innkeeper-flagged \
unit streamed in within {SCAN_TIMEOUT_SECS}s of the hop (the `.go` may have \
been refused, or Teldrassil never streamed) — environmental, not a defect"
);
probe.phase = Phase::Done;
}
}
Phase::Menu { sent_at } => {
let Some(innkeeper) = probe.innkeeper else {
probe.phase = Phase::Done;
return;
};
let open = gossip.npc == Some(innkeeper) && !gossip.options.is_empty();
// Wait for the feed's push as well as the parse: step 3 reads the icon type back out
// of the VM, so the snapshot must already be there.
let pushed = vm_gossip_values(&script) as usize == gossip.options.len() * 2;
if open && pushed {
info!(
"PROBE_BINDER: PASS (2 menu) — {} option(s) open on {innkeeper:#x}: {}",
gossip.options.len(),
gossip
.options
.iter()
.map(|o| format!("[{} icon={} {:?}]", o.index, o.icon, o.message))
.collect::<Vec<_>>()
.join(" ")
);
probe.passes += 1;
assert_icon_and_select(&mut probe, &gossip, &script, &net, innkeeper, now);
} else if now - sent_at > MENU_TIMEOUT_SECS {
error!(
"PROBE_BINDER: FAIL (2 menu) — no gossip menu for {innkeeper:#x} within \
{MENU_TIMEOUT_SECS}s (parsed npc={:?} options={} vm_values={})",
gossip.npc,
gossip.options.len(),
vm_gossip_values(&script)
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Confirm {
since,
events_baseline,
} => {
let Some(innkeeper) = probe.innkeeper else {
probe.phase = Phase::Done;
return;
};
let pending = binder.npc() == Some(innkeeper);
let fired = events_len(&script) > events_baseline;
let area = last_event(&script);
if pending && fired && !area.is_empty() {
info!(
"PROBE_BINDER: PASS (5 confirm) — SMSG_BINDER_CONFIRM parked on \
{innkeeper:#x} and CONFIRM_BINDER fired with area {area:?} (the dialog reads \
\"Do you want to make {area} your new home?\")"
);
probe.passes += 1;
probe.phase = Phase::Accept {
since: now,
sent: false,
};
} else if now - since > CONFIRM_TIMEOUT_SECS {
error!(
"PROBE_BINDER: FAIL (5 confirm) — no answered question within \
{CONFIRM_TIMEOUT_SECS}s of the select: BinderState pending={:?} (wanted \
{innkeeper:#x}), CONFIRM_BINDER fired={fired}, arg1={area:?}. Before decision \
1331 the packet had no parse arm at all and fell through to \
ServerPacket::Other — that is what this reading looks like.",
binder.npc()
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Accept { since, sent } => {
if !sent {
// Clear the bind BEFORE answering: a rebind to the area we already hearth in
// re-sends SMSG_BINDPOINTUPDATE with an identical payload, so only the
// None → Some transition proves a fresh packet landed (module doc).
probe.baseline_area = home.0;
home.0 = None;
if let Err(e) = script.run("ConfirmBinder()") {
warn!(
"PROBE_BINDER: SKIP (6 bind) — ConfirmBinder() would not run in the live \
VM: {e} (environmental, not a wire failure)"
);
probe.phase = Phase::Done;
return;
}
info!(
"PROBE_BINDER: (6 bind) ConfirmBinder() run in the live VM — HomeBind cleared \
from {:?}, waiting for a fresh SMSG_BINDPOINTUPDATE",
probe.baseline_area
);
probe.phase = Phase::Accept {
since: now,
sent: true,
};
return;
}
let name = home
.0
.and_then(|id| areas.as_deref()?.0.name(id))
.unwrap_or_default()
.to_string();
let vm = vm_bind_location(&script);
if !name.is_empty() && vm == name {
info!(
"PROBE_BINDER: PASS (6 bind) — HomeBind repopulated to area {:?} = {name:?} \
(was {:?}), and the VM's GetBindLocation() agrees: {vm:?}",
home.0, probe.baseline_area
);
probe.passes += 1;
probe.bound_name = name;
probe.phase = Phase::Line { since: now };
} else if now - since > BIND_TIMEOUT_SECS {
error!(
"PROBE_BINDER: FAIL (6 bind) — the hearthstone did not take within \
{BIND_TIMEOUT_SECS}s of ConfirmBinder(): HomeBind={:?} AreaTable name={name:?} \
GetBindLocation()={vm:?} (was area {:?} before the clear)",
home.0, probe.baseline_area
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Line { since } => {
// The feedback half of B249: "accepting appears to change nothing" was partly that
// nothing ever SAID it had. SMSG_PLAYERBOUND prints ERR_DEATHBIND_SUCCESS_S
// ("%s is now your home.") as CHAT_MSG_SYSTEM — VERIFIED at `0x5e3d3f`, decision 1335.
let want = bound_line(&probe.bound_name);
let lines = system_lines(&script);
if lines.iter().any(|l| l == &want) {
info!("PROBE_BINDER: PASS (7 line) — the bind announced itself: {want:?}");
probe.passes += 1;
probe.phase = Phase::Done;
} else if now - since > LINE_TIMEOUT_SECS {
error!(
"PROBE_BINDER: FAIL (7 line) — no {want:?} within {LINE_TIMEOUT_SECS}s of the \
bind; CHAT_MSG_SYSTEM lines seen: {lines:?}"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_BINDER: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_BINDER: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
/// Steps 3 and 4 — B249's first half, then the click.
///
/// Split out of the phase match because it is one straight line of asserts with several exits, and
/// inlining it buried the phase machine's shape. Sets `probe.phase` on every path.
fn assert_icon_and_select(
probe: &mut BinderProbe,
gossip: &GossipState,
script: &UiScript,
net: &NetCommands,
innkeeper: u64,
now: f64,
) {
// Step 3 — the icon. The row is found by the WIRE byte, because that byte is the fact under
// test: every GOSSIP_OPTION_INNKEEPER row in the world DB sends 5 (module doc).
let Some((pos, opt)) = gossip
.options
.iter()
.enumerate()
.find(|(_, o)| o.icon == ICON_INNKEEPER)
else {
error!(
"PROBE_BINDER: FAIL (3 icon) — no wire icon=={ICON_INNKEEPER} row in Keldamyr's menu \
(icons seen: {:?}); the DB pairs option_id 8 with icon 5 on all 21 of its rows, so \
either the parse or the menu is wrong",
gossip.options.iter().map(|o| o.icon).collect::<Vec<_>>()
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
};
let ty = vm_icon_type(script, pos + 1);
let texture = vm_binder_texture(script);
if ty == ICON_TYPE_REGRESSION {
error!(
"PROBE_BINDER: FAIL (3 icon) — the innkeeper's row {:?} (wire icon={ICON_INNKEEPER}) \
maps to {ICON_TYPE_REGRESSION:?}, the chat bubble. THIS IS THE B249 REGRESSION: \
decision 1331's table must index byte 5 to {ICON_TYPE_BINDER:?}.",
opt.message
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
if ty != ICON_TYPE_BINDER {
error!(
"PROBE_BINDER: FAIL (3 icon) — the innkeeper's row {:?} (wire icon={ICON_INNKEEPER}) \
maps to {ty:?}, wanted {ICON_TYPE_BINDER:?}",
opt.message
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
if texture.is_empty() {
error!(
"PROBE_BINDER: FAIL (3 icon) — the app maps the row to {ICON_TYPE_BINDER:?} but \
BENILLA_GOSSIP_ICONS.binder resolves to nothing in the live VM, so the row would \
still draw the fallback bubble"
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
info!(
"PROBE_BINDER: PASS (3 icon) — row {} {:?}: wire icon={ICON_INNKEEPER} index={} → type \
{ty:?}, BENILLA_GOSSIP_ICONS.binder = {texture:?}",
pos + 1,
opt.message,
opt.index
);
probe.passes += 1;
// Step 4 — the click. Guarded on the label, and sent with the row's WIRE index — read off the
// packet, never derived from where the row sits in the list (the drain's own rule).
if !opt.message.to_lowercase().contains(BIND_LABEL_HINT) {
warn!(
"PROBE_BINDER: SKIP (4 select) — the icon=={ICON_INNKEEPER} row reads {:?}, which does \
not contain {BIND_LABEL_HINT:?}; refusing to select something that may not be the \
bind line",
opt.message
);
probe.phase = Phase::Done;
return;
}
let _ = net.0.send(ClientCommand::GossipSelectOption {
guid: innkeeper,
option: opt.index,
});
info!(
"PROBE_BINDER: (4 select) GossipSelectOption({innkeeper:#x}, wire index {}) sent for {:?}",
opt.index, opt.message
);
probe.bind_index = Some(opt.index);
probe.phase = Phase::Confirm {
since: now,
events_baseline: events_len(script),
};
}
@@ -0,0 +1,262 @@
//! The world-book live probe (`WOW_PROBE_BOOK=1`) — B240's instrument: what does having the item-
//! text reader open actually cost per frame, on the real object the report names?
//!
//! Goudy, 2026-08-09 (`#bugs` `1535810645956632686`): *"page text with html text absolutely
//! annihilates performance"* — ~50% fps drop (62 → 36 fps, 16.0 → 28.0 ms) while the *Alliance
//! Military Ranks* plaque's reader is up, recovering the moment it closes. That is a **frame-cost
//! A/B**, and eyeballing an fps counter is exactly the way not to settle one (method.md's
//! "timing and feel are measured, never eyeballed"). So this probe teleports to the plaque, samples
//! the UI pass's own per-phase meter ([`crate::ui_script::UiFrameCost`]) with the reader CLOSED,
//! opens it on the real route, samples again, and prints the two side by side — including how many
//! of each window's frames the extract gate (decision 0740) *skipped*, which is what decides
//! whether a paint-pass cost is paid once or every frame.
//!
//! The object: `GameObject` 3011 (`gameobject_template` entry 2857, `GAMEOBJECT_TYPE_TEXT` = 9)
//! in Stormwind's Old Town, whose `data[0]` is `page_text` 2676 — a 647-byte HTML body. The
//! `.go xyz` below is Goudy's own reported position (his debug panel: `-8760.2 402.3 103.9`).
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe0 WOW_PASS=pprobe0 WOW_CHAR=Probezero \
//! WOW_PROBE_BOOK=1 cargo run -q -p benilla --release
//! ```
//! (the slot-keyed probe identity — `pool-N` → `probeN`/`pprobeN`/`Probe<N-spelled>`, method.md
//! "The local vmangos server"). An outer `timeout` + a grep on `PROBE_BOOK:` is the whole harness;
//! the probe self-exits ([`super::probes::ProbeExitPlugin`]'s pattern) once DONE.
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::ui_item_text::ItemTextOpen;
use crate::ui_script::{UiCostWanted, UiFrameCost};
/// Goudy's own reported standing position beside the plaque (his debug panel, screenshot
/// `1535810480424222760-1`).
const PLAQUE_AT: [f32; 3] = [-8760.2, 402.3, 103.9];
/// `GAMEOBJECT_TYPE_TEXT` — the strategy type a book/plaque carries (decision 1105).
const GO_TYPE_TEXT: i32 = 9;
/// Scan radius around the landing spot, in yards — generous, so a slightly-off `.go` still finds it.
const SCAN_RANGE: f32 = 20.0;
/// Frames sampled per window. Long enough that a one-off hitch cannot carry the mean, short enough
/// that the whole run is a few seconds.
const SAMPLE_FRAMES: usize = 240;
const SETTLE_SECS: f64 = 4.0;
const SCAN_TIMEOUT_SECS: f64 = 20.0;
const READY_TIMEOUT_SECS: f64 = 15.0;
pub(crate) struct ProbeBookPlugin;
impl Plugin for ProbeBookPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BookProbe>()
.add_systems(Update, book_probe);
}
}
/// One frame's reading off the UI meter.
#[derive(Clone, Copy)]
struct Sample {
/// The paint pass — the phase the glyph rasterization and the ellipsis seam live in.
convert: u128,
/// Everything the pass costs, whether or not the gate skipped: tick + resolve + measure +
/// extract + convert + diff.
total: u128,
/// Did the extract gate skip the conversion this frame (decision 0740)?
skipped: bool,
}
#[derive(Resource, Default)]
struct BookProbe {
phase: Phase,
/// The plaque's guid, once the scan finds it.
plaque: Option<u64>,
closed: Vec<Sample>,
open: Vec<Sample>,
/// The page body the reader actually painted, in chars — the `n` the cost is quadratic in.
page_chars: i64,
fails: u32,
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
/// `.go` issued; letting the world stream the plaque in.
Settling {
sent_at: f64,
},
/// Sampling with the reader CLOSED — the control window.
Closed,
/// Reader opened on the real route; waiting for `ITEM_TEXT_READY` to paint a body.
WaitReady {
since: f64,
},
/// Sampling with the reader OPEN — the reported condition.
Open,
Done,
}
/// The reader's painted state, straight out of the live VM: `(shown, page body length)`.
fn reader_state(script: &UiScript) -> (bool, i64) {
let shown = script
.eval::<bool>("return ItemTextFrame:IsShown() and 1 or nil")
.unwrap_or(false);
let chars = script
.eval::<i64>("return strlen(ItemTextPageText:GetText() or \"\")")
.unwrap_or(0);
(shown, chars)
}
/// Mean, median and max of a sample column, in µs.
fn stats(v: &[u128]) -> (f64, u128, u128) {
if v.is_empty() {
return (0.0, 0, 0);
}
let mut sorted = v.to_vec();
sorted.sort_unstable();
#[allow(clippy::cast_precision_loss)]
let mean = v.iter().sum::<u128>() as f64 / v.len() as f64;
(mean, sorted[sorted.len() / 2], *sorted.last().unwrap())
}
/// One window's line: the paint phase, the whole pass, and how often the gate skipped.
fn report(label: &str, s: &[Sample]) {
let (cm, cmed, cmax) = stats(&s.iter().map(|x| x.convert).collect::<Vec<_>>());
let (tm, tmed, tmax) = stats(&s.iter().map(|x| x.total).collect::<Vec<_>>());
let skipped = s.iter().filter(|x| x.skipped).count();
info!(
"PROBE_BOOK: {label:<7} n={:<4} convert mean={cm:.0}us med={cmed}us max={cmax}us | \
ui-pass mean={tm:.0}us med={tmed}us max={tmax}us | gate skipped {skipped}/{}",
s.len(),
s.len(),
);
}
#[allow(clippy::too_many_arguments)]
fn book_probe(
time: ProbeClock,
mut probe: ResMut<BookProbe>,
mut wanted: ResMut<UiCostWanted>,
cost: Res<UiFrameCost>,
mut reader: ResMut<ItemTextOpen>,
script: Option<NonSendMut<UiScript>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
objects: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build — nothing this probe can drive
};
let now = time.elapsed_secs_f64();
let sample = Sample {
convert: cost.convert,
total: cost.tick + cost.resolve + cost.measure + cost.extract + cost.convert + cost.diff,
skipped: cost.skipped,
};
let phase = probe.phase;
match phase {
Phase::Wait => {
// Arm the pass's phase split for this run (decision 1174's direction: the consumer
// asks, the pass never learns the instrument's name).
wanted.0 = true;
let [x, y, z] = PLAQUE_AT;
info!("PROBE_BOOK: heading to the Old Town plaque ({x} {y} {z}) — GameObject type 9, page_text 2676");
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} 0"),
});
probe.phase = Phase::Settling { sent_at: now };
}
Phase::Settling { sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let me = player.pos;
let plaque = objects.iter().find(|(_, net_e, store, tf)| {
net_e.kind == EntityKind::GameObject
&& store.0.gameobject_type_id() == GO_TYPE_TEXT
&& tf.translation.distance(me) < SCAN_RANGE
});
if let Some((guid, ..)) = plaque {
info!(
"PROBE_BOOK: plaque {:#x} in range — sampling {SAMPLE_FRAMES} frames with the reader CLOSED",
guid.0
);
probe.plaque = Some(guid.0);
probe.phase = Phase::Closed;
} else if now - sent_at > SCAN_TIMEOUT_SECS {
error!("PROBE_BOOK: FAIL — no type-9 GameObject streamed in within {SCAN_TIMEOUT_SECS}s");
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Closed => {
probe.closed.push(sample);
if probe.closed.len() >= SAMPLE_FRAMES {
let guid = probe.plaque.unwrap_or_default();
info!("PROBE_BOOK: opening the reader on {guid:#x} (the right-click's own route — `ItemTextOpen::open_pages`, decision 1105)");
reader.open_pages(guid);
probe.phase = Phase::WaitReady { since: now };
}
}
Phase::WaitReady { since } => {
let (shown, chars) = reader_state(&script);
if shown && chars > 0 {
probe.page_chars = chars;
info!("PROBE_BOOK: reader painted a {chars}-char body — sampling {SAMPLE_FRAMES} frames with it OPEN");
probe.phase = Phase::Open;
} else if now - since > READY_TIMEOUT_SECS {
error!("PROBE_BOOK: FAIL — reader never painted a page within {READY_TIMEOUT_SECS}s (shown={shown} chars={chars})");
probe.fails += 1;
probe.phase = Phase::Done;
}
}
Phase::Open => {
probe.open.push(sample);
if probe.open.len() >= SAMPLE_FRAMES {
probe.phase = Phase::Done;
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
report("CLOSED", &probe.closed);
report("OPEN", &probe.open);
let (closed_mean, ..) =
stats(&probe.closed.iter().map(|s| s.convert).collect::<Vec<_>>());
let (open_mean, ..) = stats(&probe.open.iter().map(|s| s.convert).collect::<Vec<_>>());
info!(
"PROBE_BOOK: DONE page={} chars, reader costs {:+.0}us/frame in the paint pass fail={}",
probe.page_chars,
open_mean - closed_mean,
probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_BOOK: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
+13
View File
@@ -216,6 +216,19 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_BANK").is_ok() {
app.add_plugins(crate::capture::ProbeBankPlugin);
}
// The innkeeper-bind live probe: `WOW_PROBE_BINDER=1` GM-hops to Innkeeper Keldamyr, asserts
// the bind row's icon reads "binder", selects it, and answers the server's confirm through the
// live VM's own `ConfirmBinder()` — decision 1331's end-to-end instrument, the evidence that
// closes B249 (see `capture::ProbeBinderPlugin`).
if std::env::var("WOW_PROBE_BINDER").is_ok() {
app.add_plugins(crate::capture::ProbeBinderPlugin);
}
// The world-book live probe: `WOW_PROBE_BOOK=1` teleports to the Old Town plaque and
// measures what having the item-text reader open costs per frame, closed vs open —
// B240's instrument (see `capture::ProbeBookPlugin`).
if std::env::var("WOW_PROBE_BOOK").is_ok() {
app.add_plugins(crate::capture::ProbeBookPlugin);
}
// The cast-cancel live probe: `WOW_PROBE=castcancel` hearths and presses W mid-cast — the
// local self-cancel's end-to-end timing instrument (see `capture::ProbeCastCancelPlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("castcancel") {
+12
View File
@@ -96,6 +96,7 @@ mod transport;
mod ui_action;
mod ui_aura;
mod ui_bank;
mod ui_binder;
mod ui_cast;
mod ui_char;
mod ui_chat;
@@ -105,6 +106,7 @@ mod ui_duel;
mod ui_follow;
mod ui_gamma;
mod ui_gossip;
mod ui_guild;
mod ui_hide;
mod ui_inspect;
mod ui_item_text;
@@ -164,6 +166,7 @@ use transport::TransportPlugin;
use ui_action::UiActionPlugin;
use ui_aura::UiAuraPlugin;
use ui_bank::UiBankPlugin;
use ui_binder::UiBinderPlugin;
use ui_cast::UiCastPlugin;
use ui_char::UiCharPlugin;
use ui_chat::UiChatPlugin;
@@ -171,6 +174,7 @@ use ui_craft::UiCraftPlugin;
use ui_duel::UiDuelPlugin;
use ui_follow::UiFollowPlugin;
use ui_gossip::UiGossipPlugin;
use ui_guild::UiGuildPlugin;
use ui_item_text::UiItemTextPlugin;
use ui_items::UiItemsPlugin;
use ui_logout::UiLogoutPlugin;
@@ -499,6 +503,10 @@ pub fn run(build: BuildId) -> AppExit {
// Duels (decision 0633): the wire session, the client-side countdown tick, the four Era
// events, and the accept/cancel/challenge intents.
.add_plugins(UiDuelPlugin)
// Setting your hearthstone (decision 1331): the innkeeper's SMSG_BINDER_CONFIRM question, the
// CONFIRM_BINDER dialog it raises, and the CMSG_BINDER_ACTIVATE its Accept sends — the only
// packet in the flow that actually binds anything.
.add_plugins(UiBinderPlugin)
// Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and
// the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound.
.add_plugins(UiFollowPlugin)
@@ -506,6 +514,10 @@ pub fn run(build: BuildId) -> AppExit {
// 20-second answer narrated as the CAMP/QUIT countdown, and the process exit.
.add_plugins(UiLogoutPlugin)
.add_plugins(UiSocialPlugin)
// Guilds (decision 1257): the identity/roster mirror behind the four guild windows, the
// membership verbs, and the `ERR_GUILD_*` lines. Right after the social session, whose
// FriendsFrame it shares a window with and whose ignore list its sign-on lines consult.
.add_plugins(UiGuildPlugin)
.add_plugins(UiTooltipPlugin)
// The character-window feed (decision 0208): the combat-stats/inventory snapshots + events
// the paper doll reads, and the paper-doll booth's yaw mirror.
+76
View File
@@ -1033,6 +1033,12 @@ pub(crate) enum ClientCommand {
/// else the one item. Success is durability rising + coinage falling via `UPDATE_OBJECT`
/// (no dedicated answer packet).
RepairItem { vendor: u64, item_guid: u64 },
/// Accept an innkeeper's bind offer (`CMSG_BINDER_ACTIVATE`, decision 1331): the
/// `CONFIRM_BINDER` dialog's Accept, carrying the guid `SMSG_BINDER_CONFIRM` asked with. This
/// is the ONLY packet in the flow that binds anything — selecting the gossip line just raises
/// the question. Answered by `SMSG_BINDPOINTUPDATE` + `SMSG_PLAYERBOUND` once the innkeeper's
/// Bind cast lands; declining sends nothing.
BinderActivate { binder: u64 },
/// Open the bank (`CMSG_BANKER_ACTIVATE`, decision 0604): the direct opener a right-click on
/// a pure banker (bit 8 the lowest service bit) uses — a gossip-flagged banker routes through
/// the gossip menu instead, whose bank option makes the server volunteer the same answer.
@@ -1361,6 +1367,76 @@ pub(crate) enum ClientCommand {
/// the unit popup's PvP row, both through the VM's intent queue. Nothing local changes; the
/// answer is the descriptor's PvP bit (and flagging *off* waits out the server's 300 s timer).
TogglePvp,
// ── The guild family (writer bodies in benilla-protocol `world/writer/guild.rs`) ──────────
//
// Three things shape every caller of this band. **Members are addressed by NAME**, not by
// guid as the friend list is. **Rank ids are 0-based with 0 = guild master**, and authority
// falls as the id rises (promote = `rank - 1`). And **almost nothing is acked
// individually**: the mutating verbs answer with a whole fresh `SMSG_GUILD_ROSTER` (plus an
// `SMSG_GUILD_QUERY_RESPONSE` for the rank verbs), so the model updates when that snapshot
// lands, never optimistically at the send; a refusal arrives separately as
// `SMSG_GUILD_COMMAND_RESULT`.
//
// Every one of these is sent by `crate::ui_guild` (decision 1257) except `GuildCreate`,
// which keeps its `#[allow(dead_code)]`: *founding* a guild is the charter/petition flow,
// deliberately out of the membership slice, and vmangos registers the opcode `STATUS_NEVER`
// so nothing would answer it anyway.
/// Ask a guild's public identity by id (`CMSG_GUILD_QUERY`) — the ask-once cache fill behind
/// every "which guild is that?" (a roster row, a `/who` hit, a guild chat line).
GuildQuery { guild_id: u32 },
/// Found a guild by name (`CMSG_GUILD_CREATE`). vmangos registers the opcode `STATUS_NEVER` —
/// at 1.12 founding runs through the charter flow — so this draws no reply.
#[allow(dead_code)]
GuildCreate { name: String },
/// Invite a character into our guild (`CMSG_GUILD_INVITE`).
GuildInvite { name: String },
/// Accept the guild invitation we are holding (`CMSG_GUILD_ACCEPT`, empty body) — which one is
/// the server's pending state, not a field.
GuildAccept,
/// Turn it down (`CMSG_GUILD_DECLINE`, empty body); the inviter hears about it, we don't.
GuildDecline,
/// Ask our guild's founding date + member/account counts (`CMSG_GUILD_INFO`, empty body).
GuildInfoRequest,
/// Refresh the guild roster (`CMSG_GUILD_ROSTER`, empty body) — the guild pane's opener. The
/// server also pushes the roster unasked after every change, so this is never the only path.
GuildRosterRequest,
/// Promote a member one rank (`CMSG_GUILD_PROMOTE`) — towards guild master.
GuildPromote { name: String },
/// Demote a member one rank (`CMSG_GUILD_DEMOTE`).
GuildDemote { name: String },
/// Leave our guild (`CMSG_GUILD_LEAVE`, empty body). Refused while we are the guild master and
/// anyone else remains.
GuildLeave,
/// Kick a member by name (`CMSG_GUILD_REMOVE`).
GuildRemove { name: String },
/// Disband the guild (`CMSG_GUILD_DISBAND`, empty body) — guild master only, irreversible.
GuildDisband,
/// Hand the guild to another member (`CMSG_GUILD_LEADER`).
GuildLeader { name: String },
/// Set the message of the day (`CMSG_GUILD_MOTD`); `""` clears it.
GuildMotd { motd: String },
/// Rewrite one rank's name **and** rights together (`CMSG_GUILD_RANK`) — there is no partial
/// form, so a caller changing one must resend the other's current value. Rank 0's rights are
/// ignored and forced to "all"; an over-long name gets the session kicked, so cap it at
/// `messages::GUILD_RANK_MAX_LENGTH`.
GuildRank {
rank_id: u32,
rights: u32,
name: String,
},
/// Append a rank at the bottom of the ladder (`CMSG_GUILD_ADD_RANK`).
GuildAddRank { name: String },
/// Delete the **lowest** rank (`CMSG_GUILD_DEL_RANK`, empty body) — there is no rank id on the
/// wire, it is always the last one.
GuildDelRank,
/// Set a member's public note (`CMSG_GUILD_SET_PUBLIC_NOTE`).
GuildSetPublicNote { name: String, note: String },
/// Set a member's officer note (`CMSG_GUILD_SET_OFFICER_NOTE`). Editing and *viewing* officer
/// notes are separate rights — an all-empty officer column may mean we cannot see them.
GuildSetOfficerNote { name: String, note: String },
/// Set the guild information text (`CMSG_GUILD_INFO_TEXT`) — the long free-text pane, which
/// rides back as the roster's `info` field.
GuildInfoText { text: String },
// ── The taxi/flight-master family (decision 0484 phase 1; writer bodies in
// benilla-protocol `messages::taxi`) ──────────────────────────────────────────────────
/// Ask a nearby flight master's known status (`CMSG_TAXINODE_STATUS_QUERY`): the guid of the
+44 -3
View File
@@ -197,10 +197,16 @@ pub(super) fn apply_net_updates(
// entry stops the whole system implementing `SystemParam` — nesting is the same
// escape this tuple already is.
// escape this tuple already is. Third member: the guild session (decision 1257) —
// the identity/roster mirror the seven `SessionEvent::Guild*` arms below drive.
(
ResMut<crate::ui_item_text::PageTexts>,
ResMut<crate::net::PlayedTimeAnswer>,
ResMut<crate::ui_guild::GuildState>,
// The innkeeper's pending bind question (decision 1331) — `SMSG_BINDER_CONFIRM`
// parks the innkeeper's guid here and `crate::ui_binder` turns it into the
// CONFIRM_BINDER dialog, whose Accept is the only thing that binds anything.
ResMut<crate::ui_binder::BinderState>,
),
),
),
@@ -348,7 +354,7 @@ pub(super) fn apply_net_updates(
mut mirror_timers,
mut pet_bar,
mut ui_error_keys,
(mut page_texts, mut played_time_answer),
(mut page_texts, mut played_time_answer, mut guild, mut binder),
),
) = caches;
let (
@@ -437,6 +443,7 @@ pub(super) fn apply_net_updates(
&mut bank_open,
&mut duel,
&mut social,
&mut guild,
&mut aura.6,
&mut disconnects,
);
@@ -620,6 +627,17 @@ pub(super) fn apply_net_updates(
session::reputation_visible(list_id, &mut reputations)
}
SessionEvent::BindPoint { area } => home_bind.0 = Some(area),
SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc),
SessionEvent::PlayerBound { binder: npc, area } => {
debug!("net: bound to area {area} by {npc:#x}");
crate::ui_binder::apply::bound(
area,
&mut binder,
&mut chat_log,
area_table.as_deref(),
&mut audio.0,
)
}
SessionEvent::Proficiency {
item_class,
subclass_mask,
@@ -885,6 +903,29 @@ pub(super) fn apply_net_updates(
crate::ui_social::apply::friend_status(&mut social, update)
}
SessionEvent::WhoResults(results) => crate::ui_social::apply::who(&mut social, results),
// ── The guild family (decision 1257): the identity cache, the roster, and the
// `ERR_GUILD_*` lines the engine composes. Every arm's law lives in
// `ui_guild::apply` beside the state it drives; the guild EVENTS fire off the mirror
// in `ui_guild::feed_guild`, on their edges.
SessionEvent::GuildQueryResponse(response) => {
crate::ui_guild::apply::query_response(&mut guild, response)
}
SessionEvent::GuildRoster(roster) => crate::ui_guild::apply::roster(&mut guild, roster),
// The sign-on/sign-off pair's trailing guid exists for exactly one purpose — the
// ignore check that suppresses their line — which is why this arm reads `social`.
SessionEvent::GuildEvent(notice) => {
crate::ui_guild::apply::event(&mut guild, &mut chat_log, &social, notice)
}
SessionEvent::GuildCommandResult(result) => {
crate::ui_guild::apply::command_result(&mut guild, &mut chat_log, result)
}
SessionEvent::GuildInvite { inviter, guild: g } => {
crate::ui_guild::apply::invite(&mut guild, &mut chat_log, inviter, g)
}
SessionEvent::GuildDecline { name } => {
crate::ui_guild::apply::decline(&mut chat_log, &name)
}
SessionEvent::GuildInfo(info) => crate::ui_guild::apply::info(&mut chat_log, info),
SessionEvent::LootResponse {
guid,
loot_type,
@@ -1277,7 +1318,7 @@ pub(super) fn apply_net_updates(
greeting,
} => npc::trainer_list(trainer, trainer_type, services, greeting, &mut trainer_open),
SessionEvent::TrainerBuySucceeded { trainer, spell_id } => {
npc::trainer_buy_succeeded(trainer, spell_id, &trainer_open, &net_commands)
npc::trainer_buy_succeeded(trainer, spell_id, &mut trainer_open, &net_commands)
}
SessionEvent::TrainerBuyFailed { error, .. } => {
npc::trainer_buy_failed(error, &mut ui_actions.8)
+9 -1
View File
@@ -159,14 +159,22 @@ pub(super) fn trainer_list(
/// any next rank); the server never auto-resends on a buy (VERIFIED vmangos
/// `HandleTrainerBuySpellOpcode`). Guard on the window still being open for this trainer so a
/// late reply for a closed/switched window doesn't re-open it.
///
/// This re-request is **benilla's**, not the reference's — the reference repaints a purchase by
/// re-deriving every service's state client-side (`0x4d7d40`, decision 1128 §4.2) and receives no
/// packet at all. So the answering list is marked as a *refresh* ([`TrainerOpen::refresh_pending`]):
/// it repaints the open window, and must not carry the reference's per-packet filter/collapse reset,
/// which is what dropped the player's "Available only" choice the moment they learned a spell
/// (B256).
pub(super) fn trainer_buy_succeeded(
trainer: u64,
spell_id: u32,
trainer_open: &TrainerOpen,
trainer_open: &mut TrainerOpen,
net_commands: &NetCommands,
) {
debug!("net: trainer {trainer:#x} taught spell {spell_id} — re-listing");
if trainer_open.trainer == Some(trainer) {
trainer_open.refresh_pending = true;
let _ = net_commands.0.send(ClientCommand::TrainerList { trainer });
}
}
@@ -160,6 +160,7 @@ pub(super) fn disconnected(
bank: &mut crate::ui_bank::BankOpen,
duel: &mut crate::ui_duel::DuelState,
social: &mut crate::ui_social::SocialState,
guild: &mut crate::ui_guild::GuildState,
pending_transfer: &mut PendingTransfer,
disconnects: &mut MessageWriter<DisconnectedMessage>,
) {
@@ -232,6 +233,13 @@ pub(super) fn disconnected(
// server re-pushes both lists at the next login, and a stale ignore list would silence the
// wrong guids after a reconnect renumbers nothing but re-streams everything.
*social = crate::ui_social::SocialState::default();
// The guild session is login-scoped the same way (decision 1257) — and more strictly, because
// the next login may be a *different character*, whose guild id, rank, rights and roster share
// nothing with this one's. The identity cache goes too: it is keyed by guild id, so it would
// survive correctly, but the reference's own is backed by `guildcache.wdb` and re-primed
// lazily, and keeping a cache alive across a socket only to save one query is not worth the
// one wrong name a renamed guild would show.
*guild = crate::ui_guild::GuildState::default();
// The death stores are session-scoped too: a reclaim expiry, resurrect offer, or corpse
// marker must not survive the socket (the reconnect re-sends the reclaim delay when dead).
*death_net = crate::death::DeathNet::default();
+29
View File
@@ -763,6 +763,7 @@ fn writer_loop(
ClientCommand::RepairItem { vendor, item_guid } => {
w.repair_item(vendor, item_guid)
}
ClientCommand::BinderActivate { binder } => w.binder_activate(binder),
ClientCommand::BankerActivate { guid } => w.banker_activate(guid),
ClientCommand::BuyBankSlot { guid } => w.buy_bank_slot(guid),
ClientCommand::AutoBankItem { bag, slot } => w.autobank_item(bag, slot),
@@ -923,6 +924,34 @@ fn writer_loop(
ClientCommand::DelIgnore { guid } => w.del_ignore(guid),
ClientCommand::Who { request } => w.who(&request),
ClientCommand::ChatIgnored { guid } => w.chat_ignored(guid),
ClientCommand::GuildQuery { guild_id } => w.guild_query(guild_id),
ClientCommand::GuildCreate { name } => w.guild_create(&name),
ClientCommand::GuildInvite { name } => w.guild_invite(&name),
ClientCommand::GuildAccept => w.guild_accept(),
ClientCommand::GuildDecline => w.guild_decline(),
ClientCommand::GuildInfoRequest => w.guild_info(),
ClientCommand::GuildRosterRequest => w.guild_roster(),
ClientCommand::GuildPromote { name } => w.guild_promote(&name),
ClientCommand::GuildDemote { name } => w.guild_demote(&name),
ClientCommand::GuildLeave => w.guild_leave(),
ClientCommand::GuildRemove { name } => w.guild_remove(&name),
ClientCommand::GuildDisband => w.guild_disband(),
ClientCommand::GuildLeader { name } => w.guild_leader(&name),
ClientCommand::GuildMotd { motd } => w.guild_motd(&motd),
ClientCommand::GuildRank {
rank_id,
rights,
name,
} => w.guild_rank(rank_id, rights, &name),
ClientCommand::GuildAddRank { name } => w.guild_add_rank(&name),
ClientCommand::GuildDelRank => w.guild_del_rank(),
ClientCommand::GuildSetPublicNote { name, note } => {
w.guild_set_public_note(&name, &note)
}
ClientCommand::GuildSetOfficerNote { name, note } => {
w.guild_set_officer_note(&name, &note)
}
ClientCommand::GuildInfoText { text } => w.guild_info_text(&text),
ClientCommand::TaxiNodeStatusQuery { guid } => w.taxi_node_status_query(guid),
ClientCommand::TaxiQueryNodes { guid } => w.taxi_query_available_nodes(guid),
ClientCommand::ActivateTaxi {
+118 -4
View File
@@ -81,10 +81,18 @@ pub(super) fn query_statuses(
quest.retain_statuses(|npc| index.0.contains_key(&npc));
for (guid, obj) in &units {
if obj.0.unit_npc_flags() & NPC_FLAG_QUESTGIVER == 0 {
// No longer a questgiver: drop the stale answer (and the marker with it).
if state.asked.remove(&guid.0).is_some() {
quest.clear_status(guid.0);
}
// No longer a questgiver: drop the stale answer (and the marker with it) —
// **unconditionally**, never "only if we had asked". The reference's sweep callback
// (`0x5eb0a0`) tears the marker down on the flag test alone, with no prior-asked
// precondition (decision 0647), and the gate we used to put here was disarmed by the
// very sweep that arrives with it: an escort's giver drops `UNIT_NPC_FLAGS` in the same
// server tick as the quest-log write (vmangos `FollowerAI::StartFollow`,
// `ScriptedEscortAI`'s "disable npcflags"), so the quest-log change bumps the
// generation, `asked` is cleared above, `remove` finds nothing, and the cached
// AVAILABLE status — with its `!` — was frozen over the NPC for the whole escort, with
// no way back: the flag stays off, so this arm never queries either (B257).
state.asked.remove(&guid.0);
quest.clear_status(guid.0);
continue;
}
// Re-ask when this unit's own key moves — its service bits or its faction template. The
@@ -285,6 +293,112 @@ mod tests {
);
}
/// **B257 — an escort giver's `!` outlived the accept.** The teardown branch used to be gated on
/// the guid still being in `asked` ("did we ever query it?"), which is a memo about *questions*
/// standing in for a fact about *answers* — and the sweep clears that memo. On accepting an
/// escort both halves land in the same update: vmangos writes the quest-log slot and, from the
/// script hook in the same handler, zeroes `UNIT_NPC_FLAGS`
/// (`FollowerAI::StartFollow` / `ScriptedEscortAI`'s "disable npcflags"). So the quest-log write
/// bumps the generation, `asked.clear()` runs first, `remove` finds nothing, and the cached
/// AVAILABLE status stays — forever, because the flag stays off and this arm never queries.
/// The reference tears down on the flag test alone (`0x5eb0a0`, decision 0647).
///
/// The control below is the half that must NOT change: an ordinary giver, whose flag stays on,
/// is re-asked by the same sweep rather than torn down.
#[test]
fn an_escort_giver_loses_its_marker_when_the_flag_drops_with_the_quest_log_write() {
use crate::net::{Guid, NetEntity};
use benilla_protocol::{EntityKind, ObjectFields};
const ESCORT: u64 = 0x5115; // Mist: gives the quest, then follows with npcflags off
const PLAIN: u64 = 0x5116; // an ordinary giver standing next to her
const FIELD_NPC_FLAGS: u16 = 147;
const FIELD_QUEST_LOG_1_1: u16 = 198;
let net_entity = || NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
};
let (tx, _rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.insert_resource(NetCommands(tx))
.init_resource::<GuidIndex>()
.init_resource::<QuestGiver>()
.add_systems(Update, query_statuses);
let me = app
.world_mut()
.spawn((SelfPlayer, net_entity(), Guid(1), ObjectStore::default()))
.id();
let spawn = |app: &mut App, guid: u64| {
let e = app
.world_mut()
.spawn((
net_entity(),
Guid(guid),
ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, 0x2)])),
))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(guid, e);
e
};
let escort = spawn(&mut app, ESCORT);
spawn(&mut app, PLAIN);
app.update(); // both asked, both marked in `asked`
// The server's answer: a gold `!` over each.
for npc in [ESCORT, PLAIN] {
app.world_mut()
.resource_mut::<QuestGiver>()
.set_status(npc, 5);
}
// The accept, as one update: the quest-log slot appears in OUR store (the sweep) and the
// escort's questgiver bit goes off (the teardown) — in the same drained batch.
*app.world_mut()
.entity_mut(me)
.get_mut::<ObjectStore>()
.unwrap() = ObjectStore(ObjectFields::from_pairs(&[(FIELD_QUEST_LOG_1_1, 938)]));
*app.world_mut()
.entity_mut(escort)
.get_mut::<ObjectStore>()
.unwrap() = ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, 0x0)]));
app.update();
assert!(
app.world()
.resource::<QuestGiver>()
.status(ESCORT)
.is_none(),
"the escortee stopped being a questgiver: its stale AVAILABLE status — and the `!` \
the marker layer draws from it — must go, sweep or no sweep"
);
assert_eq!(
app.world().resource::<QuestGiver>().status(PLAIN),
Some(5),
"the control: a giver whose flag is still on keeps its answer until the server \
replies to the sweep's fresh query"
);
// And it self-heals: when the escort ends and the bit returns, the NPC is asked again.
*app.world_mut()
.entity_mut(escort)
.get_mut::<ObjectStore>()
.unwrap() = ObjectStore(ObjectFields::from_pairs(&[(FIELD_NPC_FLAGS, 0x2)]));
app.update();
assert!(
app.world()
.resource::<QuestGiver>()
.status(ESCORT)
.is_none(),
"still nothing cached — the answer arrives from the server, not from us"
);
}
/// The rest of the reference's trigger set (0654): the remaining self-descriptor watches, the
/// packet epoch that stands in for its four packet handlers, and the per-unit key that catches
/// a unit's own service bits or faction moving. Each leg must re-ask exactly once.
+41 -1
View File
@@ -40,6 +40,40 @@
@group(2) @binding(4) var<uniform> mask_rect: vec4<f32>;
@group(2) @binding(5) var mask_texture: texture_2d<f32>;
@group(2) @binding(6) var mask_sampler: sampler;
// `Texture:SetDesaturated(1)` — the greyed-out icon (decisions 1327, 1330). This is the verb every
// disabled affordance in the reference is built on: a bag icon under the game menu, an unusable pet
// action, a disconnected party portrait — and the unavailable talent B162 reported.
//
// It is not a tint and not a stage flag: `+0x128` on the texture object is a `CGxShader*`, and the
// one shader the UI ever loads is `Shaders\Pixel\Desaturate.bls` (wow-re
// `system/ui/scratch/texture-desaturate-law.md`, VERIFIED at the asset's own bytes). Its whole body
// is four instructions, and the two that matter are reproduced exactly below:
//
// MUL result.color.w , fragment.color.primary, texel ; a = vertexColour.a x texel.a
// DP3 result.color.xyz , texel, c[0] ; rgb = dot(texel.rgb, LUMA)
//
// **The vertex colour's RGB is DISCARDED, not modulated in.** A bound fragment program supersedes
// the fixed-function stage chain entirely, so the `MODULATE(TEXTURE, DIFFUSE)` law that governs
// every other UI quad simply does not run here — there is no desaturate-then-tint or
// tint-then-desaturate, the tint is *absent*. FrameXML walks straight into this:
// `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` still SETS that 0.65, and on
// shader-capable hardware it has no effect on colour. Only its ALPHA survives, which is why the
// alpha multiply below is shared with the ordinary path rather than special-cased — dropping it
// would make every desaturated icon ignore `SetAlpha` and its frame's alpha.
//
// The fold runs on the GAMMA byte (after `linear_to_srgb`) because the reference's UI is 8-bit
// arithmetic end to end; greying a linearized texel would land a different byte than the client's.
@group(2) @binding(7) var<uniform> desaturate: u32;
// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`,
// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first
// guess, corrected by the carve), not BT.709.
//
// **Do not normalise these and do not compute them in f64.** They sum to 1.0000000074505806, so a
// white texel evaluates just above 1.0 and relies on the output clamp; the ARB text and the D3D
// blob round-trip to the same three f32 words, so the backend cannot change a bit and neither may
// we.
const LUMA: vec3<f32> = vec3<f32>(0.299, 0.587, 0.114);
// Linear → sRGB (the exact IEC 61966-2-1 curve the hardware's sRGB store uses, so this inverts the
// sampler's decode bit-for-bit in f32). Alpha carries no gamma and never passes through here.
@@ -60,7 +94,13 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
// Back to the client's byte space, then tint there: `UiQuad.color` is already a client-space
// sRGB value (FrameXML's `<Color>`, `|cff…`, quality colors), so this multiply IS the FFP's
// gamma-space `tint × texel`.
let rgb = linear_to_srgb(t.rgb) * c.rgb;
let texel = linear_to_srgb(t.rgb);
// The desaturated arm REPLACES the modulate — see the `desaturate` binding above. `c.rgb` is
// deliberately unread here; only `c.a` carries into the alpha below, as it does on both paths.
var rgb = texel * c.rgb;
if desaturate != 0u {
rgb = vec3<f32>(dot(texel, LUMA));
}
var a = t.a * c.a;
if circular != 0u {
// Soft ~2%-of-width edge: reads as the ref's stencil at portrait size, no jaggies.
+323
View File
@@ -0,0 +1,323 @@
//! Setting your hearthstone — the innkeeper's bind question, its dialog, and its answer
//! (decision 1331).
//!
//! **The law this module exists for: selecting the innkeeper's gossip line binds nothing.** It
//! makes the server close the gossip menu and ask (`SMSG_BINDER_CONFIRM`, vmangos
//! `Player::SetBindPoint`); the bind happens only when the client answers `CMSG_BINDER_ACTIVATE`,
//! at which point the innkeeper casts spell 3286 "Bind" and `SPELL_EFFECT_BIND` sends
//! `SMSG_BINDPOINTUPDATE` + `SMSG_PLAYERBOUND`. Before this module the question was parsed as an
//! unknown opcode and dropped, so the click looked like it did nothing at all — half of B249.
//!
//! The Era surface it drives is the reference's own, and it is small (`StaticPopup.lua:1308-1322`,
//! `UIParent.lua:125`/`547-548`):
//!
//! - the `CONFIRM_BINDER` event, whose **one argument** is the name that fills
//! `"Do you want to make %s your new home?"`;
//! - `ConfirmBinder()` — the dialog's Accept, the only call that sends the activate;
//! - `CheckBinderDist()` — polled from the dialog's `OnUpdate`, and the dialog hides itself the
//! frame it goes false.
//!
//! Both halves are now **byte-pinned** against the reference's own handler `0x5dfdc0` and its
//! `SMSG_BINDER_CONFIRM` arm `0x5e4aa2` (wow-re
//! `system/ui/scratch/gossip-icon-and-binder-flow.md`; folded back by decision 1335, which
//! promotes what decision 1331 had to leave INFERRED):
//!
//! 1. **`arg1` is an AREA name, never the NPC's** — the handler never looks a name up. It resolves
//! the player's own **sub-area** through `AreaTable.dbc`, falls back to the **parent zone** when
//! that row is missing, and falls back again to the localized `HOME_INN` GlobalString. It always
//! fires; there is no path on which the question is withheld for want of a name. [`area_name`]
//! is that chain.
//! 2. **`CheckBinderDist()` is the same gate the rest of the NPC surface uses** — not a
//! resemblance: the reference's range test is `d² <= [0xc4c28c]`, and `[0xc4c28c]` is
//! `5.55555534362793²`, the identical constant behind [`crate::target::SERVICE_RANGE_SQ`]
//! (boundary-inclusive at both sites). So modelling the question as an [`NpcSession`] and
//! letting [`crate::ui_session::close_npc_session_out_of_range`] close it is faithful, not a
//! convenience. It is also why nothing here needs a disconnect teardown: an unstreamed guid
//! reads as gone and closes the question on the next frame.
//!
//! One deviation remains, and it is deliberate: **the reference never clears its latched guid**
//! (`0xc4d7c0`), so a second `ConfirmBinder()` while still in range re-sends the activate. We drop
//! the guid when `SMSG_PLAYERBOUND` says the bind took, because a question that has been answered
//! is not a question — the reference's own dialog is gone by then either way.
use benilla_ui::script::{ScriptValue, UiScript};
use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, NpcSession};
/// `HOME_INN` (GlobalStrings.lua:2278, verbatim) — the tail of the reference's `arg1` chain, used
/// when neither the sub-area nor the zone resolves an `AreaTable` name. Its own `GetBindLocation`
/// uses the identical fallback, which is how wow-re cross-checked the order.
const HOME_INN: &str = "your inn";
/// The innkeeper's pending bind question. Written by the net drain's `BinderConfirm` arm, read by
/// [`feed_binder`] (which fires `CONFIRM_BINDER` and publishes `CheckBinderDist`'s answer) and by
/// [`drain_binder`] (which turns the dialog's Accept into the activate).
///
/// There is no snapshot beside it: everything the dialog reads arrives as the event's argument —
/// [`crate::ui_duel::DuelState`]'s shape, for the same reason.
#[derive(Resource, Default)]
pub(crate) struct BinderState {
/// The innkeeper who asked; `None` = no question pending. This guid is what goes back on the
/// wire, and vmangos resolves it to a live innkeeper in range — a stale one binds nothing.
npc: Option<u64>,
/// A question the feed still owes the UI. Set per *packet*, not per state edge: asking the
/// same innkeeper twice in a row (decline, then click the line again) is two dialogs, and an
/// edge-diff would swallow the second.
ask: bool,
}
impl BinderState {
/// `SMSG_BINDER_CONFIRM` — park the innkeeper's guid and owe the UI a dialog.
pub(crate) fn ask(&mut self, npc: u64) {
self.npc = Some(npc);
self.ask = true;
}
/// The guid to answer with, if a question is live.
fn pending(&self) -> Option<u64> {
self.npc
}
/// Retract the question — the range guard's close, and `SMSG_PLAYERBOUND`'s (the bind landed,
/// so there is nothing left to ask). Inherent so the net drain can call it without importing
/// [`NpcSession`], exactly as [`crate::ui_gossip::GossipState::clear`] is.
pub(crate) fn clear(&mut self) {
self.npc = None;
self.ask = false;
}
}
/// The question is an NPC session: the standardized range guard closes it — the same
/// no-packet clear declining does — when the player walks out of the innkeeper's service range or
/// the innkeeper despawns. That close is what `CheckBinderDist()` reports (module doc, deviation 2).
impl NpcSession for BinderState {
fn npc(&self) -> Option<u64> {
self.npc
}
fn close(&mut self) {
self.clear();
}
}
/// Fire `CONFIRM_BINDER` for a question the UI is still owed, and publish `CheckBinderDist()`'s
/// answer every frame.
///
/// The fire is **unconditional** once a packet has asked — [`area_name`] always yields a string,
/// exactly as the reference's handler always reaches its `SignalEvent2`.
fn feed_binder(
script: Option<NonSendMut<UiScript>>,
mut binder: ResMut<BinderState>,
world: benilla_world::world_point::WorldPoint,
areas: Option<Res<AreaTableRes>>,
) {
let Some(mut script) = script else {
return;
};
script.set_binder_pending(binder.pending().is_some());
if !binder.ask {
return;
}
binder.ask = false;
let name = area_name(&world, areas.as_deref());
script.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str(name)]);
}
/// The name that fills the dialog's `%s` — the reference's own three-step chain (`0x5dfe5e`):
/// **sub-area, else parent zone, else `HOME_INN`**.
///
/// Never `None`, because the reference never withholds the question: a missing catalog or an
/// unresolvable area still fires, carrying the GlobalString. That is the difference between "we
/// don't know where you are" and "there is nothing to ask" — only the second should be silent, and
/// only the packet decides it.
fn area_name(
world: &benilla_world::world_point::WorldPoint,
areas: Option<&AreaTableRes>,
) -> String {
area_name_of(world.area(), areas)
}
/// [`area_name`]'s chain over a bare leaf id — split out so the three legs are testable against the
/// real `AreaTable` without standing up a world.
fn area_name_of(leaf: Option<u32>, areas: Option<&AreaTableRes>) -> String {
let named = |id: u32| {
areas
.and_then(|a| a.0.get(id))
.filter(|row| !row.name.is_empty())
.map(|row| row.name.clone())
};
// The parent zone is the leaf's single-hop `zone_id` (itself when 0) — the same walk
// `crate::area`'s zone-text resolve does.
let zone = leaf
.and_then(|id| areas.and_then(|a| a.0.get(id)))
.map(|row| row.zone_id)
.filter(|z| *z != 0);
leaf.and_then(named)
.or_else(|| zone.and_then(named))
.unwrap_or_else(|| HOME_INN.to_string())
}
/// The `SMSG_PLAYERBOUND` line, composed — `ERR_DEATHBIND_SUCCESS_S` with the packet's area name.
fn bound_line(area_name: &str) -> String {
ERR_DEATHBIND_SUCCESS_S.replace("%s", area_name)
}
/// Turn the dialog's Accept into `CMSG_BINDER_ACTIVATE`.
///
/// Gated on a question still being pending, exactly as the arbiter gate guards
/// [`crate::ui_duel`]'s accept: a `ConfirmBinder()` typed at the console with no innkeeper asking
/// would otherwise send a zero guid the server can only drop.
fn drain_binder(
script: Option<NonSendMut<UiScript>>,
binder: Res<BinderState>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
let confirms = script.take_binder_confirms();
if confirms == 0 {
return;
}
let Some(npc) = binder.pending() else {
return;
};
for _ in 0..confirms {
let _ = commands
.0
.send(ClientCommand::BinderActivate { binder: npc });
}
}
/// `ERR_DEATHBIND_SUCCESS_S` (GlobalStrings.lua:1543, verbatim) — the line the reference prints
/// when the bind lands, substituted with the **packet's** area name.
const ERR_DEATHBIND_SUCCESS_S: &str = "%s is now your home.";
/// `SoundEntries.dbc` id 1141 — played on `SMSG_PLAYERBOUND` **unconditionally**, before the area
/// is resolved (`0x5e3d6e`, ahead of the bounds test). So an area id the catalog cannot name is
/// silent but still audible, which is the reference's own ordering rather than an accident of ours.
const SOUND_PLAYERBOUND: u32 = 1141;
/// The net drain's `SessionEvent::PlayerBound` arm, factored here so the wire law lives beside the
/// state it drives.
pub(crate) mod apply {
use super::*;
use bevy::ecs::message::MessageWriter;
use crate::net::{ServerSoundKind, ServerSoundMessage};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
/// `SMSG_PLAYERBOUND` — the bind took. Retract the question, play the sound, and print
/// "<area> is now your home." as CHAT_MSG_SYSTEM (`0x5e3d3f`: the sound first, then
/// `DisplayError(0x138)` with the packet's own area id resolved through `AreaTable`).
///
/// This is the **feedback half of B249** — "accepting appears to change nothing" was partly
/// that nothing ever said it had. The hearthstone itself moves on `SMSG_BINDPOINTUPDATE`, the
/// packet beside this one; this arm deliberately does not touch it, exactly as the reference's
/// does not (its `GetBindLocation` reads a different global entirely).
pub(crate) fn bound(
area: u32,
binder: &mut BinderState,
chat_log: &mut ChatLog,
areas: Option<&AreaTableRes>,
sounds: &mut MessageWriter<ServerSoundMessage>,
) {
binder.clear();
sounds.write(ServerSoundMessage {
kind: ServerSoundKind::Sound2d,
sound_id: SOUND_PLAYERBOUND,
source: None,
});
let Some(name) = areas.and_then(|a| a.0.name(area)).filter(|n| !n.is_empty()) else {
debug!("ui_binder: bound to area {area}, which AreaTable does not name — no line");
return;
};
chat_log.push_event(ChatEvent::text_only(
ChatEventKind::System,
super::bound_line(name),
));
}
}
/// The innkeeper bind flow: the range guard, the dialog's feed, and its answer.
pub(crate) struct UiBinderPlugin;
impl Plugin for UiBinderPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BinderState>().add_systems(
Update,
(
// Range-close before the feed so walking away takes the dialog down the same
// frame (the gossip window's ordering, for the same reason).
close_npc_session_out_of_range::<BinderState>.before(feed_binder),
feed_binder.before(UiInput),
drain_binder.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A second question from the same innkeeper is a second dialog. The `ask` flag is per packet
/// precisely so a decline-then-click-again (which never passes through `None`) is not
/// swallowed by a state diff.
#[test]
fn asking_twice_owes_two_dialogs() {
let mut binder = BinderState::default();
assert_eq!(binder.pending(), None);
binder.ask(0x2a);
assert!(binder.ask);
binder.ask = false; // the feed fired the first dialog
assert_eq!(binder.pending(), Some(0x2a), "the guid outlives the fire");
binder.ask(0x2a);
assert!(binder.ask, "the same innkeeper asking again owes a dialog");
}
/// The `arg1` chain, against the REAL AreaTable (`0x5dfe5e`): sub-area, else parent zone, else
/// the `HOME_INN` GlobalString — and never nothing, because the reference never withholds the
/// question for want of a name. 186 is Dolanaar, the leaf the bug's own innkeeper stands in.
/// Skips without client data.
#[test]
fn the_dialogs_name_falls_back_sub_area_then_zone_then_home_inn() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let areas = AreaTableRes(
benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc"),
);
assert_eq!(area_name_of(Some(186), Some(&areas)), "Dolanaar");
// No row, no catalog, no id at all — all three reach the GlobalString rather than "".
assert_eq!(area_name_of(Some(0xffff), Some(&areas)), HOME_INN);
assert_eq!(area_name_of(Some(186), None), HOME_INN);
assert_eq!(area_name_of(None, Some(&areas)), HOME_INN);
}
/// The line the bind lands with — the feedback half of B249, since "accepting appears to change
/// nothing" was partly that nothing ever said it had.
#[test]
fn the_bind_prints_the_area_is_now_your_home() {
assert_eq!(bound_line("Dolanaar"), "Dolanaar is now your home.");
}
/// Closing (the range guard, or the bind landing) retracts both the guid and any unfired
/// question — so `CheckBinderDist()` goes false and a later `ConfirmBinder()` sends nothing.
#[test]
fn closing_retracts_the_guid_and_the_unfired_dialog() {
let mut binder = BinderState::default();
binder.ask(0x2a);
binder.close();
assert_eq!(binder.pending(), None);
assert!(!binder.ask);
}
}
+106 -25
View File
@@ -131,24 +131,58 @@ impl NpcSession for GossipState {
}
}
/// The lowercase Era `GetGossipOptions()` icon *type* for a wire `GOSSIP_ICON` byte — the value the
/// XML resolves to a `Interface\GossipFrame\<Type>GossipIcon` texture. Only the common icons are
/// mapped (decision 0081 v1: minimal-but-era-shaped); anything else falls back to the chat bubble,
/// so an unmapped icon still shows a row rather than nothing. The GOSSIP_ICON values are vmangos's
/// (`GossipDef.h`): 0 chat · 1 vendor · 2 taxi · 3 trainer · 4 interact-1 · 5 interact-2 · 6 money ·
/// 7 talk · 8 tabard · 9 battlefield.
/// The lowercase Era `GetGossipOptions()` icon *type* for a wire `GOSSIP_ICON` byte — the value
/// the XML resolves to a `Interface\GossipFrame\<Type>GossipIcon` texture.
///
/// **The byte is a bare index into [`GOSSIP_ICON_TYPES`], with no bounds check anywhere on the
/// client's path** — VERIFIED at the bytes (wow-re `system/ui/scratch/gossip-icon-and-binder-flow.md`,
/// decision 1335): `GetGossipOptions 0x4e28d0` reads the stored byte and does
/// `mov edx,[eax*4 + 0x84b7ac]` straight into a 14-entry pointer table, then `lua_pushstring`s it.
///
/// The old map read the byte through **vmangos's `GossipDef.h` enum *names*** (`INTERACT_1`,
/// `MONEY_BAG`, `TALK`, …), which name a **later** client's icon art. It was wrong for six of the
/// eleven values; 5 — the icon every `GOSSIP_OPTION_INNKEEPER` row in the world DB sends — had no
/// entry at all and fell through to the chat bubble, which is the icon half of B249 (decision 1331).
///
/// Two of the reference's out-of-range behaviours we deliberately do **not** reproduce, because
/// both are its missing guard rather than its design (wow-re's note says so in as many words):
/// index **14** is a NULL in the table, so the reference pushes `nil` as the option's type and the
/// row draws whatever the XML's own fallback gives it — the chat bubble, which is what we return
/// directly. Index **≥15** walks off the end into the gossip Lua *binding* table, pushing API-name
/// literals and `strlen`'d `.text` addresses as type strings, and eventually faults. We clamp.
fn gossip_icon_type(icon: u8) -> &'static str {
match icon {
1 => "vendor",
2 => "taxi",
3 => "trainer",
4 => "binder",
6 => "tabard",
9 => "battlemaster",
_ => "gossip", // 0/5/7/8/unknown → the chat bubble
}
GOSSIP_ICON_TYPES
.get(icon as usize)
.copied()
.unwrap_or("gossip")
}
/// The client's icon-name table verbatim — `0x84b7ac`, 14 entries, indexed by the wire
/// `GOSSIP_ICON` byte (see [`gossip_icon_type`]). Indices 11-13 are genuine `gossip` aliases in the
/// binary, not padding.
///
/// Ten of the eleven distinct names have a `Interface\GossipFrame\<Type>GossipIcon.blp` behind
/// them on the 5875 chain; **`auctioneer` does not** — the table names art the client never shipped
/// (auction houses came with 1.9, the icon later). [`gossip_icon_types_name_the_shipped_art`] pins
/// both halves of that, and the XML deliberately has no `auctioneer` key: a missing texture draws
/// nothing, our fallback draws the bubble, and the bubble is the better wrong answer.
const GOSSIP_ICON_TYPES: [&str; 14] = [
"gossip",
"vendor",
"taxi",
"trainer",
"healer",
"binder",
"banker",
"petition",
"tabard",
"battlemaster",
"auctioneer",
"gossip",
"gossip",
"gossip",
];
/// Build the Lua-facing snapshot from [`GossipState`] — `None` when no menu is open.
fn snapshot(state: &GossipState) -> Option<GossipMenu> {
state.npc?;
@@ -312,16 +346,9 @@ fn drain_gossip(
mod tests {
use super::*;
#[test]
fn icon_types_map_the_common_gossip_icons() {
assert_eq!(gossip_icon_type(0), "gossip");
assert_eq!(gossip_icon_type(1), "vendor");
assert_eq!(gossip_icon_type(2), "taxi");
assert_eq!(gossip_icon_type(3), "trainer");
assert_eq!(gossip_icon_type(7), "gossip"); // talk bubble
assert_eq!(gossip_icon_type(9), "battlemaster");
assert_eq!(gossip_icon_type(200), "gossip"); // unknown → bubble
}
/// The one type string [`GOSSIP_ICON_TYPES`] names that the 5875 install ships no art for.
/// Test-only: nothing in the client needs to know, because the XML simply has no key for it.
const GOSSIP_ICON_TYPE_UNSHIPPED: &str = "auctioneer";
#[test]
fn snapshot_is_none_until_a_menu_opens() {
@@ -388,4 +415,58 @@ mod tests {
);
assert_eq!(state.draw_greeting(78, 0), None, "record not yet arrived");
}
/// The icon byte is an INDEX into the client's own table (`0x84b7ac`, VERIFIED — decision
/// 1335), and these are the seats that were wrong before 1331. Each is pinned to the
/// `option_id` the world DB pairs it with, because that pairing is what makes the table
/// legible: spirit healers send 4, innkeepers 5, bankers 6, petitioners 7, tabard designers 8,
/// battlemasters 9.
#[test]
fn the_icon_byte_indexes_the_clients_own_table() {
assert_eq!(gossip_icon_type(0), "gossip");
assert_eq!(gossip_icon_type(1), "vendor"); // GOSSIP_OPTION_VENDOR / _ARMORER
assert_eq!(gossip_icon_type(2), "taxi"); // _TAXIVENDOR
assert_eq!(gossip_icon_type(3), "trainer"); // _TRAINER
assert_eq!(gossip_icon_type(4), "healer"); // _SPIRITHEALER / _SPIRITGUIDE — was "binder"
assert_eq!(gossip_icon_type(5), "binder"); // _INNKEEPER — B249: was the chat bubble
assert_eq!(gossip_icon_type(6), "banker"); // _BANKER — was "tabard"
assert_eq!(gossip_icon_type(7), "petition"); // _PETITIONER — was the chat bubble
assert_eq!(gossip_icon_type(8), "tabard"); // _TABARDDESIGNER — was the chat bubble
assert_eq!(gossip_icon_type(9), "battlemaster"); // _BATTLEFIELD
assert_eq!(gossip_icon_type(10), "auctioneer"); // _AUCTIONEER — was the chat bubble
// 11-13 are genuine `gossip` aliases in the binary, not padding.
assert_eq!(gossip_icon_type(11), "gossip");
assert_eq!(gossip_icon_type(13), "gossip");
// 14 is a NULL the reference pushes as `nil`, and 15+ walks off its table into the Lua
// binding array. Both are its missing guard, not its design — we clamp (see the fn's doc).
assert_eq!(gossip_icon_type(14), "gossip");
assert_eq!(gossip_icon_type(255), "gossip");
}
/// The table must name the art the install actually ships — the check that could not have
/// passed while the mapping was wrong-by-naming, because the reference builds the path by
/// concatenation (`GossipFrame.lua:123`:
/// `SetTexture("Interface\\GossipFrame\\" .. arg[i+1] .. "GossipIcon")`), so a type with no BLP
/// behind it is a blank row.
///
/// **`auctioneer` is the one exception, and it is the client's, not ours**: index 10 names a
/// texture 5875 never shipped, so the reference itself draws nothing for it. Asserting the
/// absence pins that as a fact rather than leaving it as a hole someone later "fixes" by
/// inventing a path. Skips without client data.
#[test]
fn gossip_icon_types_name_the_shipped_art() {
let data = benilla_formats::wow_data_or_skip!();
let chain = benilla_formats::open_chain(&data).expect("open chain");
let blp = |ty: &str| format!("Interface\\GossipFrame\\{ty}GossipIcon.blp");
for ty in GOSSIP_ICON_TYPES {
if ty == GOSSIP_ICON_TYPE_UNSHIPPED {
continue;
}
assert!(chain.contains(&blp(ty)), "{} is not on the chain", blp(ty));
}
assert!(
!chain.contains(&blp(GOSSIP_ICON_TYPE_UNSHIPPED)),
"5875 has grown a {GOSSIP_ICON_TYPE_UNSHIPPED} gossip icon — give the XML its key"
);
}
}
+528
View File
@@ -0,0 +1,528 @@
//! The guild VM feed/drain — the systems half of [`super`]: resolve the wire's ids into the
//! display-ready snapshot the guild windows read, fire the events on their edges, and turn the
//! Lua-side [`GuildRequest`] intents into their sends.
//!
//! Everything resolved here is resolved *engine-side in the reference too*:
//! `GetGuildRosterInfo 0x4d1200` hands Lua the localized `ChrClasses` and `AreaTable` names, not
//! the ids (`0x4d1355`, `0x4d1391`), and the rank name out of the cached guild record — the house
//! law from `script/social.rs:8-13`. Unlike the friend list and `/who`, the guild roster carries
//! level, class and zone for **offline** members as well, so those columns stay populated and
//! merely grey out.
use benilla_formats::AreaTableCatalog;
use benilla_protocol::messages::{
guild_presence, GuildRosterMember, GUILD_RANKS_MAX_COUNT, GUILD_RANKS_MIN_COUNT,
GUILD_RANK_MAX_LENGTH,
};
use benilla_ui::script::{
GuildMemberInfo, GuildRankInfo, GuildRequest, GuildState as VmGuild, ScriptValue, UiScript,
};
use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_unit::class_names;
use super::{last_online, GuildState, Identity, RosterRow, ROSTER_REQUEST_THROTTLE_SECS};
/// The `<AFK>`/`<DND>` tags, `CHAT_FLAG_AFK`/`CHAT_FLAG_DND` (`GlobalStrings.lua:766-767`) — the
/// same pair the friend list and the chat frame use, which is what the reference loads at
/// `0x4d1404`/`0x4d13ed`.
const CHAT_FLAG_AFK: &str = "<AFK>";
const CHAT_FLAG_DND: &str = "<DND>";
/// What the feed last announced, so the events fire on edges rather than every frame.
#[derive(Default)]
pub(super) struct FedGuild {
/// Has the VM been given a snapshot at all yet? The first push fires the roster update, so a
/// frame loaded after the roster arrived still populates.
seeded: bool,
motd: String,
invite: Option<(String, String)>,
}
/// Build the display snapshot, push it, and fire the guild events on their edges.
pub(super) fn feed_guild(
script: Option<NonSendMut<UiScript>>,
mut guild: ResMut<GuildState>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
areas: Option<Res<AreaTableRes>>,
commands: Res<NetCommands>,
mut fed: Local<crate::ui_script::VmMemo<FedGuild>>,
) {
let Some(mut script) = script else {
return;
};
let fed = fed.get(&script);
// Our own guild id and rank live in our own descriptor, not in any guild packet, so they are
// mirrored per frame from it. Only when the avatar is actually streamed: a frame between the
// logout despawn and the disconnect teardown must not read as "left the guild".
let mut id_changed = false;
let mut rank_changed = false;
if let Some(store) = self_q.iter().next() {
let (id, rank) = (store.0.player_guild_id(), store.0.player_guild_rank());
id_changed = id != guild.guild_id;
rank_changed = rank != guild.rank_index;
guild.mirror_self(id, rank);
}
// Rebuilding a large roster costs a string clone per column per member, so it happens on a
// change rather than per frame — every apply and every local intent sets the flag.
if !guild.dirty && fed.seeded {
return;
}
guild.dirty = false;
// The lazy identity fill (`super`'s module doc): asking here is what the reference's
// `GetGuildRosterInfo` does inline on a cache miss, and the answer arrives as its own
// no-arg GUILD_ROSTER_UPDATE.
let guild_id = guild.guild_id;
guild.request_identity(guild_id, &commands);
let identity = guild.identity(guild_id).cloned();
let identity = identity.as_ref();
let rows = display_rows(&guild, areas.as_deref().map(|a| &a.0), identity);
let display_order: Vec<u64> = rows.iter().map(|r| r.guid).collect();
let selection = index_of(&display_order, guild.selection);
guild.display_order = display_order;
script.set_guild(VmGuild {
in_guild: guild.in_guild(),
name: identity.map(|i| i.name.clone()).unwrap_or_default(),
rank_name: identity
.map(|i| i.rank_name(guild.rank_index).to_string())
.unwrap_or_default(),
rank_index: guild.rank_index,
// `IsGuildLeader 0x516e40` — rank 0 IS the guild master, and being guildless is not being
// one, however the rank field happens to read.
is_leader: guild.in_guild() && guild.rank_index == 0,
rights: guild.own_rights(),
motd: guild.motd.clone(),
info_text: guild.info_text.clone(),
num_members: guild.num_members(),
roster: rows.into_iter().map(|r| r.info).collect(),
ranks: guild
.rank_rights
.iter()
.enumerate()
.map(|(i, rights)| GuildRankInfo {
name: identity
.map(|id| id.rank_name(i as u32).to_string())
.unwrap_or_default(),
rights: *rights,
})
.collect(),
selection,
show_offline: guild.show_offline,
});
// GUILD_ROSTER_UPDATE, and whether it carries arg1 — the whole of that decision is
// `super::RosterUpdate`, including why the number 1 rather than a boolean.
let first = !fed.seeded;
fed.seeded = true;
if let Some(kind) = guild.roster_event.take().or(first.then_some(
// A frame that loads after the roster did still needs one push to draw from.
super::RosterUpdate::Applied,
)) {
let args = if kind.arg1() {
vec![ScriptValue::Int(1)]
} else {
Vec::new()
};
script.fire_event("GUILD_ROSTER_UPDATE", args);
}
// GUILD_MOTD carries the text itself; the chat line it prints is the FrameXML's
// (`ChatFrame.lua:1335-1340`), which is why nothing composes one here.
if fed.motd != guild.motd {
fed.motd = guild.motd.clone();
script.fire_event("GUILD_MOTD", vec![ScriptValue::Str(guild.motd.clone())]);
}
// PLAYER_GUILD_UPDATE. Two of the reference's three paths are descriptor field callbacks:
// PLAYER_GUILDRANK's fires with **no arguments** for any player object, and PLAYER_GUILDID's
// fires **once per unit token** naming that player, with the token as arg1
// (`0x5e27b0` / `0x5e2770` → `0x515e50`). Ours covers our own avatar, which is every consumer
// 1.12's FrameXML has — `InGuildCheck` and the paper doll both read `GetGuildInfo("player")`
// and ignore the argument. The per-token broadcast for OTHER units is deliberately not built:
// it has no consumer here, and the broadcaster's per-token walk is the one part of that path
// wow-re flags INFERRED, so it is not something to build on yet.
if id_changed {
script.fire_event(
"PLAYER_GUILD_UPDATE",
vec![ScriptValue::Str("player".to_string())],
);
}
if rank_changed {
script.fire_event("PLAYER_GUILD_UPDATE", Vec::new());
}
// GUILD_INVITE_REQUEST arms the popup. There is **no** hide edge: `GUILD_INVITE_CANCEL`'s
// wrapper `0x48f470` has zero callers image-wide, so nothing in 1.12 raises it and neither do
// we — the FrameXML registers for it and simply never hears it.
if fed.invite != guild.pending_invite {
fed.invite = guild.pending_invite.clone();
if let Some((inviter, name)) = &guild.pending_invite {
script.fire_event(
"GUILD_INVITE_REQUEST",
vec![
ScriptValue::Str(inviter.clone()),
ScriptValue::Str(name.clone()),
],
);
}
}
}
/// The roster in display order — **every** member, sorted, never filtered (see [`super::sort`]).
///
/// The rank name comes from the guild's identity, which is why an unanswered query leaves that one
/// column empty rather than the whole row absent.
pub(super) fn display_rows(
guild: &GuildState,
areas: Option<&AreaTableCatalog>,
identity: Option<&Identity>,
) -> Vec<RosterRow> {
let mut rows: Vec<RosterRow> = guild
.members
.iter()
.map(|m| row(m, areas, identity))
.collect();
guild.sort.order(&mut rows, guild.show_offline);
rows
}
/// One roster row, ids resolved. Level, class and zone are filled for **offline** members too:
/// the server keeps them in the guild table and puts them on the wire, so the pane greys the row
/// rather than blanking its columns — the opposite of the friend list, whose wire carries nothing
/// for an offline friend.
fn row(
m: &GuildRosterMember,
areas: Option<&AreaTableCatalog>,
identity: Option<&Identity>,
) -> RosterRow {
let online = m.is_online();
RosterRow {
guid: m.guid,
last_online_days: m.last_online_days,
info: GuildMemberInfo {
name: m.name.clone(),
rank: identity
.map(|i| i.rank_name(m.rank_id).to_string())
.unwrap_or_default(),
rank_index: m.rank_id,
level: u32::from(m.level),
class: class_names(m.class)
.map(|(display, _)| display.to_string())
.unwrap_or_default(),
zone: areas
.and_then(|a| a.name(m.zone))
.unwrap_or_default()
.to_string(),
note: m.public_note.clone(),
officer_note: m.officer_note.clone(),
online,
status: status_tag(m.presence).to_string(),
// The wire sends no float at all for an online member, and the formatter reads
// all-zeroes exactly as it reads nil.
last_online: if online {
Default::default()
} else {
last_online(m.last_online_days)
},
},
}
}
/// The away tag a member's presence byte carries — `GetGuildRosterInfo`'s tenth return, and a
/// **string on every path**, `""` and never nil.
///
/// **DND is tested before AFK** (`0x4d13e4 test al,0x4` before `0x4d13fb test al,0x2`), so a
/// member flagged both displays as DND. The `online` flag beside it is the whole presence byte
/// against zero (`0x4d0c12 test dl,dl`), never a mask against the ONLINE bit — see
/// [`GuildRosterMember::is_online`].
pub(super) fn status_tag(presence: u8) -> &'static str {
if presence & guild_presence::DND != 0 {
CHAT_FLAG_DND
} else if presence & guild_presence::AFK != 0 {
CHAT_FLAG_AFK
} else {
""
}
}
/// The 1-based row a guid occupies in the shown order, `0` when it is not shown — the reference's
/// own guid→index conversion (`GetGuildRosterSelection 0x4d1890` → `0x4d1030`, a linear search
/// returning `index + 1`, so a member who left reads as "nothing selected").
pub(super) fn index_of(order: &[u64], guid: u64) -> u32 {
if guid == 0 {
return 0;
}
order
.iter()
.position(|g| *g == guid)
.map_or(0, |i| i as u32 + 1)
}
/// Turn the Era API's guild intents into their sends.
///
/// Two of them need the app because only the app knows the display order: the note verbs carry a
/// 1-based **row** and the wire wants the member's **name**. Three are local-only — select, sort
/// and show-offline change what the next push says and send nothing at all.
pub(super) fn drain_guild(
script: Option<NonSendMut<UiScript>>,
mut guild: ResMut<GuildState>,
commands: Res<NetCommands>,
time: Res<Time<Real>>,
) {
let Some(mut script) = script else {
return;
};
let requests = script.take_guild_requests();
if requests.is_empty() {
return;
}
for request in requests {
match request {
GuildRequest::Roster => {
// The reference throttles this binding itself, to one request per 10 s against
// `0xb73130` (`0x4d10d0`), and gives no signal when it swallows one. Real time,
// not virtual: this is a span measured against the server, and the virtual clock's
// 250 ms `max_delta` falls behind under a hitch (decision 0846).
let now = time.elapsed_secs_f64();
if now >= guild.roster_allowed_at {
guild.roster_allowed_at = now + ROSTER_REQUEST_THROTTLE_SECS;
let _ = commands.0.send(ClientCommand::GuildRosterRequest);
}
}
GuildRequest::Info => {
let _ = commands.0.send(ClientCommand::GuildInfoRequest);
}
GuildRequest::Invite(name) => {
let _ = commands.0.send(ClientCommand::GuildInvite { name });
}
GuildRequest::Uninvite(name) => {
let _ = commands.0.send(ClientCommand::GuildRemove { name });
}
GuildRequest::Promote(name) => {
let _ = commands.0.send(ClientCommand::GuildPromote { name });
}
GuildRequest::Demote(name) => {
let _ = commands.0.send(ClientCommand::GuildDemote { name });
}
GuildRequest::SetLeader(name) => {
let _ = commands.0.send(ClientCommand::GuildLeader { name });
}
GuildRequest::Accept => {
guild.clear_invite();
let _ = commands.0.send(ClientCommand::GuildAccept);
}
GuildRequest::Decline => {
guild.clear_invite();
let _ = commands.0.send(ClientCommand::GuildDecline);
}
GuildRequest::Leave => {
let _ = commands.0.send(ClientCommand::GuildLeave);
}
GuildRequest::Disband => {
let _ = commands.0.send(ClientCommand::GuildDisband);
}
GuildRequest::SetMotd(motd) => {
let _ = commands.0.send(ClientCommand::GuildMotd { motd });
}
GuildRequest::SetInfoText(text) => {
// Deliberately no local write: the reference's `SetGuildInfoText 0x4d2380` sends
// and does NOT touch `0xb72720`, so the visible text only changes when the next
// roster brings it back.
let _ = commands.0.send(ClientCommand::GuildInfoText { text });
}
GuildRequest::SetPublicNote { index, note } => {
set_note(&mut guild, &commands, index, note, false);
}
GuildRequest::SetOfficerNote { index, note } => {
set_note(&mut guild, &commands, index, note, true);
}
GuildRequest::SaveRank {
rank_index,
rights,
name,
} => {
if let Some(name) = capped_rank_name(name) {
let _ = commands.0.send(ClientCommand::GuildRank {
rank_id: rank_index,
rights,
name,
});
}
}
GuildRequest::AddRank(name) => {
// The client enforces the ladder's bounds itself, and silently: the reference's
// Add button disables at ten ranks and its Remove button only appears above five
// (`FriendsFrame.lua:887`, `:908`). Enforcing it here as well is what keeps a
// macro or an addon from sending what the server would only ignore.
if guild.rank_rights.len() < GUILD_RANKS_MAX_COUNT {
if let Some(name) = capped_rank_name(name) {
let _ = commands.0.send(ClientCommand::GuildAddRank { name });
}
}
}
GuildRequest::DelRank => {
if guild.rank_rights.len() > GUILD_RANKS_MIN_COUNT {
let _ = commands.0.send(ClientCommand::GuildDelRank);
}
}
GuildRequest::Select(index) => guild.select(index),
GuildRequest::SetShowOffline(on) => guild.set_show_offline(on),
GuildRequest::Sort(field) => guild.sort_by(&field),
}
}
}
/// A note verb: resolve the display row to the member's name, write the note locally, and send
/// **only if the text actually changed** — the reference writes the record first and gates the
/// packet on that write having changed anything (`0x4d15e0`, whose `0x64a480` result is the
/// condition on the send).
fn set_note(
guild: &mut GuildState,
commands: &NetCommands,
index: u32,
note: String,
officer: bool,
) {
let Some(member) = guild.member_at_mut(index) else {
return;
};
let slot = if officer {
&mut member.officer_note
} else {
&mut member.public_note
};
if *slot == note {
return;
}
*slot = note.clone();
let name = member.name.clone();
guild.dirty = true;
let command = if officer {
ClientCommand::GuildSetOfficerNote { name, note }
} else {
ClientCommand::GuildSetPublicNote { name, note }
};
let _ = commands.0.send(command);
}
/// A rank name the wire will accept, or `None`.
///
/// Over-long is not a polite refusal on this packet: `HandleGuildRankOpcode` /
/// `HandleGuildAddRankOpcode` run `ProcessAnticheatAction(… CHEAT_ACTION_KICK)`
/// (`Handlers/GuildHandler.cpp:580-584`, `:600-604`). The reference caps at the edit box's
/// `maxLetters`; we cap at the send too, and **drop rather than truncate** — a silently shortened
/// rank name is the wrong name, which is its own bug.
fn capped_rank_name(name: String) -> Option<String> {
if name.chars().count() > GUILD_RANK_MAX_LENGTH {
warn!(
"guild: refusing a {}-character rank name (the server kicks over {GUILD_RANK_MAX_LENGTH})",
name.chars().count()
);
return None;
}
Some(name)
}
#[cfg(test)]
mod tests {
use super::*;
/// DND is tested **before** AFK, so a member flagged both shows as DND — and the tag is a
/// string on every path, including an offline member's.
#[test]
fn dnd_wins_over_afk_and_the_tag_is_always_a_string() {
use guild_presence::{AFK, DND, OFFLINE, ONLINE};
assert_eq!(status_tag(ONLINE | AFK | DND), "<DND>");
assert_eq!(status_tag(ONLINE | DND), "<DND>");
assert_eq!(status_tag(ONLINE | AFK), "<AFK>");
assert_eq!(status_tag(ONLINE), "");
assert_eq!(status_tag(OFFLINE), "", "never nil, even offline");
}
/// `online` is the whole presence byte against zero, not a mask against the ONLINE bit — so an
/// unknown future bit reads as online, which is the safe direction (it is also what decides
/// whether the wire carries the last-online float at all).
#[test]
fn online_is_the_whole_byte() {
let member = |presence| GuildRosterMember {
presence,
..Default::default()
};
assert!(member(guild_presence::ONLINE).is_online());
assert!(member(guild_presence::AFK).is_online(), "no ONLINE bit set");
assert!(member(0x80).is_online(), "an unknown bit reads as online");
assert!(!member(guild_presence::OFFLINE).is_online());
}
/// An offline member keeps their level, class and zone — the guild table has them and the
/// wire sends them, which is exactly what the friend list cannot do.
#[test]
fn an_offline_row_keeps_its_level_class_and_zone() {
let member = GuildRosterMember {
guid: 5,
presence: guild_presence::OFFLINE,
name: "Kaplan".into(),
rank_id: 1,
level: 42,
class: 8, // Mage
zone: 12,
last_online_days: 3.5,
public_note: "alt".into(),
officer_note: String::new(),
};
let row = row(&member, None, None);
assert_eq!(row.info.level, 42);
assert_eq!(row.info.class, "Mage");
assert!(!row.info.online);
assert_eq!(row.info.last_online.days, 3);
assert_eq!(row.info.status, "");
assert_eq!(row.guid, 5);
}
/// An online member's last-online reads as all zeroes — the wire carries no float for them,
/// and the formatter reads that exactly as it reads nil.
#[test]
fn an_online_row_reports_no_last_online() {
let member = GuildRosterMember {
presence: guild_presence::ONLINE,
// Even if a value were somehow present, an online member never reaches the
// arithmetic in the reference either (`0x4d14fd`).
last_online_days: 9.0,
..Default::default()
};
assert_eq!(
row(&member, None, None).info.last_online,
Default::default()
);
}
/// A row index maps through the *shown* order, and 0/past-the-end map to nothing.
#[test]
fn the_shown_order_maps_both_ways() {
assert_eq!(index_of(&[11, 22, 33], 22), 2);
assert_eq!(index_of(&[22, 11, 33], 22), 1, "same player, new row");
assert_eq!(index_of(&[11, 33], 22), 0, "no longer listed");
assert_eq!(index_of(&[11, 22], 0), 0, "nothing selected");
}
/// An over-long rank name is dropped, not truncated and not sent — the server kicks over it.
#[test]
fn an_over_long_rank_name_is_refused() {
assert_eq!(
capped_rank_name("Officer".into()).as_deref(),
Some("Officer")
);
assert!(capped_rank_name("A".repeat(GUILD_RANK_MAX_LENGTH)).is_some());
assert_eq!(
capped_rank_name("A".repeat(GUILD_RANK_MAX_LENGTH + 1)),
None
);
}
}
+354
View File
@@ -0,0 +1,354 @@
//! The guild session's **system lines** — the `ERR_GUILD_*` chat output the engine composes.
//!
//! Every guild event and every command verdict prints one line, and the *engine* composes it, not
//! the FrameXML: `SMSG_GUILD_EVENT`'s handler `0x5e7180` routes its arms into
//! `CGGameUI::DisplayError 0x496720` (ids `0x59`, `0x5a`, `0x57`, `0x5c`, `0x6a`, `0x6b`, `0x5d`,
//! `0x6c`, `0x106`, `0x107` — wow-re RF-0077), and the invite / decline / info / command-result
//! handlers do the same (`0x5e6f65`, `0x5e6f9a`, `0x5e706b`, `0x5e7520`). Not one of the
//! `ERR_GUILD_*` keys those resolve to appears anywhere in the reference FrameXML, which is what
//! identifies them as engine-composed — [`crate::ui_social`]'s own test, applied again.
//!
//! **The `GUILD_MOTD` line is deliberately not here.** The reference's `ChatFrame_OnEvent`
//! composes `GUILD_MOTD_TEMPLATE` itself off the `GUILD_MOTD` event (`ChatFrame.lua:1335-1340`),
//! so firing the event is the whole job and composing a line beside it would double it.
//!
//! Which id maps to which GlobalStrings key is *inferred by name* (the catalog's own id→key table
//! is uncited in wow-re), exactly as [`crate::ui_party`]'s party-result table is — but the
//! sign-on/sign-off pair is better than inferred: vmangos annotates `GE_SIGNED_ON`/`GE_SIGNED_OFF`
//! with the key names themselves (`Guild/Guild.h:134-135`), and the two ids the reference uses for
//! them (`0x106`/`0x107`) sit inside the friend band `0x104..0x114`.
use benilla_protocol::messages::{
guild_command, guild_command_error, guild_event, GuildCommandResult, GuildEventNotice,
GuildInfo,
};
// ── The templates, quoted verbatim from the reference's own patch chain (decision 0246
// extraction; the `GlobalStrings.lua` line is on each) ──────────────────────────────────────
const ERR_GUILD_CREATE_S: &str = "%s created."; // GlobalStrings:1597
const ERR_GUILD_INVITE_S: &str = "You have invited %s to join your guild."; // :1606
const ERR_GUILD_QUIT_S: &str = "You are no longer a member of %s."; // :1629
const ERR_GUILD_FOUNDER_S: &str = "Congratulations, you are a founding member of %s!"; // :1604
const ERR_GUILD_INTERNAL: &str = "Internal guild error."; // :1605
const ERR_ALREADY_IN_GUILD: &str = "You are already in a guild."; // :1466
const ERR_ALREADY_IN_GUILD_S: &str = "%s is already in a guild."; // :1467
const ERR_INVITED_TO_GUILD: &str = "You have already been invited into a guild."; // :1655
const ERR_ALREADY_INVITED_TO_GUILD_S: &str = "%s has already been invited to a guild."; // :1464
const ERR_GUILD_NAME_INVALID: &str = "That name contains invalid characters, Enter a new name."; // :1616
const ERR_GUILD_NAME_EXISTS_S: &str = "There is already a guild named \"%s\"."; // :1615
pub(super) const ERR_GUILD_LEADER_LEAVE: &str =
"You must promote a new Guild Master using /gleader before leaving the guild."; // :1610
pub(super) const ERR_GUILD_PERMISSIONS: &str = "You don't have permission to do that."; // :1624
const ERR_GUILD_PLAYER_NOT_IN_GUILD: &str = "You are not in a guild."; // :1626
const ERR_GUILD_PLAYER_NOT_IN_GUILD_S: &str = "%s is not in your guild."; // :1627
const ERR_GUILD_PLAYER_NOT_FOUND_S: &str = "\"%s\" not found."; // :1625
const ERR_GUILD_NOT_ALLIED: &str =
"You cannot invite players from the opposing alliance into your guild"; // :1623
const ERR_GUILD_RANK_TOO_HIGH_S: &str = "%s's rank is too high"; // :1632
const ERR_GUILD_RANK_TOO_LOW_S: &str = "%s is already at the lowest rank"; // :1633
const ERR_GUILD_RANKS_LOCKED: &str = "Temporary guild error. Please try again!"; // :1630
const ERR_GUILD_RANK_IN_USE: &str = "That guild rank is currently in use."; // :1631
const ERR_IGNORING_YOU_S: &str = "%s is ignoring you."; // :1643
const ERR_GUILD_PROMOTE_SSS: &str = "%s has promoted %s to %s."; // :1628
const ERR_GUILD_DEMOTE_SSS: &str = "%s has demoted %s to %s."; // :1599
const ERR_GUILD_JOIN_S: &str = "%s has joined the guild."; // :1607
const ERR_GUILD_LEAVE_S: &str = "%s has left the guild."; // :1614
const ERR_GUILD_REMOVE_SS: &str = "%s has been kicked out of the guild by %s."; // :1635
const ERR_GUILD_LEADER_IS_S: &str = "%s is the leader of your guild."; // :1609
const ERR_GUILD_LEADER_CHANGED_SS: &str = "%s has made %s the new Guild Master."; // :1608
const ERR_GUILD_DISBANDED: &str = "Guild has been disbanded."; // :1600
const ERR_GUILD_DECLINE_S: &str = "%s declines your guild invitation."; // :1598
const ERR_INVITED_TO_GUILD_SS: &str = "%s invites you join %s."; // :1656
/// `ERR_FRIEND_ONLINE_SS` / `ERR_FRIEND_OFFLINE_S` (`GlobalStrings:1576`, `:1575`) — a guildmate
/// signing on and off prints the **friend-list** lines (module doc), which is also why
/// [`crate::ui_social`] holds its own copy of the same two strings.
const ERR_FRIEND_ONLINE_SS: &str = "|Hplayer:%s|h[%s]|h has come online.";
/// See [`ERR_FRIEND_ONLINE_SS`].
const ERR_FRIEND_OFFLINE_S: &str = "%s has gone offline.";
/// `GUILD_NAME_TEMPLATE` / `GUILD_INFO_TEMPLATE` (`GlobalStrings:2091`, `:2079`) — the two lines
/// `/ginfo` prints, in this order: the handler `0x5e6fb0` formats the name through `0x8604f0` at
/// `0x5e700f` and the counts through `0x8604dc` at `0x5e706b`.
const GUILD_NAME_TEMPLATE: &str = "Guild: %s";
/// See [`GUILD_NAME_TEMPLATE`]. **The template is month-day-year and the wire is day-month-year** —
/// the handler's own cdecl pushes (`0x5e704d`–`0x5e7061`) swap the first two, which is exactly what
/// an enUS `m-d-y` template does to vmangos's `createdDay, createdMonth, createdYear` order.
const GUILD_INFO_TEMPLATE: &str = "Guild created %d-%d-%d, %d players, %d accounts";
/// Fill a `%s` template **positionally** — one argument per slot, in order.
///
/// Distinct from [`crate::ui_social`]'s replace-all `fill_line`, and it has to be: the guild
/// templates take *different* values in their two and three slots ("%s has promoted %s to %s.")
/// where the friend one takes the same name twice.
fn fill(template: &str, args: &[&str]) -> String {
let mut out = template.to_string();
for arg in args {
out = out.replacen("%s", arg, 1);
}
out
}
/// [`fill`] for the one `%d` template ([`GUILD_INFO_TEMPLATE`]).
fn fill_d(template: &str, args: &[u32]) -> String {
let mut out = template.to_string();
for arg in args {
out = out.replacen("%d", &arg.to_string(), 1);
}
out
}
/// The line one `SMSG_GUILD_EVENT` prints, if any.
///
/// `ignored` is [`crate::ui_social::SocialState::is_ignored`] on the event's trailing guid — the
/// reference runs exactly that check on the sign-on/sign-off pair (`0x5ae810`) and prints nothing
/// for an ignored guildmate. That check is the entire reason those two arms read a guid at all.
pub(super) fn event_line(notice: &GuildEventNotice, ignored: bool) -> Option<String> {
let p = |i: usize| notice.params.get(i).map(String::as_str).unwrap_or_default();
match notice.event {
guild_event::PROMOTION => Some(fill(ERR_GUILD_PROMOTE_SSS, &[p(0), p(1), p(2)])),
guild_event::DEMOTION => Some(fill(ERR_GUILD_DEMOTE_SSS, &[p(0), p(1), p(2)])),
guild_event::JOINED => Some(fill(ERR_GUILD_JOIN_S, &[p(0)])),
guild_event::LEFT => Some(fill(ERR_GUILD_LEAVE_S, &[p(0)])),
guild_event::REMOVED => Some(fill(ERR_GUILD_REMOVE_SS, &[p(0), p(1)])),
guild_event::LEADER_IS => Some(fill(ERR_GUILD_LEADER_IS_S, &[p(0)])),
guild_event::LEADER_CHANGED => Some(fill(ERR_GUILD_LEADER_CHANGED_SS, &[p(0), p(1)])),
guild_event::DISBANDED => Some(ERR_GUILD_DISBANDED.to_string()),
guild_event::SIGNED_ON if !ignored => Some(fill(ERR_FRIEND_ONLINE_SS, &[p(0), p(0)])),
guild_event::SIGNED_OFF if !ignored => Some(fill(ERR_FRIEND_OFFLINE_S, &[p(0)])),
// MOTD's line is the FrameXML's (module doc); UPDATE_RANK_NAME and UPDATE_ROSTER are
// silent in the reference too; TABARD_CHANGE, an ignored guildmate's sign-on, and whatever
// the server invents past 0x0d show nothing. (The reference's default arm displays catalog
// id 0x69, whose GlobalStrings key is not settled — an unnamed key shows nothing, which is
// the GlobalStrings data-suppression every other absent key in this client wears.)
_ => None,
}
}
/// The line one `SMSG_GUILD_COMMAND_RESULT` prints, if any.
///
/// `result == 0` is the success side — the reference's `0x5e7550 test eax,eax` selects on exactly
/// that — and then the [`guild_command`] tag chooses the line. Otherwise the result code does,
/// except for `0x08`, whose two meanings are told apart by that same tag.
pub(super) fn command_line(result: &GuildCommandResult) -> Option<String> {
let name = result.name.as_str();
if result.result == guild_command_error::PLAYER_NO_MORE_IN_GUILD {
return match result.command {
guild_command::CREATE => Some(fill(ERR_GUILD_CREATE_S, &[name])),
guild_command::INVITE => Some(fill(ERR_GUILD_INVITE_S, &[name])),
guild_command::QUIT => Some(fill(ERR_GUILD_QUIT_S, &[name])),
guild_command::FOUNDER => Some(fill(ERR_GUILD_FOUNDER_S, &[name])),
// vmangos's own note on the rest of `Typecommand`: they "have no effect" here.
_ => None,
};
}
match result.result {
guild_command_error::INTERNAL => Some(ERR_GUILD_INTERNAL.to_string()),
guild_command_error::ALREADY_IN_GUILD => Some(ERR_ALREADY_IN_GUILD.to_string()),
guild_command_error::ALREADY_IN_GUILD_S => Some(fill(ERR_ALREADY_IN_GUILD_S, &[name])),
guild_command_error::INVITED_TO_GUILD => Some(ERR_INVITED_TO_GUILD.to_string()),
guild_command_error::ALREADY_INVITED_TO_GUILD_S => {
Some(fill(ERR_ALREADY_INVITED_TO_GUILD_S, &[name]))
}
guild_command_error::NAME_INVALID => Some(ERR_GUILD_NAME_INVALID.to_string()),
guild_command_error::NAME_EXISTS_S => Some(fill(ERR_GUILD_NAME_EXISTS_S, &[name])),
// The one collision: `0x08` is ERR_GUILD_LEADER_LEAVE under a QUIT and
// ERR_GUILD_PERMISSIONS under anything else (benilla-protocol's own note on
// `guild_command_error::LEADER_LEAVE`), which is why the command tag has to survive the
// trip from the wire to here.
guild_command_error::PERMISSIONS if result.command == guild_command::QUIT => {
Some(ERR_GUILD_LEADER_LEAVE.to_string())
}
guild_command_error::PERMISSIONS => Some(ERR_GUILD_PERMISSIONS.to_string()),
guild_command_error::PLAYER_NOT_IN_GUILD => Some(ERR_GUILD_PLAYER_NOT_IN_GUILD.to_string()),
guild_command_error::PLAYER_NOT_IN_GUILD_S => {
Some(fill(ERR_GUILD_PLAYER_NOT_IN_GUILD_S, &[name]))
}
guild_command_error::PLAYER_NOT_FOUND_S => {
Some(fill(ERR_GUILD_PLAYER_NOT_FOUND_S, &[name]))
}
guild_command_error::NOT_ALLIED => Some(ERR_GUILD_NOT_ALLIED.to_string()),
guild_command_error::RANK_TOO_HIGH_S => Some(fill(ERR_GUILD_RANK_TOO_HIGH_S, &[name])),
guild_command_error::RANK_TOO_LOW_S => Some(fill(ERR_GUILD_RANK_TOO_LOW_S, &[name])),
guild_command_error::RANKS_LOCKED => Some(ERR_GUILD_RANKS_LOCKED.to_string()),
guild_command_error::RANK_IN_USE => Some(ERR_GUILD_RANK_IN_USE.to_string()),
guild_command_error::IGNORING_YOU_S => Some(fill(ERR_IGNORING_YOU_S, &[name])),
// UNK20 ("for Typecommand 0x05 only") and anything unknown: nothing.
_ => None,
}
}
/// `SMSG_GUILD_INVITE`'s notice line — the one the reference prints *beside* the popup
/// (`0x5e6f65 DisplayError(0x4f, inviter, guild)`; the popup is the separate
/// `GUILD_INVITE_REQUEST` fire at `0x5e6f53`).
pub(super) fn invite_line(inviter: &str, guild: &str) -> String {
fill(ERR_INVITED_TO_GUILD_SS, &[inviter, guild])
}
/// `SMSG_GUILD_DECLINE` — our invitee said no (delivered to the inviter only).
pub(super) fn decline_line(name: &str) -> String {
fill(ERR_GUILD_DECLINE_S, &[name])
}
/// `SMSG_GUILD_INFO` — the `/ginfo` answer, which is purely two chat lines: nothing in the packet
/// overlaps the roster and no frame reads it.
pub(super) fn info_lines(info: &GuildInfo) -> Vec<String> {
vec![
fill(GUILD_NAME_TEMPLATE, &[&info.name]),
fill_d(
GUILD_INFO_TEMPLATE,
// month, day, year — the enUS swap, see [`GUILD_INFO_TEMPLATE`].
&[
info.created_month,
info.created_day,
info.created_year,
info.member_count,
info.account_count,
],
),
]
}
#[cfg(test)]
mod tests {
use super::*;
/// The `%s` slots of a guild line take DIFFERENT values, unlike the friend list's repeated
/// name — a replace-all substitution would print "Tigole has promoted Tigole to Tigole."
#[test]
fn the_multi_slot_lines_fill_positionally() {
let notice = GuildEventNotice {
event: guild_event::PROMOTION,
params: vec!["Tigole".into(), "Furor".into(), "Officer".into()],
guid: None,
};
assert_eq!(
event_line(&notice, false).as_deref(),
Some("Tigole has promoted Furor to Officer.")
);
}
/// The sign-on/sign-off lines are the friend-list ones — and an ignored guildmate's are
/// suppressed entirely, which is the whole reason those two arms carry a guid.
#[test]
fn sign_on_lines_are_the_friend_lines_and_honour_the_ignore_list() {
let notice = GuildEventNotice {
event: guild_event::SIGNED_ON,
params: vec!["Tigole".into()],
guid: Some(9),
};
assert_eq!(
event_line(&notice, false).as_deref(),
Some("|Hplayer:Tigole|h[Tigole]|h has come online."),
"the name fills the link and the bracket both"
);
assert_eq!(event_line(&notice, true), None, "ignored prints nothing");
}
/// The MOTD's line belongs to the FrameXML, and the two silent events stay silent.
#[test]
fn the_silent_events_print_nothing() {
for event in [
guild_event::MOTD,
guild_event::UPDATE_RANK_NAME,
guild_event::UPDATE_ROSTER,
guild_event::TABARD_CHANGE,
0x77,
] {
let notice = GuildEventNotice {
event,
params: vec!["x".into(), "y".into()],
guid: None,
};
assert_eq!(event_line(&notice, false), None, "event {event:#04x}");
}
}
/// Result `0x08` means two different things, and only the command tag beside it says which.
#[test]
fn the_two_meanings_of_result_eight_are_told_apart_by_the_command() {
assert_eq!(
command_line(&GuildCommandResult {
command: guild_command::QUIT,
name: String::new(),
result: guild_command_error::LEADER_LEAVE,
})
.as_deref(),
Some(ERR_GUILD_LEADER_LEAVE)
);
assert_eq!(
command_line(&GuildCommandResult {
command: guild_command::INVITE,
name: String::new(),
result: guild_command_error::PERMISSIONS,
})
.as_deref(),
Some(ERR_GUILD_PERMISSIONS)
);
}
/// Result `0` is the success side, and there the command tag picks the line.
#[test]
fn result_zero_is_the_success_side() {
assert_eq!(
command_line(&GuildCommandResult {
command: guild_command::INVITE,
name: "Kaplan".into(),
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
})
.as_deref(),
Some("You have invited Kaplan to join your guild.")
);
assert_eq!(
command_line(&GuildCommandResult {
command: guild_command::QUIT,
name: "Legacy of Steel".into(),
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
})
.as_deref(),
Some("You are no longer a member of Legacy of Steel.")
);
assert_eq!(
command_line(&GuildCommandResult {
command: 0x99,
name: String::new(),
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
}),
None,
"a command with no message says nothing"
);
}
/// `/ginfo`'s two lines, with the date swapped into the enUS month-day-year the wire is not in.
#[test]
fn the_ginfo_lines_swap_the_date_into_month_day_year() {
assert_eq!(
info_lines(&GuildInfo {
name: "Legacy of Steel".into(),
created_day: 14,
created_month: 3,
created_year: 2005,
member_count: 42,
account_count: 30,
}),
vec![
"Guild: Legacy of Steel".to_string(),
"Guild created 3-14-2005, 42 players, 30 accounts".to_string(),
]
);
}
/// The invite notice names the inviter first and the guild second.
#[test]
fn the_invite_notice_names_both() {
assert_eq!(
invite_line("Tigole", "Legacy of Steel"),
"Tigole invites you join Legacy of Steel."
);
assert_eq!(
decline_line("Kaplan"),
"Kaplan declines your guild invitation."
);
}
}
+977
View File
@@ -0,0 +1,977 @@
//! The guild session — the identity cache, the roster, the ranks, and the outbound verbs
//! (decision 1257).
//!
//! [`GuildState`] mirrors the wire the way [`crate::ui_social`]'s `SocialState` does: the seven
//! server packets replace it or patch it, and the feed ([`feed`]) turns it into the display-ready
//! snapshot `benilla_ui::script::guild` reads. The laws below are the ones that are *not* what the
//! obvious design would do, each verified at the reference's bytes (wow-re
//! `system/ui/scratch/guild-api-carve.md`, `guild-roster-wire.md`, RF-0077):
//!
//! - **Identity and roster are two caches with two lifetimes.** `SMSG_GUILD_ROSTER` carries the
//! MOTD, the info text, the rank *rights* and the members; it carries neither the guild's name
//! nor the rank *names*. Those come only from `SMSG_GUILD_QUERY_RESPONSE`, keyed by guild id.
//! [`GuildState::identities`] is that second cache, keyed by id precisely because
//! `PLAYER_GUILDID` (191) is PUBLIC: every visible player's guild has to be nameable, not just
//! ours ([`unit_guild`]).
//! - **That cache is LAZY, and nothing primes it.** No query goes out at world entry. The first
//! read that needs an uncached guild fires `CMSG_GUILD_QUERY` **and answers empty for that
//! call** — `GetGuildRosterInfo` does it inline (`0x4d1291`, returning its whole nil tuple on
//! the miss) and the arrival fires `GUILD_ROSTER_UPDATE` with no args (`0x4d1480`). Ours is
//! [`GuildState::resolve_identity`], shaped like [`crate::names::NameCache::resolve`], which is
//! this house's existing name for that idiom.
//! - **An empty name in the query response is "no such guild"**, not a guild with a blank name —
//! the reference tests exactly that (`0x5552ae test al,al`, choosing the cache *insert*
//! `0x561070` or the cache *remove* `0x561390`). Cached as a negative so the query is not
//! re-sent forever.
//! - **The roster is sorted, never filtered.** Show-offline changes the *count*
//! ([`GuildState::num_members`]), never the array. See [`sort`], and see
//! `benilla_ui::script::GuildState::num_members` for why collapsing the two is wrong.
//! - **Selection is a guid**, not a row: `SetGuildRosterSelection 0x4d1820` stores the member's
//! guid (`0x4d186a`/`0x4d1872`) and `GetGuildRosterSelection 0x4d1890` linear-searches it back
//! to a 1-based index, so a re-sort keeps the same *player* highlighted and a member who leaves
//! silently becomes "nothing selected" — exactly the friend list's `+0x648` shape.
//! - **`GuildRoster()` is throttled to one request per 10 s** (`0x4d10d0` against `0xb73130`), and
//! the one branch that sets `GUILD_ROSTER_UPDATE`'s arg1 clears that throttle first
//! (`0x4d1160`). See [`RosterUpdate`]: the two halves are one mechanism, and implementing either
//! without the other breaks it.
use std::collections::{HashMap, HashSet};
use benilla_protocol::messages::{
guild_event, GuildCommandResult, GuildEventNotice, GuildQueryResponse, GuildRoster,
GuildRosterMember, GUILD_RANKS_MAX_COUNT,
};
use benilla_protocol::ObjectFields;
use benilla_ui::script::{LastOnline, UnitGuild};
use bevy::prelude::*;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::UiInput;
mod feed;
mod lines;
mod sort;
pub(crate) use sort::RosterRow;
use sort::{SortField, SortStack};
/// How long `GuildRoster()` is silenced for after a request goes out — the reference's own
/// 10 000 ms against `0xb73130` (`0x4d10d0`), which is what stops a pane that re-requests on every
/// guild event from hammering the server. Cleared, not merely bypassed, by the arg1 branch of
/// [`RosterUpdate`].
const ROSTER_REQUEST_THROTTLE_SECS: f64 = 10.0;
/// One guild's public identity — everything `SMSG_GUILD_QUERY_RESPONSE` carries that we keep.
#[derive(Clone, Debug, Default)]
struct Identity {
/// The guild's name — **empty is the negative cache**: "this id names no guild" (module doc).
name: String,
/// The ten rank names, index 0 = guild master; empty past the guild's real rank count.
rank_names: [String; GUILD_RANKS_MAX_COUNT],
}
impl Identity {
/// The name of rank `index` (0-based), or `""` — bounded, unlike the reference's own
/// `shl edx,0x6` at `0x4c93e7`, which happily indexes past its ten slots.
fn rank_name(&self, index: u32) -> &str {
usize::try_from(index)
.ok()
.and_then(|i| self.rank_names.get(i))
.map(String::as_str)
.unwrap_or_default()
}
/// Is this a real guild, or the "no such guild" negative? See the module doc.
fn exists(&self) -> bool {
!self.name.is_empty()
}
}
/// Which `GUILD_ROSTER_UPDATE` the feed owes the VM — **and this is the whole of that decision**.
///
/// `arg1` is a control signal, not data: `FriendsFrame_OnEvent`'s arm is
/// `if ( arg1 ) then GuildRoster() end` before its repaint (`FriendsFrame.lua:646-653`), so a
/// truthy arg1 means "the roster you hold is stale, ask the server for a fresh one". The engine
/// side is one function, `0x4d1160`, and the complete caller census is:
///
/// | arg1 | site | meaning |
/// |---|---|---|
/// | — | `0x4d0d3c` | a fresh `SMSG_GUILD_ROSTER` was parsed |
/// | — | `0x4d0f9f` | show-offline changed and the view was re-sorted |
/// | — | `0x4d1022` | `SortGuildRoster` re-sorted the view |
/// | — | `0x4d148c` | the guild-name cache record arrived |
/// | `1` | `0x5e74c4` | `SMSG_GUILD_EVENT`, cases `0x00`–`0x0b` **only** |
/// | `1` | `0x5e7792` | `SMSG_GUILD_COMMAND_RESULT`, its two arg-bearing arms |
///
/// Two things in that census are easy to get wrong. **`SIGNED_ON`/`SIGNED_OFF` (`0x0c`/`0x0d`) do
/// NOT set arg1** — a guildmate logging in or out does not force a re-request. And the arg-bearing
/// branch **clears the `GuildRoster()` throttle** (`mov dword ptr [0xb73130],0`) before firing, so
/// that the `GuildRoster()` the FrameXML issues in response cannot be the one the 10 s limiter
/// swallows; [`GuildState::note_roster_update`] does the same, and without it the arg1 path would
/// be a no-op nine times out of ten.
///
/// Getting the flag itself backwards fails in both directions: arg1 on the *applied* edge is an
/// infinite request loop (roster → update(1) → `GuildRoster()` → roster → …), and no arg1 on the
/// *stale* edge is a pane that never refreshes. The reference cannot loop for exactly this reason
/// — the roster's own arrival fires the no-arg form.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RosterUpdate {
/// Redraw from what you have: a roster we just applied, a local re-order, a late identity.
Applied,
/// The cached roster is stale — re-request it.
Stale,
}
impl RosterUpdate {
/// Does this edge carry arg1? The one place the answer lives.
fn arg1(self) -> bool {
self == RosterUpdate::Stale
}
}
/// The guild session mirror. Filled by the net drain's guild arms, read by the feed, cleared on
/// disconnect beside the other per-login resources.
#[derive(Resource, Default)]
pub(crate) struct GuildState {
/// Our own `PLAYER_GUILDID` (field 191), mirrored from the descriptor; `0` = guildless. This,
/// not the roster, is what `IsInGuild` answers — it is true from the moment the descriptor
/// streams, before any packet has been asked for.
guild_id: u32,
/// Our own `PLAYER_GUILDRANK` (field 192), **0-based**, `0` = guild master.
rank_index: u32,
/// Guild id → identity, from `SMSG_GUILD_QUERY_RESPONSE`. Holds negatives (module doc).
identities: HashMap<u32, Identity>,
/// Guild ids with a `CMSG_GUILD_QUERY` in flight — the ask-once gate.
queried: HashSet<u32>,
/// The message of the day. Kept beside the roster rather than inside it because `GE_MOTD`
/// updates it on its own, and at login vmangos sends that event *before* any roster exists
/// (`CharacterHandler.cpp:558`).
motd: String,
/// The guild information text (the `GuildInfoFrame` body) — roster-only.
info_text: String,
/// One rights word per rank, indexed by rank id. **Its length is the guild's real rank
/// count** — the only field on the wire that says how many of the identity's ten rank-name
/// slots are live.
rank_rights: Vec<u32>,
/// The members, in wire order. Display order is the feed's ([`Self::display_order`]).
members: Vec<GuildRosterMember>,
/// The sort chain ([`sort::SortStack`]).
sort: SortStack,
/// `SetGuildRosterShowOffline`. Starts off, like the reference's own zeroed `ds:0xb73124`
/// (`0x4d0a25`); the FrameXML restores the player's saved variable at `VARIABLES_LOADED`.
/// **It changes the count and the order, never the membership** — see [`sort`].
show_offline: bool,
/// The selected member, stored as a **guid** like the reference's `ds:0xb73128`; `0` = none.
selection: u64,
/// The member guids in the order the feed last showed them — written by the feed, read by the
/// drain so a row index from Lua maps back to the same player the user clicked.
display_order: Vec<u64>,
/// The invitation we are holding, `(inviter, guild)`. The wire's accept/decline say nothing
/// about *which* invitation they answer, so this is ours to remember.
pending_invite: Option<(String, String)>,
/// The `GUILD_ROSTER_UPDATE` the feed owes, if any ([`RosterUpdate`]).
roster_event: Option<RosterUpdate>,
/// Real-time seconds before which a `GuildRoster()` is swallowed
/// ([`ROSTER_REQUEST_THROTTLE_SECS`]). `0.0` = allowed now.
roster_allowed_at: f64,
/// Set whenever the pushed snapshot went stale — the feed rebuilds and pushes on this and
/// skips the work otherwise (a 500-member roster is not worth re-resolving every frame).
dirty: bool,
}
impl GuildState {
/// Are we in a guild? Our own `PLAYER_GUILDID`, nothing else — answerable before any packet.
pub(crate) fn in_guild(&self) -> bool {
self.guild_id != 0
}
/// Mirror our own descriptor's guild fields. Returns `true` when either moved, which is the
/// `PLAYER_GUILD_UPDATE` edge.
///
/// It deliberately asks for **nothing**: the identity cache is lazy (module doc) and the
/// roster is the FrameXML's to request when it opens the pane.
fn mirror_self(&mut self, guild_id: u32, rank_index: u32) -> bool {
if (self.guild_id, self.rank_index) == (guild_id, rank_index) {
return false;
}
let left = self.guild_id != guild_id;
self.guild_id = guild_id;
self.rank_index = rank_index;
if left {
// A different guild (or none): everything the old roster said is about a guild we are
// no longer in. The identity cache survives — it is keyed by id and still true.
self.motd.clear();
self.info_text.clear();
self.rank_rights.clear();
self.members.clear();
self.selection = 0;
self.note_roster_update(RosterUpdate::Applied);
}
self.dirty = true;
true
}
/// Note that the VM owes a `GUILD_ROSTER_UPDATE`. [`RosterUpdate::Stale`] wins when both land
/// in one frame — a re-request repaints too, so the stronger signal subsumes the weaker — and
/// it **clears the request throttle**, which is half of what that branch is for.
fn note_roster_update(&mut self, kind: RosterUpdate) {
if kind.arg1() {
self.roster_allowed_at = 0.0;
}
if kind.arg1() || self.roster_event.is_none() {
self.roster_event = Some(kind);
}
self.dirty = true;
}
/// Ask for a guild's identity if we do not already hold one — the lazy cache fill (module
/// doc). Nothing else in this client ever sends `CMSG_GUILD_QUERY`: no world-entry priming, no
/// sweep. A negative (`""` name) counts as held, so it is never re-asked.
fn request_identity(&mut self, guild_id: u32, commands: &NetCommands) {
if guild_id != 0
&& !self.identities.contains_key(&guild_id)
&& self.queried.insert(guild_id)
{
let _ = commands.0.send(ClientCommand::GuildQuery { guild_id });
}
}
/// A guild's identity, if we hold a real one. `None` covers both "not asked yet / in flight"
/// and "no such guild".
fn identity(&self, guild_id: u32) -> Option<&Identity> {
self.identities.get(&guild_id).filter(|i| i.exists())
}
/// [`Self::request_identity`] then [`Self::identity`] — the read that also asks, shaped like
/// [`crate::names::NameCache::resolve`]: it answers `None` for *this* call and the answer
/// arrives later, which is the reference's behaviour and not a gap to paper over.
fn resolve_identity(&mut self, guild_id: u32, commands: &NetCommands) -> Option<&Identity> {
self.request_identity(guild_id, commands);
self.identity(guild_id)
}
/// `SMSG_GUILD_QUERY_RESPONSE` — fill (or negatively fill) the identity cache.
fn apply_query_response(&mut self, response: GuildQueryResponse) {
self.queried.remove(&response.guild_id);
let ours = response.guild_id == self.guild_id;
self.identities.insert(
response.guild_id,
Identity {
name: response.name,
rank_names: response.rank_names,
},
);
self.dirty = true;
if ours {
// Our own rank names just landed under the roster rows that display them, so the pane
// has to repaint — but nothing on the server moved, so this must not re-request. The
// reference fires exactly this, from the cache callback `0x4d1480` (`0x4d148c`).
self.note_roster_update(RosterUpdate::Applied);
}
}
/// `SMSG_GUILD_ROSTER` — always a complete snapshot, never a delta.
fn apply_roster(&mut self, roster: GuildRoster) {
self.motd = roster.motd;
self.info_text = roster.info;
// The reference's own rank-rights loop has no bound check and overruns its ten-slot array
// into the member array's control block on a hostile `rankCount >= 12` (wow-re
// `guild-api-carve.md` §2). We clamp: a memory-safety divergence, deliberately.
self.rank_rights = roster.rank_rights;
self.rank_rights.truncate(GUILD_RANKS_MAX_COUNT);
self.members = roster.members;
self.note_roster_update(RosterUpdate::Applied);
}
/// `SMSG_GUILD_EVENT` — what it does to the mirror. The line it prints is [`lines`]'.
fn apply_event(&mut self, notice: &GuildEventNotice) {
match notice.event {
guild_event::MOTD => {
// The line is the FrameXML's (`ChatFrame.lua:1338`); ours is the state, so
// `GetGuildRosterMOTD` agrees with the event before the next roster lands.
self.motd = notice.params.first().cloned().unwrap_or_default();
self.dirty = true;
}
guild_event::UPDATE_RANK_NAME => {
// A rank was renamed. The reference writes it straight into its cached record
// (`0x560e30`) and fires no FrameScript event of its own, so the pushed snapshot
// is the only thing that can carry it to the pane. Params are `RankID,
// NewRankName` (vmangos `Guild.h:133`) — the id as text, like every parameter of
// this packet.
let rank = notice.params.first().and_then(|p| p.parse::<usize>().ok());
let name = notice.params.get(1).cloned().unwrap_or_default();
if let (Some(rank), Some(identity)) =
(rank, self.identities.get_mut(&self.guild_id))
{
if let Some(slot) = identity.rank_names.get_mut(rank) {
*slot = name;
self.dirty = true;
}
}
}
_ => {}
}
// Cases 0x00–0x0b carry arg1; the sign-on/sign-off pair (0x0c/0x0d) does not, and neither
// does anything past it — see [`RosterUpdate`]'s census. This is the whole condition.
if notice.event <= guild_event::UPDATE_ROSTER {
self.note_roster_update(RosterUpdate::Stale);
}
}
/// `SMSG_GUILD_COMMAND_RESULT` — its effect on the mirror; its line is [`lines`]'.
///
/// The reference's arg-bearing arm is narrow: `0x5e7792` fires the stale signal only for a
/// successful command `0x13`/`0x14`, or for `result == 0x14 && command == 0x05`. Every other
/// verdict changes nothing here — the server re-sends the whole roster for anything that did.
fn apply_command_result(&mut self, result: &GuildCommandResult) {
use benilla_protocol::messages::{guild_command, guild_command_error};
let stale = match result.result {
guild_command_error::PLAYER_NO_MORE_IN_GUILD => {
matches!(result.command, guild_command::UNK19 | guild_command::UNK20)
}
guild_command_error::UNK20 => result.command == 0x05,
_ => false,
};
if stale {
self.note_roster_update(RosterUpdate::Stale);
}
}
/// `SMSG_GUILD_INVITE` — arm the popup's show edge.
///
/// There is no hide edge: `GUILD_INVITE_CANCEL`'s wrapper `0x48f470` has **zero callers
/// image-wide**, so nothing in 1.12 ever raises it. The FrameXML registers for it and simply
/// never hears it, which is faithful. We still clear the invitation when it is answered
/// ([`Self::clear_invite`]) so that a second invitation re-fires the show edge.
fn apply_invite(&mut self, inviter: String, guild: String) {
self.pending_invite = Some((inviter, guild));
self.dirty = true;
}
/// Answer the invitation we are holding — the popup's Accept/Decline.
fn clear_invite(&mut self) {
if self.pending_invite.take().is_some() {
self.dirty = true;
}
}
/// `SortGuildRoster(field)`: re-order and repaint, never re-request ([`sort::SortStack`]).
fn sort_by(&mut self, field: &str) {
self.sort.select(SortField::parse(field));
self.note_roster_update(RosterUpdate::Applied);
}
/// `SetGuildRosterShowOffline(flag)`: re-sorts (it is a comparator input) and changes the
/// count. The reference's worker `0x4d0f70` no-ops entirely when the value is unchanged — no
/// write, no re-sort, no event — so this does too.
fn set_show_offline(&mut self, on: bool) {
if self.show_offline != on {
self.show_offline = on;
self.note_roster_update(RosterUpdate::Applied);
}
}
/// `SetGuildRosterSelection(index)`: resolve the 1-based display row to the guid we keep it
/// as. No event — the reference's own caller repaints itself
/// (`FriendsFrameGuildStatusButton_OnClick` calls `GuildStatus_Update` on the next line).
fn select(&mut self, index: u32) {
let guid = self.guid_at(index).unwrap_or(0);
if self.selection != guid {
self.selection = guid;
self.dirty = true;
}
}
/// The guid at a 1-based display row. The addressable range is the **whole** roster, never the
/// show-offline count (module doc).
fn guid_at(&self, index: u32) -> Option<u64> {
index
.checked_sub(1)
.and_then(|i| self.display_order.get(i as usize))
.copied()
}
/// The member at a 1-based display row — the note verbs address a ROW and the wire wants a
/// NAME, and only the app knows the order that row refers to. Mutable because those same verbs
/// write the note into the record before deciding whether to send it (see the drain).
fn member_at_mut(&mut self, index: u32) -> Option<&mut GuildRosterMember> {
let guid = self.guid_at(index)?;
self.members.iter_mut().find(|m| m.guid == guid)
}
/// How many rows `GetNumGuildMembers()` reports — **an advisory loop bound, not the
/// addressable range**: the whole roster while show-offline is on, the online tally while it
/// is off (`0x4d1190` against `0xb73118`/`0xb7311c`). The comparator's pre-gate is what makes
/// that tally name exactly the array's leading rows ([`sort`]).
fn num_members(&self) -> usize {
if self.show_offline {
self.members.len()
} else {
self.members.iter().filter(|m| m.is_online()).count()
}
}
/// Our own rank's rights word — what every `Can*` predicate tests. `0` when no roster has
/// arrived, which reads as "no permissions" and is the honest answer: we do not know them yet.
fn own_rights(&self) -> u32 {
self.rank_rights
.get(self.rank_index as usize)
.copied()
.unwrap_or(0)
}
}
/// Decompose "days since last logout" the way `GetGuildRosterLastOnline 0x4d14a0` does — a **full**
/// nested decomposition, not just the largest unit.
///
/// VERIFIED at the bytes, constants and all: `years = ftol(d × 1/365)`, `rem = d − years×365`,
/// `months = ftol(rem × 1/30)`, `rem2 = rem − months×30` (narrowed back to an f32 at
/// `0x4d1544 fstp DWORD PTR [ebp-0x10]` and used as that f32 twice), `days = ftol(rem2)`,
/// `hours = ftol((rem2 − days) × 24)` — reads at `0x4d1508`–`0x4d1598`, the five constants read out
/// of the PE at `0x80733c` = 1/365, `0x807338` = 365, `0x807334` = 1/30, `0x807330` = 30,
/// `0x80732c` = 24. `0x40a2b0` is the MSVC `_ftol` (`or ah,0xc` → round toward zero), so every
/// step **truncates**.
///
/// The consumer only ever reads the largest non-zero unit — `GuildFrame_GetLastOnline` tests years,
/// then months, then days, then hours and stops at the first that is neither `0` nor `nil`
/// (`FriendsFrame.lua:957-978`) — so the lower units are invisible to the reference UI and visible
/// to any addon, and filling them is what the engine does.
///
/// An online member carries no float at all on the wire, and `LastOnline::default()` (all zeroes)
/// is exactly the shape that formatter reads as "< an hour", which is also what it shows for nil.
pub(crate) fn last_online(days: f32) -> LastOnline {
if days.is_nan() || days <= 0.0 {
// NaN, negative, and an online member's absent field all land here. Truncation toward zero
// would answer all-zeroes anyway; this is the guard that says so on purpose.
return LastOnline::default();
}
// The x87 path runs in extended precision; f64 is the closest thing that costs nothing. The
// reciprocals are written the way the reference stores them — f32 roundings of 1/365 and
// 1/30, multiplied, not divided.
const PER_YEAR: f32 = 1.0 / 365.0;
const PER_MONTH: f32 = 1.0 / 30.0;
let days = f64::from(days);
let years = (days * f64::from(PER_YEAR)) as u32;
let rem = days - f64::from(years) * 365.0;
let months = (rem * f64::from(PER_MONTH)) as u32;
let rem = (rem - f64::from(months) * 30.0) as f32;
let day = rem as u32;
LastOnline {
years,
months,
days: day,
hours: ((rem - day as f32) * 24.0) as u32,
}
}
/// A unit's guild membership for `GetGuildInfo(unit)` — its own PUBLIC descriptor fields joined
/// against the identity cache, **asking for that identity if we do not hold it**.
///
/// `None` for a guildless unit, for a creature (which has no player block at all), and for a guild
/// whose query has not answered yet: the reference takes the same nil path on the cache-miss leg
/// as on the guildless one (`0x4c93d7 test eax,eax` / `je 0x4c943c`, the branch that pushes
/// nil, nil). A name we do not have yet is not a blank name — and the miss is what *starts* the
/// query, which is the whole of the lazy cache (module doc).
pub(crate) fn unit_guild(
fields: &ObjectFields,
guild: &mut GuildState,
commands: &NetCommands,
) -> Option<UnitGuild> {
let guild_id = fields.player_guild_id();
let rank_index = fields.player_guild_rank();
let identity = guild.resolve_identity(guild_id, commands)?;
Some(UnitGuild {
name: identity.name.clone(),
rank_name: identity.rank_name(rank_index).to_string(),
rank_index,
})
}
/// The net drain's `SessionEvent::Guild*` arms, factored here so the wire laws live beside the
/// state they drive ([`crate::ui_social::apply`]'s shape). The ones that owe chat lines push what
/// [`lines`] composed, the way `crate::net::apply`'s group shims do.
pub(crate) mod apply {
use super::*;
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_social::SocialState;
use benilla_protocol::messages::GuildInfo;
fn push_lines(chat_log: &mut ChatLog, lines: impl IntoIterator<Item = String>) {
for line in lines {
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, line));
}
}
/// `SMSG_GUILD_QUERY_RESPONSE`.
pub(crate) fn query_response(guild: &mut GuildState, response: GuildQueryResponse) {
guild.apply_query_response(response);
}
/// `SMSG_GUILD_ROSTER`.
pub(crate) fn roster(guild: &mut GuildState, roster: GuildRoster) {
guild.apply_roster(roster);
}
/// `SMSG_GUILD_EVENT`. The trailing guid rides only on the sign-on/sign-off pair, and the
/// ignore check is exactly what the reference does with it (`0x5ae810`).
pub(crate) fn event(
guild: &mut GuildState,
chat_log: &mut ChatLog,
social: &SocialState,
notice: GuildEventNotice,
) {
let ignored = notice.guid.is_some_and(|g| social.is_ignored(g));
guild.apply_event(&notice);
push_lines(chat_log, lines::event_line(&notice, ignored));
}
/// `SMSG_GUILD_COMMAND_RESULT`.
pub(crate) fn command_result(
guild: &mut GuildState,
chat_log: &mut ChatLog,
result: GuildCommandResult,
) {
guild.apply_command_result(&result);
push_lines(chat_log, lines::command_line(&result));
}
/// `SMSG_GUILD_INVITE` — the popup's arm edge, plus the notice line the reference prints
/// beside it.
pub(crate) fn invite(
guild: &mut GuildState,
chat_log: &mut ChatLog,
inviter: String,
guild_name: String,
) {
push_lines(chat_log, [lines::invite_line(&inviter, &guild_name)]);
guild.apply_invite(inviter, guild_name);
}
/// `SMSG_GUILD_DECLINE` — a line only; there is no state behind it.
pub(crate) fn decline(chat_log: &mut ChatLog, name: &str) {
push_lines(chat_log, [lines::decline_line(name)]);
}
/// `SMSG_GUILD_INFO` — the `/ginfo` answer, two lines and no state.
pub(crate) fn info(chat_log: &mut ChatLog, info: GuildInfo) {
push_lines(chat_log, lines::info_lines(&info));
}
}
/// The guild windows' session: the wire mirror, the VM feed, and the outbound intents.
pub(crate) struct UiGuildPlugin;
impl Plugin for UiGuildPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GuildState>().add_systems(
Update,
(
feed::feed_guild.before(UiInput),
feed::drain_guild.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{guild_command, guild_command_error, guild_presence};
fn identity(name: &str, ranks: &[&str]) -> Identity {
let mut rank_names: [String; GUILD_RANKS_MAX_COUNT] = Default::default();
for (slot, name) in rank_names.iter_mut().zip(ranks) {
*slot = (*name).to_string();
}
Identity {
name: name.to_string(),
rank_names,
}
}
fn member(guid: u64, name: &str, presence: u8) -> GuildRosterMember {
GuildRosterMember {
guid,
presence,
name: name.to_string(),
level: 60,
..Default::default()
}
}
fn event(event: u8, params: &[&str], guid: Option<u64>) -> GuildEventNotice {
GuildEventNotice {
event,
params: params.iter().map(|p| (*p).to_string()).collect(),
guid,
}
}
/// `show_offline` changes what `GetNumGuildMembers` counts and how the rows are ordered —
/// **and never what the roster contains**. The three index-taking bindings address the whole
/// array, so filtering it would make a stale index name a different member, or none.
#[test]
fn show_offline_changes_the_count_and_the_order_not_the_membership() {
let mut guild = GuildState::default();
guild.apply_roster(GuildRoster {
members: vec![
member(1, "Zed", guild_presence::ONLINE),
member(2, "Gone", guild_presence::OFFLINE),
member(3, "Away", guild_presence::ONLINE | guild_presence::AFK),
],
..Default::default()
});
let rows = feed::display_rows(&guild, None, None);
assert_eq!(rows.len(), 3, "every member, always");
assert_eq!(guild.num_members(), 2, "but the count is the online tally");
assert!(
rows[..2].iter().all(|r| r.info.online),
"and the counted rows are the leading ones"
);
assert!(!rows[2].info.online);
guild.set_show_offline(true);
let rows = feed::display_rows(&guild, None, None);
assert_eq!(rows.len(), 3);
assert_eq!(guild.num_members(), 3, "now the count is everybody");
}
/// The four unit boundaries the formatter cascades through, plus the shape below the smallest
/// one. The largest non-zero unit is what the reference displays; the rest is the remainder
/// the engine fills in behind it.
#[test]
fn last_online_decomposes_largest_unit_first() {
// Under an hour: every unit zero, which `GuildFrame_GetLastOnline` reads as "< an hour".
assert_eq!(last_online(0.02), LastOnline::default());
assert_eq!(last_online(0.0), LastOnline::default());
// Just over an hour.
assert_eq!(
last_online(1.0 / 24.0 + 0.001),
LastOnline {
hours: 1,
..Default::default()
}
);
// Just under and just over a day.
assert_eq!(last_online(0.99).days, 0);
assert_eq!(last_online(0.99).hours, 23);
assert_eq!(last_online(1.01).days, 1);
// Just under and just over a month (30 days).
assert_eq!(last_online(29.9).months, 0);
assert_eq!(last_online(29.9).days, 29);
assert_eq!(
last_online(30.0),
LastOnline {
months: 1,
..Default::default()
}
);
// Just under and just over a year (365 days).
assert_eq!(last_online(364.9).years, 0);
assert_eq!(last_online(364.9).months, 12, "12 months, not 1 year");
assert_eq!(
last_online(365.0),
LastOnline {
years: 1,
..Default::default()
}
);
// And the full decomposition the reference actually returns rather than only the largest
// unit: 400.5 days is 1 year, 1 month, 5 days, 12 hours.
assert_eq!(
last_online(400.5),
LastOnline {
years: 1,
months: 1,
days: 5,
hours: 12,
}
);
}
/// An empty name in the query response is "no such guild", not a guild called "". It must be
/// cached (so the query is not re-sent forever) and it must not answer `GetGuildInfo`.
#[test]
fn an_empty_query_name_is_a_negative_not_a_blank_guild() {
let (commands, rx) = net_commands();
let mut guild = GuildState::default();
// The miss asks, once, and answers nothing this call — the lazy cache.
assert!(guild.resolve_identity(7, &commands).is_none());
assert!(guild.resolve_identity(7, &commands).is_none());
assert_eq!(rx.try_iter().count(), 1, "asked exactly once");
guild.apply_query_response(GuildQueryResponse {
guild_id: 7,
name: String::new(),
..Default::default()
});
assert!(guild.identities.contains_key(&7), "cached as a negative");
assert!(!guild.queried.contains(&7), "and no longer in flight");
assert!(
guild.resolve_identity(7, &commands).is_none(),
"an empty name is not a guild"
);
assert_eq!(rx.try_iter().count(), 0, "and is never re-asked");
}
/// The one decision that is an infinite loop in one direction and a frozen pane in the other:
/// a guild EVENT means "re-ask" (arg1), a fresh roster or a local re-order means "repaint"
/// (no arg1) — and the sign-on/sign-off pair means neither.
#[test]
fn a_guild_event_asks_again_and_a_local_resort_does_not() {
let mut guild = GuildState::default();
guild.apply_event(&event(guild_event::JOINED, &["Furor"], None));
assert_eq!(guild.roster_event.take(), Some(RosterUpdate::Stale));
assert!(RosterUpdate::Stale.arg1(), "the pane re-requests");
guild.sort_by("level");
assert_eq!(guild.roster_event.take(), Some(RosterUpdate::Applied));
assert!(
!RosterUpdate::Applied.arg1(),
"a column click must never ask the server for a roster"
);
guild.set_show_offline(true);
assert_eq!(guild.roster_event.take(), Some(RosterUpdate::Applied));
guild.apply_roster(GuildRoster::default());
assert_eq!(
guild.roster_event.take(),
Some(RosterUpdate::Applied),
"the roster we just applied is the one we have — asking for it again is the loop"
);
// Both in one frame: the re-request subsumes the repaint.
guild.apply_roster(GuildRoster::default());
guild.apply_event(&event(guild_event::PROMOTION, &["A", "B", "Officer"], None));
assert_eq!(guild.roster_event.take(), Some(RosterUpdate::Stale));
}
/// A guildmate logging in or out is the one guild event that does NOT force a re-request —
/// cases 0x0c/0x0d are outside the arg-bearing band.
#[test]
fn signing_on_and_off_does_not_re_request() {
let mut guild = GuildState::default();
for ev in [guild_event::SIGNED_ON, guild_event::SIGNED_OFF] {
guild.roster_event = None;
guild.apply_event(&event(ev, &["Tigole"], Some(9)));
assert_eq!(guild.roster_event, None, "event {ev:#04x}");
}
// …while the case immediately below them does.
guild.apply_event(&event(guild_event::UPDATE_ROSTER, &[], None));
assert_eq!(guild.roster_event, Some(RosterUpdate::Stale));
}
/// The stale edge clears the `GuildRoster()` throttle. Without that, the arg1 the FrameXML
/// answers with `GuildRoster()` would be swallowed by the 10 s limiter and the "re-request"
/// would silently do nothing.
#[test]
fn the_stale_edge_clears_the_request_throttle() {
let mut guild = GuildState {
roster_allowed_at: 12_345.0,
..Default::default()
};
guild.note_roster_update(RosterUpdate::Applied);
assert_eq!(guild.roster_allowed_at, 12_345.0, "a repaint does not");
guild.note_roster_update(RosterUpdate::Stale);
assert_eq!(guild.roster_allowed_at, 0.0);
}
/// Only the two arg-bearing command-result arms move the roster; an ordinary refusal changes
/// nothing here (it says something, and that is [`lines`]' job).
#[test]
fn most_command_results_do_not_move_the_roster() {
let mut guild = GuildState::default();
guild.apply_command_result(&GuildCommandResult {
command: guild_command::INVITE,
name: "Kaplan".into(),
result: guild_command_error::PERMISSIONS,
});
assert_eq!(guild.roster_event, None);
guild.apply_command_result(&GuildCommandResult {
command: guild_command::UNK19,
name: String::new(),
result: guild_command_error::PLAYER_NO_MORE_IN_GUILD,
});
assert_eq!(guild.roster_event, Some(RosterUpdate::Stale));
}
/// Leaving a guild drops the roster of the guild we left — but keeps the identity cache,
/// which is keyed by id and is still true.
#[test]
fn leaving_the_guild_drops_the_roster_but_not_the_identities() {
let mut guild = GuildState::default();
guild.identities.insert(7, identity("Legacy", &["GM"]));
assert!(guild.mirror_self(7, 0), "joined");
guild.apply_roster(GuildRoster {
motd: "Raid at eight".into(),
rank_rights: vec![0xffff, 0x3],
members: vec![member(1, "Tigole", guild_presence::ONLINE)],
..Default::default()
});
guild.selection = 1;
assert!(guild.in_guild());
assert!(guild.mirror_self(0, 0), "left");
assert!(!guild.in_guild());
assert!(guild.members.is_empty());
assert!(guild.motd.is_empty());
assert_eq!(guild.selection, 0);
assert!(guild.identities.contains_key(&7), "identities survive");
assert!(!guild.mirror_self(0, 0), "no edge when nothing moved");
}
/// Our own rank's rights word is read out of the roster's array by our own descriptor rank,
/// and answers 0 (no permissions) rather than guessing while no roster has arrived.
#[test]
fn own_rights_index_through_the_descriptor_rank() {
let mut guild = GuildState::default();
guild.mirror_self(7, 1);
assert_eq!(guild.own_rights(), 0, "no roster yet");
guild.apply_roster(GuildRoster {
rank_rights: vec![0xffff, 0x00ff, 0x000f],
..Default::default()
});
assert_eq!(guild.own_rights(), 0x00ff);
assert_eq!(guild.rank_rights.len(), 3);
guild.mirror_self(7, 9); // a rank past the array
assert_eq!(guild.own_rights(), 0);
}
/// A roster claiming more ranks than the ten that exist is clamped — the reference's own loop
/// walks off its array into the member list's control block instead.
#[test]
fn an_over_long_rank_array_is_clamped() {
let mut guild = GuildState::default();
guild.apply_roster(GuildRoster {
rank_rights: vec![1; 14],
..Default::default()
});
assert_eq!(guild.rank_rights.len(), GUILD_RANKS_MAX_COUNT);
}
/// A rank rename lands in the identity cache — the reference writes it straight into its own
/// cached record, so the pushed snapshot is the only thing that can carry it to the pane.
#[test]
fn a_rank_rename_patches_the_identity_cache() {
let mut guild = GuildState::default();
guild.mirror_self(7, 0);
guild
.identities
.insert(7, identity("Legacy", &["GM", "Off"]));
guild.apply_event(&event(
guild_event::UPDATE_RANK_NAME,
&["1", "Officer"],
None,
));
assert_eq!(guild.identities[&7].rank_name(1), "Officer");
assert!(guild.dirty);
}
/// The selection is a guid, so it survives a re-sort pointing at the same player — the row
/// index the VM sees is derived, never stored — and a member who leaves silently becomes
/// "nothing selected" rather than addressing whoever took their row.
#[test]
fn selection_follows_the_player_not_the_row() {
let mut guild = GuildState::default();
guild.apply_roster(GuildRoster {
members: vec![
member(11, "Alice", guild_presence::ONLINE),
member(22, "Bob", guild_presence::ONLINE),
],
..Default::default()
});
guild.display_order = vec![11, 22];
guild.select(2);
assert_eq!(guild.selection, 22);
assert_eq!(
guild.member_at_mut(2).map(|m| m.name.clone()),
Some("Bob".to_string()),
"a display row resolves to the member the wire wants by name"
);
// The list re-orders under it; the same PLAYER stays selected.
guild.display_order = vec![22, 11];
assert_eq!(feed::index_of(&guild.display_order, guild.selection), 1);
guild.select(0);
assert_eq!(guild.selection, 0, "0 = nothing selected");
guild.select(9);
assert_eq!(guild.selection, 0, "past the end selects nothing");
// Bob leaves. Nothing resets the stored guid — it simply stops resolving.
guild.selection = 22;
guild.apply_roster(GuildRoster {
members: vec![member(11, "Alice", guild_presence::ONLINE)],
..Default::default()
});
guild.display_order = vec![11];
assert_eq!(feed::index_of(&guild.display_order, guild.selection), 0);
}
/// The invitation is the popup's show edge, and answering it re-arms rather than firing a
/// cancel — nothing in 1.12 raises `GUILD_INVITE_CANCEL`.
#[test]
fn an_invitation_arms_and_answering_clears_it() {
let mut guild = GuildState::default();
guild.apply_invite("Tigole".into(), "Legacy of Steel".into());
assert_eq!(
guild.pending_invite,
Some(("Tigole".into(), "Legacy of Steel".into()))
);
guild.clear_invite();
assert_eq!(guild.pending_invite, None);
}
/// `GetGuildInfo(unit)` answers nil for a guildless unit AND for one whose guild query has not
/// answered — the reference takes the same branch for both (`0x4c93d7`) — and the miss is what
/// starts the query.
#[test]
fn unit_guild_asks_on_a_miss_and_never_names_a_blank_guild() {
let (commands, rx) = net_commands();
let mut guild = GuildState::default();
guild
.identities
.insert(7, identity("Legacy", &["GM", "Off"]));
// A creature, and a guildless player: no PLAYER_GUILDID at all.
let guildless = ObjectFields::from_pairs(&[]);
assert!(unit_guild(&guildless, &mut guild, &commands).is_none());
assert_eq!(rx.try_iter().count(), 0, "guild id 0 asks nothing");
// Fields 191/192 = PLAYER_GUILDID/PLAYER_GUILDRANK.
let unknown = ObjectFields::from_pairs(&[(191, 9), (192, 1)]);
assert!(
unit_guild(&unknown, &mut guild, &commands).is_none(),
"a query in flight is not a blank-named guild"
);
assert_eq!(rx.try_iter().count(), 1, "and the miss asked for it");
let member = ObjectFields::from_pairs(&[(191, 7), (192, 1)]);
let resolved = unit_guild(&member, &mut guild, &commands).expect("cached");
assert_eq!(resolved.name, "Legacy");
assert_eq!(resolved.rank_name, "Off");
assert_eq!(resolved.rank_index, 1);
// A rank past the identity's ten slots names nothing rather than reading off the end,
// which is the one place the reference does not bound itself.
let odd_rank = ObjectFields::from_pairs(&[(191, 7), (192, 99)]);
assert_eq!(
unit_guild(&odd_rank, &mut guild, &commands)
.unwrap()
.rank_name,
""
);
}
/// A command channel whose receiver stays alive, so a send neither blocks nor is dropped.
fn net_commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
(NetCommands(tx), rx)
}
}
+362
View File
@@ -0,0 +1,362 @@
//! The guild roster's **view** — sorted, never filtered (wow-re
//! `system/ui/scratch/guild-api-carve.md` §3).
//!
//! Two things about it are the opposite of the obvious design, and both are verified at the bytes:
//!
//! - **The sort is multi-level.** The reference keeps an eight-slot `{key, direction}` chain
//! (`0xb72680`), most-recently-chosen first, and its comparator walks the whole chain — so every
//! column ever clicked survives as a tie-break behind the current one ([`SortStack`]).
//! - **Show-offline is a sort input, not a filter.** When it is off, the comparator runs a pre-gate
//! that sinks offline members below online ones (`0x4d0d60`–`0x4d0d8a`); the array still holds
//! every member. That pre-gate is exactly what makes `GetNumGuildMembers`' smaller count address
//! the online prefix (see [`super::GuildState::num_members`]).
use std::cmp::Ordering;
use benilla_ui::script::GuildMemberInfo;
/// A column the guild roster can be ordered by — the eight `SortGuildRoster` accepts, in the
/// reference's own key numbering (`0x4d1cb0`'s string chain: `rank` 0, `level` 1, `name` 2,
/// `zone` 3, `class` 4, `group` 5, `online` 6, `note` 7). The numbering matters because the chain
/// is initialized to exactly `key[i] = i` (`0x4d0a50`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SortField {
Rank,
Level,
Name,
Zone,
Class,
/// **A dead key.** Its jump-table entry is the comparator loop's own continue label
/// (`0x4d0f44`'s slot 5 → `0x4d0efd`): it reads no field and orders nothing. Kept rather than
/// dropped because dropping it would silently re-map an addon's `"group"` onto `name`.
Group,
Online,
Note,
}
/// The eight fields in key order — [`SortStack`]'s initial state, and the reference's own
/// `for (i = 0; i < 8; i++) { key[i] = i; dir[i] = 0; }` (`0x4d0a50`–`0x4d0a62`).
const SORT_FIELDS: [SortField; 8] = [
SortField::Rank,
SortField::Level,
SortField::Name,
SortField::Zone,
SortField::Class,
SortField::Group,
SortField::Online,
SortField::Note,
];
impl SortField {
/// The field a `SortGuildRoster(field)` string names. **Case-insensitive** (the reference's
/// own collation `0x64a4c0` → `0x414310` folds `A-Z` with `+0x20` before comparing), and
/// **anything unrecognised sorts by `name`** — the string chain leaves `edi` at its preloaded
/// `mov edi,2` and calls with it anyway (`0x4d1cdb`, `0x4d1df4`). It neither raises nor no-ops.
pub(super) fn parse(field: &str) -> SortField {
match field {
f if f.eq_ignore_ascii_case("rank") => SortField::Rank,
f if f.eq_ignore_ascii_case("level") => SortField::Level,
f if f.eq_ignore_ascii_case("zone") => SortField::Zone,
f if f.eq_ignore_ascii_case("class") => SortField::Class,
f if f.eq_ignore_ascii_case("group") => SortField::Group,
f if f.eq_ignore_ascii_case("online") => SortField::Online,
f if f.eq_ignore_ascii_case("note") => SortField::Note,
_ => SortField::Name,
}
}
/// Order two rows by this column alone. `Equal` means "this column does not decide it", which
/// is what the reference's arms mean when they jump to the loop tail.
fn compare(self, a: &RosterRow, b: &RosterRow) -> Ordering {
match self {
// Rank is the one column ordered by the OPPOSITE of its number: `0x4d0e54 cmp ecx,eax`
// puts b against a and the `sbb/and/inc` idiom answers −1 when `b.rank < a.rank`, so
// the HIGHER rank id — the lowest authority — comes first and the guild master sorts
// last. Every other numeric column is the plain a-vs-b order (`0x4d0dd3`'s `setge`).
SortField::Rank => b.info.rank_index.cmp(&a.info.rank_index),
SortField::Level => a.info.level.cmp(&b.info.level),
SortField::Name => icmp(&a.info.name, &b.info.name),
// Zone and class order by the resolved DBC *name*, not by the id (`0x4d0e67` and
// `0x4d0ded` walk the id to a row and compare the localized string at
// `+0x2c + locale*4` / `+0x14 + locale*4`) — and a row the DBC does not resolve makes
// the column abstain rather than sort first (`0x4d0ea1`/`0x4d0e27 je` → the loop tail).
SortField::Zone => abstaining(&a.info.zone, &b.info.zone),
SortField::Class => abstaining(&a.info.class, &b.info.class),
SortField::Group => Ordering::Equal,
SortField::Online => match (a.info.online, b.info.online) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
// Both offline: the more recently seen first — the reference's `fld`/`fcomp` on
// the raw days-since-logout float (`0x4d0f19`). It answers ±1 rather than 0 for an
// exact tie, which we deliberately do not copy: Rust's sort demands a total order,
// and a non-antisymmetric comparator is a panic here and a coin flip there.
(false, false) => a
.last_online_days
.partial_cmp(&b.last_online_days)
.unwrap_or(Ordering::Equal),
},
SortField::Note => icmp(&a.info.note, &b.info.note),
}
}
}
/// Case-insensitive ASCII order, allocation-free — the reference's `0x414310` (fold `A-Z` by
/// `+0x20`, then compare bytes).
fn icmp(a: &str, b: &str) -> Ordering {
a.bytes()
.map(|c| c.to_ascii_lowercase())
.cmp(b.bytes().map(|c| c.to_ascii_lowercase()))
}
/// [`icmp`], except that an **empty** value on either side abstains instead of sorting first — the
/// DBC-miss leg of the zone and class arms.
fn abstaining(a: &str, b: &str) -> Ordering {
if a.is_empty() || b.is_empty() {
return Ordering::Equal;
}
icmp(a, b)
}
/// The eight-slot `{key, direction}` chain the reference keeps (`0x4d0fb0` maintains it,
/// `0x4d0d50` walks it), most-recently-chosen first.
///
/// `SortGuildRoster(field)` does one of two things:
/// - the field is already at slot 0 → **flip its direction** (`0x4d0fcf`'s `sete`);
/// - otherwise → **move it to slot 0**, shifting the rest down, keeping the direction it was last
/// left at (`0x4d0fdc`–`0x4d0ff3`).
///
/// The chain starts as the eight columns in key order, all ascending, so a column's *first* click
/// is ascending and its second reverses — but a column returned to after being reversed comes back
/// **reversed**, and the column clicked before it is still the tie-break. A one-column-plus-
/// direction model gets both of those wrong.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct SortStack([(SortField, bool); 8]);
impl Default for SortStack {
fn default() -> Self {
SortStack(SORT_FIELDS.map(|f| (f, false)))
}
}
impl SortStack {
/// Apply a `SortGuildRoster(field)` — see the type doc for the two cases.
pub(super) fn select(&mut self, field: SortField) {
let Some(at) = self.0.iter().position(|(f, _)| *f == field) else {
return; // unreachable: the chain holds all eight, always
};
if at == 0 {
self.0[0].1 = !self.0[0].1;
return;
}
let moved = self.0[at];
self.0.copy_within(0..at, 1);
self.0[0] = moved;
}
/// The current primary column and its direction — what the display order is *by*.
#[cfg(test)]
fn primary(&self) -> (SortField, bool) {
self.0[0]
}
/// Order two rows. `show_offline` is a real input, not a filter: while it is **off**, an
/// offline member sinks below every online one before any key is consulted (the pre-gate at
/// `0x4d0d60`–`0x4d0d8a`, skipped entirely when it is on). Otherwise the first key on the
/// chain that decides wins, with only *that* key's direction applied (`0x4d0f36`'s `neg eax`
/// reads the deciding slot's flag, not slot 0's).
fn compare(&self, a: &RosterRow, b: &RosterRow, show_offline: bool) -> Ordering {
if !show_offline {
match (a.info.online, b.info.online) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
_ => {}
}
}
for (field, descending) in self.0 {
let ord = field.compare(a, b);
if ord != Ordering::Equal {
return if descending { ord.reverse() } else { ord };
}
}
Ordering::Equal
}
/// Put `rows` in display order.
///
/// `sort_unstable_by`, deliberately: the reference is the MSVC CRT `qsort` (`0x73f727`,
/// median-of-3 with an insertion-sort cutoff), which is **not** stable. Adding a stability we
/// do not have would diverge on ties rather than converge.
pub(super) fn order(&self, rows: &mut [RosterRow], show_offline: bool) {
rows.sort_unstable_by(|a, b| self.compare(a, b, show_offline));
}
}
/// One roster row on its way to the VM: the display-resolved [`GuildMemberInfo`] the snapshot
/// carries, plus the two things the *app* still needs — the member's guid (the selection is a
/// guid, see [`super`]) and the raw days-since-logout the online column's tie-break compares.
#[derive(Clone, Debug, Default)]
pub(crate) struct RosterRow {
pub(crate) guid: u64,
pub(crate) last_online_days: f32,
pub(crate) info: GuildMemberInfo,
}
#[cfg(test)]
mod tests {
use super::*;
fn row(name: &str, online: bool, level: u32, rank_index: u32) -> RosterRow {
RosterRow {
guid: 0,
last_online_days: 0.0,
info: GuildMemberInfo {
name: name.to_string(),
rank_index,
level,
online,
..Default::default()
},
}
}
/// A repeat click reverses the column and a fresh column starts ascending — plus the half a
/// `(field, direction)` pair cannot hold: a column returned to comes back **reversed**,
/// because the reference remembers all eight directions.
#[test]
fn a_repeat_column_reverses_and_a_returned_one_keeps_its_direction() {
let mut sort = SortStack::default();
sort.select(SortField::Name);
assert_eq!(sort.primary(), (SortField::Name, false), "first click");
sort.select(SortField::Name);
assert_eq!(sort.primary(), (SortField::Name, true), "reversed");
sort.select(SortField::Level);
assert_eq!(
sort.primary(),
(SortField::Level, false),
"a column not clicked before starts ascending"
);
sort.select(SortField::Name);
assert_eq!(
sort.primary(),
(SortField::Name, true),
"and one returned to keeps the direction it was left at — NOT a reset to ascending"
);
}
/// The chain is the tie-break: the column chosen *before* the current one still decides rows
/// the current one ties on.
#[test]
fn the_previous_column_breaks_the_new_ones_ties() {
let mut sort = SortStack::default();
sort.select(SortField::Name); // then...
sort.select(SortField::Level); // ...level on top, name behind it
let a = row("Alice", true, 10, 0);
let b = row("Bob", true, 10, 0);
assert_eq!(
sort.compare(&a, &b, true),
Ordering::Less,
"same level → by name"
);
assert_eq!(
sort.compare(&row("Zed", true, 5, 0), &a, true),
Ordering::Less,
"different level → by level, and the name is never consulted"
);
}
/// Every one of the reference's eight strings maps to its own column, matching is
/// case-insensitive, and anything else sorts by name rather than raising or no-opping.
#[test]
fn the_sort_field_names_are_the_references_eight() {
for (text, field) in [
("rank", SortField::Rank),
("level", SortField::Level),
("name", SortField::Name),
("zone", SortField::Zone),
("class", SortField::Class),
("group", SortField::Group),
("online", SortField::Online),
("note", SortField::Note),
("ZONE", SortField::Zone),
("nonsense", SortField::Name),
("", SortField::Name),
] {
assert_eq!(SortField::parse(text), field, "{text:?}");
}
}
/// `group` is accepted and orders nothing — the key under it decides.
#[test]
fn the_group_key_is_dead() {
let mut sort = SortStack::default();
sort.select(SortField::Level);
sort.select(SortField::Group);
assert_eq!(sort.primary(), (SortField::Group, false));
assert_eq!(
sort.compare(&row("A", true, 5, 0), &row("B", true, 9, 0), true),
Ordering::Less,
"level, the key it was promoted over, still decides"
);
}
/// Rank sorts the guild master LAST at direction 0 — the one column whose byte order is the
/// reverse of its number.
#[test]
fn rank_sorts_the_guild_master_last() {
let mut sort = SortStack::default();
sort.select(SortField::Rank);
sort.select(SortField::Rank); // back to ascending (the default already had it at slot 0)
let master = row("Tigole", true, 60, 0);
let initiate = row("Kaplan", true, 60, 4);
assert_eq!(sort.compare(&initiate, &master, true), Ordering::Less);
}
/// While show-offline is OFF the offline members sink to the bottom of the SAME array — which
/// is what makes the smaller `GetNumGuildMembers` count address exactly the online prefix.
/// While it is ON they interleave under the field keys.
#[test]
fn show_offline_sinks_rather_than_filters() {
let mut sort = SortStack::default();
sort.select(SortField::Name);
let mut rows = vec![
row("Alice", false, 60, 0),
row("Bob", true, 60, 0),
row("Carol", false, 60, 0),
row("Dave", true, 60, 0),
];
sort.order(&mut rows, false);
let names: Vec<&str> = rows.iter().map(|r| r.info.name.as_str()).collect();
assert_eq!(names, ["Bob", "Dave", "Alice", "Carol"], "online prefix");
assert_eq!(rows.len(), 4, "nothing is removed, ever");
sort.order(&mut rows, true);
let names: Vec<&str> = rows.iter().map(|r| r.info.name.as_str()).collect();
assert_eq!(names, ["Alice", "Bob", "Carol", "Dave"], "interleaved");
}
/// Between two offline members the online column breaks the tie by who was seen most
/// recently, and a zone or class the DBC could not name abstains instead of sorting first.
#[test]
fn the_online_tiebreak_and_the_dbc_miss_abstention() {
let mut sort = SortStack::default();
sort.select(SortField::Online);
let recent = RosterRow {
last_online_days: 0.5,
..row("Recent", false, 60, 0)
};
let ancient = RosterRow {
last_online_days: 90.0,
..row("Ancient", false, 60, 0)
};
assert_eq!(sort.compare(&recent, &ancient, true), Ordering::Less);
assert_eq!(abstaining("", "Ironforge"), Ordering::Equal);
assert_eq!(abstaining("Ironforge", ""), Ordering::Equal);
assert_eq!(abstaining("Elwynn", "Ironforge"), Ordering::Less);
assert_eq!(icmp("alice", "Alice"), Ordering::Equal, "case-insensitive");
}
}
+72 -2
View File
@@ -156,6 +156,12 @@ pub(crate) struct UiQuad {
/// real client's round bake stencil). Splits the run like `additive`; in practice a portrait's
/// render-target texture is unique to it, so no batching is actually lost.
pub circular: bool,
/// Draw the sampled texel as its **luminance** — `Texture:SetDesaturated(1)`, the greyed-out
/// icon every disabled affordance in the reference wears (decision 1327). Folded in the
/// client's GAMMA byte space and applied BEFORE the vertex-colour multiply, so the reference's
/// own `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` lands as greyscale *and* dim.
/// Splits the run like `additive`/`circular`.
pub desaturated: bool,
/// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped.
pub clip: Option<Rect>,
/// Rotate the quad's corners by this many radians **clockwise on screen** about the rect's
@@ -200,6 +206,7 @@ impl Default for UiQuad {
color: [1.0, 1.0, 1.0, 1.0],
additive: false,
circular: false,
desaturated: false,
clip: None,
rotation: 0.0,
mask: None,
@@ -277,6 +284,9 @@ pub(crate) struct UiQuadMaterial {
/// Mask to the inscribed circle (live unit portraits) — see [`UiQuad::circular`].
#[uniform(3)]
circular: u32,
/// Fold the texel to luminance — see [`UiQuad::desaturated`].
#[uniform(7)]
desaturate: u32,
/// The screen-anchored mask span in **physical framebuffer px** (`min.xy, max.xy` — the
/// fragment shader compares `@builtin(position)`, which is physical): [`UiQuadMask::rect`]
/// scaled by the window's scale factor at mesh build. `z <= x` (degenerate) disables masking.
@@ -485,6 +495,7 @@ struct Run {
texture: Handle<Image>,
additive: bool,
circular: bool,
desaturated: bool,
mask: Option<UiQuadMask>,
positions: Vec<[f32; 3]>,
uvs: Vec<[f32; 2]>,
@@ -497,12 +508,14 @@ impl Run {
texture: Handle<Image>,
additive: bool,
circular: bool,
desaturated: bool,
mask: Option<UiQuadMask>,
) -> Self {
Self {
texture,
additive,
circular,
desaturated,
mask,
positions: Vec::new(),
uvs: Vec::new(),
@@ -582,7 +595,14 @@ struct BatchPools {
/// A material's full identity: texture, blend/shape flags, mask texture + mask rect (as bits, so
/// NaN/-0.0 can't split cache entries byte-equal materials would share).
type MatKey = (AssetId<Image>, bool, bool, Option<AssetId<Image>>, [u32; 4]);
type MatKey = (
AssetId<Image>,
bool,
bool,
bool,
Option<AssetId<Image>>,
[u32; 4],
);
/// Retire every pooled batch entity (blank frame / no drawable content). Mesh handles and the
/// material cache stay — assets referenced only by the pool cost nothing to keep and come back
@@ -711,10 +731,17 @@ fn rebuild_ui_mesh(
r.texture == texture
&& r.additive == q.additive
&& r.circular == q.circular
&& r.desaturated == q.desaturated
&& r.mask == q.mask
});
if !same_run {
runs.push(Run::new(texture, q.additive, q.circular, q.mask.clone()));
runs.push(Run::new(
texture,
q.additive,
q.circular,
q.desaturated,
q.mask.clone(),
));
}
let run = runs.last_mut().unwrap();
match (q.corners, plain) {
@@ -799,6 +826,7 @@ fn rebuild_ui_mesh(
run.texture.id(),
run.additive,
run.circular,
run.desaturated,
mask.as_ref().map(bevy::asset::Handle::id),
mask_rect.to_array().map(f32::to_bits),
);
@@ -810,6 +838,7 @@ fn rebuild_ui_mesh(
additive: u32::from(run.additive),
texture: Some(run.texture),
circular: u32::from(run.circular),
desaturate: u32::from(run.desaturated),
mask_rect,
mask,
})
@@ -996,6 +1025,47 @@ mod tests {
);
}
/// **The desaturation flag reaches the MATERIAL, and splits the run** (decision 1327).
///
/// Everything upstream of here can be right — the Lua sets it, extract carries it, the quad
/// holds it — and the screen still not change, because the greyscale lives in a shader uniform
/// and a uniform only exists per material. Two quads that differ ONLY in `desaturated` must
/// therefore batch apart; if they merged, whichever of the two came first would decide the look
/// of both, and the visible symptom would be a talent tree that greys in blocks.
#[test]
fn desaturation_splits_the_run_and_lands_on_the_material() {
let mut app = rebuild_app();
// Same texture (the shared white), same blend, adjacent in z — everything that batches
// agrees, so `desaturated` is the only thing that can split them.
let mut quads = app.world_mut().resource_mut::<UiQuads>();
quads.quads.push(UiQuad {
rect: Rect::new(64.0, 0.0, 128.0, 64.0),
z_key: 1,
desaturated: true,
..default()
});
quads.dirty = true;
app.update();
assert_eq!(
batches(&mut app),
2,
"a desaturated quad cannot share a material with a full-colour one"
);
let mut flags: Vec<u32> = app
.world_mut()
.resource::<Assets<UiQuadMaterial>>()
.iter()
.map(|(_, m)| m.desaturate)
.collect();
flags.sort_unstable();
assert_eq!(
flags,
vec![0, 1],
"one material greys and one does not — the uniform the shader branches on"
);
}
// The mirror-preservation guarantee, and the reason [`UvRect`] exists instead of
// `bevy::math::Rect`: PlayerFrameTexture's `<TexCoords left="1.0" right="0.09375">`
// (ref-PlayerFrame.xml l.55-57) must reach the vertex buffer with `u0 > u1`. `Rect::from_corners`
@@ -0,0 +1,105 @@
//! The innkeeper bind confirm (decision 1331, BinderConfirm.xml): the dialog `ui_binder`'s feed
//! raises, its Accept, and the range poll that takes it away — driven exactly as that feed and the
//! app's NPC-session guard drive it.
use benilla_ui::script::{ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the duel tests'
/// loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
report.frames
}
/// The app's own pre-state: a question is pending and in range, which is what
/// `CheckBinderDist()` reports while the dialog is up.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "BinderConfirm.xml");
s.set_binder_pending(true);
s
}
/// `CONFIRM_BINDER(area)` raises the dialog with the area name filled in, and Accept queues the
/// one `ConfirmBinder()` that becomes `CMSG_BINDER_ACTIVATE`. The whole point of the arc: before
/// this wiring the click produced no dialog and no packet at all (B249).
#[test]
fn the_confirm_shows_the_area_and_accept_queues_the_bind() {
let mut s = setup();
s.fire_event(
"CONFIRM_BINDER",
vec![ScriptValue::Str("Dolanaar".to_string())],
);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"CONFIRM_BINDER shows the dialog"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Do you want to make Dolanaar your new home?",
"arg1 fills the GlobalStrings template's %s"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_binder_confirms(), 1);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"accepting closes it"
);
}
/// Cancel and ESC both send **nothing**: declining an innkeeper is silent on the wire (there is
/// no decline opcode), so the only observable is that no confirm was queued.
#[test]
fn declining_sends_nothing() {
let mut s = setup();
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Goldshire".into())]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_binder_confirms(), 0);
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Goldshire".into())]);
s.run("ToggleGameMenu()").unwrap();
assert_eq!(s.take_binder_confirms(), 0);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"hideOnEscape takes it down"
);
}
/// Walking away takes the question off screen: the entry's OnUpdate polls `CheckBinderDist()`,
/// which the app drives from the shared NPC-session range guard. While it holds, ticking changes
/// nothing; the frame it goes false, the dialog hides itself — with no packet either way.
#[test]
fn leaving_the_innkeepers_range_hides_the_confirm() {
let mut s = setup();
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Kharanos".into())]);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"in range, the tick leaves it alone"
);
s.set_binder_pending(false);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"out of range, the dialog takes itself down"
);
assert_eq!(s.take_binder_confirms(), 0, "and sends nothing");
}
@@ -749,3 +749,89 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
assert!((s.eval::<f64>("return BuffFrame:GetRight()").unwrap() - resting).abs() < 1e-3);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The duration line against the **real shipped strings**, end to end — the leg the engine's own
/// synthetic-template tests cannot reach.
///
/// The engine formats through `tooltip::duration_text` (`0x52fa50`'s ladder, wow-re
/// §3-BUFF-TIME-FORMAT) over whatever `GlobalStrings.lua` the player's install carries; this runs
/// that file into a real VM exactly as the boot's `load_global_strings` does, then asserts the
/// wording that comes back. Every expectation below is a reading the PREVIOUS two-arm formatter got
/// wrong, so this is the carve's whole delta in one place. Skips without client data.
#[test]
fn the_duration_line_reads_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
s.set_spell_tooltip(
1459,
benilla_ui::script::SpellTooltipView {
name: "Arcane Intellect".into(),
aura_description: "Intellect increased by 2.".into(),
..Default::default()
},
);
s.tick(10.0); // GetTime = 10
s.run(
r#"
BENILLA_ANCHOR = CreateFrame("Button", "BF9")
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
BENILLA_TIP = CreateFrame("GameTooltip", "TT9")
"#,
)
.unwrap();
// `secs_left` seconds remaining at GetTime = 10, read back as the tooltip's last line.
let mut line = |secs_left: f64| -> String {
s.set_auras(
"player",
Some(vec![AuraState {
spell_id: 1459,
name: Some("Arcane Intellect".into()),
duration: 86_400.0,
expiration_time: 10.0 + secs_left,
helpful: true,
..Default::default()
}]),
);
s.run(
r#"
BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT")
BENILLA_TIP:SetPlayerBuff(0)
BENILLA_LAST = TT9TextLeft3:GetText()
"#,
)
.unwrap();
s.eval::<String>("return BENILLA_LAST").unwrap()
};
// The headline divergence: anything past an hour was reading in MINUTES.
assert_eq!(line(7_200.0), "2 hours remaining", "was '120 minutes'");
assert_eq!(line(3_600.0), "1 hour remaining", "the hour edge, singular");
assert_eq!(
line(3_599.999),
"60 minutes remaining",
"one ms under the hour stays in minutes — no '1 hour' until it is whole"
);
// The singular half: the shipped pair really is "%d minute" / "%d minutes".
assert_eq!(line(60.0), "1 minute remaining", "was '1 minutes'");
assert_eq!(line(61.0), "2 minutes remaining", "the minute arm ceils");
// The seconds arm truncates where every arm above it ceils.
assert_eq!(line(5.4), "5 seconds remaining", "was '6 seconds'");
assert_eq!(line(1.0), "1 second remaining", "singular at exactly one");
assert_eq!(
line(-0.4),
"0 seconds remaining",
"the lapsing read, plural at zero"
);
// And the top of the ladder, which had no arm at all.
assert_eq!(line(129_600.0), "2 days remaining", "was '2160 minutes'");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
@@ -29,6 +29,65 @@ fn ui_cost_enabled() -> bool {
*ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1"))
}
/// `WOW_UI_GATE=1` — the extract gate's miss reporter ([`report_gate_miss`]). Off by default and
/// read once, the `[ui-cost]` meter's posture.
fn gate_log_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_UI_GATE").as_deref() == Ok("1"))
}
/// One line naming why the extract gate did not skip this frame — the input that differed, and for
/// the render list the first entry that moved. A settled UI is what makes the paint pass free; when
/// it is never settled, this says what is moving.
fn report_gate_miss(
script: &UiScript,
now: &[benilla_ui::script::ExtractedQuad],
prev: &[benilla_ui::script::ExtractedQuad],
dims_eq: bool,
text_ui_eq: bool,
portraits_eq: bool,
) {
if !dims_eq {
eprintln!("[ui-gate] miss: window/scale changed");
return;
}
if !text_ui_eq {
eprintln!("[ui-gate] miss: focused editbox text-UI changed");
return;
}
if !portraits_eq {
eprintln!("[ui-gate] miss: portrait sources changed");
return;
}
if now.len() != prev.len() {
eprintln!(
"[ui-gate] miss: render list {} -> {} entries",
prev.len(),
now.len()
);
return;
}
match now.iter().zip(prev).position(|(a, b)| a != b) {
Some(i) => {
let (a, b) = (&now[i], &prev[i]);
let (was, now_s) = (format!("{b:?}"), format!("{a:?}"));
let owner = match a.target {
benilla_ui::order::ZTarget::Frame(fh) => script.frame_name(fh),
benilla_ui::order::ZTarget::Region(_) => None,
};
eprintln!(
"[ui-gate] miss: entry {i}/{} target={:?} name={owner:?}\n was {}\n now {}",
now.len(),
a.target,
&was[..was.len().min(280)],
&now_s[..now_s.len().min(280)],
);
}
// Nothing in this pass's own inputs moved — the miss came from the capture-mode arm.
None => eprintln!("[ui-gate] miss: capture mode (the gate never skips under a capture)"),
}
}
/// Last frame's extract-gate inputs (decision 0740), held together as one `Local` — the gate
/// compares all four or none, and a Bevy system has a hard param budget this was eating.
#[derive(Default)]
@@ -375,6 +434,19 @@ pub(super) fn drive_script(
}
return;
}
// `WOW_UI_GATE=1` — why the gate MISSED. A skip is worth the whole conversion, so the
// question "what moved?" is where a per-frame cost always ends, and a `Vec` inequality does
// not answer it. Off by default, like the `[ui-cost]` meter beside it.
if gate_log_enabled() {
report_gate_miss(
&script,
&extracted,
&prev.extracted,
prev.dims == Some(dims),
text_ui == prev.text_ui,
booths.images.0 == prev.portraits,
);
}
prev.dims = Some(dims);
prev.text_ui = text_ui.clone();
prev.portraits = booths.images.0.clone();
@@ -436,6 +508,7 @@ pub(super) fn drive_script(
circular,
portrait_unit,
rotation,
desaturated,
} => {
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
@@ -555,6 +628,7 @@ pub(super) fn drive_script(
// The engine's SetRotation is counterclockwise-positive; the quad pass spins
// clockwise-on-screen (`UiQuad::rotation`) — negate to convert.
rotation: -rotation,
desaturated,
..default()
});
}
@@ -155,6 +155,7 @@ pub(super) fn emit(
color: [hc[0], hc[1], hc[2], hc[3] * host.alpha],
additive: false,
circular: false,
desaturated: false,
clip: text_clip,
rotation: 0.0,
mask: None,
@@ -171,10 +172,11 @@ pub(super) fn emit(
// gate inside is geometric and needs no fixed-vs-measured flag. Computed before the shadow
// so the shadow pass draws the same display string (the client's shadow is a second draw of
// the same truncated CGxString).
let ellipsized = if ebox.is_none() && !matches!(host.target, ZTarget::Frame(_)) {
crate::ui_text::ellipsize_to_fit(atlas, draw_text, draw_rect, spec)
} else {
None
let ellipsized = match host.target {
ZTarget::Region(region) if ebox.is_none() => {
crate::ui_text::ellipsize_to_fit(atlas, region, draw_text, draw_rect, spec)
}
_ => None,
};
if let Some(display) = ellipsized.as_deref() {
draw_text = display;
@@ -339,6 +341,7 @@ pub(super) fn emit(
color: [1.0, 1.0, 1.0, host.alpha],
additive: false,
circular: false,
desaturated: false,
clip: text_clip,
rotation: 0.0,
mask: None,
@@ -55,6 +55,11 @@ fn setup() -> UiScript {
// the manifest includes it (it loads well before FriendsFrame.xml in the real order).
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
// The guild pane's frames inherit the reference's shared UIPanelButtonTemplate /
// UIPanelCloseButton / UIPanelScrollFrameTemplate rather than a private copy (decision 1257),
// so this file's slice needs the shared kit — and the strict `load_xml` above is what would
// otherwise let those frames load art-less and silent.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
s
}
@@ -76,8 +81,17 @@ fn push(s: &mut UiScript, state: SocialState, event: &str) {
s.fire_event(event, Vec::new());
}
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — which is
/// what a character in no guild sees in the reference, and what we always are.
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — the
/// GUILDLESS case of `InGuildCheck`.
///
/// **This assertion changed meaning with decision 1257 and was kept rather than deleted.** It used
/// to pin a hardcoded `PanelTemplates_DisableTab(this, 3)`, because there was no guild arc and the
/// tab was permanently dead. The tab is live now, and this is the other half of its law: with no
/// guild seated in the engine, `IsInGuild()` answers nil, `FriendsFrame_OnShow` runs
/// `InGuildCheck()`, and the tab greys — which is exactly what the reference does for a character
/// in no guild. The IN-guild half is `guild_tests`. Note that this file seats no guild fixture at
/// all: it is asserting against the REAL `script::guild` bindings' empty state, which is the state
/// every character starts a session in.
#[test]
fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
let s = setup();
@@ -91,12 +105,27 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
.unwrap(),
"Friends List"
);
assert_eq!(
s.eval::<i64>("return IsInGuild() and 1 or 0").unwrap(),
0,
"no guild is seated, which is what makes the next assertion mean something"
);
assert_eq!(
s.eval::<i64>("return FriendsFrameTab3.isDisabled or 0")
.unwrap(),
1,
"the guild tab is disabled, not absent"
);
// …and the tab refuses to be reached by NUMBER either, which is the second of the two locks
// (`ToggleFriendsFrame`'s own `tab == 3 and not IsInGuild()` early-out).
s.run("ToggleFriendsFrame(3)").unwrap();
assert!(
!s.eval::<bool>("return GuildFrame:IsVisible()").unwrap(),
"a guildless character cannot open the guild pane at all"
);
assert!(s
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
// The two tab kinds are different templates and must not be swapped: the strip along the
// bottom is the big window tab (20px end slices), the Friends/Ignore pair inside tab 1 is the
// ref's compact TabButtonTemplate (16px). Getting this wrong is invisible to every behaviour
@@ -788,9 +817,13 @@ fn the_window_geometry_matches_the_reference_framexml() {
"FriendsFrameFriendsScrollFrame",
"FriendsFrameIgnoreScrollFrame",
"WhoListScrollFrame",
// No UIPanelCloseButton template ships here, so ours states the 32x32 the ref inherits.
// …and the guild pane's, the fourth of the same kind (decision 1257).
"GuildListScrollFrame",
// The window's own close button predates UIPanelTemplates.xml shipping the reference's
// `UIPanelCloseButton`, so it states inline the 32x32 that template confers. The guild
// pane's two close buttons inherit it and therefore need no entry here.
"FriendsFrameCloseButton",
];
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 61);
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 210);
}
@@ -607,16 +607,16 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
s.resolve();
// The backpack icon + the four slot rings, by name.
let art = |s: &UiScript, owner: &str| -> Vec<(String, Option<[f32; 4]>)> {
let art = |s: &UiScript, owner: &str| -> Vec<(String, bool)> {
s.extract()
.into_iter()
.filter(|eq| s.quad_owner_name(eq.target).as_deref() == Some(owner))
.filter_map(|eq| match &eq.content {
benilla_ui::script::QuadContent::Texture {
path: Some(p),
color,
desaturated,
..
} => Some((p.clone(), *color)),
} => Some((p.clone(), *desaturated)),
_ => None,
})
.collect()
@@ -650,7 +650,8 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
"{owner} must still DRAW under the open menu — greyed is not gone"
);
}
// The backpack icon specifically: still its own art, and tinted to SetDesaturation's grey.
// The backpack icon specifically: still its own art, and carrying SetDesaturation's greyscale
// flag to the renderer (decision 1327 — before it, the grey was the ref's no-shader 0.5 tint).
let toggle = art(&s, "MainMenuBarBackpackButton");
assert!(
toggle
@@ -661,8 +662,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
assert!(
toggle
.iter()
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
&& c.is_some_and(|c| (c[0] - 0.5).abs() < 0.01)),
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && *grey),
"…and it is greyed, not full-bright: {toggle:?}"
);
@@ -672,8 +672,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
assert!(
toggle
.iter()
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
&& c.is_none_or(|c| (c[0] - 1.0).abs() < 0.01)),
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && !*grey),
"closing the menu restores full colour: {toggle:?}"
);
assert!(
File diff suppressed because it is too large Load Diff
+9
View File
@@ -738,6 +738,9 @@ mod delete_item_tests;
#[cfg(test)]
mod static_popup_tests;
#[cfg(test)]
mod binder_tests;
#[cfg(test)]
mod death_tests;
@@ -754,6 +757,12 @@ mod framexml_diff;
#[cfg(test)]
mod friends_tests;
/// The four guild windows — the social window's third tab and its three satellites (decision
/// 1257). Its own module rather than more of `friends_tests` because it stands the whole guild
/// engine API in for in Lua before the XML loads, which that file must not.
#[cfg(test)]
mod guild_tests;
#[cfg(test)]
mod quest_tests;
@@ -348,24 +348,21 @@ fn a_disabled_bar_greys_rather_than_hides() {
2,
"the bar is still on screen"
);
// `SetDesaturation` has no shader path in this engine, so the grey is the reference's own
// no-shader fallback: vertex colour 0.5 (UiPanels.xml's transcription).
let tint = quads
// Since decision 1327 `SetDesaturation` takes its SHADER arm — the icon carries the greyscale
// flag to the renderer and keeps its own tint, rather than the reference's no-shader 0.5
// vertex-colour fallback (which is what this asserted while nothing greyed).
let grey = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
desaturated,
..
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*color),
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*desaturated),
_ => None,
})
.expect("Claw's icon");
assert_eq!(
tint.map(|c| [c[0], c[1], c[2]]),
Some([0.5, 0.5, 0.5]),
"a disabled bar greys every icon on it"
);
assert!(grey, "a disabled bar greys every icon on it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -547,3 +547,180 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_SPELL")
.unwrap());
}
/// The spellbook's **macro-editor fork**, pinned in both directions (report B248).
///
/// The reference's contract, read off the 1.12.1 install's own `SpellBookFrame.lua:271-283` and
/// `MacroFrame.lua:93-97`, is narrow and easy to "fix" into a later expansion's:
///
/// * only a **shift**-click reaches the editor at all — a plain left OR **right** click still
/// casts, exactly as it does with no macro window on screen;
/// * what lands is a whole **`/cast <name>[(<rank>)]` line**, not the bare name (the bare-name
/// insert is 2.x's `ChatEdit_InsertLink`, a different client — and `MACRO_HELP_TEXT_LINE5`,
/// which advertises this feature, says "*Shift* click");
/// * it is **appended with no separator** — the reference's own `GetText()..line` — so two
/// shift-clicks run together on one line;
/// * a **passive** contributes nothing, and neither does a shift-click while the name/icon
/// popup has the body box hidden;
/// * with the editor hidden the shift-click is a **pickup** again.
///
/// DO NOT "FIX" the `/cast` prefix away (the standing rule, decision 1030): B248 reported it as
/// wrong, and the install says it is what 1.12.1 does.
#[test]
fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The macro window's own strings (the app runs the real `GlobalStrings.lua`; `macro_tests`'
// harness is the source of this list).
s.run(
r#"
CREATE_MACROS = "Create Macros"
GENERAL_MACROS = "General Macros"
CHARACTER_SPECIFIC_MACROS = "%s Specific Macros"
ENTER_MACRO_LABEL = "Enter Macro Commands:"
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"
MACRO_POPUP_CHOOSE_ICON = "Choose an Icon:"
CHANGE_MACRO_NAME_ICON = "Change Name/Icon"
DELETE = "Delete" NEW = "New" EXIT = "Exit" CANCEL = "Cancel" OKAY = "Okay"
MACROS = "Macros"
TOOLTIP_DEFAULT_COLOR = { r = 1.0, g = 1.0, b = 1.0 }
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
"#,
)
.unwrap();
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ScrollTemplates.xml",
"MicroMenu.xml",
"ActionBar.xml",
"MacroFrame.xml",
"SpellBookFrame.xml",
] {
load_xml(&s, file);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The file's own book plus a third Fire spell that is PASSIVE — book id 3, which the ref's
// column-major grid puts on SpellButton5.
let mut b = book();
b.tabs[0].num_spells = 3;
b.tabs[1].offset = 3;
b.slots.insert(
2,
SpellSlotView {
spell_id: 168,
name: "Frost Warding".into(),
texture: Some("Interface\\Icons\\Spell_Frost_FrostWard".into()),
passive: true,
..Default::default()
},
);
s.set_spellbook(b);
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
let body = |s: &UiScript| -> String {
s.eval::<String>("return BenillaMacroFrameText:GetText()")
.unwrap()
};
assert!(
s.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
.unwrap(),
"the editor's body box is up — the ref's own `MacroFrame_AddMacroLine` gate"
);
assert_eq!(body(&s), "");
// ── B248's reported gesture: a plain RIGHT click still CASTS, and writes nothing ──────────
click(&mut s, "SpellButton1", "RightButton");
assert!(
s.errors().is_empty(),
"right-click errors: {:?}",
s.errors()
);
assert_eq!(s.take_spell_casts(), vec![133], "a right-click casts");
assert_eq!(
body(&s),
"",
"B248: an UNSHIFTED click must never reach the macro editor (ref l.271 tests shift first)"
);
assert!(s.cursor_payload().is_none());
// …and so does a plain left click, the other half of the same arm.
click(&mut s, "SpellButton1", "LeftButton");
assert_eq!(s.take_spell_casts(), vec![133]);
assert_eq!(body(&s), "");
// ── The reference gesture: shift-click APPENDS a whole `/cast` line, and never casts ──────
s.set_modifiers(true, false, false);
click(&mut s, "SpellButton1", "LeftButton");
assert!(
s.errors().is_empty(),
"shift-click errors: {:?}",
s.errors()
);
assert_eq!(
body(&s),
"/cast Fireball(Rank 1)",
"ref l.276: SLASH_CAST1 + name + the rank in parens"
);
assert!(
s.take_spell_casts().is_empty(),
"the macro arm replaces the cast, it does not add to it"
);
assert!(
s.cursor_payload().is_none(),
"with the editor open a shift-click writes instead of picking up"
);
assert!(
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
.unwrap(),
"the write goes through the box's own OnTextChanged, so the window is dirty"
);
// A second one runs straight on: the reference appends with NO separator (`GetText()..line`),
// which is why a two-spell macro built this way needs the player to break the line himself.
// DO NOT "fix" this into a newline join.
click(&mut s, "SpellButton3", "RightButton"); // shift outranks the button — ref l.271 vs l.284
assert_eq!(
body(&s),
"/cast Fireball(Rank 1)/cast Fire Blast(Rank 1)",
"ref MacroFrame.lua:95 — appended, unseparated"
);
assert!(s.take_spell_casts().is_empty());
// A PASSIVE writes nothing at all — and does not fall through to a pickup either (ref l.274's
// guard sits INSIDE the macro-frame arm).
let before = body(&s);
click(&mut s, "SpellButton5", "LeftButton");
assert_eq!(body(&s), before, "a passive is not a castable line");
assert!(s.cursor_payload().is_none());
// The name/icon popup hides the body box; the ref's AddMacroLine gates on exactly that.
s.run("BenillaMacroNewButton_OnClick()").unwrap();
assert!(!s
.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
.unwrap());
click(&mut s, "SpellButton1", "LeftButton");
assert!(s.errors().is_empty(), "popup-open errors: {:?}", s.errors());
assert!(s.cursor_payload().is_none(), "still not a pickup");
s.run("BenillaMacroPopupFrame:Hide()").unwrap();
s.run("BenillaMacroFrame_Update()").unwrap();
assert_eq!(body(&s), before, "nothing landed while the popup was up");
// ── Editor hidden: the shift-click is a PICKUP again (ref l.281-282's else) ───────────────
s.run("HideUIPanel(BenillaMacroFrame)").unwrap();
click(&mut s, "SpellButton1", "LeftButton");
s.set_modifiers(false, false, false);
assert!(s.errors().is_empty(), "pickup errors: {:?}", s.errors());
let (kind, spell_id) = s
.eval::<(String, i64)>("local k, _, _, id = GetCursorInfo() return k, id")
.unwrap();
assert_eq!((kind.as_str(), spell_id), ("spell", 133));
assert!(s.take_spell_casts().is_empty());
}
@@ -723,3 +723,255 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap());
}
/// A profession trainer's list: one skill line, a long recipe name and a short one, each with a rank
/// subtext — the shape of the B253 report (a Leatherworking trainer at "Handstitched Leather Pants").
fn recipe_menu() -> TrainerState {
let mut long = service(
3756,
"Handstitched Leather Pants",
TrainerServiceCategory::Available,
50,
1,
165,
"Leatherworking",
None,
vec![],
);
long.subtext = Some("Rank 1".into());
let mut short = service(
2149,
"Belt",
TrainerServiceCategory::Available,
50,
1,
165,
"Leatherworking",
None,
vec![],
);
short.subtext = Some("Rank 1".into());
TrainerState {
greeting: "Can I teach you how to turn beast hides into armor?".into(),
trainer_type: 2,
groups: Vec::new(),
services: vec![long, short],
}
}
/// **B253 — a long row name drew two lines over the row beneath it.** The row is a FLOW, not two
/// fixed columns: the name carries no width (a FontString given one wraps at it, and a 16 px row
/// cannot grow), and the rank follows the name's right edge rather than sitting at an invented
/// x=188. Both halves are the reference's own row
/// (`ClassTrainerFrameTemplates.xml`'s `<ButtonText>` at width 0 + the per-row
/// `SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`), read off the player's chain.
///
/// The mutation check is the first assertion: put a `<Size>` back on `$parentName` and the request
/// carries a wrap width, the long name measures two lines, and its height passes the row height.
#[test]
fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
let mut s = trainer_script();
s.set_money(5000);
s.set_trainer(Some(recipe_menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Nadyia".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The host measure: 6 px per character, 14 px per line, wrapped at whatever width was asked for.
// Capture the row names' requests on the way past — what they ASK is the structural fact.
let mut name_wraps: Vec<Option<f32>> = Vec::new();
let mut answer = |s: &mut UiScript, collect: bool| {
let reqs = s.fontstrings_needing_measure();
if collect {
name_wraps.extend(
reqs.iter()
.filter(|r| r.text.contains("Handstitched"))
.map(|r| r.wrap_width),
);
}
let answers: Vec<(u32, f32, f32, u64)> = reqs
.into_iter()
.map(|r| {
let ink = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(w) => (r.id, ink.min(w), (ink / w).ceil().max(1.0) * 14.0, r.key),
None => (r.id, ink, 14.0, r.key),
}
})
.collect();
s.set_measured_text_unwrapped(&answers);
};
answer(&mut s, true);
s.resolve();
s.tick(0.016);
answer(&mut s, false);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!name_wraps.is_empty() && name_wraps.iter().all(|w| w.is_none()),
"the row name must ask for NO wrap width — it is a single line whatever the name is; \
got {name_wraps:?}"
);
// Find each name's row by what it painted (the group comparator owns the order, not this test).
let row_of = |s: &mut UiScript, needle: &str| -> i64 {
s.eval::<i64>(&format!(
"for i = 1, 11 do local b = getglobal('BenillaTrainerService' .. i) \
local t = b.name:GetText() if t and strfind(t, '{needle}', 1, 1) then return i end \
end return 0"
))
.unwrap()
};
let long_row = row_of(&mut s, "Handstitched");
let short_row = row_of(&mut s, "Belt");
assert!(long_row > 0 && short_row > 0, "both services painted");
let geom = |s: &mut UiScript, row: i64| -> (f32, f32, f32, f32) {
s.eval::<(f32, f32, f32, f32)>(&format!(
"local b = getglobal('BenillaTrainerService{row}') \
return b.name:GetHeight(), b.name:GetRight(), b.subtext:GetLeft(), b:GetHeight()"
))
.unwrap()
};
let (long_h, long_name_right, long_sub_left, row_h) = geom(&mut s, long_row);
let (short_h, short_name_right, short_sub_left, _) = geom(&mut s, short_row);
assert!(
long_h <= row_h + 0.5,
"the long name is one line inside its own row: name {long_h} px, row {row_h} px"
);
assert!((long_h - short_h).abs() < 0.5, "and so is the short one");
for (name, right, left) in [
("long", long_name_right, long_sub_left),
("short", short_name_right, short_sub_left),
] {
assert!(
(left - right - 10.0).abs() < 0.5,
"the {name} row's rank sits 10 px past its NAME's right edge (the reference's own \
offset), not at a fixed column: name right {right}, subtext left {left}"
);
}
assert!(
long_sub_left > short_sub_left + 50.0,
"so a longer name pushes its rank along instead of running under it: {long_sub_left} vs \
{short_sub_left}"
);
}
/// **B256 — "filter to Available, learn a spell, the filter comes back partly reset."** The window
/// is driven here through the real [`crate::ui_trainer::TrainerOpen`], so the packet→reset decision
/// under test is the app's own and not this test's: the closure below is the trainer feed's three
/// lines (`if open.fresh_list { reset } ; set_trainer ; fire`), and everything else is the shipped
/// Lua.
///
/// The reference cannot produce this bug because it never gets a list packet with the window open —
/// it repaints a purchase from a client-side state re-derivation (`0x4d7d40`, decision 1128 §4.2).
/// benilla re-asks the server instead, so the reference's per-packet mask reset (`0x4d75d9`) was
/// riding in on a packet the reference never sends, and taking the player's choice — and their
/// collapsed groups, which 1128 recorded as "a collapse does not survive a purchase" — with it.
#[test]
fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
use crate::ui_trainer::TrainerOpen;
const DAZALAR: u64 = 0xabc;
let mut s = trainer_script();
// The reference's own file-scope defaults (1128), then the player's choice below.
s.run(
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0 \
BenillaTrainerFrame_ApplyFilter()",
)
.unwrap();
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_filter(open.trainer_type);
open.fresh_list = false;
}
s.set_trainer(Some(state));
s.fire_event(event, vec![ScriptValue::Str("Dazalar".into())]);
}
let filter_on = |s: &mut UiScript, kind: &str| {
s.eval::<bool>(&format!(
"return GetTrainerServiceTypeFilter('{kind}') == 1"
))
.unwrap()
};
let rows = |s: &mut UiScript| s.eval::<i64>("return GetNumTrainerServices()").unwrap();
// He opens the trainer and filters the list down to what he can actually learn.
let mut open = TrainerOpen::default();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, menu(), "TRAINER_SHOW");
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(
!filter_on(&mut s, "unavailable"),
"his choice reached the engine"
);
assert_eq!(
rows(&mut s),
4,
"two headers over the two learnable services"
);
// …and folds the first group away while he is at it.
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(rows(&mut s), 3, "the folded group keeps its header only");
// He trains. The app re-asks for the list (`trainer_buy_succeeded`) and marks its own answer as
// the repaint it is; the bought service comes back gray.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!filter_on(&mut s, "unavailable"),
"learning a spell is a repaint, not a new window: his filter stands"
);
assert_eq!(
rows(&mut s),
2,
"and the list is still his — the folded Arms group is empty of learnables now, so it goes \
with its header, leaving Fury over Thunder Clap"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(
!s.eval::<bool>("return DropDownList1Button2Check:IsVisible() and true or false")
.unwrap(),
"the dropdown agrees with the list, instead of claiming a filter the list ignores"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
// He walks away and comes back: THIS packet opens a window, so the reference's reset lands —
// and the window's own OnShow/TRAINER_SHOW puts the saved globals straight back over it. The
// filter survives (it is saved), the collapse does not (it is engine-side, and the reset clears
// it) — the reference's own split.
open.clear();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
feed(&mut s, &mut open, learned, "TRAINER_SHOW");
assert!(
!filter_on(&mut s, "unavailable"),
"the saved filter is pushed back over the packet reset"
);
assert!(
filter_on(&mut s, "available"),
"and the states he never turned off are still on"
);
assert_eq!(
rows(&mut s),
2,
"nothing collapsed now — but still filtered"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+51
View File
@@ -621,6 +621,11 @@ pub(crate) struct UiFontAtlas {
/// Shared (never copied) with the script VM's synchronous measurer, so a string measured
/// mid-tick and the same string measured at extract are the same number by construction.
pub(crate) metrics: Arc<TextMetrics>,
/// Per-region ellipsis display strings ([`super::EllipsisMemo`]) — the client's own
/// `CGxString+0xf8` cache, keyed by the inputs instead of a dirty flag. It lives here because
/// a re-bake replaces this whole resource, which is exactly when every answer in it is stale:
/// the step table it was computed against is gone.
pub(super) ellipsis: super::EllipsisMemo,
}
impl UiFontAtlas {
@@ -972,6 +977,7 @@ fn load_fonts_and_build_atlas(
sizes_by_family,
scale,
}),
ellipsis: super::EllipsisMemo::default(),
});
}
@@ -1408,3 +1414,48 @@ mod metrics_tests {
w
}
}
/// Real-font [`TextMetrics`] for a test or bench: the named client faces, read through the app's
/// own patch chain and baked at the given logical sizes. `None` when there is no install, or the
/// chain or a face will not open — every caller **skips** rather than fails, the same posture
/// [`metrics_tests`]'s install-backed tests take (`wow_data_or_skip!`).
///
/// The first face is the default family. Only the metric table matters to the callers (the wrap +
/// step law); the glyph cells are built because [`build_char_steps`] reads their baked advances.
#[cfg(test)]
pub(super) fn test_metrics(faces: &[(&str, &[f32])], scale: f32) -> Option<TextMetrics> {
let data = benilla_formats::wow_data()?;
let chain = benilla_formats::open_chain(&data).ok()?;
let charset = default_charset();
let mut font_system = FontSystem::new_with_fonts(std::iter::empty());
let mut swash = SwashCache::new();
let mut built: Vec<(fontdb::ID, String, Vec<f32>)> = Vec::new();
let mut path_to_family = HashMap::new();
let mut sizes_by_family = HashMap::new();
for (path, sizes) in faces {
let bytes = chain.read(path).ok()?;
let (id, family) = register_font(&mut font_system, bytes).ok()?;
path_to_family.insert(path.to_ascii_lowercase(), family.clone());
sizes_by_family.insert(family.clone(), sizes.to_vec());
built.push((id, family, sizes.to_vec()));
}
let (_, default_family, sizes) = built.first()?.clone();
let atlas = build_atlas(
&mut font_system,
&mut swash,
&built,
&charset,
1024,
scale,
&HashMap::new(),
);
Some(TextMetrics {
steps: build_char_steps(&mut font_system, &atlas.glyphs, &built, &charset, scale),
path_to_family,
default_family,
sizes,
sizes_by_family,
scale,
})
}
+196 -10
View File
@@ -482,16 +482,45 @@ fn wrapped_rows(
step_extra: f32,
text: &str,
wrap_width: f32,
) -> usize {
wrapped_rows_capped(
metrics,
family,
font_size,
step_extra,
text,
wrap_width,
usize::MAX,
)
}
/// [`wrapped_rows`] that stops once `cap` rows exist — the client's own fit walk is bounded by the
/// BOX, not by the string: `GxuFont_GetMaxCharsWithinHeight` (`0x5c21c0`) lays out `min(needed,
/// fits + 1)` lines and abandons the rest, because the height test sits downstream of the per-line
/// kernel call (wow-re `system/ui/scratch/fontstring-ellipsis-cost.md`). A caller that only needs
/// to know whether the text OVERFLOWS passes `allowed + 1` and never pays for the tail; the chat
/// band's row count, which is a real number, passes [`usize::MAX`].
fn wrapped_rows_capped(
metrics: &TextMetrics,
family: &str,
font_size: f32,
step_extra: f32,
text: &str,
wrap_width: f32,
cap: usize,
) -> usize {
let lines = parse_markup(text, [1.0, 1.0, 1.0, 1.0]);
if wrap_width > WRAP_MIN_WIDTH {
lines
.iter()
.map(|line| wrap_line(metrics, family, font_size, step_extra, line, wrap_width).len())
.sum()
} else {
lines.len()
if wrap_width <= WRAP_MIN_WIDTH {
return lines.len().min(cap);
}
let mut rows = 0usize;
for line in &lines {
rows += wrap_line(metrics, family, font_size, step_extra, line, wrap_width).len();
if rows >= cap {
return cap;
}
}
rows
}
/// The FontString display string under the height-gated ellipsis-truncate — `CSimpleFontString
@@ -507,6 +536,7 @@ fn wrapped_rows(
/// C++ counterparts never call `0x771ec0`.
pub(crate) fn ellipsize_to_fit(
atlas: &mut UiFontAtlas,
region: benilla_ui::widget::RegionHandle,
text: &str,
rect: Rect,
font: FontSpec,
@@ -531,10 +561,26 @@ pub(crate) fn ellipsize_to_fit(
// drawn stack agree line for line.
// The line-count law lives in `overflow` (it is the FIT law here, not the render stack's) —
// see `overflow::ellipsize_in_box`.
// The remembered answer, under exactly these inputs ([`super::EllipsisMemo`] — the client's
// `CGxString+0xf8`). The paint pass runs every frame; this seam is what the client rebuilds
// only on invalidation, and it is the most expensive thing in the pass by an order of
// magnitude (B240, decision 1332).
if let Some(hit) = atlas.ellipsis.get(region, text, box_w, box_h, &font) {
return hit.clone();
}
// The fit test only has to decide OVERFLOW, so it stops one row past the box — the client's
// own bound ([`wrapped_rows_capped`]).
let cap = overflow::lines_fitting(box_h.max(font_size), font_size) + 1;
let metrics = &atlas.metrics;
overflow::ellipsize_in_box(text, box_h, font_size, |candidate| {
wrapped_rows(metrics, &family, font_size, step_extra, candidate, box_w)
})
let display = overflow::ellipsize_in_box(text, box_h, font_size, |candidate| {
wrapped_rows_capped(
metrics, &family, font_size, step_extra, candidate, box_w, cap,
)
});
atlas
.ellipsis
.put(region, text, box_w, box_h, &font, display.clone());
display
}
/// The drawn line's origin for the EditBox text-UI overlays: the line's x0 under `justifyH`
@@ -1179,3 +1225,143 @@ mod row_start_tests {
assert_eq!(starts("xy", &runs(&["xy"]), 3), vec![3]);
}
}
/// **B240's performance half, measured.** A world book halves the framerate while its reader is
/// open (Goudy, 2026-08-09: *"page text with html text absolutely annihilates performance"* —
/// 62 → 36 fps, recovering the moment the window closes).
///
/// The seam this measures: the page body draws in a **height-pinned** FontString (270×304, the
/// rect the reference gives its SimpleHTML), and a body that overflows it enters
/// [`ellipsize_to_fit`] — a back-off that strips one character at a time from the END of the
/// string, re-testing the whole candidate at every step. That shape is the real client's, byte for
/// byte (wow-re `system/ui/scratch/fontstring-ellipsis-cost.md`, §5 trio: the loop seeds from
/// `strlen`, the `fitCount` it already has is discarded, and it steps one UTF-8 code point per
/// iteration). What the client does *not* do is run it per frame, or re-lay the whole string on
/// each step — the two divergences decision 1332 closes.
///
/// The HTML body is only the loudest case, not the boundary: the seam is armed by OVERFLOW, and
/// vmangos ships 1257 page bodies of which the longest are **plain prose** — measured here beside
/// the reported one, and four times worse.
///
/// Run with `cargo test --release -p benilla-app -- --ignored --nocapture ellipsis_cost`. Skips
/// without an install.
#[cfg(test)]
mod ellipsis_cost {
use super::*;
use std::cell::Cell;
use std::time::Instant;
/// The reported page verbatim — vmangos `page_text` 2676, the *Alliance Military Ranks* plaque
/// in Stormwind's Old Town (`GameObject` 3011, the object in Goudy's screenshots). 647 bytes.
const PAGE: &str = concat!(
"<HTML>\n",
"<BODY>\n",
"<H1 align=\"center\">ALLIANCE MILITARY RANKS</H1><BR/>\n",
"<P align=\"center\">OFFICERS</P><BR/>\n",
"<P align=\"center\">Grand Marshal</P>\n",
"<P align=\"center\">Field Marshal</P>\n",
"<P align=\"center\">Marshal</P>\n",
"<P align=\"center\">Commander</P>\n",
"<P align=\"center\">Lieutenant Commander</P>\n",
"<P align=\"center\">Knight-Champion</P>\n",
"<P align=\"center\">Knight-Captain</P>\n",
"<P align=\"center\">Knight-Lieutenant</P>\n",
"<P align=\"center\">Knight</P><BR/>\n",
"<P align=\"center\">ENLISTED</P><BR/>\n",
"<P align=\"center\">Sergeant Major</P>\n",
"<P align=\"center\">Master Sergeant</P>\n",
"<P align=\"center\">Sergeant</P>\n",
"<P align=\"center\">Corporal</P>\n",
"<P align=\"center\">Private</P>\n",
"</BODY>\n",
"</HTML>",
);
/// The longest body vmangos actually ships (`page_text` 2880, a Hearthglen letter, 928 bytes)
/// — **plain prose, no markup at all**. It is here because the report's framing ("html text")
/// names the loudest case, not the boundary: the seam is armed by OVERFLOW, and the longest
/// pages in the world are plain. `$b` arrives expanded (`npc_text::substitute`, the feed).
const PLAIN: &str = concat!(
"Reuben,\n\nI write this letter knowing you may never see it; I simply can't remain idle, ",
"listening to the constant pounding against the Hearthglen walls. The undead are outside ",
"our village, unceasing in their assault, and we have been charged with defending the ",
"townsfolk until reinforcements arrive.\n\nMy leg was broken in the last charge, and so I ",
"sit, useless, with my sword at my side should there be a breach in our defenses. There is ",
"no idle banter... only the sounds of fighting and death. The air is thick with fear.\n\n",
"Prince Arthas is here, fighting on the front lines with the men. Were he not present we ",
"would have fallen long ago. His love for this land and its people is infectious; I gladly ",
"serve under him, and will to the end of my days.\n\nThe fighting grows more intense; ",
"broken leg or not, I cannot sit here. Every sword is needed. I hope these words find you ",
"in happier times.\n\nYour friend,\nLeagrem\n\n",
);
/// The reader's own wrapper — `ItemTextFrame.xml`'s READY handler frames an authorless page
/// with a leading and a trailing newline.
/// The reader's own wrapper — `ItemTextFrame.xml`'s READY handler frames an authorless page
/// with a leading and a trailing newline.
fn page_body(page: &str) -> String {
format!("\n{page}\n")
}
/// `ItemTextPageText`'s geometry and face: 270×304 at `ItemTextFontNormal`
/// (`Fonts\MORPHEUS.TTF`, height 15). At the 768-tall design window the seam scale is 1, so
/// these are also the drawn px; a taller window scales box and pitch together, which leaves
/// the line counts — and therefore this whole measurement — unchanged.
const FACE: &str = "Fonts\\MORPHEUS.TTF";
const SIZE: f32 = 15.0;
const BOX_W: f32 = 270.0;
const BOX_H: f32 = 304.0;
#[test]
#[ignore = "bakes a real font atlas from the install; run explicitly"]
fn a_page_that_overflows_its_box_costs_a_whole_back_off() {
let Some(metrics) = super::super::atlas::test_metrics(&[(FACE, &[SIZE])], 1.0) else {
eprintln!("skipping: no install / font chain");
return;
};
let family = metrics.family_for(Some(FACE));
let font_size = metrics.snap_for(&family, SIZE);
let step_extra = step_extra_of(Outline::None);
let fits = overflow::lines_fitting(BOX_H, font_size);
for (label, page) in [("html 2676", PAGE), ("plain 2880", PLAIN)] {
let text = page_body(page);
let full = wrapped_rows(&metrics, &family, font_size, step_extra, &text, BOX_W);
eprintln!(
"[ellipsis-cost] {label:<11} {:>4} chars -> {full:>2} rows in a {fits}-row box",
text.chars().count(),
);
// Uncapped (what shipped) against the client's box-bounded fit walk. Both must return
// the same display string — the cap decides overflow, it does not decide the answer.
let mut out = Vec::new();
for (how, cap) in [("whole string", usize::MAX), ("box-bounded", fits + 1)] {
let probes = Cell::new(0usize);
let chars = Cell::new(0usize);
let t = Instant::now();
let got = overflow::ellipsize_in_box(&text, BOX_H, font_size, |candidate| {
probes.set(probes.get() + 1);
chars.set(chars.get() + candidate.chars().count());
wrapped_rows_capped(
&metrics, &family, font_size, step_extra, candidate, BOX_W, cap,
)
});
let us = t.elapsed().as_secs_f64() * 1e6;
eprintln!(
"[ellipsis-cost] {how:<13} {:>4} probes, {:>6} candidate chars, {us:>7.0} us",
probes.get(),
chars.get(),
);
assert!(
got.is_some(),
"{label} overflows its box — the seam is armed"
);
out.push(got);
}
assert_eq!(
out[0], out[1],
"{label}: the row cap changed the display string"
);
}
}
}
+293
View File
@@ -48,6 +48,9 @@ mod markup;
mod measurer;
pub(crate) use atlas::{UiFontAtlas, UiTextPlugin};
use benilla_ui::script::Outline;
use benilla_ui::widget::RegionHandle;
pub(crate) use measurer::{measure_request, AtlasMeasurer};
/// The width law itself, for the differential test that pins it to the pre-table shaped sum
@@ -139,3 +142,293 @@ mod cap_tests {
// cosmic-text's convention, not the client's, and read as "line height too large" against the
// reference. Cosmic buffers get `size` as their `Metrics` line height too — every run shapes
// single-line (`Wrap::None`), so that value never affects layout.
/// The FontString **display string** — the ellipsis seam's answer, remembered per region.
///
/// The real client does not recompute this per frame, and the reason it can afford the back-off's
/// shape is that it almost never runs (wow-re `system/ui/scratch/fontstring-ellipsis-cost.md`, §5
/// trio + byte arbitration): `0x771ec0`'s result is built into the `CGxString` at `+0xf8` and
/// rebuilt only when the rebuild guard `[fontstring+0x60] & 1` is cleared — by `SetFont`
/// (`0x7715e0`), `SetTextHeight` (`0x771666`), `SetText` (`0x771ea4`), or a resolved-rect **size**
/// change (`0x768d20`, which fires nothing when all four edges move less than `1e-5`). Nothing on
/// the draw path can reach it at all: the note's dword scan of every PE section finds `0x771ec0`
/// and `0x7724a0` in no vtable, table or pointer cell, so the complete caller set is their direct
/// `call` sites — none of them per-frame. **A pure move, a same-text `SetText`, and any no-op
/// setter cost zero.**
///
/// 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
/// 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).
///
/// This is the same invalidation set, expressed as a comparison of the inputs rather than a dirty
/// flag — which cannot go stale by construction, because the answer is a pure function of exactly
/// what is compared. **Position is deliberately not compared** (the client's guard is a size
/// change; a window dragged across the screen re-uses its string), and sizes compare at the
/// client's own `1e-5`, so anchor-graph dust cannot evict an entry every frame.
#[derive(Default)]
pub(crate) struct EllipsisMemo {
entries: std::collections::HashMap<RegionHandle, Remembered>,
}
/// What a region's remembered answer depends on — every input [`layout::ellipsize_to_fit`] reads.
struct Remembered {
text: String,
box_w: f32,
box_h: f32,
font: Option<String>,
height: Option<f32>,
outline: Outline,
/// The answer itself. `None` = "this text fits, draw it raw" — a real answer worth
/// remembering, and the common case for every FontString that is merely *near* its box.
display: Option<String>,
}
/// The client's own rect-change tolerance (`0x768d20`: a resolve whose four edges all move less
/// than this fires no rebuild).
const RECT_EPS: f32 = 1e-5;
/// Entries past which the map is dropped wholesale. One entry per region that has ever passed the
/// seam's geometric gate, so the live set is small (the overflow-capable FontStrings on screen);
/// the cap only catches handle churn — a `/reload` builds a whole new frame tree, and the old
/// regions' handles never come back.
const MEMO_CAP: usize = 4096;
impl EllipsisMemo {
/// The remembered display string for `region` under these exact inputs, or `None` if this
/// region has no entry or any input moved.
fn get(
&self,
region: RegionHandle,
text: &str,
box_w: f32,
box_h: f32,
font: &FontSpec,
) -> Option<&Option<String>> {
let e = self.entries.get(&region)?;
(e.text == text
&& (e.box_w - box_w).abs() < RECT_EPS
&& (e.box_h - box_h).abs() < RECT_EPS
&& e.font.as_deref() == font.path
&& e.height == font.height
&& e.outline == font.outline)
.then_some(&e.display)
}
/// Remember `display` as `region`'s answer under these inputs.
fn put(
&mut self,
region: RegionHandle,
text: &str,
box_w: f32,
box_h: f32,
font: &FontSpec,
display: Option<String>,
) {
if self.entries.len() >= MEMO_CAP {
self.entries.clear();
}
self.entries.insert(
region,
Remembered {
text: text.to_string(),
box_w,
box_h,
font: font.path.map(str::to_string),
height: font.height,
outline: font.outline,
display,
},
);
}
}
/// [`EllipsisMemo`]'s invalidation set — the client's own (`fontstring-ellipsis-cost.md` §6:
/// `SetFont`, `SetTextHeight`, `SetText`, and a resolved-rect SIZE change past `1e-5`), expressed
/// as an input comparison. A memo that misses is merely slow; one that HITS when an input moved
/// draws a stale string, so each input gets its own test.
#[cfg(test)]
mod ellipsis_memo_tests {
use super::*;
use benilla_ui::widget::RegionHandle;
fn spec(
path: Option<&'static str>,
height: Option<f32>,
outline: Outline,
) -> FontSpec<'static> {
FontSpec {
path,
height,
outline,
paint_halo: true,
alpha_gradient: None,
}
}
fn base() -> FontSpec<'static> {
spec(Some("Fonts\\MORPHEUS.TTF"), Some(15.0), Outline::None)
}
/// Real `RegionHandle`s — the engine owns their construction, so the honest way to get some
/// is to build regions and read their targets off `extract()`. Returns them in draw order.
fn regions(n: usize) -> Vec<RegionHandle> {
use benilla_ui::order::ZTarget;
let mut script = benilla_ui::script::UiScript::new().expect("a VM");
script.set_screen_size(1024.0, 768.0);
script
.run(
r#"
local f = CreateFrame("Frame", "MemoHost")
f:SetPoint("TOPLEFT", 0, 0)
f:SetSize(100, 100)
for i = 1, 8 do
local t = f:CreateTexture(nil, "ARTWORK")
t:SetTexture(1, 0, 0)
t:SetAllPoints()
end
"#,
)
.expect("the fixture loads");
script.resolve();
let out: Vec<RegionHandle> = script
.extract()
.into_iter()
.filter_map(|q| match q.target {
ZTarget::Region(r) => Some(r),
ZTarget::Frame(_) => None,
})
.collect();
assert!(out.len() >= n, "the fixture makes enough regions");
out.into_iter().take(n).collect()
}
/// A stored answer comes back under identical inputs — including `None`, which is a real
/// answer ("this fits, draw it raw") and the common case for text merely near its box.
#[test]
fn identical_inputs_hit() {
let mut m = EllipsisMemo::default();
let r = regions(1)[0];
m.put(
r,
"a long page body",
270.0,
304.0,
&base(),
Some("a lo...".into()),
);
assert_eq!(
m.get(r, "a long page body", 270.0, 304.0, &base()),
Some(&Some("a lo...".into()))
);
m.put(r, "short", 270.0, 304.0, &base(), None);
assert_eq!(m.get(r, "short", 270.0, 304.0, &base()), Some(&None));
}
/// Every input that changes the answer evicts. `SetText`, `SetFont`, `SetTextHeight`, and a
/// box that resized — the client's four.
#[test]
fn a_changed_input_misses() {
let mut m = EllipsisMemo::default();
let r = regions(1)[0];
m.put(r, "body", 270.0, 304.0, &base(), Some("bo...".into()));
assert!(
m.get(r, "other", 270.0, 304.0, &base()).is_none(),
"SetText"
);
assert!(
m.get(r, "body", 271.0, 304.0, &base()).is_none(),
"wider box"
);
assert!(
m.get(r, "body", 270.0, 305.0, &base()).is_none(),
"taller box"
);
assert!(
m.get(
r,
"body",
270.0,
304.0,
&spec(Some("Fonts\\FRIZQT__.TTF"), Some(15.0), Outline::None)
)
.is_none(),
"SetFont"
);
assert!(
m.get(
r,
"body",
270.0,
304.0,
&spec(Some("Fonts\\MORPHEUS.TTF"), Some(16.0), Outline::None)
)
.is_none(),
"SetTextHeight"
);
assert!(
m.get(
r,
"body",
270.0,
304.0,
&spec(Some("Fonts\\MORPHEUS.TTF"), Some(15.0), Outline::Thick)
)
.is_none(),
"the outline biases the step law, so it changes where the wrap breaks"
);
}
/// Anchor-graph dust must not evict every frame — the client's guard is `1e-5` and so is this.
/// A box that really resized still does.
#[test]
fn sub_epsilon_rect_dust_still_hits() {
let mut m = EllipsisMemo::default();
let r = regions(1)[0];
m.put(r, "body", 270.0, 304.0, &base(), Some("bo...".into()));
assert!(m
.get(r, "body", 270.0 + 1e-7, 304.0 - 1e-7, &base())
.is_some());
assert!(m.get(r, "body", 270.001, 304.0, &base()).is_none());
}
/// One entry per region — two regions holding the same text keep their own answers, and a
/// region with no entry misses.
#[test]
fn entries_are_per_region() {
let mut m = EllipsisMemo::default();
let r = regions(3);
m.put(r[0], "body", 270.0, 304.0, &base(), Some("one...".into()));
m.put(r[1], "body", 100.0, 40.0, &base(), Some("two...".into()));
assert_eq!(
m.get(r[0], "body", 270.0, 304.0, &base()),
Some(&Some("one...".into()))
);
assert_eq!(
m.get(r[1], "body", 100.0, 40.0, &base()),
Some(&Some("two...".into()))
);
assert!(m.get(r[2], "body", 270.0, 304.0, &base()).is_none());
}
/// The handle-churn backstop: the map is dropped wholesale rather than grown without bound.
#[test]
fn the_map_is_capped() {
let mut m = EllipsisMemo::default();
// Distinct keys are what the cap counts, and the engine hands out only a few per fixture —
// so the same handles are re-put under distinct TEXTS, which is the churn shape anyway.
let r = regions(8);
for i in 0..=MEMO_CAP {
m.put(
r[i % r.len()],
&format!("body {i}"),
270.0,
304.0,
&base(),
None,
);
}
assert!(m.entries.len() <= MEMO_CAP);
}
}
+23 -5
View File
@@ -66,11 +66,24 @@ pub(crate) struct TrainerOpen {
pub(crate) trainer_type: u32,
/// The trainer's greeting line (`SMSG_TRAINER_LIST`'s trailing string).
pub(crate) greeting: String,
/// A `SMSG_TRAINER_LIST` has landed and the feed has not yet handed it to the engine. Drives the
/// engine's **per-packet** filter/collapse reset ([`UiScript::reset_trainer_filter`]) — the
/// reference's builder rewrites both masks on every list packet, and only on a packet (decision
/// 1128). It is not the same edge as a snapshot change: those re-push the same list.
/// A `SMSG_TRAINER_LIST` that **begins a window session** has landed and the feed has not yet
/// handed it to the engine. Drives the engine's filter/collapse reset
/// ([`UiScript::reset_trainer_filter`]) — the reference's builder rewrites both masks on every
/// list packet (decision 1128). It is not the same edge as a snapshot change: those re-push the
/// same list.
///
/// "Begins a session", not "a packet arrived", because the two are the same thing in the
/// reference and are **not** in benilla (B253/B256's arc): the reference gets a list packet only
/// when a trainer opens — it repaints a purchase from a client-side state re-derivation
/// (`0x4d7d40`, decision 1128 §4.2) — while we ask for a fresh list after every buy. Letting our
/// own refresh carry the reference's per-packet reset made the state filter (and the collapse
/// set) evaporate on learning a spell, which is not something the reference can do. See
/// [`Self::refresh_pending`].
pub(crate) fresh_list: bool,
/// The post-buy re-list **we asked for** ([`crate::net::apply`]'s `trainer_buy_succeeded`) is in
/// flight: the answering packet repaints the open window rather than opening one, so it must not
/// reset the filter/collapse masks. Cleared by the packet it belongs to, and by any close.
pub(crate) refresh_pending: bool,
}
impl TrainerOpen {
@@ -82,11 +95,14 @@ impl TrainerOpen {
services: Vec<TrainerSpell>,
greeting: String,
) {
// A repaint of the trainer already on screen, asked for by our own post-buy re-request, is
// not a new window session — so it carries no mask reset (the field's own doc says why).
let refresh = self.trainer == Some(trainer) && std::mem::take(&mut self.refresh_pending);
self.trainer = Some(trainer);
self.trainer_type = trainer_type;
self.services = services;
self.greeting = greeting;
self.fresh_list = true;
self.fresh_list = !refresh;
}
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
@@ -96,6 +112,8 @@ impl TrainerOpen {
self.trainer_type = 0;
self.greeting.clear();
self.fresh_list = false;
// An in-flight refresh dies with the window: the next list is a real open, and must reset.
self.refresh_pending = false;
}
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
@@ -676,3 +676,56 @@ fn tooltip_falls_back_to_the_wire_spell() {
},
);
}
/// **B256 — the state filter died on every purchase.** The reset the reference's list builder does
/// per packet (`0x4d75d9`, decision 1128) is only ever observable there at a window *opening*: the
/// reference never receives a list packet with the window already up, because it repaints a purchase
/// from a client-side state re-derivation (`0x4d7d40`). benilla has no such re-derivation and asks
/// the server for a fresh list after every buy — so our synthetic packet was dragging a reset along
/// that the reference cannot produce, and the player's "Available only" came back as
/// available+unavailable the moment they learned a spell (their collapsed groups went with it).
///
/// So [`TrainerOpen::fresh_list`] means *this packet begins a window session*, and the post-buy
/// re-request marks its own answer as the repaint it is. The four cases below are the whole law.
#[test]
fn only_a_packet_that_opens_a_window_resets_the_filter() {
const TRAINER: u64 = 0xabc;
let list = || vec![wire(1, trainer_spell_state::GREEN, 10, 1, 0)];
let mut open = TrainerOpen::default();
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(open.fresh_list, "a first list opens the window: reset");
open.fresh_list = false; // the feed consumes it
// The post-buy re-request (`net::apply::npc::trainer_buy_succeeded`) marks its own answer.
open.refresh_pending = true;
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(
!open.fresh_list,
"our own re-list repaints the open window — the player's filter and collapse stand"
);
assert!(
!open.refresh_pending,
"and the mark is spent, so the NEXT packet is a real open again"
);
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(
open.fresh_list,
"an unmarked packet is always a session start"
);
// A refresh that never came back must not eat a later open's reset: the close spends it.
open.refresh_pending = true;
open.clear();
open.open(TRAINER, 0, list(), "Greetings".into());
assert!(open.fresh_list, "a close drops any in-flight refresh mark");
// Nor may a mark meant for one trainer be spent by another's list.
open.refresh_pending = true;
open.open(TRAINER + 1, 0, list(), "Greetings".into());
assert!(
open.fresh_list,
"a different trainer is a new window whatever is in flight"
);
}
+12
View File
@@ -774,6 +774,10 @@ fn feed_units(
reputations: Res<Reputations>,
group: Res<crate::ui_party::GroupState>,
mut chat: ResMut<ChatLog>,
// The guild-identity cache `GetGuildInfo(unit)` reads — `ResMut` because it is a LAZY cache
// (decision 1257): a lookup that misses is what sends the `CMSG_GUILD_QUERY`, exactly as a
// `NameCache::resolve` miss sends the name query above.
mut guild: ResMut<crate::ui_guild::GuildState>,
) {
let Some(mut script) = script else {
return;
@@ -791,6 +795,11 @@ fn feed_units(
s.guid = guid.0;
s.raid_target = group.raid_target_index(guid.0);
s.faction_group = faction_group(store, factions.as_deref());
// `GetGuildInfo("player")` — the unit's own PUBLIC descriptor fields (191/192) joined
// against the app's guild-identity cache, which the miss also asks for (decision 1257).
// Filled here rather than in `snapshot` for the reason `faction_group` and `can_attack`
// are: it needs a resource, and `snapshot`'s other six call sites hold none.
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
s
});
// The GM-mode confound, made audible (decision 0657). A self player whose faction template
@@ -841,6 +850,9 @@ fn feed_units(
&reputations,
self_pair.map(|(s, _)| s),
);
// `GetGuildInfo("target")` — see the player leg. PLAYER_GUILDID/RANK are PUBLIC, which is
// the whole reason the binding is per-unit rather than per-player.
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
enrich_unit(
&mut s,
guid,
+67 -2
View File
@@ -16,9 +16,13 @@ use benilla_ui::script::{
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The talent window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
const FILES: [&str; 5] = [
/// The talent window's load prefix — the app's own order (`assets/ui/benilla.toc`), members only.
/// `ItemButtonTemplate.xml` is the `SetItemButton*` family the talent buttons grey through (the
/// reference's own verb; see TalentFrame.xml's header) — it sits at .toc line 32, above every
/// other entry here bar `Fonts.xml`.
const FILES: [&str; 6] = [
"Fonts.xml",
"ItemButtonTemplate.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
@@ -236,6 +240,67 @@ fn a_locked_tier_still_draws_the_branch_gray() {
.expect("gray branch");
}
/// **B162 — an unavailable talent goes GREYSCALE, not merely dim.**
///
/// The reporter's own A/B: 1.12.1 at 0 talent points draws every unlearned icon in black and
/// white; benilla drew them in full colour. The Lua was never wrong — it asked for the grey-out
/// and the ask reached the region — but the engine had no `Texture:SetDesaturated`, so
/// `SetItemButtonDesaturated`'s no-shader arm was the only one available and "greyed" meant a
/// `SetVertexColor(0.65)` brightness multiply. On colourful art that reads as a slightly dimmer
/// colourful icon, which is exactly what was reported.
///
/// So the assertion is on the DESATURATION FLAG reaching the renderer, not on the tint: the tint
/// was always there and was never the thing that was missing. The fixture is the reporter's state
/// — points spent, none left — and Deep Wounds' tier is locked besides.
///
/// The control is the same extract's learned talent: full colour, no flag. A regression that
/// greys the whole tree passes the first assertion and fails this one.
#[test]
fn an_unavailable_talent_reaches_the_renderer_desaturated() {
let mut script = open_window(2);
script.resolve();
let icon = |quads: &[benilla_ui::script::ExtractedQuad], leaf: &str| -> (bool, [f32; 4]) {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
desaturated,
..
} if p.ends_with(leaf) => Some((*desaturated, color.unwrap_or([1.0; 4]))),
_ => None,
})
.unwrap_or_else(|| panic!("no {leaf} icon quad in the extract"))
};
let quads = script.extract();
// Deep Wounds: tier 3 with 2 points spent — locked, and the player has 2 points to spend, so
// this is the tier gate alone, not the no-points force-desaturate.
let (grey, tint) = icon(&quads, "Ability_BackStab");
assert!(
grey,
"an unavailable talent's icon must carry the greyscale flag — the whole of B162"
);
// The reference's own `(1, 0.65, 0.65, 0.65)` still SETS that tint, and on shader-capable
// hardware it has no effect on colour — the desaturated fragment discards the vertex RGB
// entirely (wow-re `texture-desaturate-law.md` §6.2; decision 1330 corrected 1327 here). It is
// pinned anyway because the value must keep reaching the quad: it is what the no-shader arm
// would have drawn with, and its ALPHA is read on both paths.
assert!(
(tint[0] - 0.65).abs() < 1e-3,
"the ref's 0.65 still lands on the quad (inert on RGB, live on alpha), got {tint:?}"
);
// The control: Improved Rend is learned to max, so it is available — full colour, no flag.
let (grey, tint) = icon(&quads, "Ability_Gouge");
assert!(!grey, "a learned talent must NOT be greyed");
assert!(
(tint[0] - 1.0).abs() < 1e-3,
"…and draws at full colour, got {tint:?}"
);
}
#[test]
fn a_wheel_spin_over_the_grid_scrolls_the_tree() {
// The wheel's whole chain, driven from the pointer entry point the app feeds: hit-test on
+35 -1
View File
@@ -12,7 +12,8 @@
use crate::messages::{
ActionButton, AttackerState, ChannelNoticeTail, Character, CreateSpline, DamageShield,
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo,
GuildQueryResponse, GuildRoster, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems,
@@ -337,6 +338,13 @@ pub enum SessionEvent {
/// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`): the AreaTable id the
/// `$z` token names ("Returns you to <area>.").
BindPoint { area: u32 },
/// An innkeeper is asking whether to make this your home (`SMSG_BINDER_CONFIRM`) — the
/// question the `CONFIRM_BINDER` dialog puts on screen. `binder` is the innkeeper's guid, and
/// it must be echoed in `CMSG_BINDER_ACTIVATE` for the bind to happen at all (decision 1331).
BinderConfirm { binder: u64 },
/// The bind took (`SMSG_PLAYERBOUND`): `area` is the AreaTable id we are now bound in, the
/// same one [`Self::BindPoint`] carries in the packet beside it.
PlayerBound { binder: u64, area: u32 },
/// The player's equip proficiencies for one item class (`SMSG_SET_PROFICIENCY`, at login +
/// on train): the subclass bitmask the item tooltip's slot-line red compares against
/// (the client's `0xc4d4a0[class]` store).
@@ -1036,6 +1044,32 @@ pub enum SessionEvent {
FriendStatus(FriendStatusUpdate),
/// A `/who` answer (`SMSG_WHO`) — at most 49 rows, plus the true match total.
WhoResults(WhoResults),
/// One guild's public identity (`SMSG_GUILD_QUERY_RESPONSE`) — name, its ten rank names,
/// tabard. The guild twin of the name query: an ask-once cache fill keyed by guild id, so a
/// consumer holds these by [`GuildQueryResponse::guild_id`] and a roster row or a chat line
/// can name its guild late rather than never.
GuildQueryResponse(GuildQueryResponse),
/// The whole guild (`SMSG_GUILD_ROSTER`) — MOTD, info text, per-rank rights, every member.
/// Always a complete snapshot, never a delta, so it *replaces* whatever is held; the server
/// re-sends it for anything worth showing (a note edit, a rank-rights change, a promotion).
GuildRoster(GuildRoster),
/// One thing that happened in the guild (`SMSG_GUILD_EVENT`) — a
/// [`guild_event`](crate::messages::guild_event) id plus its already-formatted arguments. The
/// arguments are the display text; the optional guid rides only on the sign-on/off pair.
GuildEvent(GuildEventNotice),
/// The server's verdict on a guild verb we sent (`SMSG_GUILD_COMMAND_RESULT`). Carry the
/// command tag through to the display: it is what tells the two meanings of result `0x08`
/// apart (see [`guild_command_error`](crate::messages::guild_command_error)).
GuildCommandResult(GuildCommandResult),
/// Somebody asked us into their guild (`SMSG_GUILD_INVITE`) — the popup. Answered with
/// `CMSG_GUILD_ACCEPT`/`_DECLINE`, neither of which says *which* invitation it means, so the
/// pending invitation is the consumer's own state.
GuildInvite { inviter: String, guild: String },
/// The player we invited turned it down (`SMSG_GUILD_DECLINE`) — delivered to the inviter only.
GuildDecline { name: String },
/// The guild's "founded on / N members / N accounts" summary (`SMSG_GUILD_INFO`) — a separate
/// ask from the roster, sharing no fields with it.
GuildInfo(GuildInfo),
/// The taxi map (`SMSG_SHOWTAXINODES`, decision 0484): `flightmaster` is the NPC the menu
/// opened on, `nearest_node` the node it sits at, `known_mask` the full known-node bitmask
/// ([`TaxiMask::is_known`]). The wire's window-framing constant carries no state and is
@@ -494,6 +494,17 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::IgnoreList { guids } => vec![SessionEvent::IgnoreList { guids }],
ServerPacket::FriendStatus(status) => vec![SessionEvent::FriendStatus(status)],
ServerPacket::WhoResults(results) => vec![SessionEvent::WhoResults(results)],
ServerPacket::GuildQueryResponse(response) => {
vec![SessionEvent::GuildQueryResponse(response)]
}
ServerPacket::GuildRoster(roster) => vec![SessionEvent::GuildRoster(roster)],
ServerPacket::GuildEvent(notice) => vec![SessionEvent::GuildEvent(notice)],
ServerPacket::GuildCommandResult(result) => vec![SessionEvent::GuildCommandResult(result)],
ServerPacket::GuildInvite { inviter, guild } => {
vec![SessionEvent::GuildInvite { inviter, guild }]
}
ServerPacket::GuildDecline { name } => vec![SessionEvent::GuildDecline { name }],
ServerPacket::GuildInfo(info) => vec![SessionEvent::GuildInfo(info)],
ServerPacket::DestroyObject { guid } => vec![SessionEvent::ObjectDestroyed(guid)],
ServerPacket::TriggerCinematic { cinematic_id } => {
vec![SessionEvent::CinematicTriggered { cinematic_id }]
@@ -593,6 +604,10 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::ServerUnixTime { unix_time }]
}
ServerPacket::BindPoint { area, .. } => vec![SessionEvent::BindPoint { area }],
ServerPacket::BinderConfirm { binder } => vec![SessionEvent::BinderConfirm { binder }],
ServerPacket::PlayerBound { binder, area } => {
vec![SessionEvent::PlayerBound { binder, area }]
}
ServerPacket::SetProficiency {
item_class,
subclass_mask,
@@ -0,0 +1,96 @@
//! The innkeeper bind family — the confirm question, its answer, and the "you are bound" echo
//! (opcodes 747, 437, 344; decision 1331).
//!
//! Three packets and one law worth stating plainly, because getting it wrong is silent: **selecting
//! the innkeeper's gossip line does not bind anything.** vmangos's `GOSSIP_OPTION_INNKEEPER` arm
//! (`Player.cpp:12341`) closes the gossip menu and calls `Player::SetBindPoint`, which sends
//! `SMSG_BINDER_CONFIRM` and stops. The bind happens only when the client answers
//! `CMSG_BINDER_ACTIVATE` (`HandleBinderActivateOpcode`, `NPCHandler.cpp:479`), at which point the
//! innkeeper casts spell 3286 "Bind" on the player and `SPELL_EFFECT_BIND`
//! (`SpellEffects.cpp:5837`) writes the homebind and sends `SMSG_BINDPOINTUPDATE` +
//! `SMSG_PLAYERBOUND`. A client that shows the gossip line and never sends the answer therefore
//! looks exactly like a server that ignored the click — which is how B249 read.
//!
//! | opcode | direction | body |
//! |---|---|---|
//! | `SMSG_BINDER_CONFIRM` 0x2eb | in | `u64` binder guid |
//! | `CMSG_BINDER_ACTIVATE` 0x1b5 | out | `u64` binder guid |
//! | `SMSG_PLAYERBOUND` 0x158 | in | `u64` binder guid + `u32` area id |
//!
//! The server bodies are VERIFIED against vmangos `Server/Packets/Npc.{h,cpp}` and
//! `Server/Packets/Misc.{h,cpp}` (`BinderConfirm`, `BinderActivate`, `PlayerBound`). What the
//! *client* puts in `CMSG_BINDER_ACTIVATE` is the guid it stored from the confirm: vmangos reads
//! that guid and uses it (`GetNPCIfCanInteractWith(packet.npcGuid, UNIT_NPC_FLAG_INNKEEPER)`), so
//! unlike the duel echo this one is load-bearing — a zero here is an ignored bind.
use std::io;
use crate::wire::{read_u32_le, read_u64_le};
/// `SMSG_PLAYERBOUND` — the server's echo once the bind has actually taken.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PlayerBound {
/// The NPC that bound us (vmangos sends the *caster* of spell 3286, i.e. the innkeeper).
pub binder: u64,
/// The `AreaTable` id the player is now bound in — the same id `SMSG_BINDPOINTUPDATE` carries
/// in the packet beside it, so the two can never disagree.
pub area: u32,
}
/// Body of `CMSG_BINDER_ACTIVATE`: the binder's full 8-byte guid — the one the client stored from
/// [`read_binder_confirm`]. **Not decorative:** `HandleBinderActivateOpcode` resolves this guid to
/// a live `UNIT_NPC_FLAG_INNKEEPER` creature in interact range and drops the request if it can't,
/// so an empty or stale guid binds nothing and reports nothing.
pub fn binder_activate(binder_guid: u64) -> Vec<u8> {
binder_guid.to_le_bytes().to_vec()
}
/// Read `SMSG_BINDER_CONFIRM` (vmangos `Npc.cpp`, `buffer << binderGuid`): one full 8-byte guid.
///
/// This is the *question*, and it arrives with `SMSG_GOSSIP_COMPLETE` right before it — the gossip
/// window is already closing when the dialog goes up, which is why the confirm is not part of the
/// gossip session's state.
pub(super) fn read_binder_confirm(r: &mut &[u8]) -> io::Result<u64> {
read_u64_le(r)
}
/// Read `SMSG_PLAYERBOUND` (vmangos `Misc.cpp`, `buffer << binderGuid << areaId`).
pub(super) fn read_player_bound(r: &mut &[u8]) -> io::Result<PlayerBound> {
Ok(PlayerBound {
binder: read_u64_le(r)?,
area: read_u32_le(r)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The activate body is the bare guid, little-endian — the shape vmangos's `BinderActivate`
/// reads back.
#[test]
fn the_activate_body_is_one_little_endian_guid() {
assert_eq!(
binder_activate(0x0000_0000_0000_2a01),
vec![0x01, 0x2a, 0, 0, 0, 0, 0, 0]
);
}
/// Confirm in, bound out: both read the guid first, and `SMSG_PLAYERBOUND` carries the area
/// id after it.
#[test]
fn the_two_inbound_bodies_round_trip() {
let guid: u64 = 0xF130_0000_0001_2345;
let mut bytes = guid.to_le_bytes().to_vec();
assert_eq!(read_binder_confirm(&mut &bytes[..]).unwrap(), guid);
bytes.extend_from_slice(&141u32.to_le_bytes());
assert_eq!(
read_player_bound(&mut &bytes[..]).unwrap(),
PlayerBound {
binder: guid,
area: 141
}
);
}
}
@@ -0,0 +1,865 @@
//! The guild family — the roster, the guild query, invitations, rank administration and the event
//! broadcast (opcodes `0x54`/`0x55`, `0x81`–`0x93`, `0x231`–`0x235`, `0x2FC`).
//!
//! The server side is pinned at the bytes by vmangos `Server/Packets/Guild.{h,cpp}` (every
//! `ReadFromWorldPacket` / `AppendBodyTo`), `Guild/Guild.{h,cpp}` (`SendGuildRoster`,
//! `SendQueryResponse`, `BroadcastEvent`, and the enums) and `Handlers/GuildHandler.cpp` (which
//! `NullClientPacket` handlers take an empty body).
//!
//! The client side of `SMSG_GUILD_EVENT` is VERIFIED in wow-re
//! (`system/object-layer/scratch/rf77-smsg-chat-wire-order.md`, RF-0077 corrected): opcode `0x92`
//! registers handler `0x5e7180` (`0x5e337a mov ecx,0x92; call 0x5ab650`), which reads `u8
//! eventType`, `u8 strCount` (≤ 3 — three stack buffers), `strCount` × CString, and then — **only**
//! in the `0xc`/`0xd` arms of its `switch` (jump table `0x5e74dc`) — a trailing `u64` guid via
//! `0x4190b0`. That is the read [`read_guild_event`] implements, and it is *narrower* than what
//! vmangos writes (see that function's doc).
//!
//! The client side of `SMSG_GUILD_ROSTER` and `SMSG_GUILD_QUERY_RESPONSE` is likewise VERIFIED in
//! wow-re (`system/ui/scratch/guild-roster-wire.md`), by a §5 trio deriving each independently.
//! The two do **not** live in the guild-init block the event handler does: `0x8a` is handler
//! `0x4d0ad0` (registered `0x4d0a0e`) in a **`GuildInfo.cpp`** TU, and `0x55` is `0x555290`
//! (registered `0x555073`) in **dbcache**, delegating to decoder `0x62f260` — which wow-re already
//! held bit-exact as `PRIMITIVE:dbcache_guild`. Per-field addresses are on the two readers below.
//!
//! **The parse trap in this family is `SMSG_GUILD_ROSTER`'s `f32 lastOnlineTime`**: it rides on the
//! member's presence byte, so reading it unconditionally desynchronises every *later* member —
//! exactly the shape [`super::social::read_friend_status`] already has, and written the same way
//! (an explicit condition on the field the wire keys off, never a "read if bytes remain").
//!
//! **One reference behaviour we deliberately do not reproduce: capped string reads.** The client's
//! `0x4191b0` takes a per-field capacity (counting the NUL, so the real cap is `cap - 1`) — roster
//! name `0x30`, notes `0x80`, motd `0x200`, info text `0x7d0`, query guild name `0x60`, rank name
//! `0x40`. On overflow it does not truncate gracefully: it slams the cursor to `size + 1`
//! (`0x4192aa`) and NULs `dest[0]` (`0x4192be`), so the field arrives **empty** *and every later
//! read in that packet silently no-ops*. [`read_cstring`] is unbounded, which is strictly safer and
//! cannot poison a packet — and it costs no fidelity, because every one of those caps is far above
//! the content limit the server enforces (a 15-character rank name into a 64-byte buffer, a
//! 128-character MOTD into 512). The caps are recorded here because they explain a class of
//! "the guild pane went blank" behaviour in the reference, not because we want them.
use std::io::{self, Read};
use crate::wire::{read_cstring, read_f32_le, read_u32_le, read_u64_le, read_u8};
/// A guild always has at least this many ranks — vmangos `GUILD_RANKS_MIN_COUNT` (`Guild/Guild.h:31`);
/// the five defaults are GM / Officer / Veteran / Member / Initiate ([`guild_default_rank`]).
pub const GUILD_RANKS_MIN_COUNT: usize = 5;
/// …and at most this many — vmangos `GUILD_RANKS_MAX_COUNT` (`Guild/Guild.h:32`). Load-bearing for
/// the parse, not just a policy number: `SMSG_GUILD_QUERY_RESPONSE` always carries **exactly**
/// this many rank-name cstrings, padding unused ranks with empty strings
/// (`Guild/Guild.cpp:868-872`, whose own comment is "show always 10 ranks").
pub const GUILD_RANKS_MAX_COUNT: usize = 10;
/// Rank-name cap, in *characters* — vmangos `GUILD_RANK_MAX_LENGTH` (`Guild/Guild.h:36`).
///
/// These five caps are the reference UI's edit-box `maxLetters`, and vmangos re-checks the three
/// it can (`utf8length`, not byte length). Over-long is **not** a polite refusal on the rank ones:
/// `HandleGuildRankOpcode` / `HandleGuildAddRankOpcode` (`Handlers/GuildHandler.cpp:580-584`,
/// `:600-604`) run `ProcessAnticheatAction(… CHEAT_ACTION_KICK)`. The builders below do **not**
/// truncate — silently shortening a rank name or a note would send the wrong text, which is its
/// own bug — so the caller enforces the cap at the edit box, as the reference UI does.
pub const GUILD_RANK_MAX_LENGTH: usize = 15;
/// Guild-name cap, in characters — vmangos `GUILD_NAME_MAX_LENGTH` (`Guild/Guild.h:37`).
pub const GUILD_NAME_MAX_LENGTH: usize = 24;
/// Public/officer note cap, in characters — vmangos `GUILD_NOTE_MAX_LENGTH` (`Guild/Guild.h:38`).
pub const GUILD_NOTE_MAX_LENGTH: usize = 31;
/// Guild info text cap, in characters — vmangos `GUILD_INFO_MAX_LENGTH` (`Guild/Guild.h:39`).
pub const GUILD_INFO_MAX_LENGTH: usize = 500;
/// MOTD cap, in characters — vmangos `GUILD_MOTD_MAX_LENGTH` (`Guild/Guild.h:40`).
pub const GUILD_MOTD_MAX_LENGTH: usize = 128;
/// The five ranks every guild is created with and cannot delete — vmangos `GuildDefaultRanks`
/// (`Guild/Guild.h:44-54`). Rank ids are **0-based with 0 = guild master**, and the ordering is the
/// authority hierarchy: promote does `rank--`, demote does `rank++` (vmangos's own comments).
pub mod guild_default_rank {
/// `GR_GUILDMASTER` — rank 0. `HandleGuildRankOpcode` force-overwrites this rank's rights with
/// `GR_RIGHT_ALL` whatever the client sends, "to prevent loss of leader rights".
pub const GUILDMASTER: u32 = 0;
/// `GR_OFFICER`.
pub const OFFICER: u32 = 1;
/// `GR_VETERAN`.
pub const VETERAN: u32 = 2;
/// `GR_MEMBER`.
pub const MEMBER: u32 = 3;
/// `GR_INITIATE` — the lowest default rank.
pub const INITIATE: u32 = 4;
}
/// One rank's rights bitmask — vmangos `GuildRankRights` (`Guild/Guild.h:56-74`), **with
/// `GR_RIGHT_EMPTY` factored out**.
///
/// vmangos writes every named right as `GR_RIGHT_EMPTY | bit` (`GR_RIGHT_GCHATLISTEN = 0x41`,
/// not `0x01`) because its test is `(rights & right) != GR_RIGHT_EMPTY` — the `0x40` is a sentinel
/// that makes that comparison work, not a right. The meaningful bit is the other half, and that is
/// what is named here; [`EMPTY`] is kept so the vmangos constants can be reconstructed and
/// recognised.
pub mod guild_rank_right {
/// `GR_RIGHT_EMPTY` (`0x40`) — the sentinel described in the module doc. Carries no permission.
pub const EMPTY: u32 = 0x0000_0040;
/// Read guild chat (`GR_RIGHT_GCHATLISTEN`, vmangos `0x41`).
pub const GCHAT_LISTEN: u32 = 0x0000_0001;
/// Speak in guild chat (`GR_RIGHT_GCHATSPEAK`, vmangos `0x42`).
pub const GCHAT_SPEAK: u32 = 0x0000_0002;
/// Read officer chat (`GR_RIGHT_OFFCHATLISTEN`, vmangos `0x44`).
pub const OFFCHAT_LISTEN: u32 = 0x0000_0004;
/// Speak in officer chat (`GR_RIGHT_OFFCHATSPEAK`, vmangos `0x48`).
pub const OFFCHAT_SPEAK: u32 = 0x0000_0008;
/// Invite new members (`GR_RIGHT_INVITE`, vmangos `0x50`).
pub const INVITE: u32 = 0x0000_0010;
/// Kick members (`GR_RIGHT_REMOVE`, vmangos `0x60`).
pub const REMOVE: u32 = 0x0000_0020;
/// Promote members (`GR_RIGHT_PROMOTE`, vmangos `0xC0`).
pub const PROMOTE: u32 = 0x0000_0080;
/// Demote members (`GR_RIGHT_DEMOTE`, vmangos `0x140`).
pub const DEMOTE: u32 = 0x0000_0100;
/// Set the message of the day (`GR_RIGHT_SETMOTD`, vmangos `0x1040`).
pub const SET_MOTD: u32 = 0x0000_1000;
/// Edit any member's public note (`GR_RIGHT_EPNOTE`, vmangos `0x2040`).
pub const EDIT_PUBLIC_NOTE: u32 = 0x0000_2000;
/// **See** officer notes at all (`GR_RIGHT_VIEWOFFNOTE`, vmangos `0x4040`). Without it the
/// server blanks every `officerNote` in the roster it sends us
/// (`Guild/Guild.cpp:821`, `:844`) — the empty string is a *permission* signal, not "no note".
pub const VIEW_OFFICER_NOTE: u32 = 0x0000_4000;
/// Edit officer notes (`GR_RIGHT_EOFFNOTE`, vmangos `0x8040`).
pub const EDIT_OFFICER_NOTE: u32 = 0x0000_8000;
/// Edit the guild information text (`GR_RIGHT_MODIFY_GUILD_INFO`, vmangos `0x10040`).
pub const MODIFY_GUILD_INFO: u32 = 0x0001_0000;
/// `GR_RIGHT_ALL` — what the server force-writes onto rank 0 regardless of what we send.
/// Unlike the named rights above, this is a **raw wire value, sentinel and all**: it includes
/// [`EMPTY`], and it lights bits (`0x0000_0E00`, `0x000E_0000`) that no 1.12 right owns. Test
/// a rank against the named bits, never against this.
pub const ALL: u32 = 0x000F_F1FF;
}
/// The thirteen rank-right checkboxes **in the order the FrameXML API indexes them**.
///
/// `GuildControlGetRankFlags()` returns a flat list and `GuildControlSetRankFlag(i, on)` takes an
/// index into it, so **index order is the API contract** — and it is deliberately *not* bit order:
/// Promote/Demote (indices 5/6 here) sit above Invite/Remove (7/8) in bit value. Anything that
/// computes the bit as `1 << (i - 1)` is wrong; this table is the mapping.
pub const GUILD_RANK_RIGHT_ORDER: [u32; 13] = [
guild_rank_right::GCHAT_LISTEN,
guild_rank_right::GCHAT_SPEAK,
guild_rank_right::OFFCHAT_LISTEN,
guild_rank_right::OFFCHAT_SPEAK,
guild_rank_right::PROMOTE,
guild_rank_right::DEMOTE,
guild_rank_right::INVITE,
guild_rank_right::REMOVE,
guild_rank_right::SET_MOTD,
guild_rank_right::EDIT_PUBLIC_NOTE,
guild_rank_right::VIEW_OFFICER_NOTE,
guild_rank_right::EDIT_OFFICER_NOTE,
guild_rank_right::MODIFY_GUILD_INFO,
];
/// A roster member's presence byte — vmangos `GuildRosterPresenceFlags` (`Guild/Guild.h:139-144`).
///
/// `0` (no bit at all) is the only value the wire treats specially: it is what makes the member's
/// `f32 lastOnlineTime` present. The AFK/DND bits ride alongside `ONLINE`, exactly as the friend
/// list's [`super::social::friend_status`] does.
///
/// The reference client tests these as its own chat flags, and **DND is tested first**
/// (`0x4d13e4` before `0x4d13fb`, wow-re `system/ui/scratch/guild-roster-wire.md`) — so a member
/// flagged both AFK and DND displays as DND. That precedence is a display rule, not a wire rule,
/// but it belongs with the bits.
pub mod guild_presence {
/// Offline — and **only** this value carries the `lastOnlineTime` float.
pub const OFFLINE: u8 = 0x00;
/// `GRF_ONLINE`. Value INFERRED from vmangos: the reference client only ever tests the whole
/// byte against zero, so nothing in the binary pins this bit specifically.
pub const ONLINE: u8 = 0x01;
/// `GRF_AFK` — set alongside [`ONLINE`]. VERIFIED as the client's own `CHAT_FLAG_AFK`.
pub const AFK: u8 = 0x02;
/// `GRF_DND` — set alongside [`ONLINE`]. VERIFIED as the client's own `CHAT_FLAG_DND`, and
/// tested ahead of [`AFK`].
pub const DND: u8 = 0x04;
}
/// `SMSG_GUILD_EVENT`'s leading byte — vmangos `GuildEvents` (`Guild/Guild.h:121-137`), which is
/// also the reference client's own `switch` (14 arms, jump table `0x5e74dc`; wow-re RF-0077).
pub mod guild_event {
/// `GE_PROMOTION` — params: promoter, promoted, new rank name.
pub const PROMOTION: u8 = 0x00;
/// `GE_DEMOTION` — params: demoter, demoted, new rank name.
pub const DEMOTION: u8 = 0x01;
/// `GE_MOTD` — param: the new message of the day. The reference client routes this one
/// through the profanity filter (`0x703f50(0x11c,…)`, CVar-gated), not the normal event path.
pub const MOTD: u8 = 0x02;
/// `GE_JOINED` — param: the joiner's name.
pub const JOINED: u8 = 0x03;
/// `GE_LEFT` — param: the leaver's name.
pub const LEFT: u8 = 0x04;
/// `GE_REMOVED` — params: the removed player, then who removed them.
pub const REMOVED: u8 = 0x05;
/// `GE_LEADER_IS` — param: the current leader.
pub const LEADER_IS: u8 = 0x06;
/// `GE_LEADER_CHANGED` — params: old leader, new leader.
pub const LEADER_CHANGED: u8 = 0x07;
/// `GE_DISBANDED` — no params.
pub const DISBANDED: u8 = 0x08;
/// `GE_TABARDCHANGE`. The reference client falls through to its default arm for this one.
pub const TABARD_CHANGE: u8 = 0x09;
/// `GE_UPDATE_RANK_NAME` — params: rank id, new rank name. The reference client writes it
/// straight into the roster cache (`0x560e30`) and fires **no** FrameScript event.
pub const UPDATE_RANK_NAME: u8 = 0x0A;
/// `GE_UPDATE_ROSTER` — "re-ask for the roster". Silent in the reference client; vmangos never
/// sends it (it re-sends the whole `SMSG_GUILD_ROSTER` instead).
pub const UPDATE_ROSTER: u8 = 0x0B;
/// `GE_SIGNED_ON` — param: the guildmate's name. **Carries the trailing guid.**
pub const SIGNED_ON: u8 = 0x0C;
/// `GE_SIGNED_OFF` — param: the guildmate's name. **Carries the trailing guid.**
pub const SIGNED_OFF: u8 = 0x0D;
}
/// `SMSG_GUILD_COMMAND_RESULT`'s leading `u32` — which *verb* the result is about (vmangos
/// `Typecommand`, `Guild/Guild.h:76-85`).
///
/// It is a much coarser tag than the opcode that provoked it: vmangos answers "you're not in a
/// guild" to `CMSG_GUILD_INFO`, `_ROSTER`, `_LEAVE`, `_DISBAND` and half a dozen others all as
/// [`CREATE`], and every permission refusal as [`INVITE`]. So the pair
/// `(command, result)` names the *message*, not the request — vmangos's own comment notes that
/// values 2, 4-13 and 15-18 "have no effect" here.
pub mod guild_command {
/// `GUILD_CREATE_S` — also vmangos's catch-all for "you are not in a guild".
pub const CREATE: u32 = 0x00;
/// `GUILD_INVITE_S` — also vmangos's catch-all for a permission refusal.
pub const INVITE: u32 = 0x01;
/// `GUILD_QUIT_S` — leaving/being removed.
pub const QUIT: u32 = 0x03;
/// `GUILD_FOUNDER_S`.
pub const FOUNDER: u32 = 0x0E;
/// `GUILD_UNK19`.
pub const UNK19: u32 = 0x13;
/// `GUILD_UNK20`.
pub const UNK20: u32 = 0x14;
}
/// `SMSG_GUILD_COMMAND_RESULT`'s trailing `u32` — vmangos `CommandErrors`
/// (`Guild/Guild.h:96-119`). The string field between the two carries whatever `%s` the matching
/// `ERR_GUILD_*` GlobalString needs (a player name, a guild name, or empty).
///
/// Note [`LEADER_LEAVE`] and [`PERMISSIONS`] are the **same** value `0x08`: which one it means is
/// decided by the [`guild_command`] tag beside it (`QUIT` → the leader-can't-leave message,
/// anything else → the permission refusal). That collision is the reason the command tag has to be
/// carried through to the consumer rather than dropped.
pub mod guild_command_error {
/// `ERR_PLAYER_NO_MORE_IN_GUILD` — vmangos's own comment: "no message/error".
pub const PLAYER_NO_MORE_IN_GUILD: u32 = 0x00;
/// `ERR_GUILD_INTERNAL`.
pub const INTERNAL: u32 = 0x01;
/// `ERR_ALREADY_IN_GUILD` — *you* are.
pub const ALREADY_IN_GUILD: u32 = 0x02;
/// `ERR_ALREADY_IN_GUILD_S` — *they* are (the string names them).
pub const ALREADY_IN_GUILD_S: u32 = 0x03;
/// `ERR_INVITED_TO_GUILD`.
pub const INVITED_TO_GUILD: u32 = 0x04;
/// `ERR_ALREADY_INVITED_TO_GUILD_S`.
pub const ALREADY_INVITED_TO_GUILD_S: u32 = 0x05;
/// `ERR_GUILD_NAME_INVALID`.
pub const NAME_INVALID: u32 = 0x06;
/// `ERR_GUILD_NAME_EXISTS_S`.
pub const NAME_EXISTS_S: u32 = 0x07;
/// `ERR_GUILD_LEADER_LEAVE` — the guild master cannot `/gquit` while anyone else remains.
/// Same value as [`PERMISSIONS`]; disambiguated by [`guild_command::QUIT`].
pub const LEADER_LEAVE: u32 = 0x08;
/// `ERR_GUILD_PERMISSIONS` — see [`LEADER_LEAVE`].
pub const PERMISSIONS: u32 = 0x08;
/// `ERR_GUILD_PLAYER_NOT_IN_GUILD` — *you* aren't.
pub const PLAYER_NOT_IN_GUILD: u32 = 0x09;
/// `ERR_GUILD_PLAYER_NOT_IN_GUILD_S` — *they* aren't.
pub const PLAYER_NOT_IN_GUILD_S: u32 = 0x0A;
/// `ERR_GUILD_PLAYER_NOT_FOUND_S`.
pub const PLAYER_NOT_FOUND_S: u32 = 0x0B;
/// `ERR_GUILD_NOT_ALLIED` — cross-faction, refused by config.
pub const NOT_ALLIED: u32 = 0x0C;
/// `ERR_GUILD_RANK_TOO_HIGH_S`.
pub const RANK_TOO_HIGH_S: u32 = 0x0D;
/// `ERR_GUILD_RANK_TOO_LOW_S`.
pub const RANK_TOO_LOW_S: u32 = 0x0E;
/// `ERR_GUILD_RANKS_LOCKED` (`0x0F`/`0x10` are unused at 5875).
pub const RANKS_LOCKED: u32 = 0x11;
/// `ERR_GUILD_RANK_IN_USE` — can't delete the lowest rank while somebody holds it.
pub const RANK_IN_USE: u32 = 0x12;
/// `ERR_GUILD_IGNORING_YOU_S`.
pub const IGNORING_YOU_S: u32 = 0x13;
/// `ERR_GUILD_UNK20` — vmangos: "for Typecommand 0x05 only".
pub const UNK20: u32 = 0x14;
}
/// `SMSG_GUILD_QUERY_RESPONSE` — one guild's public identity: its name, its ten rank names, and
/// its tabard.
///
/// This is an ask-once cache fill, the guild twin of the name query: the roster and every chat
/// line reference a guild by id, and this is what turns that id into text.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GuildQueryResponse {
/// The guild id we asked about.
pub guild_id: u32,
/// The guild's name — and, **empty, the "no such guild" answer**. The reference client tests
/// exactly this (`0x5552ae test al,al` on the decoded record's name, choosing the cache
/// *insert* `0x561070` or the cache *remove* `0x561390`); there is no separate not-found flag,
/// and the full record is consumed either way. A consumer must therefore treat an empty name
/// as "this id names nothing", not as "a guild with a blank name".
pub name: String,
/// **Always exactly [`GUILD_RANKS_MAX_COUNT`] entries**, indexed by rank id (0 = guild
/// master); ranks the guild has not created are empty strings, not absent. A guild has between
/// [`GUILD_RANKS_MIN_COUNT`] and [`GUILD_RANKS_MAX_COUNT`] real ranks, so the tail of this
/// array is normally empty — "how many ranks exist" is the count of leading non-empty entries,
/// or better, the roster's `rank_rights` length.
pub rank_names: [String; GUILD_RANKS_MAX_COUNT],
/// Tabard emblem symbol index.
pub emblem_style: u32,
/// Tabard emblem colour index.
pub emblem_color: u32,
/// Tabard border style index.
pub border_style: u32,
/// Tabard border colour index.
pub border_color: u32,
/// Tabard background colour index.
pub background_color: u32,
}
/// Read `SMSG_GUILD_QUERY_RESPONSE` (VERIFIED **both ends**): `u32 guildId`, the guild name, then
/// **exactly ten** rank-name cstrings, then the five tabard `u32`s.
///
/// Server: vmangos `Server/Packets/Guild.cpp:118-131` `GuildQueryResponse::AppendBodyTo`, filled by
/// `Guild/Guild.cpp:862-880` `SendQueryResponse`. Client: wow-re
/// `system/ui/scratch/guild-roster-wire.md` — handler `0x555290` (dbcache) delegating to decoder
/// `0x62f260`, reads at `0x62f26e` (id), `0x62f27b` (name), `0x62f295` × 10, then `0x62f2af`,
/// `0x62f2bd`, `0x62f2cb`, `0x62f2d9`, `0x62f2e7`.
///
/// The ten is a fixed loop over the sender's `rankNames[10]` array, **not** a counted list — the
/// server pads with empty strings for ranks the guild never created ("show always 10 ranks", its
/// own comment), and the client's own loop bound is the compile-time constant `0x62f283 mov
/// [ebp+8],0xa` on a `do`/`while`, so it always runs ten times. Reading a count first, or stopping
/// at the guild's real rank count, would land the parse in the middle of the emblem block.
pub(super) fn read_guild_query_response(r: &mut impl Read) -> io::Result<GuildQueryResponse> {
let guild_id = read_u32_le(r)?;
let name = read_cstring(r)?;
let mut rank_names: [String; GUILD_RANKS_MAX_COUNT] = Default::default();
for rank_name in &mut rank_names {
*rank_name = read_cstring(r)?;
}
Ok(GuildQueryResponse {
guild_id,
name,
rank_names,
emblem_style: read_u32_le(r)?,
emblem_color: read_u32_le(r)?,
border_style: read_u32_le(r)?,
border_color: read_u32_le(r)?,
background_color: read_u32_le(r)?,
})
}
/// One row of `SMSG_GUILD_ROSTER`.
///
/// Unlike a friend-list slot, every field the guild pane shows *is* on the wire — a roster is a
/// snapshot the server composed, so no name-cache round trip is needed to draw it.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct GuildRosterMember {
/// The member's player guid (vmangos always builds it as `ObjectGuid(HIGHGUID_PLAYER, …)`).
pub guid: u64,
/// [`guild_presence`] bits. [`guild_presence::OFFLINE`] (`0`) is the value that makes
/// [`Self::last_online_days`] present on the wire — see [`Self::is_online`].
pub presence: u8,
/// Character name.
pub name: String,
/// Rank id, 0-based with 0 = guild master; indexes [`GuildQueryResponse::rank_names`] and
/// [`GuildRoster::rank_rights`].
pub rank_id: u32,
/// Character level. A `u8` on the wire, not a `u32` — the roster is the widest packet in the
/// game and it is packed accordingly.
pub level: u8,
/// Class id (`ChrClasses.dbc`).
pub class: u8,
/// Zone id (`AreaTable.dbc`). For an offline member this is their *last known* zone, out of
/// the character table.
pub zone: u32,
/// Days since this member last logged out, as a fraction — vmangos computes
/// `(now - logoutTime) / DAY` (`Guild/Guild.cpp:841`). **Only on the wire for offline
/// members**; reads back as `0.0` for online ones, where it is meaningless. The reference UI's
/// `GetGuildRosterLastOnline` binding is what splits it into years/months/days.
pub last_online_days: f32,
/// The member's public note (empty if unset).
pub public_note: String,
/// The member's officer note — **empty when *we* lack [`guild_rank_right::VIEW_OFFICER_NOTE`]**,
/// because the server blanks it per-viewer (`Guild/Guild.cpp:821`, `:844`). An empty string
/// therefore does not distinguish "no note" from "not allowed to see it"; the viewer's own
/// rank rights do.
pub officer_note: String,
}
impl GuildRosterMember {
/// Is this member online at all (any of ONLINE/AFK/DND)? This is the wire's own test — vmangos
/// writes [`Self::last_online_days`] under exactly `if (!member.presenceFlags)`, and
/// [`read_guild_roster`] reads it under the same condition.
///
/// It is a **whole-byte** comparison against [`guild_presence::OFFLINE`], not `presence &
/// ONLINE`, and that is the reference client's own predicate rather than a convenience: it
/// derives the flag with `0x4d0c12 test dl,dl` / `0x4d0c1d setne cl` before branching on it.
/// An unknown future presence bit therefore reads as *online*, which is the safe direction —
/// masking `0x1` would mis-classify it and take the wrong branch on the float.
pub fn is_online(&self) -> bool {
self.presence != guild_presence::OFFLINE
}
}
/// `SMSG_GUILD_ROSTER` — the whole guild in one packet: the MOTD, the info text, every rank's
/// rights, and every member.
///
/// Always a complete snapshot, never a delta: vmangos re-sends the whole thing for any change
/// worth showing (a note edit, a rank-rights change, a promotion). It is also the game's widest
/// packet, and the sender **truncates the member list** to fit `GUILD_ROSTER_MAX_LENGTH`
/// (`0x8000 - 4` bytes, `Guild/Guild.h:41`) — so [`Self::members`] can be shorter than the guild
/// for a very large guild with very long notes, which is the server's behaviour, not a parse error.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct GuildRoster {
/// The message of the day.
pub motd: String,
/// The guild information text (the "Guild Information" pane). 1.12-only — vmangos writes it
/// under `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_8_4`.
pub info: String,
/// One rights bitmask ([`guild_rank_right`]) per rank, indexed by rank id. Its **length is the
/// guild's real rank count** — the one place on the wire that says how many of
/// [`GuildQueryResponse::rank_names`]'s ten slots are live.
///
/// More than [`GUILD_RANKS_MAX_COUNT`] entries is malformed. We still read them all (dropping
/// any would desynchronise everything after) and hand them over; a consumer indexing a fixed
/// ten-slot model must bound itself. The reference client does **not**: its loop writes
/// straight into a 10-entry array at `[0xb726d0, 0xb726f8)`, so a `rankCount >= 12` walks over
/// that array's own container fields — a bug to clamp around, never to reproduce (wow-re
/// `system/ui/scratch/guild-roster-wire.md`).
pub rank_rights: Vec<u32>,
/// The members, **arrival order** — vmangos iterates its `members` map, i.e. by low guid.
///
/// This is not display order and is not stable in any way a UI should lean on: the reference
/// client re-`qsort`s its own copy after every packet (`0x4d0d32`). Sorting is the consumer's
/// job; this vector is what came off the wire.
pub members: Vec<GuildRosterMember>,
}
/// A defensive cap on the `Vec::with_capacity` hint taken from the wire's member count: a
/// corrupt/hostile `u32` must not turn into a multi-gigabyte allocation before the first read
/// fails. The real ceiling is the packet's own `0x8000`-byte budget, which cannot hold more than
/// ~1.4k members at the minimum member size.
const ROSTER_CAPACITY_HINT_CAP: usize = 2048;
/// Read `SMSG_GUILD_ROSTER` (VERIFIED **both ends**): `u32 memberCount`, the MOTD, the info text,
/// `u32 rankCount`, that many `u32` rights, then the members.
///
/// Each member is `u64 guid`, `u8 presenceFlags`, name, `u32 rankId`, `u8 level`, `u8 classId`,
/// `u32 zoneId`, **`f32 lastOnlineTime` if and only if `presenceFlags == 0`**, public note,
/// officer note.
///
/// Server: vmangos `Server/Packets/Guild.cpp:143-173` `GuildRoster::AppendBodyTo`, filled by
/// `Guild/Guild.cpp:803-858` `SendGuildRoster`. Client: wow-re
/// `system/ui/scratch/guild-roster-wire.md` — handler `0x4d0ad0`, head reads at `0x4d0af3`,
/// `0x4d0b65`, `0x4d0b82`, `0x4d0b9a`, `0x4d0bb3`; member reads at `0x4d0bfd`, `0x4d0c08`,
/// `0x4d0c43`, `0x4d0c56`, `0x4d0c61`, `0x4d0c6c`, `0x4d0ca0`, `0x4d0cc2`, `0x4d0ce7`, `0x4d0d02`.
///
/// That condition is the whole difficulty of this packet. An offline member is 4 bytes longer than
/// an online one, so a parser that reads the float unconditionally does not merely get one field
/// wrong — it walks off into the following cstrings and mis-parses **every remaining member**.
/// It is spelled out as an explicit test on the presence byte rather than a "read if bytes remain"
/// check for the same reason [`super::social::read_friend_status`] is: the wire keys the field off
/// a value, and only that value can decide it.
///
/// Three independent sources agree on it, and on its exact shape. vmangos states it twice — the
/// writer (`Guild.cpp:164-165`) and the size accounting that decides where to truncate
/// (`Guild/Guild.cpp:793-794`). The binary states it once, precisely: `0x4d0cad cmp
/// DWORD PTR [eax+0x48],edi` (with `edi` held at `0` across the loop) / `0x4d0cb0 je 0x4d0cba`,
/// whose taken leg is the guarded read `0x4d0cc2`; the other leg consumes nothing and stores `0`.
/// Two details of that carve are load-bearing here: the predicate is the **whole presence byte**
/// against zero (`0x4d0c12 test dl,dl` → `0x4d0c1d setne cl`), never `flags & ONLINE` — which is
/// why [`GuildRosterMember::is_online`] compares against [`guild_presence::OFFLINE`] rather than
/// masking a bit. And the field really is an `f32`: the client's 4-byte getters are byte-identical
/// template clones that could never settle a type, so it is pinned at two *consumers* instead —
/// `0x4d0f19 fld` / `0x4d0f1f fcomp` in the roster comparator, and `0x4d1508 fld` in
/// `GetGuildRosterLastOnline`.
pub(super) fn read_guild_roster(r: &mut impl Read) -> io::Result<GuildRoster> {
let member_count = read_u32_le(r)?;
let motd = read_cstring(r)?;
let info = read_cstring(r)?;
let rank_count = read_u32_le(r)?;
let mut rank_rights = Vec::with_capacity((rank_count as usize).min(GUILD_RANKS_MAX_COUNT));
for _ in 0..rank_count {
rank_rights.push(read_u32_le(r)?);
}
let mut members = Vec::with_capacity((member_count as usize).min(ROSTER_CAPACITY_HINT_CAP));
for _ in 0..member_count {
let mut member = GuildRosterMember {
guid: read_u64_le(r)?,
presence: read_u8(r)?,
name: read_cstring(r)?,
rank_id: read_u32_le(r)?,
level: read_u8(r)?,
class: read_u8(r)?,
zone: read_u32_le(r)?,
..Default::default()
};
if !member.is_online() {
member.last_online_days = read_f32_le(r)?;
}
member.public_note = read_cstring(r)?;
member.officer_note = read_cstring(r)?;
members.push(member);
}
Ok(GuildRoster {
motd,
info,
rank_rights,
members,
})
}
/// `SMSG_GUILD_EVENT` — one thing that happened in the guild, as the event id plus its
/// already-formatted `%s` arguments.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GuildEventNotice {
/// A [`guild_event`] id. The reference client's `switch` covers `0x00`–`0x0D` and treats
/// anything above as its default arm, so an unknown id is a display question, not a parse one.
pub event: u8,
/// The event's string arguments, in order — at most **three** (the reference handler has
/// exactly three stack buffers and its `strCount` is capped at 3).
pub params: Vec<String>,
/// The guildmate the event is about — present for [`guild_event::SIGNED_ON`] and
/// [`guild_event::SIGNED_OFF`] only. See [`read_guild_event`] for why that is narrower than
/// what vmangos writes.
pub guid: Option<u64>,
}
/// Read `SMSG_GUILD_EVENT` (VERIFIED both ends: vmangos `Server/Packets/Guild.cpp:133-141`
/// `GuildEvent::AppendBodyTo`, and the reference client's own handler `0x5e7180` — wow-re RF-0077,
/// `system/object-layer/scratch/rf77-smsg-chat-wire-order.md`): `u8 event`, `u8 paramCount`, that
/// many cstrings, then the optional trailing guid.
///
/// **The two ends disagree about *when* the guid is there, and we follow the client.** vmangos
/// writes it whenever its `affectedPlayerGuid` is set, which its callers do for `GE_JOINED`
/// (`Handlers/GuildHandler.cpp:218`) and `GE_LEFT` (`:405`, `Guild/Guild.cpp:572`) as well as
/// `GE_SIGNED_ON`/`GE_SIGNED_OFF`. The reference client reads it in **only** the `0xc`/`0xd` arms
/// of its jump table — for the other two the guid is simply trailing slack it never touches (the
/// body is length-framed, so ignoring a trailing field costs nothing and desynchronises nothing).
/// benilla is the client, so this reads on the event id, and `GE_JOINED`/`GE_LEFT` report
/// `guid: None` even when the bytes are present. Nothing is lost: those two carry the player's
/// **name** in `params`, which is what the event is displayed by.
pub(super) fn read_guild_event(r: &mut impl Read) -> io::Result<GuildEventNotice> {
let event = read_u8(r)?;
let param_count = read_u8(r)?;
let mut params = Vec::with_capacity(param_count as usize);
for _ in 0..param_count {
params.push(read_cstring(r)?);
}
let guid = matches!(event, guild_event::SIGNED_ON | guild_event::SIGNED_OFF)
.then(|| read_u64_le(r))
.transpose()?;
Ok(GuildEventNotice {
event,
params,
guid,
})
}
/// `SMSG_GUILD_COMMAND_RESULT` — the server's verdict on a guild verb we sent.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GuildCommandResult {
/// A [`guild_command`] tag. Load-bearing rather than decorative: it is what disambiguates the
/// two meanings of result `0x08` (see [`guild_command_error::LEADER_LEAVE`]).
pub command: u32,
/// The `%s` the matching error string wants — a player name, a guild name, or empty.
pub name: String,
/// A [`guild_command_error`] code.
pub result: u32,
}
/// Read `SMSG_GUILD_COMMAND_RESULT` (VERIFIED vmangos `Server/Packets/Guild.cpp:96-101`
/// `GuildCommandResult::AppendBodyTo`): `u32 command`, cstring, `u32 result` — the string is in the
/// **middle**, which is the field order to get right.
pub(super) fn read_guild_command_result(r: &mut impl Read) -> io::Result<GuildCommandResult> {
Ok(GuildCommandResult {
command: read_u32_le(r)?,
name: read_cstring(r)?,
result: read_u32_le(r)?,
})
}
/// `SMSG_GUILD_INFO` — the small "founded on / N members / N accounts" summary, answering
/// `CMSG_GUILD_INFO`. Nothing here overlaps [`GuildRoster`]; it is a separate ask.
///
/// The layout — a cstring then five `u32`s — is VERIFIED at both ends. The **field names below are
/// INFERRED**: they are vmangos's, and the binary does not label them. What the binary does supply
/// is a corroboration of the first three: the `GUILD_INFO_TEMPLATE` pushes at `0x5e704d`–`0x5e7061`
/// reorder them **wire #2, wire #1, wire #3-5**, which is exactly the swap an enUS `M/D/Y` date
/// template applies to a `day, month, year` wire (wow-re `system/ui/scratch/guild-roster-wire.md`).
/// Good evidence, not a labelled proof — treat a surprising founding date as a lead, not an
/// impossibility.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GuildInfo {
/// The guild's name.
pub name: String,
/// Founding day-of-month (INFERRED — see the struct doc).
pub created_day: u32,
/// Founding month (INFERRED).
pub created_month: u32,
/// Founding year (INFERRED).
pub created_year: u32,
/// How many characters are in the guild.
pub member_count: u32,
/// How many distinct *accounts* those characters belong to — the number the guild pane shows
/// alongside the member count.
pub account_count: u32,
}
/// Read `SMSG_GUILD_INFO` (VERIFIED vmangos `Server/Packets/Guild.cpp:103-111`
/// `GuildInfo::AppendBodyTo`): the guild name, then day/month/year and the two counts, all `u32`.
pub(super) fn read_guild_info(r: &mut impl Read) -> io::Result<GuildInfo> {
Ok(GuildInfo {
name: read_cstring(r)?,
created_day: read_u32_le(r)?,
created_month: read_u32_le(r)?,
created_year: read_u32_le(r)?,
member_count: read_u32_le(r)?,
account_count: read_u32_le(r)?,
})
}
/// Read `SMSG_GUILD_INVITE` (VERIFIED vmangos `Server/Packets/Guild.cpp:85-89`
/// `GuildInviteNotification::AppendBodyTo`): two cstrings — who invited us, and into which guild.
/// Answered by `CMSG_GUILD_ACCEPT` or `CMSG_GUILD_DECLINE`, neither of which echoes anything, so
/// the pending invite is client-side state.
pub(super) fn read_guild_invite(r: &mut impl Read) -> io::Result<(String, String)> {
let inviter = read_cstring(r)?;
let guild = read_cstring(r)?;
Ok((inviter, guild))
}
/// Read `SMSG_GUILD_DECLINE` (VERIFIED vmangos `Server/Packets/Guild.cpp:91-94`
/// `GuildDeclineNotification::AppendBodyTo`): one cstring — the name of the player who turned our
/// invitation down. Delivered only to the inviter (`Handlers/GuildHandler.cpp:230-236`).
pub(super) fn read_guild_decline(r: &mut impl Read) -> io::Result<String> {
read_cstring(r)
}
/// Body of `CMSG_GUILD_QUERY` (VERIFIED vmangos `Server/Packets/Guild.cpp:8-11`
/// `GuildQuery::ReadFromWorldPacket`): one `u32`, the guild id. Answered by
/// `SMSG_GUILD_QUERY_RESPONSE` — or, if no such guild exists, by an
/// `SMSG_GUILD_COMMAND_RESULT`(`CREATE`, `PLAYER_NOT_IN_GUILD`), which is vmangos's catch-all and
/// not literally about us (`Handlers/GuildHandler.cpp:44`).
pub fn guild_query(guild_id: u32) -> Vec<u8> {
guild_id.to_le_bytes().to_vec()
}
/// Body of `CMSG_GUILD_CREATE` (VERIFIED vmangos `Server/Packets/Guild.cpp:3-6`
/// `GuildCreate::ReadFromWorldPacket`): one cstring, the desired name.
///
/// vmangos registers this opcode `STATUS_NEVER` (`Server/Protocol/Opcodes.cpp:210`) — on a 1.12
/// realm a guild is founded through the charter/petition flow, never by this packet. Built for
/// completeness of the family; do not expect a reply.
pub fn guild_create(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_GUILD_INVITE` (VERIFIED vmangos `Server/Packets/Guild.cpp:13-16`
/// `GuildInvite::ReadFromWorldPacket`): one cstring, the name to invite. The server normalises the
/// case itself (`normalizePlayerName`), so the client need not.
pub fn guild_invite(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_GUILD_ACCEPT` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:213` —
/// `NullClientPacket`): empty. Which invitation it accepts is the server's own pending state
/// (`GetGuildIdInvited`), not a field.
pub fn guild_accept() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_GUILD_DECLINE` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:214` —
/// `NullClientPacket`): empty. Sends the inviter an `SMSG_GUILD_DECLINE` naming us.
pub fn guild_decline() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_GUILD_INFO` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:216` —
/// `NullClientPacket`): empty. Answered by `SMSG_GUILD_INFO` for our own guild.
pub fn guild_info() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_GUILD_ROSTER` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:218` —
/// `NullClientPacket`): empty. Answered by the whole `SMSG_GUILD_ROSTER`; the server also pushes
/// that packet unasked after any change, so this is a refresh.
pub fn guild_roster() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_GUILD_PROMOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:23-26`
/// `GuildPromote::ReadFromWorldPacket`): one cstring, who to promote. Promotion is by name and
/// moves them **up** one rank — the server does `rank--` (`Guild/Guild.h:52`).
pub fn guild_promote(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_GUILD_DEMOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:28-31`
/// `GuildDemote::ReadFromWorldPacket`): one cstring; the server does `rank++`.
pub fn guild_demote(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_GUILD_LEAVE` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:222` —
/// `NullClientPacket`): empty. Refused with
/// `(QUIT, LEADER_LEAVE)` if we are the guild master and anyone else is still in the guild.
pub fn guild_leave() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_GUILD_REMOVE` (VERIFIED vmangos `Server/Packets/Guild.cpp:18-21`
/// `GuildRemove::ReadFromWorldPacket`): one cstring, who to kick.
pub fn guild_remove(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_GUILD_DISBAND` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:224` —
/// `NullClientPacket`): empty. Guild master only.
pub fn guild_disband() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_GUILD_LEADER` (VERIFIED vmangos `Server/Packets/Guild.cpp:33-36`
/// `GuildLeader::ReadFromWorldPacket`): one cstring — hand the guild over to this member.
pub fn guild_leader(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_GUILD_MOTD` (VERIFIED vmangos `Server/Packets/Guild.cpp:38-42`
/// `GuildMOTD::ReadFromWorldPacket`): one cstring, the new message of the day.
///
/// **Clearing the MOTD sends `""` as a one-byte body (the NUL), not a zero-byte body.** vmangos's
/// read is guarded — `if (!recv_data.empty()) recv_data >> motd;` — so a *literally empty* body is
/// also accepted and also means "clear it" (its `motd` stays default-constructed). Both shapes
/// therefore work, and the guard exists to stop an empty packet throwing a `ByteBufferException`
/// rather than to define a second wire form. We emit the well-formed cstring in every case: one
/// builder, one shape, and the empty string is not a special case anywhere in this crate.
pub fn guild_motd(motd: &str) -> Vec<u8> {
cstring_body(motd)
}
/// Body of `CMSG_GUILD_RANK` (VERIFIED vmangos `Server/Packets/Guild.cpp:78-83`
/// `GuildRank::ReadFromWorldPacket`): `u32 rankId`, `u32 rights`, then the rank name.
///
/// This one packet both renames a rank and rewrites its rights, so a caller changing only one must
/// still send the current value of the other. Two server-side facts shape the call: sending it for
/// [`guild_default_rank::GUILDMASTER`] has its `rights` **ignored and replaced** with
/// [`guild_rank_right::ALL`], and a `name` longer than [`GUILD_RANK_MAX_LENGTH`] characters gets
/// the session **kicked** by the anticheat, not refused.
pub fn guild_rank(rank_id: u32, rights: u32, name: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(9 + name.len());
body.extend_from_slice(&rank_id.to_le_bytes());
body.extend_from_slice(&rights.to_le_bytes());
push_cstring(&mut body, name);
body
}
/// Body of `CMSG_GUILD_ADD_RANK` (VERIFIED vmangos `Server/Packets/Guild.cpp:73-76`
/// `GuildAddRank::ReadFromWorldPacket`): one cstring, the new rank's name. The rank is appended at
/// the bottom with `GCHAT_LISTEN | GCHAT_SPEAK` (`Handlers/GuildHandler.cpp:623`); the server
/// silently ignores the packet once the guild already has [`GUILD_RANKS_MAX_COUNT`] ranks.
pub fn guild_add_rank(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_GUILD_DEL_RANK` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp:655` —
/// `NullClientPacket`): empty. There is no rank id: it always deletes the **lowest** rank, which is
/// why the reference UI only ever offers to remove the last row.
pub fn guild_del_rank() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_GUILD_SET_PUBLIC_NOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:61-65`
/// `GuildSetPublicNote::ReadFromWorldPacket`): the member's name, then the note.
pub fn guild_set_public_note(name: &str, note: &str) -> Vec<u8> {
two_cstring_body(name, note)
}
/// Body of `CMSG_GUILD_SET_OFFICER_NOTE` (VERIFIED vmangos `Server/Packets/Guild.cpp:67-71`
/// `GuildSetOfficerNote::ReadFromWorldPacket`): the same two-cstring shape as
/// [`guild_set_public_note`], gated on [`guild_rank_right::EDIT_OFFICER_NOTE`] instead.
pub fn guild_set_officer_note(name: &str, note: &str) -> Vec<u8> {
two_cstring_body(name, note)
}
/// Body of `CMSG_GUILD_INFO_TEXT` (VERIFIED vmangos `Server/Packets/Guild.cpp:44-49`
/// `GuildChangeInfoText::ReadFromWorldPacket`): one cstring, the guild information text. 1.12-only
/// — vmangos compiles both the opcode's handler and the roster's matching `guildInfo` field under
/// `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_8_4`.
pub fn guild_info_text(text: &str) -> Vec<u8> {
cstring_body(text)
}
/// A body that is exactly one NUL-terminated string.
fn cstring_body(s: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(s.len() + 1);
push_cstring(&mut body, s);
body
}
/// A body that is exactly two NUL-terminated strings.
fn two_cstring_body(a: &str, b: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(a.len() + b.len() + 2);
push_cstring(&mut body, a);
push_cstring(&mut body, b);
body
}
/// Append a NUL-terminated string to `body`.
fn push_cstring(body: &mut Vec<u8>, s: &str) {
body.extend_from_slice(s.as_bytes());
body.push(0);
}
#[cfg(test)]
mod tests {
use super::*;
/// The rights table is *not* `1 << (i - 1)`: the FrameXML checkbox order puts Promote/Demote
/// ahead of Invite/Remove, whose bits are lower. This pins the two orders apart.
#[test]
fn rank_right_checkbox_order_is_not_bit_order() {
assert_eq!(GUILD_RANK_RIGHT_ORDER[4], 0x0000_0080, "idx 5 = Promote");
assert_eq!(GUILD_RANK_RIGHT_ORDER[5], 0x0000_0100, "idx 6 = Demote");
assert_eq!(GUILD_RANK_RIGHT_ORDER[6], 0x0000_0010, "idx 7 = Invite");
assert_eq!(GUILD_RANK_RIGHT_ORDER[7], 0x0000_0020, "idx 8 = Remove");
assert!(
GUILD_RANK_RIGHT_ORDER[4] > GUILD_RANK_RIGHT_ORDER[6],
"the order is deliberately non-monotonic in bit value"
);
// Every entry is a single bit, and none of them is the GR_RIGHT_EMPTY sentinel.
for right in GUILD_RANK_RIGHT_ORDER {
assert_eq!(right.count_ones(), 1, "{right:#x} is one bit");
assert_ne!(right, guild_rank_right::EMPTY);
}
}
/// Each right, ORed with the `GR_RIGHT_EMPTY` sentinel, reproduces vmangos's own constant —
/// the check that the "factor the 0x40 out" reading of `Guild/Guild.h:56-74` is right.
#[test]
fn rank_rights_reconstruct_the_vmangos_constants() {
use guild_rank_right::*;
assert_eq!(GCHAT_LISTEN | EMPTY, 0x0000_0041);
assert_eq!(GCHAT_SPEAK | EMPTY, 0x0000_0042);
assert_eq!(OFFCHAT_LISTEN | EMPTY, 0x0000_0044);
assert_eq!(OFFCHAT_SPEAK | EMPTY, 0x0000_0048);
assert_eq!(PROMOTE | EMPTY, 0x0000_00C0);
assert_eq!(DEMOTE | EMPTY, 0x0000_0140);
assert_eq!(INVITE | EMPTY, 0x0000_0050);
assert_eq!(REMOVE | EMPTY, 0x0000_0060);
assert_eq!(SET_MOTD | EMPTY, 0x0000_1040);
assert_eq!(EDIT_PUBLIC_NOTE | EMPTY, 0x0000_2040);
assert_eq!(VIEW_OFFICER_NOTE | EMPTY, 0x0000_4040);
assert_eq!(EDIT_OFFICER_NOTE | EMPTY, 0x0000_8040);
assert_eq!(MODIFY_GUILD_INFO | EMPTY, 0x0001_0040);
}
}
@@ -19,6 +19,7 @@ mod action_bar;
mod area_trigger;
mod attack;
mod bank;
mod binder;
mod channel;
mod chat;
mod client;
@@ -28,6 +29,7 @@ mod duel;
mod gameobject;
mod gossip;
mod group;
mod guild;
mod items;
mod loot;
mod mail;
@@ -63,6 +65,7 @@ pub use attack::{attack_swing, AttackerState};
pub use bank::{
autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot,
};
pub use binder::{binder_activate, PlayerBound};
pub use channel::{channel_notice, ChannelNoticeTail, ChannelNotify};
pub use chat::{
chat_tag, ChatMessage, CHAT_MSG_AFK, CHAT_MSG_BATTLEGROUND, CHAT_MSG_BATTLEGROUND_LEADER,
@@ -107,6 +110,16 @@ pub use group::{
ready_check_start, request_party_member_stats, GroupLootInfo, GroupMemberEntry,
PartyMemberStatsInfo, RaidTargetUpdate, ReadyCheck, GROUP_MEMBER_ASSISTANT,
};
pub use guild::{
guild_accept, guild_add_rank, guild_command, guild_command_error, guild_create, guild_decline,
guild_default_rank, guild_del_rank, guild_demote, guild_disband, guild_event, guild_info,
guild_info_text, guild_invite, guild_leader, guild_leave, guild_motd, guild_presence,
guild_promote, guild_query, guild_rank, guild_rank_right, guild_remove, guild_roster,
guild_set_officer_note, guild_set_public_note, GuildCommandResult, GuildEventNotice, GuildInfo,
GuildQueryResponse, GuildRoster, GuildRosterMember, GUILD_INFO_MAX_LENGTH,
GUILD_MOTD_MAX_LENGTH, GUILD_NAME_MAX_LENGTH, GUILD_NOTE_MAX_LENGTH, GUILD_RANKS_MAX_COUNT,
GUILD_RANKS_MIN_COUNT, GUILD_RANK_MAX_LENGTH, GUILD_RANK_RIGHT_ORDER,
};
pub use items::{
auto_equip_item, auto_store_bag_item, destroy_item, item_query, open_item, set_ammo,
split_item, swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry,
+57 -3
View File
@@ -666,9 +666,20 @@ pub const CMSG_TRAINER_BUY_SPELL: u16 = 0x01B2; // 434
pub const SMSG_TRAINER_BUY_SUCCEEDED: u16 = 0x01B3; // 435
pub const SMSG_TRAINER_BUY_FAILED: u16 = 0x01B4; // 436
// The bank set (VERIFIED vmangos `Opcodes_1_12_1.h`: 439,440-441,642-643 — 0x01B5/0x01B6
// `CMSG_BINDER_ACTIVATE`/`SMSG_PLAYERBINDERROR` sit between the trainer set and this one but are
// out of scope, decision 0604). `CMSG_BANKER_ACTIVATE` right-clicks a pure banker;
// The innkeeper bind set (VERIFIED vmangos `Opcodes_1_12_1.h`: 437, 344, 747; decision 1331).
// Selecting an innkeeper's `GOSSIP_OPTION_INNKEEPER` line makes the server close the gossip menu
// and send `SMSG_BINDER_CONFIRM` — a QUESTION, not a bind. Nothing is bound until the client
// answers `CMSG_BINDER_ACTIVATE`, which is what the confirm dialog's Accept sends; the server then
// has the innkeeper cast spell 3286 "Bind" on the player, whose `SPELL_EFFECT_BIND` sends
// `SMSG_BINDPOINTUPDATE` (above) and `SMSG_PLAYERBOUND` (vmangos `SpellEffects.cpp` `EffectBind`).
// Bodies in [`super::binder`]. `SMSG_PLAYERBINDERROR` (0x01B6) is the refusal and is not parsed:
// vmangos never sends it on any path (`SendBindPoint` simply returns inside an instance).
pub const CMSG_BINDER_ACTIVATE: u16 = 0x01B5; // 437
pub const SMSG_PLAYERBOUND: u16 = 0x0158; // 344
pub const SMSG_BINDER_CONFIRM: u16 = 0x02EB; // 747
// The bank set (VERIFIED vmangos `Opcodes_1_12_1.h`: 439,440-441,642-643).
// `CMSG_BANKER_ACTIVATE` right-clicks a pure banker;
// `SMSG_SHOW_BANK` also arrives unprompted from the `GOSSIP_OPTION_BANKER` gossip option. Slot
// purchase and deposit/withdraw bodies in [`super::bank`].
pub const CMSG_BANKER_ACTIVATE: u16 = 0x01B7; // 439
@@ -780,6 +791,49 @@ pub const SMSG_IGNORE_LIST: u16 = 0x006B; // 107
pub const CMSG_ADD_IGNORE: u16 = 0x006C; // 108
pub const CMSG_DEL_IGNORE: u16 = 0x006D; // 109
// The guild family — the query/roster caches, invitations, the member verbs, rank administration
// and the event broadcast (VERIFIED vmangos `Opcodes_1_12_1.h:87-88`, `:132-151`, `:562-566`,
// `:765` + `Server/Packets/Guild.{h,cpp}`, `Guild/Guild.{h,cpp}`, `Handlers/GuildHandler.cpp`; the
// client side of `SMSG_GUILD_EVENT` is wow-re's RF-0077,
// `system/object-layer/scratch/rf77-smsg-chat-wire-order.md`, which pins handler `0x5e7180` to
// this 0x92 and reads its guild-init registration/teardown block). Bodies in [`super::guild`].
//
// The numbers are two contiguous runs plus two strays: the query pair sits with the other ask-once
// caches at 0x54/0x55, the core family runs 0x81-0x93 immediately after the group family, rank
// administration was appended at 0x231-0x235, and the guild info text at 0x2FC. The
// charter/petition/tabard opcodes that *found* a guild are a separate slice and are not here.
pub const CMSG_GUILD_QUERY: u16 = 0x0054; // 84
pub const SMSG_GUILD_QUERY_RESPONSE: u16 = 0x0055; // 85
/// vmangos registers this `STATUS_NEVER` (`Opcodes.cpp:210`): at 1.12 a guild is founded through
/// the charter/petition flow, not by this packet. Modelled for completeness; no reply comes back.
pub const CMSG_GUILD_CREATE: u16 = 0x0081; // 129
pub const CMSG_GUILD_INVITE: u16 = 0x0082; // 130
pub const SMSG_GUILD_INVITE: u16 = 0x0083; // 131
pub const CMSG_GUILD_ACCEPT: u16 = 0x0084; // 132
pub const CMSG_GUILD_DECLINE: u16 = 0x0085; // 133
pub const SMSG_GUILD_DECLINE: u16 = 0x0086; // 134
pub const CMSG_GUILD_INFO: u16 = 0x0087; // 135
pub const SMSG_GUILD_INFO: u16 = 0x0088; // 136
pub const CMSG_GUILD_ROSTER: u16 = 0x0089; // 137
/// The widest packet in the game — the sender truncates its member list to `0x8000 - 4` bytes —
/// and the one conditional-field trap in the family: see [`super::guild::read_guild_roster`].
pub const SMSG_GUILD_ROSTER: u16 = 0x008A; // 138
pub const CMSG_GUILD_PROMOTE: u16 = 0x008B; // 139
pub const CMSG_GUILD_DEMOTE: u16 = 0x008C; // 140
pub const CMSG_GUILD_LEAVE: u16 = 0x008D; // 141
pub const CMSG_GUILD_REMOVE: u16 = 0x008E; // 142
pub const CMSG_GUILD_DISBAND: u16 = 0x008F; // 143
pub const CMSG_GUILD_LEADER: u16 = 0x0090; // 144
pub const CMSG_GUILD_MOTD: u16 = 0x0091; // 145
pub const SMSG_GUILD_EVENT: u16 = 0x0092; // 146
pub const SMSG_GUILD_COMMAND_RESULT: u16 = 0x0093; // 147
pub const CMSG_GUILD_RANK: u16 = 0x0231; // 561
pub const CMSG_GUILD_ADD_RANK: u16 = 0x0232; // 562
pub const CMSG_GUILD_DEL_RANK: u16 = 0x0233; // 563
pub const CMSG_GUILD_SET_PUBLIC_NOTE: u16 = 0x0234; // 564
pub const CMSG_GUILD_SET_OFFICER_NOTE: u16 = 0x0235; // 565
pub const CMSG_GUILD_INFO_TEXT: u16 = 0x02FC; // 764
// The group/party family — invite/accept/decline/kick/leader/disband, the loot-method setting, the
// roster push (`SMSG_GROUP_LIST`), party command feedback, live member stats for the party/raid
// frame, minimap pings, raid subgroup management, raid-target icons, and ready checks (VERIFIED
@@ -8,6 +8,7 @@ use super::{
ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage,
CorpseLocation, DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry,
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry,
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode, Object,
PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, QuestComplete, QuestDetails,
@@ -206,6 +207,19 @@ pub enum ServerPacket {
map: u32,
area: u32,
},
/// `SMSG_BINDER_CONFIRM` — an innkeeper is *asking* whether to make this your home
/// (decision 1331). The body is the innkeeper's guid, which must come back in
/// `CMSG_BINDER_ACTIVATE` for anything to happen: this packet binds nothing on its own.
BinderConfirm {
binder: u64,
},
/// `SMSG_PLAYERBOUND` — the bind took: who bound us and the AreaTable id we are now bound in.
/// Arrives beside [`Self::BindPoint`], which carries the same area id plus the position; this
/// one exists for the "X is now your home" acknowledgement.
PlayerBound {
binder: u64,
area: u32,
},
/// `SMSG_SET_PROFICIENCY` (at login + on train): the subclass bitmask the player can equip
/// for one item class (2 weapons / 4 armor) — the client's `0xc4d4a0[class]` store, the item
/// tooltip's slot-line proficiency red (vmangos `Skill.h`: u8 itemClass + u32 mask).
@@ -984,6 +998,32 @@ pub enum ServerPacket {
FriendStatus(FriendStatusUpdate),
/// `SMSG_WHO` — the `/who` answer: up to 49 rows plus the true match total.
WhoResults(WhoResults),
/// `SMSG_GUILD_QUERY_RESPONSE` — one guild's public identity: name, its ten rank names, tabard.
/// The guild twin of the name query: an ask-once cache fill keyed by guild id.
GuildQueryResponse(GuildQueryResponse),
/// `SMSG_GUILD_ROSTER` — the whole guild: MOTD, info text, every rank's rights, every member.
/// Always a complete snapshot (the server re-sends it for any change), never a delta.
GuildRoster(GuildRoster),
/// `SMSG_GUILD_EVENT` — one thing that happened in the guild, as an event id plus its
/// already-formatted string arguments.
GuildEvent(GuildEventNotice),
/// `SMSG_GUILD_COMMAND_RESULT` — the verdict on a guild verb we sent. The command tag beside
/// the code is load-bearing, not decorative (result `0x08` means two different things).
GuildCommandResult(GuildCommandResult),
/// `SMSG_GUILD_INVITE` — somebody asked us into their guild. Answered by `CMSG_GUILD_ACCEPT` /
/// `CMSG_GUILD_DECLINE`, neither of which echoes anything back, so the pending invite is
/// client-side state.
GuildInvite {
inviter: String,
guild: String,
},
/// `SMSG_GUILD_DECLINE` — the player we invited turned it down. Sent only to the inviter.
GuildDecline {
name: String,
},
/// `SMSG_GUILD_INFO` — the "founded on / N members / N accounts" summary; a separate ask from
/// the roster, with no overlapping fields.
GuildInfo(GuildInfo),
/// A `SMSG_SPLINE_SET_*_SPEED` — a speed change on a unit we don't control (a creature, or a
/// player mid-spline): `[packed guid][f32 speed]`, no counter, no ack (decision 0441 — how an
/// observed unit's mounted speed reaches us).
@@ -1144,6 +1184,8 @@ impl ServerPacket {
ServerPacket::TimeSpeed { .. } => "SMSG_LOGIN_SETTIMESPEED".into(),
ServerPacket::QueryTimeResponse { .. } => "SMSG_QUERY_TIME_RESPONSE".into(),
ServerPacket::BindPoint { .. } => "SMSG_BINDPOINTUPDATE".into(),
ServerPacket::BinderConfirm { .. } => "SMSG_BINDER_CONFIRM".into(),
ServerPacket::PlayerBound { .. } => "SMSG_PLAYERBOUND".into(),
ServerPacket::SetProficiency { .. } => "SMSG_SET_PROFICIENCY".into(),
ServerPacket::InitializeFactions { .. } => "SMSG_INITIALIZE_FACTIONS".into(),
ServerPacket::SetFactionStanding { .. } => "SMSG_SET_FACTION_STANDING".into(),
@@ -1303,6 +1345,13 @@ impl ServerPacket {
ServerPacket::IgnoreList { .. } => "SMSG_IGNORE_LIST".into(),
ServerPacket::FriendStatus(..) => "SMSG_FRIEND_STATUS".into(),
ServerPacket::WhoResults(..) => "SMSG_WHO".into(),
ServerPacket::GuildQueryResponse(..) => "SMSG_GUILD_QUERY_RESPONSE".into(),
ServerPacket::GuildRoster(..) => "SMSG_GUILD_ROSTER".into(),
ServerPacket::GuildEvent(..) => "SMSG_GUILD_EVENT".into(),
ServerPacket::GuildCommandResult(..) => "SMSG_GUILD_COMMAND_RESULT".into(),
ServerPacket::GuildInvite { .. } => "SMSG_GUILD_INVITE".into(),
ServerPacket::GuildDecline { .. } => "SMSG_GUILD_DECLINE".into(),
ServerPacket::GuildInfo(..) => "SMSG_GUILD_INFO".into(),
ServerPacket::SplineSpeedChange { kind, .. } => {
format!("SMSG_SPLINE_SET_{kind:?}_SPEED")
}
+35 -4
View File
@@ -11,10 +11,11 @@ use crate::wire::{
};
use super::{
action_bar, area_trigger, attack, bank, channel, chat, combat_log, death, duel, gameobject,
gossip, group, items, loot, mail, mirror_timer, monster_move, movement, opcode, page_text, pet,
progression, quest, social, spellbook, spells, taxi, trade, trainer, update_object, vendor,
world_state, Character, CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
action_bar, area_trigger, attack, bank, binder, channel, chat, combat_log, death, duel,
gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move, movement,
opcode, page_text, pet, progression, quest, social, spellbook, spells, taxi, trade, trainer,
update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode, ServerPacket,
SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -286,6 +287,16 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
area,
}
}
opcode::SMSG_BINDER_CONFIRM => ServerPacket::BinderConfirm {
binder: binder::read_binder_confirm(&mut r)?,
},
opcode::SMSG_PLAYERBOUND => {
let bound = binder::read_player_bound(&mut r)?;
ServerPacket::PlayerBound {
binder: bound.binder,
area: bound.area,
}
}
opcode::SMSG_SET_PROFICIENCY => {
// vmangos SetProficiency::AppendBodyTo (Packets/Skill.cpp): u8 itemClass + u32 mask.
let item_class = read_u8(&mut r)?;
@@ -972,6 +983,26 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
ServerPacket::FriendStatus(social::read_friend_status(&mut r)?)
}
opcode::SMSG_WHO => ServerPacket::WhoResults(social::read_who(&mut r)?),
// The guild family (bodies in `guild`). The two big ones are caches — a query response
// fills the guild-id→identity cache, the roster replaces the whole membership — and the
// three small ones are notifications. `SMSG_GUILD_ROSTER`'s member loop carries the
// family's one conditional field; see `guild::read_guild_roster`.
opcode::SMSG_GUILD_QUERY_RESPONSE => {
ServerPacket::GuildQueryResponse(guild::read_guild_query_response(&mut r)?)
}
opcode::SMSG_GUILD_ROSTER => ServerPacket::GuildRoster(guild::read_guild_roster(&mut r)?),
opcode::SMSG_GUILD_EVENT => ServerPacket::GuildEvent(guild::read_guild_event(&mut r)?),
opcode::SMSG_GUILD_COMMAND_RESULT => {
ServerPacket::GuildCommandResult(guild::read_guild_command_result(&mut r)?)
}
opcode::SMSG_GUILD_INVITE => {
let (inviter, guild) = guild::read_guild_invite(&mut r)?;
ServerPacket::GuildInvite { inviter, guild }
}
opcode::SMSG_GUILD_DECLINE => ServerPacket::GuildDecline {
name: guild::read_guild_decline(&mut r)?,
},
opcode::SMSG_GUILD_INFO => ServerPacket::GuildInfo(guild::read_guild_info(&mut r)?),
// The observer speed legs (decision 0441): a unit we don't control changed speed — a
// creature or mid-spline player (SPLINE family), or a freely-moving player (MOVE_SET
// family, which carries a fresh pose too). No ack on either.
@@ -442,6 +442,19 @@ const FIELD_PLAYER_FLAGS: u16 = 190;
// three reads `UnitReaction 0x6061e0`'s duel leg makes (decision 0633).
const FIELD_PLAYER_DUEL_ARBITER: u16 = 188;
const FIELD_PLAYER_DUEL_TEAM: u16 = 196;
// PLAYER_GUILDID = UNIT_END(188) + 0x3, PLAYER_GUILDRANK = UNIT_END + 0x4 — both INT and both
// **PUBLIC** (VERIFIED vmangos `UpdateFields_1_12_1.h:120-121`), the same arithmetic that pins
// PLAYER_FLAGS = 190 at +0x2 above. Independently VERIFIED in the binary off the very player-block
// base the duel pair is read through: `GetGuildInfo 0x4c9330` takes the guild id at `[+0xe68]+0xc`
// (`0x4c93a9`, whose `test ecx,ecx` IS the guildless→nil path) and the rank at `+0x10` (`0x4c93e4`,
// `shl edx,0x6` — the 0x40-byte rank-name slot in the cached guild record). 0xc/4 + 188 = 191 and
// 0x10/4 + 188 = 192, with `+0x8` = PLAYER_FLAGS(190) as the third point on the same line.
//
// PUBLIC is the load-bearing half: these stream for *every visible player*, not just for us, which
// is what makes `GetGuildInfo(unit)` answerable for an arbitrary unit — and why the guild query
// response carries a fixed ten rank names to decode another player's rank against (decision 1257).
const FIELD_PLAYER_GUILDID: u16 = 191;
const FIELD_PLAYER_GUILDRANK: u16 = 192;
// PLAYER_FIELD_COMBO_TARGET = UNIT_END(188) + 0x20E (GUID, PRIVATE) — VERIFIED vmangos
// `UpdateFields_1_12_1.h:251`, and independently by the binary: `GetComboPoints 0x51a190` reads the
// pair at `[player+0xe68]+0x838/+0x83c`, and 0x838/4 + 188 = 714 (decision 0875). The same player-
@@ -433,6 +433,28 @@ impl ObjectFields {
pub fn player_duel_team(&self) -> u32 {
self.get_u32(FIELD_PLAYER_DUEL_TEAM).unwrap_or(0)
}
/// `PLAYER_GUILDID` (field 191) — the id of the guild this player belongs to, `0` for a
/// guildless one (and for the absent field, which is the same thing: the descriptor skips
/// zeroes). **PUBLIC**, so it arrives for every visible player, not only for us.
///
/// It names a guild but does not describe one: the name and the rank names come only from
/// `SMSG_GUILD_QUERY_RESPONSE`, keyed by this id (decision 1257). The real client's own
/// `GetGuildInfo 0x4c9330` reads exactly this field and answers nil on both the `0` and the
/// cache-miss legs (`0x4c93a9`, `0x4c93d7`).
pub fn player_guild_id(&self) -> u32 {
self.get_u32(FIELD_PLAYER_GUILDID).unwrap_or(0)
}
/// `PLAYER_GUILDRANK` (field 192) — this player's rank within [`Self::player_guild_id`]'s
/// guild, **0-based with `0` = guild master** (vmangos promotes with `rank--`). Meaningless
/// while the guild id is `0`. Indexes both [`crate::messages::GuildQueryResponse::rank_names`]
/// and [`crate::messages::GuildRoster::rank_rights`].
///
/// The reference indexes its cached rank-name array with this **unbounded**
/// (`0x4c93e4 mov edx,[ecx+0x10]` / `shl edx,0x6`); a consumer of ours must bound it against
/// the ten slots that array really has.
pub fn player_guild_rank(&self) -> u32 {
self.get_u32(FIELD_PLAYER_GUILDRANK).unwrap_or(0)
}
/// `PLAYER_FIELD_BYTES` byte 0 bit `0x08` — `PLAYER_FIELD_BYTE_RELEASE_TIMER`, "Display time
/// till auto release spirit" (the header's own comment): set at death iff the map is
/// non-instanceable, i.e. exactly when the server will force-release after 6:00. The client
@@ -0,0 +1,24 @@
//! The innkeeper bind answer — the client's one send in the bind family (decision 1331).
//!
//! There is no "bind me here" verb: the flow starts with the *server's* `SMSG_BINDER_CONFIRM`
//! (raised by selecting the innkeeper's gossip line), and this is the Yes. The guid is the one
//! that arrived in the confirm; vmangos resolves it back to a live innkeeper in interact range
//! (`HandleBinderActivateOpcode`), so it is load-bearing rather than an echo.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Accept an innkeeper's bind offer (`CMSG_BINDER_ACTIVATE`) — the `CONFIRM_BINDER` dialog's
/// Accept. The server answers by casting spell 3286 on us, which lands as
/// `SMSG_BINDPOINTUPDATE` + `SMSG_PLAYERBOUND`; declining sends nothing at all.
pub fn binder_activate(&mut self, binder_guid: u64) -> Result<()> {
self.send(
opcode::CMSG_BINDER_ACTIVATE,
&messages::binder_activate(binder_guid),
)
}
}
@@ -0,0 +1,167 @@
//! The guild family's `WorldWriter` sends — the two cache asks, the invitation dance, the member
//! verbs, and rank administration.
//!
//! Three shapes of the family are worth knowing before calling any of these:
//!
//! - **Almost nothing is acked individually.** Promote, demote, remove, the leader handover, the
//! MOTD, both notes and every rank verb answer with a fresh `SMSG_GUILD_ROSTER` (and, for the
//! rank verbs, an `SMSG_GUILD_QUERY_RESPONSE` too) — the server re-sends the whole snapshot
//! rather than a delta. A refusal instead comes back as `SMSG_GUILD_COMMAND_RESULT`. So the
//! caller's model updates when the roster lands, never optimistically at the send.
//! - **Members are addressed by NAME here**, unlike the friend list's remove-by-guid: every verb
//! below that targets a player takes the character name, and the server normalises its case.
//! - **Rank ids are 0-based with 0 = guild master**, and authority *decreases* as the id rises —
//! [`WorldWriter::guild_promote`] moves a member to `rank - 1`.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Ask for a guild's public identity by id (`CMSG_GUILD_QUERY`) — name, rank names, tabard.
/// The ask-once cache fill behind every "which guild is that?": a roster row, a `/who` hit and
/// a guild chat line all reference a guild by id alone.
pub fn guild_query(&mut self, guild_id: u32) -> Result<()> {
self.send(opcode::CMSG_GUILD_QUERY, &messages::guild_query(guild_id))
}
/// Found a guild by name (`CMSG_GUILD_CREATE`). vmangos registers this opcode `STATUS_NEVER`:
/// on a 1.12 realm founding runs through the charter/petition flow instead, so this send is
/// here for completeness of the family and draws no reply.
pub fn guild_create(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_CREATE, &messages::guild_create(name))
}
/// Invite a character into our guild by name (`CMSG_GUILD_INVITE`). They get an
/// `SMSG_GUILD_INVITE` popup; we get an `SMSG_GUILD_COMMAND_RESULT` only if it was refused
/// (already guilded, ignoring us, wrong faction, no permission).
pub fn guild_invite(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_INVITE, &messages::guild_invite(name))
}
/// Accept the guild invitation we are holding (`CMSG_GUILD_ACCEPT`, empty body). Which
/// invitation is the server's pending state, not a field — so there is nothing to pass, and
/// nothing to get wrong except sending it when no invite is outstanding (a silent no-op).
pub fn guild_accept(&mut self) -> Result<()> {
self.send(opcode::CMSG_GUILD_ACCEPT, &messages::guild_accept())
}
/// Turn down the guild invitation we are holding (`CMSG_GUILD_DECLINE`, empty body). The
/// inviter is told by `SMSG_GUILD_DECLINE`; we hear nothing back.
pub fn guild_decline(&mut self) -> Result<()> {
self.send(opcode::CMSG_GUILD_DECLINE, &messages::guild_decline())
}
/// Ask for our guild's founding date and member/account counts (`CMSG_GUILD_INFO`, empty
/// body), answered by `SMSG_GUILD_INFO`. A different ask from the roster, sharing no fields.
pub fn guild_info(&mut self) -> Result<()> {
self.send(opcode::CMSG_GUILD_INFO, &messages::guild_info())
}
/// Ask for the whole guild roster (`CMSG_GUILD_ROSTER`, empty body). The server also pushes
/// `SMSG_GUILD_ROSTER` unasked after every change it makes, so this is a refresh — the guild
/// pane's opener — and never the only way the roster arrives.
pub fn guild_roster(&mut self) -> Result<()> {
self.send(opcode::CMSG_GUILD_ROSTER, &messages::guild_roster())
}
/// Promote a member one rank (`CMSG_GUILD_PROMOTE`): the server does `rank - 1`, *towards*
/// guild master. Answered by a fresh roster, or refused with `RANK_TOO_HIGH_S`/`PERMISSIONS`.
pub fn guild_promote(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_PROMOTE, &messages::guild_promote(name))
}
/// Demote a member one rank (`CMSG_GUILD_DEMOTE`): `rank + 1`, away from guild master.
pub fn guild_demote(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_DEMOTE, &messages::guild_demote(name))
}
/// Leave our guild (`CMSG_GUILD_LEAVE`, empty body). Refused with
/// `(QUIT, LEADER_LEAVE)` while we are the guild master and anyone else remains — hand over
/// with [`Self::guild_leader`] first, or [`Self::guild_disband`].
pub fn guild_leave(&mut self) -> Result<()> {
self.send(opcode::CMSG_GUILD_LEAVE, &messages::guild_leave())
}
/// Kick a member by name (`CMSG_GUILD_REMOVE`); needs the REMOVE right and a rank above theirs.
pub fn guild_remove(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_REMOVE, &messages::guild_remove(name))
}
/// Disband the guild (`CMSG_GUILD_DISBAND`, empty body). Guild master only, and irreversible —
/// every member gets a `GE_DISBANDED` event.
pub fn guild_disband(&mut self) -> Result<()> {
self.send(opcode::CMSG_GUILD_DISBAND, &messages::guild_disband())
}
/// Hand the guild to another member (`CMSG_GUILD_LEADER`). Guild master only; both names ride
/// back to everyone as a `GE_LEADER_CHANGED` event.
pub fn guild_leader(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_LEADER, &messages::guild_leader(name))
}
/// Set the message of the day (`CMSG_GUILD_MOTD`). Passing `""` clears it — see
/// [`messages::guild_motd`] for why we send the one-byte empty cstring rather than an empty
/// body, which the server also accepts.
pub fn guild_motd(&mut self, motd: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_MOTD, &messages::guild_motd(motd))
}
/// Rewrite one rank's name **and** rights in a single packet (`CMSG_GUILD_RANK`).
///
/// There is no partial form: a caller changing only the name must still send the rank's
/// current rights, and vice versa. Guild master only; `rights` is ignored and replaced with
/// [`messages::guild_rank_right::ALL`] for rank 0; and a `name` over
/// [`messages::GUILD_RANK_MAX_LENGTH`] characters gets the session **kicked** by vmangos's
/// anticheat rather than refused, so the caller caps it.
pub fn guild_rank(&mut self, rank_id: u32, rights: u32, name: &str) -> Result<()> {
self.send(
opcode::CMSG_GUILD_RANK,
&messages::guild_rank(rank_id, rights, name),
)
}
/// Append a rank at the bottom of the ladder (`CMSG_GUILD_ADD_RANK`). It starts with guild
/// chat listen + speak and nothing else. Silently ignored once the guild already has
/// [`messages::GUILD_RANKS_MAX_COUNT`] ranks — the reference UI hides the button there.
pub fn guild_add_rank(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_GUILD_ADD_RANK, &messages::guild_add_rank(name))
}
/// Delete the **lowest** rank (`CMSG_GUILD_DEL_RANK`, empty body). There is no rank id on the
/// wire: it is always the last one, which is why the reference UI only ever offers to remove
/// the bottom row. Refused with `RANK_IN_USE` while a member still holds it.
pub fn guild_del_rank(&mut self) -> Result<()> {
self.send(opcode::CMSG_GUILD_DEL_RANK, &messages::guild_del_rank())
}
/// Set a member's public note (`CMSG_GUILD_SET_PUBLIC_NOTE`); needs
/// [`messages::guild_rank_right::EDIT_PUBLIC_NOTE`].
pub fn guild_set_public_note(&mut self, name: &str, note: &str) -> Result<()> {
self.send(
opcode::CMSG_GUILD_SET_PUBLIC_NOTE,
&messages::guild_set_public_note(name, note),
)
}
/// Set a member's officer note (`CMSG_GUILD_SET_OFFICER_NOTE`); needs
/// [`messages::guild_rank_right::EDIT_OFFICER_NOTE`]. Note that *seeing* officer notes is a
/// separate right — a roster whose officer notes are all empty may mean we cannot view them.
pub fn guild_set_officer_note(&mut self, name: &str, note: &str) -> Result<()> {
self.send(
opcode::CMSG_GUILD_SET_OFFICER_NOTE,
&messages::guild_set_officer_note(name, note),
)
}
/// Set the guild information text (`CMSG_GUILD_INFO_TEXT`) — the long free-text pane, not the
/// MOTD and not `SMSG_GUILD_INFO`'s counts. It rides back on the roster as its `info` field.
pub fn guild_info_text(&mut self, text: &str) -> Result<()> {
self.send(
opcode::CMSG_GUILD_INFO_TEXT,
&messages::guild_info_text(text),
)
}
}
@@ -36,6 +36,7 @@
//! | [`quest`] | the questgiver dialog walk + the two quest-log verbs |
//! | [`gameobject`] | the one USE verb + the template ask |
//! | [`group`] | party/raid: invite, kick, leader, loot method, icons, ready checks |
//! | [`guild`] | the guild: the two cache asks, invitations, member verbs, rank administration |
//! | [`trade`] | the player-trade dance |
//! | [`duel`] | accept and cancel |
//! | [`mail`] | the mailbox |
@@ -54,6 +55,7 @@ mod action_bar;
mod area_trigger;
mod attack;
mod bank;
mod binder;
mod channel;
mod chat;
mod death;
@@ -61,6 +63,7 @@ mod duel;
mod gameobject;
mod gossip;
mod group;
mod guild;
mod items;
mod lifecycle;
mod loot;
+736
View File
@@ -0,0 +1,736 @@
//! Golden tests for the guild opcode family — the query and roster caches, invitations, the member
//! verbs, rank administration, the event broadcast and the command-result feedback. CMSG bodies are
//! asserted byte-exact against the builder output; SMSG bodies are hand-built per the vmangos
//! layouts (cited inline) and round-tripped through `parse_server` + `decode`. See `tests/common`
//! for the shared `hx()` fixture helper and methodology note.
//!
//! Three of the tests below exist for a specific failure mode rather than for coverage, and each
//! was mutation-checked — the parser was broken that exact way and the test observed to fail:
//! `guild_roster_conditional_float_keeps_later_members_in_sync` (the `f32` that rides on the
//! presence byte, whose third member is the one that proves the parse re-synchronises after a
//! wider record), `guild_query_response_always_carries_ten_rank_names` (the fixed ten, which a
//! counted read walks straight past into the emblem block), and
//! `guild_event_trailing_guid_rides_on_the_event_id` (which pins us to the *client's* narrower
//! read, not vmangos's write rule).
mod common;
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages::{
self, guild_command, guild_command_error, guild_event, guild_presence, guild_rank_right,
opcode, GUILD_RANKS_MAX_COUNT,
};
use benilla_protocol::ServerPacket;
use common::hx;
/// Every CMSG builder in the family, byte-exact — the six empty-body verbs asserting an empty
/// `Vec`, the eight one-cstring verbs, the two two-cstring note verbs, the `u32` query, and the
/// three-field rank write.
#[test]
fn cmsg_bodies_golden() {
// CMSG_GUILD_QUERY (vmangos Server/Packets/Guild.cpp:8-11): one u32, the guild id.
assert_eq!(
messages::guild_query(0x1234),
hx("34120000"),
"CMSG_GUILD_QUERY body"
);
// The one-cstring verbs (Guild.cpp:3-6, 13-16, 18-21, 23-26, 28-31, 33-36, 73-76, 44-49).
assert_eq!(
messages::guild_create("Legacy"),
hx("4c656761637900"),
"CMSG_GUILD_CREATE body"
);
assert_eq!(
messages::guild_invite("Bob"),
hx("426f6200"),
"CMSG_GUILD_INVITE body"
);
assert_eq!(
messages::guild_promote("Bob"),
hx("426f6200"),
"CMSG_GUILD_PROMOTE body"
);
assert_eq!(
messages::guild_demote("Bob"),
hx("426f6200"),
"CMSG_GUILD_DEMOTE body"
);
assert_eq!(
messages::guild_remove("Bob"),
hx("426f6200"),
"CMSG_GUILD_REMOVE body"
);
assert_eq!(
messages::guild_leader("Bob"),
hx("426f6200"),
"CMSG_GUILD_LEADER body"
);
assert_eq!(
messages::guild_add_rank("Grunt"),
hx("4772756e7400"),
"CMSG_GUILD_ADD_RANK body"
);
assert_eq!(
messages::guild_info_text("We raid."),
hx("576520726169642e00"),
"CMSG_GUILD_INFO_TEXT body"
);
// CMSG_GUILD_MOTD (Guild.cpp:38-42): one cstring.
assert_eq!(
messages::guild_motd("Hello"),
hx("48656c6c6f00"),
"CMSG_GUILD_MOTD body"
);
// The empty-body verbs (vmangos Opcodes.cpp: NullClientPacket at :213, :214, :216, :218, :222,
// :224, :655). Every one of these carries its meaning entirely in the opcode.
assert_eq!(
messages::guild_accept(),
Vec::<u8>::new(),
"CMSG_GUILD_ACCEPT body"
);
assert_eq!(
messages::guild_decline(),
Vec::<u8>::new(),
"CMSG_GUILD_DECLINE body"
);
assert_eq!(
messages::guild_info(),
Vec::<u8>::new(),
"CMSG_GUILD_INFO body"
);
assert_eq!(
messages::guild_roster(),
Vec::<u8>::new(),
"CMSG_GUILD_ROSTER body"
);
assert_eq!(
messages::guild_leave(),
Vec::<u8>::new(),
"CMSG_GUILD_LEAVE body"
);
assert_eq!(
messages::guild_disband(),
Vec::<u8>::new(),
"CMSG_GUILD_DISBAND body"
);
assert_eq!(
messages::guild_del_rank(),
Vec::<u8>::new(),
"CMSG_GUILD_DEL_RANK body"
);
// CMSG_GUILD_RANK (Guild.cpp:78-83): u32 rankId, u32 rights, cstring rankName — the rights
// here are the officer set (chat listen/speak both channels, invite, promote, demote).
let rights = guild_rank_right::GCHAT_LISTEN
| guild_rank_right::GCHAT_SPEAK
| guild_rank_right::OFFCHAT_LISTEN
| guild_rank_right::OFFCHAT_SPEAK
| guild_rank_right::INVITE
| guild_rank_right::PROMOTE
| guild_rank_right::DEMOTE;
assert_eq!(rights, 0x0000_019F, "the officer rights mask");
assert_eq!(
messages::guild_rank(2, rights, "Veteran"),
hx(concat!("02000000", "9f010000", "5665746572616e00")),
"CMSG_GUILD_RANK body"
);
// CMSG_GUILD_SET_PUBLIC_NOTE / _OFFICER_NOTE (Guild.cpp:61-65 / 67-71): name then note, two
// cstrings — the same byte shape, distinguished only by opcode and by which right gates it.
assert_eq!(
messages::guild_set_public_note("Bob", "hi"),
hx(concat!("426f6200", "686900")),
"CMSG_GUILD_SET_PUBLIC_NOTE body"
);
assert_eq!(
messages::guild_set_officer_note("Bob", "hi"),
hx(concat!("426f6200", "686900")),
"CMSG_GUILD_SET_OFFICER_NOTE body"
);
}
/// Clearing the MOTD.
///
/// vmangos's read is guarded — `GuildMOTD::ReadFromWorldPacket` (Server/Packets/Guild.cpp:38-42)
/// is `if (!recv_data.empty()) recv_data >> motd;` — so a **zero-byte** body is a legal shape that
/// also means "clear it" (the packet's `motd` stays default-constructed). We emit the well-formed
/// one-byte empty cstring instead: one builder, one shape, and the empty string is not a special
/// case anywhere in the crate. Both land on the server as `motd == ""`, so this is a choice about
/// our own code, not about what the server does.
#[test]
fn guild_motd_clears_with_an_empty_cstring_not_an_empty_body() {
assert_eq!(
messages::guild_motd(""),
hx("00"),
"CMSG_GUILD_MOTD body, cleared"
);
assert_eq!(messages::guild_motd("").len(), 1, "one NUL, not zero bytes");
}
/// `SMSG_GUILD_QUERY_RESPONSE`: **exactly ten** rank-name cstrings, always — a fixed loop over the
/// sender's `rankNames[10]`, not a counted list (vmangos Server/Packets/Guild.cpp:118-131, filled
/// by Guild/Guild.cpp:862-880, whose own comment reads "show always 10 ranks").
///
/// The fixture gives the guild five real ranks, so five of the ten are empty strings. A parser
/// that read a count, or that stopped at the guild's real rank count, would land in the middle of
/// the five-`u32` emblem block — which is why the emblem values here are all distinct: getting
/// them back in order is the proof the ten were consumed.
#[test]
fn guild_query_response_always_carries_ten_rank_names() {
let mut body = Vec::new();
body.extend_from_slice(&7u32.to_le_bytes()); // guildId
body.extend_from_slice(b"Legacy of Steel\0"); // guildName
for name in [
b"Guild Master".as_slice(),
b"Officer".as_slice(),
b"Veteran".as_slice(),
b"Member".as_slice(),
b"Initiate".as_slice(),
] {
body.extend_from_slice(name);
body.push(0);
}
// The five ranks this guild never created: an empty string each — a bare NUL, not absent.
body.extend_from_slice(&[0u8; GUILD_RANKS_MAX_COUNT - 5]);
body.extend_from_slice(&1u32.to_le_bytes()); // emblemStyle
body.extend_from_slice(&2u32.to_le_bytes()); // emblemColor
body.extend_from_slice(&3u32.to_le_bytes()); // borderStyle
body.extend_from_slice(&4u32.to_le_bytes()); // borderColor
body.extend_from_slice(&5u32.to_le_bytes()); // backgroundColor
let packet = messages::parse_server(opcode::SMSG_GUILD_QUERY_RESPONSE, &body).unwrap();
let response = match &packet {
ServerPacket::GuildQueryResponse(r) => r.clone(),
other => panic!("expected GuildQueryResponse, got {}", other.name()),
};
assert_eq!(response.guild_id, 7);
assert_eq!(response.name, "Legacy of Steel");
assert_eq!(response.rank_names[0], "Guild Master");
assert_eq!(response.rank_names[4], "Initiate");
assert_eq!(
&response.rank_names[5..],
&["", "", "", "", ""],
"the five uncreated ranks are empty strings on the wire"
);
// The emblem block landing intact is what proves all ten strings were consumed.
assert_eq!(response.emblem_style, 1, "emblemStyle — the desync canary");
assert_eq!(response.emblem_color, 2);
assert_eq!(response.border_style, 3);
assert_eq!(response.border_color, 4);
assert_eq!(response.background_color, 5);
match decode(packet).as_slice() {
[SessionEvent::GuildQueryResponse(r)] => assert_eq!(r.name, "Legacy of Steel"),
other => panic!("guild query response decode: {other:?}"),
}
}
/// "No such guild" has no flag of its own: the answer is a normal, complete
/// `SMSG_GUILD_QUERY_RESPONSE` whose **guild name is empty**. The reference client branches on
/// exactly that (`0x5552ae test al,al` → cache insert `0x561070` vs cache remove `0x561390`,
/// wow-re `system/ui/scratch/guild-roster-wire.md`) and consumes the whole record either way.
///
/// So the not-found shape must still parse end to end — all ten rank strings and the emblem block
/// included — or a consumer keying off the empty name would be reading a half-parsed record.
#[test]
fn guild_query_response_reports_no_such_guild_as_an_empty_name() {
let mut body = Vec::new();
body.extend_from_slice(&404u32.to_le_bytes()); // the id we asked about
body.push(0); // guildName: empty == "this id names nothing"
body.extend_from_slice(&[0u8; GUILD_RANKS_MAX_COUNT]); // ten empty rank names, still sent
for _ in 0..5 {
body.extend_from_slice(&0u32.to_le_bytes()); // the emblem block, still sent
}
match &messages::parse_server(opcode::SMSG_GUILD_QUERY_RESPONSE, &body).unwrap() {
ServerPacket::GuildQueryResponse(response) => {
assert_eq!(response.guild_id, 404);
assert_eq!(response.name, "", "the empty name IS the not-found signal");
assert!(response.rank_names.iter().all(String::is_empty));
assert_eq!(response.background_color, 0, "the full record was consumed");
}
other => panic!("expected GuildQueryResponse, got {}", other.name()),
}
}
/// Append one `SMSG_GUILD_ROSTER` member per vmangos Server/Packets/Guild.cpp:155-172.
///
/// `last_online` is passed explicitly rather than derived from `presence`, so the *fixture* states
/// the wire rule independently of the parser under test: an offline member carries the float, an
/// online one does not.
#[allow(clippy::too_many_arguments)]
fn push_member(
body: &mut Vec<u8>,
guid: u64,
presence: u8,
name: &str,
rank_id: u32,
level: u8,
class: u8,
zone: u32,
last_online: Option<f32>,
public_note: &str,
officer_note: &str,
) {
body.extend_from_slice(&guid.to_le_bytes());
body.push(presence);
body.extend_from_slice(name.as_bytes());
body.push(0);
body.extend_from_slice(&rank_id.to_le_bytes());
body.push(level);
body.push(class);
body.extend_from_slice(&zone.to_le_bytes());
if let Some(days) = last_online {
body.extend_from_slice(&days.to_le_bytes());
}
body.extend_from_slice(public_note.as_bytes());
body.push(0);
body.extend_from_slice(officer_note.as_bytes());
body.push(0);
}
/// Append an `SMSG_GUILD_ROSTER` head (Server/Packets/Guild.cpp:143-153): member count, MOTD, info
/// text, then the counted rank-rights array.
fn roster_head(member_count: u32, motd: &str, info: &str, rank_rights: &[u32]) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&member_count.to_le_bytes());
body.extend_from_slice(motd.as_bytes());
body.push(0);
body.extend_from_slice(info.as_bytes());
body.push(0);
body.extend_from_slice(&(rank_rights.len() as u32).to_le_bytes());
for rights in rank_rights {
body.extend_from_slice(&rights.to_le_bytes());
}
body
}
/// **The one that matters.** `SMSG_GUILD_ROSTER`'s `f32 lastOnlineTime` is on the wire only when
/// the member's presence byte is `0` (vmangos Server/Packets/Guild.cpp:164-165, and independently
/// Guild/Guild.cpp:793-794's size accounting, which decides where the sender truncates).
///
/// The fixture is deliberately **online, offline, online**, and the assertions that count are on
/// the *third* member: it is the only one that can show the parse **re-synchronising** after a
/// record of a different width. Getting one member's own fields right proves the field list; only
/// a member that follows a wider one proves the stride. Mutation-checked — reading the float
/// unconditionally fails this test (and the officer-note one) on exactly those assertions.
#[test]
fn guild_roster_conditional_float_keeps_later_members_in_sync() {
let mut body = roster_head(
3,
"Raid at 8",
"We are a guild.",
&[guild_rank_right::ALL, 0x0000_019F, 0x0000_0003],
);
// 1. online — NO float.
push_member(
&mut body,
0x1111,
guild_presence::ONLINE,
"Alice",
0,
60,
2,
1519,
None,
"the GM",
"trusted",
);
// 2. offline — the float IS there. This is the member whose width differs.
push_member(
&mut body,
0x2222,
guild_presence::OFFLINE,
"Bob",
1,
58,
4,
1497,
Some(3.5),
"on holiday",
"back next week",
);
// 3. online again — the one that catches a desynchronised parse.
push_member(
&mut body,
0x3333,
guild_presence::ONLINE | guild_presence::AFK,
"Carol",
3,
42,
9,
141,
None,
"levelling",
"promote soon",
);
let packet = messages::parse_server(opcode::SMSG_GUILD_ROSTER, &body).unwrap();
let roster = match &packet {
ServerPacket::GuildRoster(r) => r.clone(),
other => panic!("expected GuildRoster, got {}", other.name()),
};
assert_eq!(roster.motd, "Raid at 8");
assert_eq!(roster.info, "We are a guild.");
assert_eq!(
roster.rank_rights,
vec![guild_rank_right::ALL, 0x0000_019F, 0x0000_0003],
"rank_rights' length is the guild's real rank count"
);
assert_eq!(roster.members.len(), 3);
let alice = &roster.members[0];
assert!(alice.is_online());
assert_eq!(alice.name, "Alice");
assert_eq!(alice.last_online_days, 0.0, "online: the float is not sent");
assert_eq!(alice.public_note, "the GM");
assert_eq!(alice.officer_note, "trusted");
let bob = &roster.members[1];
assert!(!bob.is_online());
assert_eq!(bob.name, "Bob");
assert_eq!(bob.last_online_days, 3.5, "offline: the float IS sent");
assert_eq!(bob.public_note, "on holiday");
assert_eq!(bob.officer_note, "back next week");
// The whole point of the fixture: everything about the member *after* the wider one.
let carol = &roster.members[2];
assert_eq!(carol.guid, 0x3333, "third member guid — the desync canary");
assert_eq!(carol.name, "Carol");
assert_eq!(carol.presence, guild_presence::ONLINE | guild_presence::AFK);
assert!(carol.is_online());
assert_eq!(carol.rank_id, 3);
assert_eq!(carol.level, 42);
assert_eq!(carol.class, 9);
assert_eq!(carol.zone, 141);
assert_eq!(carol.last_online_days, 0.0);
assert_eq!(carol.public_note, "levelling");
assert_eq!(carol.officer_note, "promote soon");
match decode(packet).as_slice() {
[SessionEvent::GuildRoster(r)] => assert_eq!(r.members.len(), 3),
other => panic!("guild roster decode: {other:?}"),
}
}
/// A roster with no members at all — the head alone, and nothing after the rank-rights array. The
/// degenerate shape a brand-new or just-disbanded guild produces; the member loop must simply not
/// run rather than reach for a first guid.
#[test]
fn guild_roster_with_no_members_is_just_the_head() {
let body = roster_head(0, "", "", &[guild_rank_right::ALL]);
let packet = messages::parse_server(opcode::SMSG_GUILD_ROSTER, &body).unwrap();
match &packet {
ServerPacket::GuildRoster(roster) => {
assert!(roster.members.is_empty());
assert_eq!(roster.motd, "");
assert_eq!(roster.info, "");
assert_eq!(roster.rank_rights, vec![guild_rank_right::ALL]);
}
other => panic!("expected GuildRoster, got {}", other.name()),
}
}
/// Officer notes blanked because *we* lack `GR_RIGHT_VIEWOFFNOTE`: vmangos decides that once, per
/// viewer (`canViewOfficerNote`, Guild/Guild.cpp:821), and writes `""` for every member
/// (`:844`) — the cstring is still on the wire, just empty. Dropping the field for an
/// unprivileged viewer would desynchronise exactly as the float does, so this pins that the
/// terminator is read.
#[test]
fn guild_roster_reads_the_empty_officer_notes_of_an_unprivileged_viewer() {
let mut body = roster_head(2, "motd", "info", &[guild_rank_right::ALL, 0x0000_0003]);
push_member(
&mut body,
0x1111,
guild_presence::OFFLINE,
"Alice",
0,
60,
2,
1519,
Some(0.25),
"note A",
"",
);
push_member(
&mut body,
0x2222,
guild_presence::ONLINE,
"Bob",
1,
58,
4,
1497,
None,
"note B",
"",
);
let packet = messages::parse_server(opcode::SMSG_GUILD_ROSTER, &body).unwrap();
match &packet {
ServerPacket::GuildRoster(roster) => {
assert_eq!(roster.members.len(), 2);
assert_eq!(roster.members[0].officer_note, "");
assert_eq!(
roster.members[1].name, "Bob",
"the second row still lines up"
);
assert_eq!(roster.members[1].public_note, "note B");
assert_eq!(roster.members[1].officer_note, "");
}
other => panic!("expected GuildRoster, got {}", other.name()),
}
}
/// The float's condition is the **whole presence byte** against zero, never `presence & ONLINE`.
///
/// That is the reference client's own predicate — it derives the flag with `0x4d0c12 test dl,dl` /
/// `0x4d0c1d setne cl` and branches on the result (wow-re `system/ui/scratch/guild-roster-wire.md`),
/// and the binary never tests bit `0x1` at all. The difference shows up on any presence byte with
/// a bit set that we don't have a name for: a whole-byte test reads it as online and skips the
/// float, a `& 0x1` mask reads it as offline and eats four bytes of the next field. This fixture
/// uses `0x08` — a bit no 1.12 flag owns — as the member *before* a normal one, so a masking
/// implementation desynchronises and fails on the second member.
#[test]
fn roster_presence_is_tested_whole_byte_not_masked_against_online() {
let mut body = roster_head(2, "", "", &[guild_rank_right::ALL]);
push_member(
&mut body, 0x1111, 0x08, // an unnamed presence bit: online, so NO float
"Alice", 0, 60, 2, 1519, None, "first", "",
);
push_member(
&mut body,
0x2222,
guild_presence::ONLINE,
"Bob",
1,
58,
4,
1497,
None,
"second",
"",
);
match &messages::parse_server(opcode::SMSG_GUILD_ROSTER, &body).unwrap() {
ServerPacket::GuildRoster(roster) => {
assert!(
roster.members[0].is_online(),
"an unnamed presence bit still means online"
);
assert_eq!(roster.members[0].last_online_days, 0.0);
assert_eq!(
roster.members[1].name, "Bob",
"a masked test would have desynchronised here"
);
assert_eq!(roster.members[1].public_note, "second");
}
other => panic!("expected GuildRoster, got {}", other.name()),
}
}
/// `SMSG_GUILD_EVENT`'s trailing guid rides on the **event id**, not on whether bytes remain.
///
/// The reference client's handler `0x5e7180` reads it in only the `0xc`/`0xd` arms of its jump
/// table (wow-re RF-0077, `system/object-layer/scratch/rf77-smsg-chat-wire-order.md`), while
/// vmangos *writes* one whenever `affectedPlayerGuid` is set — which its callers also do for
/// `GE_JOINED` (Handlers/GuildHandler.cpp:218) and `GE_LEFT` (`:405`). benilla is the client, so
/// the third case below is the interesting one: a `GE_JOINED` **with** vmangos's trailing guid
/// still reports `guid: None`, and its params parse correctly, exactly as the reference does.
#[test]
fn guild_event_trailing_guid_rides_on_the_event_id() {
// GE_SIGNED_ON (0x0c) — one param and the guid (Server/Packets/Guild.cpp:133-141).
let mut body = vec![guild_event::SIGNED_ON, 1];
body.extend_from_slice(b"Alice\0");
body.extend_from_slice(&0xDEAD_BEEFu64.to_le_bytes());
let packet = messages::parse_server(opcode::SMSG_GUILD_EVENT, &body).unwrap();
match &packet {
ServerPacket::GuildEvent(notice) => {
assert_eq!(notice.event, guild_event::SIGNED_ON);
assert_eq!(notice.params, vec!["Alice".to_string()]);
assert_eq!(notice.guid, Some(0xDEAD_BEEF));
}
other => panic!("expected GuildEvent, got {}", other.name()),
}
match decode(packet).as_slice() {
[SessionEvent::GuildEvent(notice)] => assert_eq!(notice.guid, Some(0xDEAD_BEEF)),
other => panic!("guild event decode: {other:?}"),
}
// GE_MOTD (0x02) — one param, no guid at all on the wire.
let mut body = vec![guild_event::MOTD, 1];
body.extend_from_slice(b"Raid at 8\0");
let packet = messages::parse_server(opcode::SMSG_GUILD_EVENT, &body).unwrap();
match &packet {
ServerPacket::GuildEvent(notice) => {
assert_eq!(notice.event, guild_event::MOTD);
assert_eq!(notice.params, vec!["Raid at 8".to_string()]);
assert_eq!(notice.guid, None);
}
other => panic!("expected GuildEvent, got {}", other.name()),
}
// GE_JOINED (0x03) — vmangos DOES append a guid here; the reference client never reads it.
// The params must still be right, and the guid must be reported absent.
let mut body = vec![guild_event::JOINED, 1];
body.extend_from_slice(b"Carol\0");
body.extend_from_slice(&0x1234u64.to_le_bytes());
match &messages::parse_server(opcode::SMSG_GUILD_EVENT, &body).unwrap() {
ServerPacket::GuildEvent(notice) => {
assert_eq!(notice.event, guild_event::JOINED);
assert_eq!(notice.params, vec!["Carol".to_string()]);
assert_eq!(
notice.guid, None,
"the client reads the guid for 0x0c/0x0d only — this one is trailing slack"
);
}
other => panic!("expected GuildEvent, got {}", other.name()),
}
// GE_PROMOTION (0x00) — the three-param maximum (promoter, promoted, new rank name).
let mut body = vec![guild_event::PROMOTION, 3];
body.extend_from_slice(b"Alice\0Bob\0Officer\0");
match &messages::parse_server(opcode::SMSG_GUILD_EVENT, &body).unwrap() {
ServerPacket::GuildEvent(notice) => {
assert_eq!(notice.params, vec!["Alice", "Bob", "Officer"]);
assert_eq!(notice.guid, None);
}
other => panic!("expected GuildEvent, got {}", other.name()),
}
// GE_DISBANDED (0x08) — no params, no guid: the two-byte minimum body.
let packet = messages::parse_server(
opcode::SMSG_GUILD_EVENT,
&hx(concat!("08", "00")), // event, paramCount
)
.unwrap();
match &packet {
ServerPacket::GuildEvent(notice) => {
assert_eq!(notice.event, guild_event::DISBANDED);
assert!(notice.params.is_empty());
assert_eq!(notice.guid, None);
}
other => panic!("expected GuildEvent, got {}", other.name()),
}
}
/// `SMSG_GUILD_COMMAND_RESULT` (Server/Packets/Guild.cpp:96-101): `u32 command`, cstring, `u32
/// result` — the string sits in the **middle**, which is the field order to get wrong.
///
/// The two cases here are the pair that share result `0x08`: with `QUIT` it is "the guild master
/// cannot leave", with anything else it is "you don't have permission". Carrying the command tag
/// through is what keeps them apart.
#[test]
fn guild_command_result_wire() {
let mut body = Vec::new();
body.extend_from_slice(&guild_command::INVITE.to_le_bytes());
body.extend_from_slice(b"Bob\0");
body.extend_from_slice(&guild_command_error::ALREADY_IN_GUILD_S.to_le_bytes());
let packet = messages::parse_server(opcode::SMSG_GUILD_COMMAND_RESULT, &body).unwrap();
match &packet {
ServerPacket::GuildCommandResult(result) => {
assert_eq!(result.command, guild_command::INVITE);
assert_eq!(result.name, "Bob");
assert_eq!(result.result, guild_command_error::ALREADY_IN_GUILD_S);
}
other => panic!("expected GuildCommandResult, got {}", other.name()),
}
match decode(packet).as_slice() {
[SessionEvent::GuildCommandResult(result)] => assert_eq!(result.name, "Bob"),
other => panic!("guild command result decode: {other:?}"),
}
// The collision: 0x08 under QUIT is ERR_GUILD_LEADER_LEAVE, under anything else it is
// ERR_GUILD_PERMISSIONS (vmangos Guild/Guild.h:106-107) — and both carry an empty string.
assert_eq!(
guild_command_error::LEADER_LEAVE,
guild_command_error::PERMISSIONS
);
let mut body = Vec::new();
body.extend_from_slice(&guild_command::QUIT.to_le_bytes());
body.extend_from_slice(b"\0");
body.extend_from_slice(&guild_command_error::LEADER_LEAVE.to_le_bytes());
match &messages::parse_server(opcode::SMSG_GUILD_COMMAND_RESULT, &body).unwrap() {
ServerPacket::GuildCommandResult(result) => {
assert_eq!(result.command, guild_command::QUIT);
assert_eq!(
result.name, "",
"the empty middle cstring is still consumed"
);
assert_eq!(result.result, 0x08);
}
other => panic!("expected GuildCommandResult, got {}", other.name()),
}
}
/// `SMSG_GUILD_INFO` (Server/Packets/Guild.cpp:103-111): the guild name, then day/month/year and
/// the member and account counts, five `u32`s in that order.
#[test]
fn guild_info_wire() {
let mut body = Vec::new();
body.extend_from_slice(b"Legacy of Steel\0");
body.extend_from_slice(&11u32.to_le_bytes()); // createdDay
body.extend_from_slice(&9u32.to_le_bytes()); // createdMonth
body.extend_from_slice(&2004u32.to_le_bytes()); // createdYear
body.extend_from_slice(&57u32.to_le_bytes()); // memberCount
body.extend_from_slice(&41u32.to_le_bytes()); // accountCount
let packet = messages::parse_server(opcode::SMSG_GUILD_INFO, &body).unwrap();
match &packet {
ServerPacket::GuildInfo(info) => {
assert_eq!(info.name, "Legacy of Steel");
assert_eq!(info.created_day, 11);
assert_eq!(info.created_month, 9);
assert_eq!(info.created_year, 2004);
assert_eq!(info.member_count, 57);
assert_eq!(
info.account_count, 41,
"accounts, not characters — the second count"
);
}
other => panic!("expected GuildInfo, got {}", other.name()),
}
match decode(packet).as_slice() {
[SessionEvent::GuildInfo(info)] => assert_eq!(info.account_count, 41),
other => panic!("guild info decode: {other:?}"),
}
}
/// The two notification packets: `SMSG_GUILD_INVITE` (Server/Packets/Guild.cpp:85-89) is inviter
/// then guild, two cstrings; `SMSG_GUILD_DECLINE` (`:91-94`) is one cstring, the decliner.
#[test]
fn guild_invite_and_decline_wire() {
let packet = messages::parse_server(
opcode::SMSG_GUILD_INVITE,
&hx(concat!("416c69636500", "4c656761637900")),
)
.unwrap();
match &packet {
ServerPacket::GuildInvite { inviter, guild } => {
assert_eq!(inviter, "Alice", "the inviter comes first");
assert_eq!(guild, "Legacy");
}
other => panic!("expected GuildInvite, got {}", other.name()),
}
match decode(packet).as_slice() {
[SessionEvent::GuildInvite { inviter, guild }] => {
assert_eq!(inviter, "Alice");
assert_eq!(guild, "Legacy");
}
other => panic!("guild invite decode: {other:?}"),
}
let packet = messages::parse_server(opcode::SMSG_GUILD_DECLINE, &hx("426f6200")).unwrap();
match &packet {
ServerPacket::GuildDecline { name } => assert_eq!(name, "Bob"),
other => panic!("expected GuildDecline, got {}", other.name()),
}
match decode(packet).as_slice() {
[SessionEvent::GuildDecline { name }] => assert_eq!(name, "Bob"),
other => panic!("guild decline decode: {other:?}"),
}
}
+62
View File
@@ -0,0 +1,62 @@
//! The innkeeper-bind **Era API surface** (decision 1331) — two globals, no snapshot.
//!
//! These are the whole Lua side of setting a hearthstone, and they exist because the reference's
//! `CONFIRM_BINDER` dialog is written against exactly them (`StaticPopup.lua:1308-1322`):
//! `OnAccept` calls [`ConfirmBinder`], and `OnUpdate` hides the dialog the moment
//! [`CheckBinderDist`] goes false. Both are engine bindings in 1.12 (`reference/1.12-globals.tsv`
//! lists `ConfirmBinder` and `CheckBinderDist` as `function`/`engine`), and there are no others in
//! the family — the question itself arrives as the `CONFIRM_BINDER` event's argument, so there is
//! nothing to read back, exactly like [`super::duel`].
//!
//! The app owns the innkeeper's guid ([`crate::script::UiScript::set_binder_pending`]'s writer),
//! which is why `ConfirmBinder()` takes no arguments and queues only a count.
use mlua::Lua;
use super::Model;
impl super::UiScript {
/// Drain the `ConfirmBinder()` calls queued since the last drain — each one is a
/// `CMSG_BINDER_ACTIVATE`. [`super::UiScript::take_played_time_asks`]'s shape, and a count for
/// the same reason: the intent has no payload of its own.
pub fn take_binder_confirms(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().binder_confirms)
}
/// Push whether an innkeeper's bind question is live and still in range — the host's half of
/// `CheckBinderDist()`. Idempotent; `false` covers both "no question pending" and "you walked
/// away", which is all the dialog's OnUpdate needs to decide to hide.
pub fn set_binder_pending(&mut self, pending: bool) {
let mut model = self.model_mut();
if model.binder_pending != pending {
model.binder_pending = pending;
}
}
}
/// Register the two binder globals (the style [`super::duel`] registers its four).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// ConfirmBinder() — the CONFIRM_BINDER dialog's Accept. The one call in the client that binds
// a hearthstone: everything before it is a question.
g.set(
"ConfirmBinder",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.binder_confirms += 1;
Ok(())
})?,
)?;
// CheckBinderDist() — polled from the dialog's OnUpdate; false hides it.
g.set(
"CheckBinderDist",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.binder_pending)
})?,
)?;
Ok(())
}
+16 -1
View File
@@ -342,7 +342,8 @@ impl UiScript {
// API change. The approximation is visible, and it is stated there and here
// rather than discovered later.
let fill = data.fill.or_else(|| data.gradient.map(|g| g.midpoint()));
let has_texture = data.texture.is_some() || fill.is_some();
let has_path = data.texture.is_some();
let has_texture = has_path || fill.is_some();
QuadContent::Texture {
path: data.texture,
color: has_texture
@@ -353,6 +354,13 @@ impl UiScript {
circular: data.circular,
portrait_unit: data.portrait_unit,
rotation: data.rotation,
// Only ever set against real ART. A pathless solid has its colour
// FOLDED into `color` two lines up (the renderer draws it as a tint on
// a 1x1 white texel), so a shader that greys the *texel* would grey
// white and change nothing — the flag would read as honoured while
// doing nothing at all. No reference consumer desaturates a solid;
// every one of them is an icon. Stated here rather than discovered.
desaturated: data.desaturated && has_path,
}
};
// A region draws at its owner's scale — same single hop as alpha (a region
@@ -403,6 +411,13 @@ impl UiScript {
/// **alpha included** (wow-re `system/ui/scratch/texture-color-composition.md`, VERIFIED — the
/// stage-0 combine's `MODULATE(TEXTURE, DIFFUSE)` for both colour and alpha).
///
/// **That law is scoped to a region with no pixel shader bound** (`+0x128 == 0` — wow-re
/// `texture-desaturate-law.md` §6.1's correction to that note). A DESATURATED region takes the
/// fragment program instead, which supersedes the whole stage chain and reads the vertex colour's
/// ALPHA only; its RGB never reaches the pixel. The colour computed here still travels — the
/// renderer needs the alpha, and the RGB is simply unread on that branch (`ui_quad.wgsl`) — so
/// nothing changes here, but the note this cites no longer says what it used to.
///
/// `fill` is the region's own solid-colour texture ([`RegionData::fill`] — the client generates a
/// real 8×8 texel block from it), so where it is set it IS the texel and the product is the drawn
/// colour. Where it isn't, the texel comes from `path`'s art and this returns the tint alone for the
File diff suppressed because it is too large Load Diff
+8
View File
@@ -41,6 +41,7 @@ mod addon_message;
mod aura;
mod backdrop;
mod bank;
mod binder;
mod binding_abi;
mod button;
mod channel;
@@ -68,6 +69,7 @@ mod follow;
pub(crate) mod font;
mod font_block;
mod gossip;
mod guild;
mod inspect;
mod item_stats;
mod item_text;
@@ -144,6 +146,10 @@ pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
pub use follow::FollowRequest;
pub use gossip::{GossipMenu, GossipOptionView, GossipQuestRow};
pub use guild::{
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
};
pub use inspect::InspectView;
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
@@ -496,6 +502,8 @@ impl UiScript {
unit::install(&lua)?;
party::install(&lua)?;
social::install(&lua)?;
guild::install(&lua)?;
binder::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
session::install(&lua)?;
+32 -5
View File
@@ -4,11 +4,11 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect,
item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, reputation,
session, skills, slider, social, spellbook, taxi, trade, tradeskill, trainer, weapon_enchant,
ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue,
SoundRequest, UnitState,
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, guild,
inspect, item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log,
reputation, session, skills, slider, social, spellbook, taxi, trade, tradeskill, trainer,
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -281,6 +281,19 @@ pub(crate) struct Model {
/// Social intents (`AddFriend`/`RemoveFriend`/`SendWho`/…) queued since the app's last
/// [`super::UiScript::take_social_requests`] drain — the outbound seam ([`social`]).
pub(crate) social_requests: Vec<social::SocialRequest>,
/// The guild snapshot the app pushes (roster, ranks, MOTD, info text — decision 1257):
/// `GetNumGuildMembers`/`GetGuildRosterInfo`/`GuildControlGetRankFlags`/… read it
/// ([`guild`]). Already display-resolved and already sorted + filtered, because the sort
/// field and the show-offline toggle live app-side where the roster does.
pub(crate) guild: guild::GuildState,
/// The rank-control popup's staging buffer ([`guild::GuildRankEdit`]) — deliberately NOT part
/// of [`Self::guild`], because a snapshot push mid-edit would discard the user's unsaved
/// checkbox clicks. Flushed by `GuildControlSaveRank`.
pub(crate) guild_control: guild::GuildRankEdit,
/// Guild intents (`GuildInviteByName`/`GuildSetMOTD`/`GuildControlSaveRank`/…) queued since
/// the app's last [`super::UiScript::take_guild_requests`] drain — the outbound seam
/// ([`guild`]).
pub(crate) guild_requests: Vec<guild::GuildRequest>,
/// Whisper targets `ChatFrame_SendTell` queued since the app's last
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
@@ -996,6 +1009,15 @@ pub(crate) struct Model {
/// Empty only before that packet has landed — `""`, never nil, matching [`Self::realm_name`]
/// beside it: a consumer concatenates this (`Necrosis.lua:1089`), so nil would be a raise.
pub(crate) bind_location: String,
/// `ConfirmBinder()` calls queued since the app last drained them — each is one
/// `CMSG_BINDER_ACTIVATE`. A COUNT for [`Self::played_time_asks`]'s reason: the intent carries
/// no payload (the app holds the innkeeper's guid), so two calls are two sends.
pub(crate) binder_confirms: u32,
/// Is an innkeeper's bind question still live and in range — the answer `CheckBinderDist()`
/// gives, pushed by the app each frame ([`super::UiScript::set_binder_pending`]). The
/// CONFIRM_BINDER dialog polls it from OnUpdate and hides itself when it goes false, which is
/// how walking away from the innkeeper takes the question off screen (decision 1331).
pub(crate) binder_pending: bool,
/// Frames per second, behind `GetFramerate()`. Pushed per tick by the app, which owns the
/// clock this crate does not have.
pub(crate) framerate: f64,
@@ -1103,6 +1125,9 @@ impl Model {
party_requests: Vec::new(),
social: social::SocialState::default(),
social_requests: Vec::new(),
guild: guild::GuildState::default(),
guild_control: guild::GuildRankEdit::default(),
guild_requests: Vec::new(),
tell_requests: Vec::new(),
open_chat_requests: Vec::new(),
default_language: None,
@@ -1266,6 +1291,8 @@ impl Model {
played_time_asks: 0,
realm_name: String::new(),
bind_location: String::new(),
binder_confirms: 0,
binder_pending: false,
framerate: 0.0,
modifiers: (false, false, false),
money: 0,
+54 -9
View File
@@ -1,7 +1,7 @@
//! Region method-table cluster: **paint** — what a Texture shows and how it is tinted,
//! blended, cropped and layered. Split out of `region.rs` at the 0716 file-size budget.
use mlua::{Lua, Table, Value};
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::object::{as_f32, draw_layer_from_str};
use crate::script::{Model, TexCoords};
@@ -154,6 +154,25 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let data = model.region_data.entry(rh).or_default();
data.circular = false;
data.portrait_unit = None;
// **`SetTexture` CLEARS the desaturation** (wow-re `texture-desaturate-law.md` §2.3,
// VERIFIED): `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture`
// (`0x770200`) writes it from its 4th stack arg, for which the Lua binding
// (`0x79bb40`) pushes slot 0 — permanently NULL — on both of its legs. A
// re-implementation that keeps a desaturate boolean independent of the texture
// handle diverges on every `icon:SetDesaturated(1)` followed by `icon:SetTexture(t)`.
//
// Scoped exactly as the binary scopes it:
// · the **same path** is inert — `0x770225` returns before ever reaching the write,
// so a repaint that re-sets the icon it already shows keeps its grey;
// · `nil`/`""` DO clear — the `test esi,esi` leg falls through to the write;
// · the **colour form** does NOT — that is `0x770360`, which is not among the four
// writers of the field.
let same_path = matches!((&arg, &data.texture),
(Value::String(s), Some(cur)) if s.to_str().is_ok_and(|s| *s == **cur));
let colour_form = matches!(&arg, Value::Number(_) | Value::Integer(_));
if !same_path && !colour_form {
data.desaturated = false;
}
// Both forms write the SAME `+0xcc` texture slot — the path form loads a file
// there (`0x770200`), the colour form generates an 8×8 solid into it
// (`0x770360`) — so each clears the other. NEITHER touches the vertex colour at
@@ -227,26 +246,52 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// elseif ( not r or not shaderSupported ) then r,g,b = 0.5,0.5,0.5 end
// icon:SetVertexColor(r, g, b)
//
// We have no desaturation in the renderer, so we answer **nil — unsupported**, which is a real
// 1.12 machine's answer and not a lie. Claiming support would suppress that grey fallback and
// leave disabled icons drawn at full colour: strictly worse-looking than saying no. When a
// desaturating shader lands, flip the return and this comment with it.
// The renderer now greys the texel (decision 1327 — `ui_quad.wgsl`'s luminance fold), so we
// answer **1 — supported**, and the branch above takes its shader arm: the icon goes greyscale
// AND wears the caller's own dim tint. Until 1327 this answered nil, which is a real 1.12
// card's answer and was the honest one while nothing greyed — but it costs the *look*: a 0.65
// grey multiply on colourful art is a dimmer, still-colourful icon, which is precisely what
// B162 reported against the talent tree.
//
// Why it matters far past one verb: **98 of the 109 addons that draw and then raise on being
// used, raise here** — `FuBar_Panel.lua:43`'s right-click reaches Dewdrop's `AddLine`
// (`Dewdrop-2.0.lua:2172`), which calls `button.arrow:SetDesaturated(true)` unguarded. A
// static scan costed this at 61 addons and it was declined; the use-probe costed it at 98 the
// moment anyone right-clicks.
//
// **The argument's truth table is not `if flag then`** (wow-re `texture-desaturate-law.md` §1.1,
// VERIFIED — `0x6f1c10(L, 2, default=1)` dispatched through the jump table at `0x6f1ce8`). Two
// of its arms are the opposite of the obvious reading, and both are reachable:
// · **no argument at all is ON**, not off — `LUA_TNONE` takes the `ja` default arm, so
// `tex:SetDesaturated()` greys. This is why the flag arrives as a `MultiValue`: mlua hands a
// missing `Value` parameter through as `Nil`, which is the one arm that means OFF.
// · **a number truncating to 0 is OFF** (`0x6f3620`+`0x40a2b0`), so `SetDesaturated(0)` clears.
// · a table/function/userdata is ON (same default arm as absent).
// The string arm (`0x6f1c51`, comparing against `0x871460`/`0x853758`) is NOT modelled — no
// corpus caller passes one, and the comparands were not read; a string takes the ON arm here.
m.set(
"SetDesaturated",
lua.create_function(|lua, (this, flag): (Table, Value)| {
let on = !matches!(flag, Value::Nil | Value::Boolean(false));
lua.create_function(|lua, args: MultiValue| {
let mut args = args.into_iter();
let this: Table = match args.next() {
Some(Value::Table(t)) => t,
_ => return Ok(Value::Nil),
};
let on = match args.next() {
None => true,
Some(Value::Nil) => false,
Some(Value::Boolean(b)) => b,
Some(Value::Integer(i)) => i != 0,
Some(Value::Number(n)) => n.trunc() != 0.0,
Some(_) => true,
};
let rh = region_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.region_data.entry(rh).or_default().desaturated = on;
// nil, not false: the reference's `shaderSupported` is a 1|nil C answer and callers
// 1, not true: the reference's `shaderSupported` is a 1|nil C answer (byte-verified —
// `0x6f3810 lua_pushnumber(L, 1.0)`, never `false`, never zero values) and callers
// write `not shaderSupported`.
Ok(Value::Nil)
Ok(Value::Number(1.0))
})?,
)?;
@@ -912,23 +912,23 @@ fn editbox_justify_v_echoes_but_multiline_alone_decides_the_pixels() {
);
}
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and ours says nil.**
/// **`Texture:SetDesaturated(flag)` answers `shaderSupported`, and since 1327 ours says yes.**
///
/// `0x79c1e0` (wow-re ledger). The reference's own `ItemButtonTemplate.lua:69` is
/// `local shaderSupported = icon:SetDesaturated(desaturated)`, and lines 70-78 fall back to a 0.5
/// grey vertex tint when that answer is falsy — 1.12 shipped on cards without the shader, so the
/// verb reporting "no" is a real machine's answer, not a stub.
///
/// We have no desaturating shader, so nil is the honest reply: claiming support would suppress
/// FrameXML's grey fallback and leave disabled icons at full colour, which looks *more* wrong.
/// The test pins the return shape rather than the state, because the return is what callers branch
/// on — and `nil`, not `false`, because the C answer is `1|nil` and the reference writes
/// `not shaderSupported`.
/// benilla answered nil for exactly as long as nothing greyed. Decision 1327 gave the renderer the
/// luminance fold, so the honest answer flipped: a caller that asks for grey now gets grey, and
/// keeps its own tint instead of having it overwritten by the 0.5 fallback. The test pins the
/// return SHAPE, because the return is what callers branch on — `1`, not `true`, because the C
/// answer is `1|nil` and every reference consumer writes `not shaderSupported`.
///
/// 98 of the 109 draw-then-raise addons in the corpus die on this one verb, via
/// `FuBar_Panel.lua:43` → Dewdrop `AddLine` → `button.arrow:SetDesaturated(true)`, unguarded.
#[test]
fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
fn set_desaturated_reports_shader_support_and_does_not_raise() {
let s = crate::script::UiScript::new().unwrap();
s.run(r#"f = CreateFrame("Frame", "DsF") tex = f:CreateTexture("DsTex", "ARTWORK")"#)
.unwrap();
@@ -936,11 +936,11 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
// Dewdrop's exact call — the one 98 addons reach. It must not raise.
s.run("DsTex:SetDesaturated(true)").unwrap();
// ...and it answers nil, so the reference's `not shaderSupported` branch is taken.
// ...and it answers truthy, so the reference's shader arm is taken.
assert!(
s.eval::<bool>("return DsTex:SetDesaturated(true) == nil")
s.eval::<bool>("return DsTex:SetDesaturated(true) == 1")
.unwrap(),
"shaderSupported must be nil (1|nil C shape), not false"
"shaderSupported must be 1 (1|nil C shape), not true"
);
assert_eq!(
s.eval::<i64>("return select('#', DsTex:SetDesaturated(true))")
@@ -949,11 +949,13 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
"one return value"
);
// The reference's own consumer, transcribed: a falsy answer greys via vertex colour.
// The reference's own consumer, transcribed: a truthy answer keeps the CALLER's tint, and the
// 0.5 fallback never fires. This is the half B162 turned on — the talent tree asks for
// `(1, 0.65, 0.65, 0.65)` and must get 0.65, greyscale, not a flat 0.5 colour multiply.
s.run(
r#"
local shaderSupported = DsTex:SetDesaturated(true)
local r, g, b = 1, 1, 1
local r, g, b = 0.65, 0.65, 0.65
if not shaderSupported then r, g, b = 0.5, 0.5, 0.5 end
DsTex:SetVertexColor(r, g, b)
"#,
@@ -962,7 +964,10 @@ fn set_desaturated_reports_no_shader_support_and_does_not_raise() {
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return DsTex:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (0.5, 0.5, 0.5), "the no-shader fallback greys");
assert!(
(r - 0.65).abs() < 1e-6 && (g - 0.65).abs() < 1e-6 && (b - 0.65).abs() < 1e-6,
"the shader arm keeps the caller's tint, got {r},{g},{b}"
);
// Both spellings of "off" are off — nil and false.
s.run("DsTex:SetDesaturated(nil) DsTex:SetDesaturated(false)")
@@ -534,6 +534,155 @@ fn a_solid_colour_texel_multiplies_with_the_vertex_colour() {
);
}
/// **`SetDesaturated` rides the extract, and only against real ART** (decision 1327).
///
/// The state was stored from the day the verb landed and read by nobody, which is why every
/// greyed-out affordance in the UI was a brightness tint (B162's talent tree). The flag now travels
/// on the quad, so this pins the two ends of that wire — set/clear — plus the one carve-out: a
/// PATHLESS solid has its colour folded into the quad's tint and draws against a 1x1 white texel,
/// so a shader that greys the texel would grey white and change nothing. Carrying the flag there
/// would read as honoured while doing nothing at all, so extract drops it.
#[test]
fn desaturation_rides_the_extract_for_art_and_never_for_a_solid() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "DsOwner")
f:SetPoint("BOTTOMLEFT", 0, 0)
f:SetWidth(100) f:SetHeight(50)
art = f:CreateTexture("DsArt", "ARTWORK")
art:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
art:SetAllPoints()
solid = f:CreateTexture("DsSolid", "OVERLAY")
solid:SetTexture(1, 0, 0)
solid:SetAllPoints()
"#,
)
.unwrap();
s.resolve();
let grey = |s: &UiScript, want_path: bool| {
s.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path, desaturated, ..
} if path.is_some() == want_path => Some(*desaturated),
_ => None,
})
.expect("the quad")
};
assert!(!grey(&s, true), "art starts full colour");
s.run("art:SetDesaturated(1) solid:SetDesaturated(1)")
.unwrap();
assert!(grey(&s, true), "the flag reaches the art quad");
assert!(
!grey(&s, false),
"a pathless solid never carries it — greying a white texel is a no-op dressed as a feature"
);
// And it clears — the reference's own `SetItemButtonDesaturated(button, nil)` restore.
s.run("art:SetDesaturated(nil)").unwrap();
assert!(!grey(&s, true), "clearing the flag restores full colour");
}
/// Read a Texture region's desaturation state straight off the model, by name.
///
/// It has no getter in Lua on purpose — `IsDesaturated` (`0x79c2c0`) is in wow-re's ledger but its
/// return shape is not carved, and inventing one to make a test convenient is how an unverified API
/// gets shipped (decision 1327's own residual). The extract quad is the other way to see it, but a
/// cleared texture emits no quad at all, which is exactly the case these tests need to observe.
fn desaturated(s: &UiScript, name: &str) -> bool {
let lua = s.lua();
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
let id = *model.region_names.get(name).expect("region name");
let h = *model.id_to_region.get(&id).expect("region handle");
model.region_data.get(&h).is_some_and(|d| d.desaturated)
}
/// **`SetTexture` clears the desaturation — except when the path does not actually change**
/// (wow-re `texture-desaturate-law.md` §2.3, VERIFIED; decision 1330).
///
/// `+0x128` is a `CGxShader*`, and `CSimpleTexture::SetTexture` writes it from a shader index the
/// Lua binding always passes as slot 0 (permanently NULL). Storing a desaturate boolean *beside*
/// the texture handle — which is what benilla did on 1327 — diverges on the single most common
/// FrameXML shape there is: a repaint that re-sets the icon and expects the grey to follow the art.
/// The same-path early-out (`0x770225`) is what makes the *idempotent* repaint keep its grey, and
/// it is the half a plausible implementation drops.
#[test]
fn set_texture_clears_desaturation_unless_the_path_is_unchanged() {
let s = script();
s.run(
r#"
local f = CreateFrame("Frame", "ClrOwner")
icon = f:CreateTexture("ClrIcon", "ARTWORK")
icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")
icon:SetDesaturated(1)
"#,
)
.unwrap();
let grey = |s: &UiScript| desaturated(s, "ClrIcon");
// The idempotent repaint: same file, so the client returns before it can clear anything.
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep")"#)
.unwrap();
assert!(grey(&s), "re-setting the SAME art keeps the grey");
// A different file reaches the write and zeroes the slot.
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Fire_Fireball")"#)
.unwrap();
assert!(!grey(&s), "a texture CHANGE clears the desaturation");
// The clear forms take the same leg (`test esi,esi` falls through to the write).
s.run("icon:SetDesaturated(1) icon:SetTexture(nil)")
.unwrap();
assert!(!grey(&s), "SetTexture(nil) clears it too");
// The COLOUR form is a different function (`0x770360`) and is not one of the field's writers.
s.run(r#"icon:SetTexture("Interface\\Icons\\Spell_Nature_Sleep") icon:SetDesaturated(1)"#)
.unwrap();
s.run("icon:SetTexture(1, 0, 0)").unwrap();
assert!(grey(&s), "the colour form does not touch the shader slot");
}
/// **`SetDesaturated`'s argument truth table has two arms that read backwards** (wow-re
/// `texture-desaturate-law.md` §1.1, VERIFIED at `0x6f1c10`'s jump table; decision 1330).
///
/// `0x6f1c10(L, 2, default=1)` takes its DEFAULT on `LUA_TNONE`, so a bare `SetDesaturated()` greys
/// — the opposite of the `if flag then` an implementation writes without looking. And a number is
/// truncated to an int, so `SetDesaturated(0)` clears where truthiness would have greyed.
#[test]
fn set_desaturated_takes_the_clients_argument_truth_table() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "ArgOwner") tex = f:CreateTexture("ArgTex", "ARTWORK")"#)
.unwrap();
let grey = |s: &UiScript| desaturated(s, "ArgTex");
// NO ARGUMENT is ON — the LUA_TNONE default arm, not the nil arm.
s.run("ArgTex:SetDesaturated()").unwrap();
assert!(
grey(&s),
"a bare SetDesaturated() greys (LUA_TNONE default)"
);
s.run("ArgTex:SetDesaturated(nil)").unwrap();
assert!(!grey(&s), "nil clears");
// A number truncating to zero clears; a non-zero one greys.
s.run("ArgTex:SetDesaturated(1) ArgTex:SetDesaturated(0)")
.unwrap();
assert!(!grey(&s), "0 clears — the number arm truncates to int");
s.run("ArgTex:SetDesaturated(0.5)").unwrap();
assert!(!grey(&s), "0.5 truncates to 0 and clears");
// Both booleans, and Dewdrop's `true` (the call 98 corpus addons reach).
s.run("ArgTex:SetDesaturated(true)").unwrap();
assert!(grey(&s), "true greys");
s.run("ArgTex:SetDesaturated(false)").unwrap();
assert!(!grey(&s), "false clears");
}
/// The draw gate is the TEXTURE slot, never the colour (`texture-color-composition.md` §4,
/// VERIFIED): `0x7706e0` tests `+0xcc` and emits NOTHING when it is empty, whatever the vertex
/// colour holds. Since the tint deliberately survives `SetTexture(nil)` ("a tint outlives the art
@@ -422,3 +422,89 @@ fn owner_anchored_tooltip_clamps_to_screen() {
.unwrap();
assert!(s.take_errors().is_empty());
}
/// `0x52fa50`'s ladder as pure logic (law §3-BUFF-TIME-FORMAT) — the arm thresholds, the
/// ceil/truncate asymmetry, and the `_P1` pick — over SYNTHETIC templates, so this asserts the
/// mechanism and carries none of the reference's own eight strings. The real ones are read off the
/// player's install and exercised in `benilla::ui_script::buff_tests`.
#[test]
fn the_duration_ladder_ceils_every_arm_but_seconds() {
// Deliberately not the shipped wording: what is under test is which key is reached and what
// number fills it, never what the string says.
let table = |key: &str| -> Option<String> {
Some(
match key {
"T_DAYS" => "<%d day>",
"T_DAYS_P1" => "<%d days>",
"T_HOURS" => "<%d hour>",
"T_HOURS_P1" => "<%d hours>",
"T_MIN" => "<%d min>",
"T_MIN_P1" => "<%d mins>",
"T_SEC" => "<%d sec>",
"T_SEC_P1" => "<%d secs>",
_ => return None,
}
.to_string(),
)
};
let d = |ms: u32| crate::script::tooltip::duration_text(ms, "T", true, &table);
// The three arm boundaries, from the compare instructions — each is exact, and the arm below
// it counts in its own unit right up to the edge.
assert_eq!(d(86_400_000).as_deref(), Some("<1 day>"), "the day edge");
assert_eq!(
d(86_399_999).as_deref(),
Some("<24 hours>"),
"one ms under a day is still HOURS, and ceil takes it to 24"
);
assert_eq!(d(3_600_000).as_deref(), Some("<1 hour>"), "the hour edge");
assert_eq!(
d(3_599_999).as_deref(),
Some("<60 mins>"),
"the carve's own example: no '1 hour' until the hour is whole"
);
assert_eq!(d(60_000).as_deref(), Some("<1 min>"), "the minute edge");
assert_eq!(
d(61_000).as_deref(),
Some("<2 mins>"),
"the carve's own example: 61 s ceils to 2, it does not truncate to 1"
);
// The asymmetry that makes this one function rather than four format calls: roundUp reaches
// the top three arms only.
assert_eq!(
d(59_999).as_deref(),
Some("<59 secs>"),
"seconds TRUNCATE — a ceil here would read 60"
);
assert_eq!(d(5_400).as_deref(), Some("<5 secs>"), "5.4 s is 5, not 6");
assert_eq!(
d(400).as_deref(),
Some("<0 secs>"),
"the lapsing second: 0, and PLURAL — the reference's own last reading"
);
assert_eq!(d(0).as_deref(), Some("<0 secs>"), "a fully lapsed aura");
assert_eq!(
d(1_000).as_deref(),
Some("<1 sec>"),
"exactly one is singular"
);
// roundUp = 0 truncates the top arms too (the parameter is real, not a constant we folded).
assert_eq!(
crate::script::tooltip::duration_text(61_000, "T", false, &table).as_deref(),
Some("<1 min>"),
);
// A key the string table does not carry yields NO LINE rather than an invented one.
assert_eq!(
crate::script::tooltip::duration_text(1_000, "NOPE", true, &table),
None
);
// …and a family shipping only the singular falls back to it rather than vanishing.
let singular_only = |key: &str| (key == "S_SEC").then(|| "<%d s>".to_string());
assert_eq!(
crate::script::tooltip::duration_text(5_000, "S", true, &singular_only).as_deref(),
Some("<5 s>"),
);
}
@@ -1,6 +1,7 @@
//! The engine spell/aura tooltip channel (decision 0274 P2, law per 0276): the verified line
//! shapes — name|rank, ONE cost|range line, ONE casttime|cooldown line, the passive omission,
//! the aura variant (white description + the SetPlayerBuff-only remaining line), and SetAction's
//! the aura variant (white description + the SetPlayerBuff-only remaining line, itself gold —
//! B62), and SetAction's
//! pure delegation.
use super::common::script;
@@ -253,6 +254,12 @@ fn player_buff_hover_is_the_aura_variant() {
);
s.run(
r#"
-- The duration line's wording comes from the VM's GlobalStrings, which the app runs off
-- the player's own install at boot. This VM has no install, so the test supplies the pair
-- itself — deliberately NOT the shipped wording, because what is under test here is that
-- the engine reads the string table at all, not what the strings say.
SPELL_TIME_REMAINING_MIN = "<%d m>"
SPELL_TIME_REMAINING_MIN_P1 = "<%d mm>"
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
@@ -260,7 +267,8 @@ fn player_buff_hover_is_the_aura_variant() {
tt:SetPlayerBuff(0)
assert(tt:NumLines() == 3, "name + description + remaining, got " .. tt:NumLines())
assert(TTTextLeft2:GetText() == "Intellect increased by 2.", "the AURA description column")
assert(TTTextLeft3:GetText() == "2 minutes remaining", "got " .. TTTextLeft3:GetText())
-- 90 s left ceils to 2 minutes, and 2 takes the _P1 twin.
assert(TTTextLeft3:GetText() == "<2 mm>", "got " .. TTTextLeft3:GetText())
"#,
)
.unwrap();
@@ -280,6 +288,16 @@ fn player_buff_hover_is_the_aura_variant() {
if t == "Arcane Intellect" && (c[1] - 210.0 / 255.0).abs() < 1e-6)
});
assert!(gold_name, "aura name is gold, not white");
// …and so is the DURATION-REMAINING line — the same `0xffffd200`, not the description's
// white. Report B62: measured on the reporter's own 1.12.1 reference shot
// (`media/1530672450247856148`), the "29 minutes remaining" glyphs are exactly
// `(255, 210, 0)`, the same pixels as that shot's title row, while its description rows are
// `(255, 255, 255)`. Ours drew the line white, which is the whole report.
let gold_remaining = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "<2 mm>" && *c == [1.0, 210.0 / 255.0, 0.0, 1.0])
});
assert!(gold_remaining, "duration-remaining line is gold, not white");
assert!(s.take_errors().is_empty());
}
@@ -817,3 +835,106 @@ fn set_craft_spell_selects_the_builder_like_the_trainer_hover_does() {
);
assert!(s.take_errors().is_empty());
}
/// Law §3-BUFF-DURATION's **gate**: `0x532b00` skips the duration block on `untilCancelled`
/// (`532bda: 8b 46 0c` / `532bdf: 75 2d`) — never on "has this aura a duration yet". The three
/// cases below are the ones that separate the two questions, and the middle two are what the old
/// `duration > 0 && left > 0` gate got wrong.
#[test]
fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(
1459,
SpellTooltipView {
name: "Arcane Intellect".into(),
aura_description: "Intellect increased by 2.".into(),
..Default::default()
},
);
s.tick(10.0);
s.run(
r#"
SPELL_TIME_REMAINING_MIN = "<%d m>"
SPELL_TIME_REMAINING_MIN_P1 = "<%d mm>"
SPELL_TIME_REMAINING_SEC = "<%d s>"
SPELL_TIME_REMAINING_SEC_P1 = "<%d ss>"
BENILLA_ANCHOR = CreateFrame("Button", "BF2")
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
BENILLA_TIP = CreateFrame("GameTooltip", "TT2")
"#,
)
.unwrap();
let hover = r#"
BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT")
BENILLA_TIP:SetPlayerBuff(0)
BENILLA_LINES = BENILLA_TIP:NumLines()
BENILLA_LAST = BENILLA_LINES == 3 and TT2TextLeft3:GetText() or ""
"#;
let mut show = |aura: AuraState| {
s.set_auras("player", Some(vec![aura]));
s.run(hover).unwrap();
(
s.eval::<i64>("return BENILLA_LINES").unwrap(),
s.eval::<String>("return BENILLA_LAST").unwrap(),
)
};
let base = AuraState {
spell_id: 1459,
name: Some("Arcane Intellect".into()),
helpful: true,
..Default::default()
};
// 1 · Permanent — and carrying a duration anyway, so only the FLAG can be doing the work.
// Title + description, nothing else.
assert_eq!(
show(AuraState {
duration: 1800.0,
expiration_time: 100.0,
until_cancelled: true,
..base.clone()
}),
(2, String::new()),
"untilCancelled suppresses the line even with a live duration on the record"
);
// 2 · Timed, but no duration packet has landed yet — the frame an aura appears. The old gate
// blanked this; the reference shows the line, counting from zero.
assert_eq!(
show(AuraState {
duration: 0.0,
expiration_time: 0.0,
until_cancelled: false,
..base.clone()
}),
(3, "<0 ss>".to_string()),
"a timed aura's first frames still carry the line"
);
// 3 · Lapsed — the reading the reference's own truncating seconds arm produces, which the old
// gate hid entirely.
assert_eq!(
show(AuraState {
duration: 30.0,
expiration_time: 9.6, // 0.4 s in the past at now = 10
until_cancelled: false,
..base.clone()
}),
(3, "<0 ss>".to_string()),
"a lapsed aura reads 0, it does not lose the line"
);
// Control: an ordinary live aura still counts, so none of the above is the line going missing
// for an unrelated reason.
assert_eq!(
show(AuraState {
duration: 1800.0,
expiration_time: 100.0,
until_cancelled: false,
..base
}),
(3, "<2 mm>".to_string()),
);
assert!(s.take_errors().is_empty());
}
@@ -400,6 +400,96 @@ pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
/// `0xffffd200` at `0xc0d3e8` (§0-BINDINGS verdict, 2026-07-14).
pub(in crate::script) const DEFAULT_TEXT_GOLD: [f32; 4] = [1.0, 210.0 / 255.0, 0.0, 1.0];
/// The client's duration-string formatter, `0x52fa50` — the one that turns a millisecond count
/// into "2 hours remaining" / "45 seconds remaining" (wow-re `ui/scratch/tooltip-content-law.md`
/// §3-BUFF-TIME-FORMAT, the 2026-08-14 carve). Its parameter vector is the binary's:
/// `(V_ms, keyPrefix, roundUp)`, minus the `extraArg` slot only the enchant line fills.
///
/// **The ladder, with the thresholds decoded from the compare instructions** (`52fa76`, `52fab4`,
/// `52fb03`, falling through to `0x52fb4f`): `≥ 86_400_000` → `_DAYS` · `≥ 3_600_000` → `_HOURS` ·
/// `≥ 60_000` → `_MIN` · else `_SEC`. Each arm divides by its own threshold, so the number shown is
/// always in the unit the arm names.
///
/// **`roundUp` ceils — but only in the top three arms; `_SEC` always truncates.** That asymmetry is
/// the whole reason this is a shared function and not four `format!`s, and it is what produces the
/// reference's three surprising readings: 61 s is "2 minutes remaining" (ceil, not "1"), 3 599 999 ms
/// is "60 minutes remaining" (the hour arm needs the full hour, so there is no "1 hour" until
/// exactly 3 600 000), and the final second before an aura lapses reads "**0** seconds remaining"
/// (truncation, not "1").
///
/// **The text is the player's own `GlobalStrings.lua`, never ours.** `get` is the VM-global lookup
/// (`load_global_strings` runs the shipped file into this VM at boot, off the patch chain); the
/// template arrives as `"%d minutes remaining"` and the count fills its one `%d`. A key the string
/// table doesn't carry yields **no line at all** rather than an invented one — the same disposition
/// 0620 §5 took for a reagent whose template hasn't streamed. That is also why this house ships no
/// copy of the eight strings: on an install they are read, and off one there is nothing to render.
pub(in crate::script) fn duration_text(
ms: u32,
key_prefix: &str,
round_up: bool,
get: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
const DAY_MS: u32 = 86_400_000;
const HOUR_MS: u32 = 3_600_000;
const MIN_MS: u32 = 60_000;
const SEC_MS: u32 = 1_000;
let (suffix, divisor) = if ms >= DAY_MS {
("DAYS", DAY_MS)
} else if ms >= HOUR_MS {
("HOURS", HOUR_MS)
} else if ms >= MIN_MS {
("MIN", MIN_MS)
} else {
("SEC", SEC_MS)
};
// `roundUp` reaches the day/hour/minute arms only — the seconds arm has no rounding branch at
// all, so it truncates whatever `roundUp` says.
let n = if round_up && divisor != SEC_MS {
ms.div_ceil(divisor)
} else {
ms / divisor
};
let template = plural_template(&format!("{key_prefix}_{suffix}"), n, get)?;
Some(template.replacen("%d", &n.to_string(), 1))
}
/// The `_P1` pick behind `GetText(token, gender, n)`. **The rule is the bare token for exactly one
/// and the `_P1` twin otherwise** — so a zero count takes the plural, which is why the lapsing
/// second reads "0 second*s* remaining".
///
/// **Byte-pinned** (wow-re §3-BUFF-PLURAL, `aff4df61`), which upgrades what this house had read
/// twice by inference — `FriendsFrame.xml`'s `guildPlural` and `TradeSkillFrame.xml`'s
/// `pluralAbbr`. The exe resolves nothing itself: `0x52fa50` snprintf's the bare key, then
/// `0x703bf0` looks up the **Lua global** `GetText` (`703c43: ba c8 2c 87 00`) and pcalls it with
/// (key, ordinal, gender), using what comes back as the printf format. The predicate lives in
/// `LocaleProperties.lua`'s `GetPluralIndex` — singular iff `not ordinal or ordinal == 1` — and
/// `> 1` is REFUTED: `703c89: 7d 07` is a *signed* test, so a zero ordinal is pushed as the number
/// `0` (Lua truthy) and takes the plural arm; only a negative becomes nil. Had it mapped zero to
/// nil the lapsing line would read "0 second remaining".
///
/// We collapse that to Rust rather than route through a `GetText` global because this line is
/// computed engine-side off the live clock, and because `LocaleProperties.lua` is a file this house
/// does not transcribe.
///
/// **Gender is not modelled, and that is faithful, not a gap:** `0x52fa50` pushes a literal `0` on
/// both arms (`52fb8d: 6a 00`, `52fbaa: 6a 00`), which `0x703bf0` turns into nil, and enUS folds
/// nil to no suffix.
///
/// **Falls back to the bare token when `_P1` is absent** — also byte-pinned: a nil `genderTag`
/// sends `GetText` down its short arm to `getglobal(token)`, the singular. (When *both* are absent
/// the reference renders an EMPTY line, off its pre-seeded `0x882748`; we render no line at all.
/// The divergence is unreachable with shipped data — all eight of this family's keys ship — and off
/// an install there is no string table to be faithful to.)
fn plural_template(token: &str, n: u32, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
if n != 1 {
if let Some(p1) = get(&format!("{token}_P1")).filter(|s| !s.is_empty()) {
return Some(p1);
}
}
get(token).filter(|s| !s.is_empty())
}
/// A colour-component argument — `lua_tonumber`'s coercion (a numeric string counts, `""` and
/// other strings don't).
fn color_num(v: Option<&Value>) -> Option<f32> {
+39 -14
View File
@@ -13,7 +13,8 @@
//! - reagents — inline red per missing item (the one builder that uses the `|cffff2020` escape;
//! joins when a reagent feed exists);
//! - description — gold, wrapped. An AURA's description is **white** (byte-verified difference),
//! and only `SetPlayerBuff` appends the duration-remaining line.
//! and only `SetPlayerBuff` appends the duration-remaining line, which is **gold** again
//! (`0xffffd200`, the title's gold — B62; see [`render_spell`]'s tail).
//!
//! The engine renders VIEWS ([`SpellTooltipView`]) the app resolves at push time — the $-token
//! substitution (values off Spell.dbc + the player's level), cast-time/duration/range text —
@@ -266,8 +267,14 @@ fn render_spell(
)?;
}
}
// The duration-remaining line (`SetPlayerBuff` only) is GOLD `0xffffd200` — the same gold as
// the aura title it sits under, NOT the description's white. Measured off the reporter's own
// 1.12.1 reference shot for B62 (`media/1530672450247856148`, Ice Armor in Wetlands): the
// "29 minutes remaining" glyphs read exactly `(255, 210, 0)`, pixel-identical to that shot's
// "Ice Armor" / "Magic" title row, while its description rows read `(255, 255, 255)`. Ours
// rendered it white (`media/1530672304877469696`, `(255, 255, 255)`) — that white *is* B62.
if let Some(rem) = remaining {
append_line(lua, this, (rem, WHITE), None, false)?;
append_line(lua, this, (rem, GOLD), None, false)?;
}
Ok(())
}
@@ -412,9 +419,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// GameTooltip:SetPlayerBuff(buffIndex) — the buff-bar hover: the aura variant (white
// AuraDescription) + the duration-remaining line only this entry point appends
// (byte-verified; remaining computed live off the aura's GetTime expiry — the TEXT shape is
// INTERIM until a live capture pins SPELL_TIME_REMAINING's exact number formatting).
// AuraDescription) + the duration-remaining line only this entry point appends (byte-verified;
// remaining computed live off the aura's GetTime expiry). The line's TEXT is no longer interim:
// §3-BUFF-TIME-FORMAT pinned `0x52fa50`'s four-arm ladder and its rounding, and
// `tooltip::duration_text` is it.
//
// **The argument is a 1.12 CACHE POSITION, not a filtered ordinal** — the same 0-based handle
// `GetPlayerBuff` returns and every `GetPlayerBuff*` sibling consumes (see
@@ -440,22 +448,31 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
let g = lua.globals();
g.get::<f64>("__benilla_now").unwrap_or(0.0)
};
let (spell_id, name, remaining) = {
let (spell_id, name, remaining_ms) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let hit = usize::try_from(index)
.ok()
.and_then(|pos| model.auras.get("player").and_then(|a| a.get(pos)));
match hit {
Some(a) => {
let left = a.expiration_time - now;
let rem = (a.duration > 0.0 && left > 0.0).then(|| {
if left >= 60.0 {
format!("{} minutes remaining", (left / 60.0).ceil() as i64)
} else {
format!("{} seconds remaining", left.ceil() as i64)
}
// The gate is `untilCancelled`, NOT "does this aura have a duration yet".
// §3-BUFF-DURATION: `0x532b00` skips the whole duration block when the
// cache record's `+0xc` is set (`532bda: 8b 46 0c` / `532bdf: 75 2d`), so
// a permanent aura shows title + description and nothing more. That flag
// is DBC-derived, so it is already right on the frame an aura appears —
// before any `SMSG_UPDATE_AURA_DURATION` lands — which is exactly why
// `AuraState::until_cancelled`'s own doc calls it a different question
// from `expiration_time == 0.0`. Gating on the duration (what this did)
// blanked the line for a timed aura's first frames, and hid the
// reference's own "0 seconds remaining" once one lapsed.
let ms = (!a.until_cancelled).then(|| {
// The reference counts integer milliseconds off GetTickCount; ours is
// a float second clock, so this is the nearest millisecond to it.
((a.expiration_time - now) * 1000.0)
.round()
.clamp(0.0, f64::from(u32::MAX)) as u32
});
(a.spell_id, a.name.clone(), rem)
(a.spell_id, a.name.clone(), ms)
}
// The miss clears rather than leaving the previous plate up — the same shape
// SetUnitBuff uses, and the one Loatheb's scan depends on: it breaks its loop
@@ -463,6 +480,14 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
None => (0, None, None),
}
};
// The text is `0x52fa50`'s, over the player's own GlobalStrings — never a string of
// ours. Off an install the table is absent and the line simply does not render.
let remaining = remaining_ms.and_then(|ms| {
let g = lua.globals();
super::tooltip::duration_text(ms, "SPELL_TIME_REMAINING", true, &|key| {
g.get::<String>(key).ok()
})
});
set_spell_by_id(
lua,
&this,
+8 -2
View File
@@ -123,6 +123,11 @@ pub enum QuadContent {
/// player arrow spins by it, standing in for the reference's engine arrow model; 0203
/// flags the stand-in). `0.0` for the overwhelmingly common unrotated case.
rotation: f32,
/// `Texture:SetDesaturated(1)` — draw the sampled texel as its **luminance** instead of
/// its colour (decision 1327). The renderer greys the texel and *then* modulates by
/// `color`, because the reference's own consumers pass a dim tint alongside the flag
/// (`SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)`) and expect both to land.
desaturated: bool,
},
/// One frame `Backdrop` piece (the tiled bg, or one of the 8 border pieces) — emitted at the
/// owning frame's own draw slot, behind its regions ([`UiScript::extract`](super::UiScript::extract)). A textured quad
@@ -446,8 +451,9 @@ pub(crate) struct RegionData {
/// unset inherited from the owner frame's rect.
pub(crate) anchors: Vec<Anchor>,
/// `Texture:SetDesaturated(flag)` — the shader desaturation state (`0x79c1e0`, verified in
/// wow-re's ledger). **Stored; the renderer does not yet honour it**, which is exactly why the
/// binding answers "shader unsupported" — see `region.rs`'s `SetDesaturated`.
/// wow-re's ledger). Rides the extract as [`QuadContent::Texture::desaturated`] and the
/// renderer greys the texel by it (decision 1327), so the binding answers "shader supported"
/// — see `region/paint.rs`'s `SetDesaturated`.
pub(crate) desaturated: bool,
/// `SetNonSpaceWrap` / `CanNonSpaceWrap` — FontString only (`0x79e9f0`/`0x79ead0`, wow-re's
/// widget-method batch). **State only here.** The real client's gx flag `0x40` feeds a
+5
View File
@@ -90,6 +90,11 @@ pub struct UnitState {
/// This token is a PLAYER character (guid family) — the unit tooltip's level line renders
/// "Race Class (Player)" instead of the creature type (decision 0276's verified law).
pub is_player: bool,
/// The unit's guild membership (`GetGuildInfo(unit)`, decision 1257). `None` = guildless, or
/// a creature, or a player whose `PLAYER_GUILDID` has not streamed yet. Filled from the
/// PUBLIC descriptor fields 191/192 joined against the app's guild-identity cache — see
/// [`super::guild::UnitGuild`].
pub guild: Option<super::guild::UnitGuild>,
/// The NPC subtitle line ("Stable Master") — the creature template's subname; the unit
/// tooltip's second line. `None` for players/untitled creatures.
pub subtitle: Option<String>,