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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¬e)
|
||||
}
|
||||
ClientCommand::GuildSetOfficerNote { name, note } => {
|
||||
w.guild_set_officer_note(&name, ¬e)
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¬ice, 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(¬ice, 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(¬ice, 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(¬ice, 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¬ice);
|
||||
push_lines(chat_log, lines::event_line(¬ice, 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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(®ion)?;
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user