guild charters: the registrar, the charter, and the twelve verbs

- founding a guild: fourteen opcodes, eight goldens (four mutation-checked), twelve engine globals, `GuildRegistrarFrame` and `PetitionFrame` — `CMSG_PETITION_BUY` is a 72-byte frame the server reads two fields of and drops in silence if short, `allowedGender` is the family's one u16, a signature is a 12-byte stride not 8, and `MSG_PETITION_DECLINE` writes the charter guid and reads the decliner's — same width, opposite meaning
- two sessions, not one: the registrar is an NPC session (closed by walking away — its show event is one of the fourteen callers of the interact latch, so the registrar turns to face you) and the charter is bound to an item guid you carry across the city; right-clicking a charter in your bags opens it, keyed on template flag `0x2000` (item 5863 has no on-use spell and no quest, so every existing item-use arm declined it)
- the byte carve came back and rescored six named guesses — three confirmed, two corrected, one refuted: `PETITION_SHOW` is deferred until every signer name has resolved (the window never paints blank rows), `ClosePetition` on somebody else's charter sends `MSG_PETITION_DECLINE` ("sends nothing" had been chosen on principle and was wrong half the time), and founding a guild is silent — no `ERR_GUILD_FOUNDER_S`, the reference emits no text at all; the four success ids go to chat and the five refusals to the red error frame, three paraphrased strings are now the shipped ones (a sweep asserts all fifteen), and the `npc` unit token had never been fed — the registrar's name banner was blank; `WOW_PROBE_CHARTER=1` drives gossip → buy → open → rename → destroy live, 10/10

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:21:00 +07:00
co-authored by Claude Fable 5
parent 1c4cc147f5
commit a5cb3cf746
35 changed files with 6181 additions and 28 deletions
@@ -0,0 +1,346 @@
<Ui>
<!--
benilla's guild registrar (decision 1672) — the window the "How do I form a guild?" gossip
row opens, and the only place a guild charter can be bought or turned in.
The geometry and art are the real `GuildRegistrarFrame.xml`'s (extracted at
`WoW/_extracted_framexml/GuildRegistrarFrame.{xml,lua}`; `ref l.N` cites are into those).
Every global, frame name, event, texture, string and number below is the reference's and is
the contract addons bind to (1260's rule, still the rule after 1602 retired the
transcription gate): what is ours is the expression — named functions instead of inline
handler bodies, the service rows over one small local template, and this comment.
TWO PANELS ON ONE CHROME, exactly as the reference has it (ref l.116, l.196):
GUILD_REGISTRAR_SHOW → the window opens on GuildRegistrarGreetingFrame — the two
service rows, "Purchase a Guild Charter" and "Register a Guild
Charter".
Purchase clicked → GuildRegistrarPurchaseFrame swaps in: the explanatory text, the
cost, and the guild-name box. NOT a second window and NOT a
server round trip — `GuildRegistrar_ShowPurchaseFrame` is a pure
local swap (ref l.8-12).
GUILD_REGISTRAR_CLOSED → the window hides.
WHY THE COST IS READ AT PANEL-SWAP TIME AND NOT AT SHOW TIME: the reference calls
`MoneyFrame_Update("GuildRegistrarMoneyFrame", GetGuildCharterCost())` inside
`GuildRegistrar_ShowPurchaseFrame` (ref l.10) — the greeting panel never displays a price.
Ours does the same, and it matters here: `GetGuildCharterCost()` answers from the *open
registrar's* showlist row, so reading it before a registrar is open would paint a 0.
THE WINDOW IS NOT SELF-CLOSING ON PURCHASE BY ACCIDENT. Both the Purchase button and the
edit box's Enter key hide the whole panel after sending (ref l.253-257, l.312-315) — a buy
is one-shot, and the charter arrives as an item rather than as any answer to this window.
That is the reference's own behaviour and it is quoted, not invented.
UIPanel slot system (decision 0084 §2): carries NO hardcoded <Anchors> — ShowUIPanel seats
it in the left slot from `UIPanelWindows["GuildRegistrarFrame"]` (UiPanels.xml), which is
the reference's own registration (`UIParent.lua:33`, area="left", pushable=0). Hiding it
goes through HideUIPanel so the slot is released and ESC reaches it.
-->
<Script><![CDATA[
-- ── The registrar's strings (GlobalStrings.lua, verbatim) ──────────────────────────────
-- The `text=` attributes below name these KEYS rather than spelling the words, which is
-- what the reference's own XML does and what the loader resolves (0991). In a real session
-- the app has already run the shipped GlobalStrings.lua, so these lines are the
-- standalone-load copy — same values, same file, line numbers named. `X = X or "…"`
-- throughout, never a bare `=`, for FriendsFrame.xml's reason: CANCEL and PURCHASE are
-- generic names the real string table already owns, and a bare assignment would overwrite
-- the shipped value with this copy on every load.
AVAILABLE_SERVICES = AVAILABLE_SERVICES or "Available Services" -- l.128
COSTS_LABEL = COSTS_LABEL or "Cost:" -- l.870
PURCHASE = PURCHASE or "Purchase" -- l.3128
CANCEL = CANCEL or "Cancel" -- l.465
GUILD_CHARTER_PURCHASE = GUILD_CHARTER_PURCHASE or "Purchase a Guild Charter" -- l.2058
GUILD_CHARTER_REGISTER = GUILD_CHARTER_REGISTER or "Register a Guild Charter" -- l.2059
GUILD_REGISTRAR_PURCHASE_TEXT = GUILD_REGISTRAR_PURCHASE_TEXT or "To create a guild you must purchase this charter, get 9 unique player signatures, and return the charter to me. Please enter the desired name for your guild." -- l.2107
-- ref GuildRegistrarFrame.lua:1-6. Every show starts on the services list: the purchase
-- panel is a transient sub-view, so re-opening the window must not land on a half-filled
-- name box from last time.
function GuildRegistrar_OnShow()
GuildRegistrarGreetingFrame:Show()
GuildRegistrarPurchaseFrame:Hide()
SetPortraitTexture(GuildRegistrarFramePortrait, "npc")
GuildRegistrarFrameNpcNameText:SetText(UnitName("npc"))
end
-- ref GuildRegistrarFrame.lua:8-12. A pure local panel swap — no packet, no round trip.
-- The cost is read HERE rather than on show because this is the only panel that displays
-- one (see the file header).
function GuildRegistrar_ShowPurchaseFrame()
GuildRegistrarPurchaseFrame:Show()
GuildRegistrarGreetingFrame:Hide()
MoneyFrame_Update("GuildRegistrarMoneyFrame", GetGuildCharterCost())
end
-- The buy, shared by the Purchase button and the box's Enter key (ref l.253-257 and
-- l.312-315, which carry the same three lines twice). Hiding the panel after sending is
-- the reference's own behaviour: a buy is one-shot and its answer is an item arriving, not
-- anything this window will be told.
function GuildRegistrar_Purchase(name)
BuyGuildCharter(name)
HideUIPanel(GuildRegistrarFrame)
GuildRegistrar_RefocusChat()
end
-- The reference indexes `ChatFrameEditBox` bare (ref l.256, l.314, l.317). Ours nil-guards
-- it, this house's convention everywhere it is touched from outside ChatFrame.xml
-- (FriendsFrame.xml's four dialogs, BagFrame.xml) — an unguarded index is a real error line
-- for anyone who loads this window without the chat frame, which the reference never has to
-- consider because there FrameXML is all-or-nothing. The refocus itself stays
-- UNCONDITIONAL when the box exists: the popups' extra `IsVisible()` test is a rule about
-- dialogs, not about this window, and borrowing it would change behaviour rather than
-- harden it.
function GuildRegistrar_RefocusChat()
if ChatFrameEditBox then
ChatFrameEditBox:SetFocus()
end
end
-- ref l.318-327. The show arm's `IsVisible` re-test is the reference's own: ShowUIPanel can
-- refuse (another window owns the slot, or the player is dead), and a registrar session the
-- server thinks is open with no window on screen is a stuck NPC — so the client closes it
-- back out immediately.
function GuildRegistrar_OnEvent(event)
if event == "GUILD_REGISTRAR_SHOW" then
ShowUIPanel(GuildRegistrarFrame)
if not GuildRegistrarFrame:IsVisible() then
CloseGuildRegistrar()
end
elseif event == "GUILD_REGISTRAR_CLOSED" then
HideUIPanel(GuildRegistrarFrame)
end
end
]]></Script>
<!-- One service row (ref inherits="QuestTitleButtonTemplate", QuestFrameTemplates.xml l.174-213):
a bullet plus the service name. Local rather than QuestFrame.xml's
`BenillaQuestTitleButtonTemplate` because that one hardwires `QuestTitleButton_OnClick()`
and would drag this window's load order behind the questgiver's for a fifteen-line shape.
Same LEFT-anchored title as that template, and for the same reason: the round bullet is
cell-centred, so a TOPLEFT-anchored label rides above it (QuestFrame.xml's own note). -->
<Button name="BenillaGuildRegistrarServiceTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="285" y="16"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\QuestFrame\UI-Quest-BulletPoint">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</Texture>
</Layer>
</Layers>
<HighlightTexture file="Interface\QuestFrame\UI-QuestTitleHighlight" alphaMode="ADD"/>
<ButtonText>
<Size><AbsDimension x="275" y="0"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="20" y="0"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="QuestFont" justifyH="LEFT"/>
</Button>
<Frame name="GuildRegistrarFrame" toplevel="true" parent="UIParent" movable="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="384" y="512"/></Size>
<!-- ref l.16-19: the window's own dead zone at the right edge and along the bottom, so a
drag on the parchment's painted border does not grab the window. -->
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="70"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<!-- The NPC's face (ref l.21-31), filled by SetPortraitTexture on show. -->
<Texture name="GuildRegistrarFramePortrait">
<Size><AbsDimension x="60" y="60"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The quest-greeting parchment, four quadrant slabs (ref l.34-65) — the same art
GossipFrame and QuestFrame wear, at the same 384x512. -->
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<!-- ref l.66-79. Defined but never written by the shipped UI; kept because it is a
name an addon can reach. -->
<FontString name="GuildRegistrarText" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The title banner (ref l.83-107): the NPC's name, two frame levels above the
parchment so it is not painted over. -->
<Frame name="GuildRegistrarNpcNameFrame">
<Size><AbsDimension x="300" y="14"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="GuildRegistrarFrame" relativePoint="TOP"><Offset><AbsDimension x="0" y="-23"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString name="GuildRegistrarFrameNpcNameText" inherits="GameFontHighlight"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>this:SetFrameLevel(this:GetFrameLevel() + 2)</OnLoad>
</Scripts>
</Frame>
<Button name="GuildRegistrarFrameCloseButton" inherits="UIPanelCloseButton">
<Anchors>
<Anchor point="CENTER" relativeTo="GuildRegistrarFrame" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-42" y="-30"/></Offset></Anchor>
</Anchors>
</Button>
<!-- ── Panel 1: the services list ─────────────────────────────────────────────── -->
<Frame name="GuildRegistrarGreetingFrame" setAllPoints="true" hidden="true">
<Layers>
<Layer level="BACKGROUND">
<FontString name="AvailableServicesText" inherits="QuestTitleFont" text="AVAILABLE_SERVICES" justifyH="LEFT">
<Size><AbsDimension x="300" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="35" y="-100"/></Offset></Anchor></Anchors>
</FontString>
<!-- The torn-parchment patch along the bottom edge (ref l.127-138). -->
<Texture file="Interface\QuestFrame\UI-Quest-BotLeftPatch">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="22" y="68"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<Button name="GuildRegistrarFrameGoodbyeButton" inherits="UIPanelButtonTemplate" text="CANCEL">
<Size><AbsDimension x="75" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="GuildRegistrarFrame" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-40" y="72"/></Offset></Anchor>
</Anchors>
<Scripts><OnClick>HideUIPanel(GuildRegistrarFrame)</OnClick></Scripts>
</Button>
<Button name="GuildRegistrarButton1" inherits="BenillaGuildRegistrarServiceTemplate" text="GUILD_CHARTER_PURCHASE">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="AvailableServicesText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-10" y="-10"/></Offset></Anchor>
</Anchors>
<Scripts><OnClick>GuildRegistrar_ShowPurchaseFrame()</OnClick></Scripts>
</Button>
<Button name="GuildRegistrarButton2" inherits="BenillaGuildRegistrarServiceTemplate" text="GUILD_CHARTER_REGISTER">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GuildRegistrarButton1" relativePoint="BOTTOMLEFT"/>
</Anchors>
<!-- Turn the charter in. Takes no argument: the engine finds the charter in
the bags, because the wire wants an item guid and Lua has no way to
name one. A player with no charter gets ERR_NO_GUILD_CHARTER and no
packet leaves (`ui_petition::feed`'s drain). -->
<Scripts><OnClick>TurnInGuildCharter()</OnClick></Scripts>
</Button>
<!-- The reference's third row, "Design a Guild Crest", is COMMENTED OUT in the
shipped file (ref l.183-194) and its `GUILD_CREST_DESIGN` string does not
exist in 1.12's GlobalStrings — tabard design runs off its own NPC gossip
option, not this window. Not shipped here either, deliberately: the
reference's own dead code is not a gap to fill. -->
</Frames>
</Frame>
<!-- ── Panel 2: buy a charter ─────────────────────────────────────────────────── -->
<Frame name="GuildRegistrarPurchaseFrame" setAllPoints="true" hidden="true">
<Layers>
<Layer level="ARTWORK">
<FontString name="GuildRegistrarPurchaseText" inherits="QuestFont" text="GUILD_REGISTRAR_PURCHASE_TEXT" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="35" y="-95"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="GuildRegistrarCostLabel" inherits="GameFontNormal" text="COSTS_LABEL">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GuildRegistrarPurchaseText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The shared money kit (MoneyFrame.xml), STATIC type — a read-only price, no
click-to-edit. GetGuildCharterCost() answers in copper, which is this
kit's whole vocabulary. -->
<Frame name="GuildRegistrarMoneyFrame" inherits="MoneyFrameTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="GuildRegistrarCostLabel" relativePoint="RIGHT"><Offset><AbsDimension x="15" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>
MoneyFrame_OnLoad()
MoneyFrame_SetType("STATIC")
</OnLoad>
</Scripts>
</Frame>
<Button name="GuildRegistrarFrameCancelButton" inherits="UIPanelButtonTemplate" text="CANCEL">
<Size><AbsDimension x="75" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="GuildRegistrarFrame" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-40" y="72"/></Offset></Anchor>
</Anchors>
<Scripts><OnClick>HideUIPanel(GuildRegistrarFrame)</OnClick></Scripts>
</Button>
<Button name="GuildRegistrarFramePurchaseButton" inherits="UIPanelButtonTemplate" text="PURCHASE">
<Size><AbsDimension x="85" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="GuildRegistrarFrame"><Offset><AbsDimension x="22" y="72"/></Offset></Anchor>
</Anchors>
<Scripts><OnClick>GuildRegistrar_Purchase(GuildRegistrarFrameEditBox:GetText())</OnClick></Scripts>
</Button>
<!-- 24 letters is the server's own MAX_CHARTER_NAME, so the box cannot compose
a name the server will reject for length (ref l.271). -->
<EditBox name="GuildRegistrarFrameEditBox" letters="24" historyLines="1">
<Size><AbsDimension x="130" y="32"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="GuildRegistrarCostLabel" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\ChatFrame\UI-ChatInputBorder-Left">
<Size><AbsDimension x="75" y="32"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.29296875" top="0" bottom="1.0"/>
</Texture>
<Texture 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>
</Layer>
</Layers>
<Scripts>
<OnEnterPressed>GuildRegistrar_Purchase(this:GetText())</OnEnterPressed>
<OnEscapePressed>GuildRegistrar_RefocusChat()</OnEscapePressed>
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
</Frames>
</Frame>
</Frames>
<Scripts>
<OnLoad>
this:RegisterEvent("GUILD_REGISTRAR_SHOW")
this:RegisterEvent("GUILD_REGISTRAR_CLOSED")
</OnLoad>
<OnEvent>GuildRegistrar_OnEvent(event)</OnEvent>
<OnShow>
GuildRegistrar_OnShow()
PlaySound("igQuestListOpen")
</OnShow>
<!-- CloseGuildRegistrar() on hide is load-bearing, not tidiness: it is what clears the
engine's registrar session, so the next GUILD_REGISTRAR_SHOW is a real open (and
GetGuildCharterCost() stops quoting a price for a registrar nobody is talking to).
Same shape as GossipFrame's CloseGossip(). -->
<OnHide>
PlaySound("igQuestListClose")
CloseGuildRegistrar()
</OnHide>
</Scripts>
</Frame>
</Ui>
@@ -0,0 +1,389 @@
<Ui>
<!--
benilla's guild-charter window (decision 1672) — the sheet of parchment nine people have to
sign before a guild exists. It opens on a charter: yours, clicked in your bags, or somebody
else's, offered to you.
The geometry and art are the real `PetitionFrame.xml`'s (extracted at
`WoW/_extracted_framexml/PetitionFrame.{xml,lua}`; `ref l.N` cites are into those). Every
global, frame name, event, texture, string and number is the reference's — the contract
addons bind to. What is ours is the expression: `PetitionFrame_Update` is four small painters
over a loop instead of one function, the event ladder is a named function, and the nine name
rows are built by a loop rather than nine near-identical FontString blocks.
ONE WINDOW, TWO FACES, decided by a single value (ref PetitionFrame.lua:10-21):
isOriginator → the LEADER's view: "Request Signature" + "Rename Guild", and the
leader instructions.
not → the SIGNER's view: "Sign Charter", and the member instructions.
The two buttons occupy the SAME anchor (ref l.304-336, both BOTTOMLEFT +22,+72) — they are
never both visible, which is why Show/Hide rather than Enable/Disable does the switching.
WHAT THE WINDOW OPENS WITHOUT, AND WHY IT REPAINTS. `SMSG_PETITION_SHOW_SIGNATURES` carries
an item guid, an owner guid, a petition id and a list of signer guids — no text at all. The
guild's proposed name comes from a separate petition-record query and the names from the
name cache, so the first paint is deliberately partial: the title reads empty and unresolved
signers read blank until their lookups land, at which point the engine re-fires
PETITION_SHOW and this file repaints. `ShowUIPanel` early-returns on an already-visible
frame and the open sound rides OnShow, so a repaint costs one `PetitionFrame_Update`.
(`ui_petition`'s module doc has the mechanism and flags what is INFERRED in it.)
THE TWO SIGNATURE NUMBERS ARE DIFFERENT NUMBERS, and this file uses both — that is the
reference's own doing, not a wrinkle we added. `MAX_PETITION_SIGNATURES` (9) sizes the name
ROWS; `maxSignatures`, from GetPetitionInfo, is what the wire says this charter REQUIRES and
is what disables Request Signature (ref l.30-40). They agree at 9 on a stock server and need
not on one that lowers its signature requirement.
UIPanel slot system (decision 0084 §2): no hardcoded <Anchors> — ShowUIPanel seats it in the
left slot from `UIPanelWindows["PetitionFrame"]` (UiPanels.xml), the reference's own
registration (`UIParent.lua:34`, area="left", pushable=0).
-->
<Script><![CDATA[
-- ── The charter's strings (GlobalStrings.lua, verbatim) ────────────────────────────────
-- The standalone-load copy of every key this file's `text=` attributes and Lua name; see
-- GuildRegistrarFrame.xml's twin block for why it is `X = X or "…"` and never a bare `=`.
GUILD_NAME = GUILD_NAME or "Guild Name" -- l.2090
GUILD_RANK0_DESC = GUILD_RANK0_DESC or "Guild Master" -- l.2102
MEMBERS = MEMBERS or "Members" -- l.2683
NOT_YET_SIGNED = NOT_YET_SIGNED or "<not yet signed>" -- l.2807
GUILD_CHARTER_TEMPLATE = GUILD_CHARTER_TEMPLATE or "%s Guild Charter" -- l.2060
SIGN_CHARTER = SIGN_CHARTER or "Sign Charter" -- l.3464
REQUEST_SIGNATURE = REQUEST_SIGNATURE or "Request Signature" -- l.3337
RENAME_GUILD = RENAME_GUILD or "Rename Guild" -- l.3318
RENAME_GUILD_LABEL = RENAME_GUILD_LABEL or "Enter new guild name:" -- l.3319
GUILD_PETITION_LEADER_INSTRUCTIONS = GUILD_PETITION_LEADER_INSTRUCTIONS or "Select a player you wish to invite and click <request signature>. To create this guild, turn it in to the guild registrar when you have filled the charter." -- l.2099
GUILD_PETITION_MEMBER_INSTRUCTIONS = GUILD_PETITION_MEMBER_INSTRUCTIONS or "Click the <Sign Charter> button to become a charter member of this guild." -- l.2100
ACCEPT = ACCEPT or "Accept" -- l.11
CANCEL = CANCEL or "Cancel" -- l.465
CLOSE = CLOSE or "Close" -- l.760
-- The nine name ROWS (ref PetitionFrame.lua:1). Not the signature requirement — that comes
-- off the wire per charter; see the file header.
MAX_PETITION_SIGNATURES = 9
-- The rename dialog (ref StaticPopup.lua:133-160). Registered here rather than in
-- UiPanels.xml for the same reason the guild dialogs live in FriendsFrame.xml: a popup
-- belongs to the window that raises it. 24 letters is the server's MAX_CHARTER_NAME, so
-- the box cannot compose a name that will be refused for length.
--
-- Accept and Enter run the same two lines, which is why they are one local function; the
-- reference spells them out twice (l.139-147). Note the ref's own Enter arm hides the
-- dialog and its Accept arm does not — the popup engine hides on a plain Accept anyway.
StaticPopupDialogs["RENAME_GUILD"] = {
text = RENAME_GUILD_LABEL,
button1 = ACCEPT,
button2 = CANCEL,
hasEditBox = 1,
maxLetters = 24,
OnAccept = function()
RenamePetition(getglobal(this:GetParent():GetName() .. "EditBox"):GetText())
end,
EditBoxOnEnterPressed = function()
RenamePetition(getglobal(this:GetParent():GetName() .. "EditBox"):GetText())
this:GetParent():Hide()
end,
OnShow = function()
getglobal(this:GetName() .. "EditBox"):SetFocus()
end,
OnHide = function()
if ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:SetFocus()
end
getglobal(this:GetName() .. "EditBox"):SetText("")
end,
timeout = 0,
exclusive = 1,
whileDead = 1,
}
-- ref PetitionFrame.lua:2-42, as four painters. The reference has one function; the split
-- is ours, because the four jobs share nothing but the tuple they read.
function PetitionFrame_Update()
local petitionType, title, bodyText, maxSignatures, originatorName, isOriginator = GetPetitionInfo()
PetitionFrame_PaintRole(isOriginator)
PetitionFrame_PaintHeader(petitionType, title, originatorName)
local numNames = PetitionFrame_PaintSignatures()
PetitionFrame_PaintRequestButton(numNames, maxSignatures)
end
-- Which face the window wears. Show/Hide rather than Enable/Disable because the two
-- buttons sit on the same anchor (see the header).
function PetitionFrame_PaintRole(isOriginator)
if isOriginator then
PetitionFrameRequestButton:Show()
PetitionFrameRenameButton:Show()
PetitionFrameSignButton:Hide()
PetitionFrameInstructions:SetText(GUILD_PETITION_LEADER_INSTRUCTIONS)
else
PetitionFrameRequestButton:Hide()
PetitionFrameRenameButton:Hide()
PetitionFrameSignButton:Show()
PetitionFrameInstructions:SetText(GUILD_PETITION_MEMBER_INSTRUCTIONS)
end
-- The Sign button's own gate is a separate question from which face is showing: a
-- charter can be a signer's view and still not be signable (already signed, already
-- guilded, full).
if CanSignPetition() then
PetitionFrameSignButton:Enable()
else
PetitionFrameSignButton:Disable()
end
end
-- The banner and the two named lines. `petitionType` has exactly one value in 1.12,
-- "charter"; the else arm is the reference's own dead branch and is kept as written.
function PetitionFrame_PaintHeader(petitionType, title, originatorName)
if petitionType == "charter" then
PetitionFrameNpcNameText:SetText(format(GUILD_CHARTER_TEMPLATE, title))
PetitionFrameCharterName:SetText(title)
PetitionFrameMasterName:SetText(originatorName)
else
PetitionFrameNpcNameText:SetText("Petition")
end
end
-- Nine rows, always nine: a signed row shows its name, an unsigned one shows
-- "<not yet signed>". Returns how many have signed, which the Request button needs.
function PetitionFrame_PaintSignatures()
local numNames = GetNumPetitionNames()
for i = 1, MAX_PETITION_SIGNATURES do
local row = getglobal("PetitionFrameMemberName" .. i)
if i <= numNames then
row:SetText(GetPetitionNameInfo(i))
else
row:SetText(NOT_YET_SIGNED)
end
end
return numNames
end
-- Disabled once the charter is full. `maxSignatures` is the WIRE's requirement, not
-- MAX_PETITION_SIGNATURES (ref PetitionFrame.lua:38).
function PetitionFrame_PaintRequestButton(numNames, maxSignatures)
if numNames >= maxSignatures then
PetitionFrameRequestButton:Disable()
else
PetitionFrameRequestButton:Enable()
end
end
-- ref l.375-387. The `IsVisible` re-test after ShowUIPanel is the reference's own: the
-- panel manager can refuse, and a charter session the engine thinks is open with no window
-- on screen would strand it.
--
-- PETITION_SHOW also arrives for a REPAINT of a charter already on screen (a name or the
-- title resolving), which costs one Update and no sound — see the file header.
function PetitionFrame_OnEvent(event)
if event == "PETITION_SHOW" then
ShowUIPanel(PetitionFrame)
if not PetitionFrame:IsVisible() then
ClosePetition()
return
end
PetitionFrame_Update()
elseif event == "PETITION_CLOSED" then
HideUIPanel(PetitionFrame)
end
end
]]></Script>
<Frame name="PetitionFrame" toplevel="true" parent="UIParent" movable="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="384" y="512"/></Size>
<HitRectInsets><AbsInset left="0" right="30" top="0" bottom="70"/></HitRectInsets>
<Layers>
<Layer level="BACKGROUND">
<!-- The charter's own icon, not an NPC portrait (ref l.20-30): this window has no
NPC. It is a piece of paper. -->
<Texture name="PetitionFramePortrait" file="Interface\PetitionFrame\GuildCharter-Icon">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-8"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The quest-greeting parchment, four quadrant slabs (ref l.32-63). -->
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
<!-- ref l.66-77. Defined but never written by the shipped UI; kept because it is
a name an addon can reach (GuildRegistrarText's twin). -->
<FontString name="PetitionText" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-10"/></Offset></Anchor></Anchors>
</FontString>
<!-- The header block: a static label over its value, three times — "Guild Name",
"Guild Master", "Members" — then the nine signature rows, then the
instructions (ref l.78-257). The labels come from GlobalStrings via `text=`
and are never rewritten; only the two *Name strings and the rows are painted.
The whole column is one anchor chain, so a row's height change pushes
everything below it — which is what lets the instructions sit under a list
that is always nine rows tall. -->
<FontString name="PetitionFrameCharterTitle" inherits="QuestTitleFont" text="GUILD_NAME" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="30" y="-95"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="PetitionFrameCharterName" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetitionFrameCharterTitle" relativePoint="BOTTOMLEFT"/>
</Anchors>
</FontString>
<FontString name="PetitionFrameMasterTitle" inherits="QuestTitleFont" text="GUILD_RANK0_DESC" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetitionFrameCharterName" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="PetitionFrameMasterName" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetitionFrameMasterTitle" relativePoint="BOTTOMLEFT"/>
</Anchors>
</FontString>
<FontString name="PetitionFrameMemberTitle" inherits="QuestTitleFont" text="MEMBERS" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetitionFrameMasterName" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- Row 1 anchors to the "Members" label; rows 2..9 chain off their predecessor
(ref l.138-245, nine identical blocks). Written out because a FontString
cannot be created from Lua in 1.12 and the chain is what makes the column. -->
<FontString name="PetitionFrameMemberName1" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberTitle" relativePoint="BOTTOMLEFT"/>
</Anchors>
</FontString>
<FontString name="PetitionFrameMemberName2" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName1" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameMemberName3" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName2" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameMemberName4" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName3" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameMemberName5" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName4" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameMemberName6" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName5" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameMemberName7" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName6" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameMemberName8" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName7" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameMemberName9" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName8" relativePoint="BOTTOMLEFT"/></Anchors>
</FontString>
<FontString name="PetitionFrameInstructions" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="285" y="0"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="PetitionFrameMemberName9" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The title banner (ref l.261-286): "<Guild> Guild Charter". -->
<Frame name="PetitionNpcNameFrame">
<Size><AbsDimension x="300" y="14"/></Size>
<Anchors>
<Anchor point="TOP" relativeTo="PetitionFrame" relativePoint="TOP"><Offset><AbsDimension x="0" y="-23"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<FontString name="PetitionFrameNpcNameText" inherits="GameFontHighlight"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>this:SetFrameLevel(this:GetFrameLevel() + 2)</OnLoad>
</Scripts>
</Frame>
<Button name="PetitionFrameCancelButton" inherits="UIPanelButtonTemplate" text="CLOSE">
<Size><AbsDimension x="75" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="PetitionFrame" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-40" y="72"/></Offset></Anchor>
</Anchors>
<Scripts><OnClick>HideUIPanel(PetitionFrame)</OnClick></Scripts>
</Button>
<!-- Sign and Request share this anchor: exactly one of them is ever shown. -->
<Button name="PetitionFrameSignButton" inherits="UIPanelButtonTemplate" text="SIGN_CHARTER">
<Size><AbsDimension x="110" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="PetitionFrame"><Offset><AbsDimension x="22" y="72"/></Offset></Anchor>
</Anchors>
<Scripts><OnClick>SignPetition()</OnClick></Scripts>
</Button>
<!-- Offers the charter to the current TARGET — the verb carries no argument, so with
nobody targeted nothing is sent (`ui_petition`'s drain). -->
<Button name="PetitionFrameRequestButton" inherits="UIPanelButtonTemplate" text="REQUEST_SIGNATURE">
<Size><AbsDimension x="140" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="PetitionFrame"><Offset><AbsDimension x="22" y="72"/></Offset></Anchor>
</Anchors>
<Scripts><OnClick>OfferPetition()</OnClick></Scripts>
</Button>
<Button name="PetitionFrameCloseButton" inherits="UIPanelCloseButton">
<Anchors>
<Anchor point="CENTER" relativeTo="PetitionFrame" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-42" y="-30"/></Offset></Anchor>
</Anchors>
</Button>
<!-- Two-point width (ref l.347-362): the button stretches to fill whatever is left
between Request and Close, so its label fits at any panel width. -->
<Button name="PetitionFrameRenameButton" inherits="UIPanelButtonTemplate" text="RENAME_GUILD">
<Size><AbsDimension x="10" y="22"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="PetitionFrameRequestButton" relativePoint="RIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="PetitionFrameCancelButton" relativePoint="LEFT"/>
</Anchors>
<Scripts><OnClick>StaticPopup_Show("RENAME_GUILD")</OnClick></Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>
this:RegisterEvent("PETITION_SHOW")
this:RegisterEvent("PETITION_CLOSED")
</OnLoad>
<OnEvent>PetitionFrame_OnEvent(event)</OnEvent>
<OnShow>PlaySound("igQuestListOpen")</OnShow>
<!-- ClosePetition() on hide clears the engine's charter session — the same load-bearing
role CloseGossip() has on GossipFrame. -->
<OnHide>
PlaySound("igQuestListClose")
ClosePetition()
</OnHide>
<!-- ref l.395-397: the window swallows the wheel rather than letting it reach whatever
is behind it. -->
<OnMouseWheel>return</OnMouseWheel>
</Scripts>
</Frame>
</Ui>
+14 -5
View File
@@ -25,11 +25,11 @@
-->
<Script><![CDATA[
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only
-- the rows benilla ships today; the ref rows with no benilla window yet (Tabard, Petition,
-- GuildRegistrar, Battlefield, Cinematic, WorldStateScore, Minigame) join as their windows
-- land (audited row-by-row against the bytes, decision 1507). Three have since left that
-- list: the doublewide AuctionFrame with decision 1511, PetStableFrame with 1676, and
-- HelpFrame with 1673 — all three are rows below.
-- the rows benilla ships today; the ref rows with no benilla window yet (Tabard,
-- Battlefield, Cinematic, WorldStateScore, Minigame) join as their windows land (audited
-- row-by-row against the bytes, decision 1507). Four have since left that list: the
-- doublewide AuctionFrame with decision 1511, PetStableFrame with 1676, HelpFrame with
-- 1673, and Petition + GuildRegistrar with 1672 — all of them are rows below.
UIPanelWindows = {}
UIPanelWindows["GossipFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["MerchantFrame"] = { area = "left", pushable = 0 }
@@ -125,6 +125,15 @@
-- (`UIPanelWindows["FriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }`,
-- UIParent.lua l.29). whileDead: a corpse run is exactly when you want to whisper someone.
UIPanelWindows["FriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }
-- The two guild-charter windows (decision 1672), the ref's own UIParent.lua l.33-34
-- verbatim. They are ordinary left-slot rivals of each other and of the gossip menu, all
-- pushable 0 — which is exactly right: selecting the registrar's gossip row makes the
-- server close the menu and push the charter list, so the registrar replacing the gossip
-- window in the same slot IS the handoff. Both must be registered or ShowUIPanel degrades
-- to a bare Show() in no slot, which is what left the item-text reader interleaved with a
-- gossip window in B288 (1507).
UIPanelWindows["GuildRegistrarFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["PetitionFrame"] = { area = "left", pushable = 0 }
-- The macro window (decision 0983, MacroFrame.xml) — the ref's own registration line
-- verbatim (`UIPanelWindows["MacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }`,
-- Blizzard_MacroUI.lua:7). whileDead is the ref's and it earns its keep: writing a macro
+10
View File
@@ -402,6 +402,16 @@ GroupLootFrame.xml
# MerchantFrame (the BenillaMoney coin helpers live there) and GossipFrame (shared parchment
# art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
QuestFrame.xml
# The two guild-charter windows (decision 1672): the guild registrar — the window the "How do I
# form a guild?" gossip row opens — and the charter itself, the sheet nine people sign. Loaded
# here, right after QuestFrame, because both wear the QuestGreeting parchment and its QuestFont /
# QuestTitleFont objects and read as that family; they need UiPanels (the panel manager seats both,
# and PetitionFrame registers its RENAME_GUILD dialog on the shared popup engine), MoneyFrame (the
# registrar's price rides MoneyFrameTemplate, resolved at LOAD — decision 1580's ordering rule) and
# UIPanelTemplates (the close/action buttons), all far above. The registrar must come first: no
# load-order dependency links them, but the flow does, and the pair reads in the order it is used.
GuildRegistrarFrame.xml
PetitionFrame.xml
# The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
# NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
# alongside QuestFrame (same arc); the 'L' binding (ui_script/input.rs) calls its
+2
View File
@@ -87,6 +87,7 @@ mod probe_binder;
mod probe_book;
mod probe_castcancel;
mod probe_charcreate;
mod probe_charter;
mod probe_chest;
mod probe_clam;
mod probe_crossing;
@@ -111,6 +112,7 @@ 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_charter::ProbeCharterPlugin;
pub(crate) use probe_chest::ProbeChestPlugin;
pub(crate) use probe_clam::ProbeClamPlugin;
pub(crate) use probe_crossing::ProbeCrossingPlugin;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -331,6 +331,13 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_GMTICKET").is_ok() {
app.add_plugins(crate::capture::ProbeGmTicketPlugin);
}
// The guild-charter live probe: `WOW_PROBE_CHARTER=1` GM-hops to the Stormwind guild
// registrar, asserts the charter row's icon reads "petition", buys a charter through the
// registrar's own window, opens it with a real bag right-click and renames it — decision
// 1672's end-to-end instrument (see `capture::ProbeCharterPlugin`).
if std::env::var("WOW_PROBE_CHARTER").is_ok() {
app.add_plugins(crate::capture::ProbeCharterPlugin);
}
// 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`).
+6
View File
@@ -139,6 +139,7 @@ mod ui_pet;
mod ui_pet_book;
mod ui_pet_doll;
mod ui_pet_stats;
mod ui_petition;
mod ui_quest;
mod ui_quest_log;
mod ui_reputation;
@@ -213,6 +214,7 @@ use ui_pet::UiPetPlugin;
use ui_pet_book::UiPetBookPlugin;
use ui_pet_doll::UiPetDollPlugin;
use ui_pet_stats::UiPetStatsPlugin;
use ui_petition::UiPetitionPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
use ui_saved::UiSavedPlugin;
@@ -617,6 +619,10 @@ pub fn run(build: BuildId) -> AppExit {
// 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)
// Founding a guild (decision 1672): the guild registrar and the charter window — the slice
// 1257 §2 left out. Right after the guild session, whose error channel its refusals ride and
// whose roster its success produces.
.add_plugins(UiPetitionPlugin)
.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.
+59
View File
@@ -1964,6 +1964,65 @@ pub(crate) enum ClientCommand {
GuildInfoText {
text: String,
},
// ── The petition family (writer bodies in benilla-protocol `world/writer/petition.rs`,
// decision 1672) — FOUNDING a guild, which at 1.12 shares nothing with the band above but
// its error channel: a refusal on any of these comes back as `SMSG_GUILD_COMMAND_RESULT`.
//
// **A charter is addressed by its ITEM guid**, not by a petition id, everywhere except the
// record query. And almost none of these is acked positively: buying answers with the new
// item alone, offering answers the TARGET, renaming echoes only if it took. Nothing here may
// be treated as applied at the send.
/// Ask a petitioner NPC for its charter list (`CMSG_PETITION_SHOWLIST`). The gossip row pushes
/// the same answer unasked, so this is the direct re-open, not the only way in.
#[allow(dead_code)]
PetitionShowList {
npc: u64,
},
/// Buy a guild charter under a name (`CMSG_PETITION_BUY`). Answered by nothing at all on
/// success — only the new item arrives.
PetitionBuy {
npc: u64,
name: String,
},
/// Open a charter and list its signatures (`CMSG_PETITION_SHOW_SIGNATURES`). Sent by the
/// item-use fork when a charter is clicked, and again to refresh after a signature lands.
PetitionShowSignatures {
item: u64,
},
/// Sign somebody's charter (`CMSG_PETITION_SIGN`). `byte` is the optional Lua argument's wire
/// byte, whose client-side default is **1**; the server reads and discards it.
PetitionSign {
item: u64,
byte: i8,
},
/// Show our charter to a player so they can sign it (`CMSG_OFFER_PETITION`) — the success path
/// answers *them*, not us.
OfferPetition {
item: u64,
player: u64,
},
/// Turn a completed charter in (`CMSG_TURN_IN_PETITION`). Owner only; a name collision answers
/// with a guild command result and **no** turn-in result at all.
TurnInPetition {
item: u64,
},
/// Ask for a petition's record by id (`CMSG_PETITION_QUERY`) — the only packet carrying the
/// proposed guild's name and its signature requirement.
PetitionQuery {
petition_id: u32,
item: u64,
},
/// Rename a charter (`MSG_PETITION_RENAME`); echoed back only on success.
PetitionRename {
item: u64,
name: String,
},
/// Decline a charter offered to us (`MSG_PETITION_DECLINE`) — we send the item guid, the owner
/// receives ours.
#[allow(dead_code)]
PetitionDecline {
item: u64,
},
// ── 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
+52
View File
@@ -242,6 +242,12 @@ pub(super) fn apply_net_updates(
// diffs on: the Help window re-polls every 10 minutes and an unchanged answer
// still has to re-fire `UPDATE_TICKET`.
ResMut<crate::ui_gm_ticket::GmTicketState>,
// The guild-charter session (decision 1672) — two resources because only the
// registrar half is NPC-bound: `SMSG_PETITION_SHOWLIST` opens the registrar, and
// `SMSG_PETITION_SHOW_SIGNATURES` opens the item-bound charter window, which must
// survive walking away from the registrar.
ResMut<crate::ui_petition::GuildRegistrarState>,
ResMut<crate::ui_petition::PetitionState>,
),
),
),
@@ -418,6 +424,8 @@ pub(super) fn apply_net_updates(
guild_notify,
mut ping,
mut gm_ticket,
mut registrar,
mut petition,
),
),
) = caches;
@@ -1123,6 +1131,50 @@ pub(super) fn apply_net_updates(
crate::ui_guild::apply::decline(&mut chat_log, &name)
}
SessionEvent::GuildInfo(info) => crate::ui_guild::apply::info(&mut chat_log, info),
// ── The petition family (decision 1672): founding a guild. The registrar half is an
// NPC window, the charter half is item-bound, and they are two resources for that
// reason — see `ui_petition`'s module doc.
SessionEvent::PetitionShowList(list) => {
// The registrar's two `UNIT_NPC_FLAGS` gates are on LIVE NPC state rather than on
// the packet, so the flags are read here — this pass holds the store. An unstreamed
// guid reads `None` and fails the gate, as the client's own resolve does.
let flags = index
.0
.get(&list.npc)
.and_then(|e| stores.get(*e).ok())
.map(|s| s.0.unit_npc_flags());
crate::ui_petition::apply::show_list(&mut registrar, list, flags)
}
SessionEvent::PetitionShowSignatures(sigs) => {
// An ignored owner suppresses the ENTIRE update — no record fetch, no list, no
// event, no error line (`0x5eeefe`). Consulted before anything else happens.
let ignored = social.is_ignored(sigs.owner);
crate::ui_petition::apply::show_signatures(
&mut petition,
sigs,
ignored,
&net_commands,
)
}
SessionEvent::PetitionQueryResponse(response) => {
crate::ui_petition::apply::query_response(&mut petition, response)
}
SessionEvent::PetitionSignResults(results) => crate::ui_petition::apply::sign_results(
&mut petition,
&mut names,
self_guid.0.unwrap_or(0),
results,
&net_commands,
),
SessionEvent::TurnInPetitionResults { result } => {
crate::ui_petition::apply::turn_in_results(&mut petition, &mut registrar, result)
}
SessionEvent::PetitionDeclined { player } => {
crate::ui_petition::apply::declined(&mut petition, &names, player)
}
SessionEvent::PetitionRenamed(rename) => {
crate::ui_petition::apply::renamed(&mut petition, rename)
}
SessionEvent::LootResponse {
guid,
loot_type,
+14
View File
@@ -1090,6 +1090,20 @@ fn writer_loop(
w.guild_set_officer_note(&name, &note)
}
ClientCommand::GuildInfoText { text } => w.guild_info_text(&text),
// The petition family (decision 1672) — founding a guild.
ClientCommand::PetitionShowList { npc } => w.petition_show_list(npc),
ClientCommand::PetitionBuy { npc, name } => w.petition_buy(npc, &name),
ClientCommand::PetitionShowSignatures { item } => {
w.petition_show_signatures(item)
}
ClientCommand::PetitionSign { item, byte } => w.petition_sign(item, byte),
ClientCommand::OfferPetition { item, player } => w.offer_petition(item, player),
ClientCommand::TurnInPetition { item } => w.turn_in_petition(item),
ClientCommand::PetitionQuery { petition_id, item } => {
w.petition_query(petition_id, item)
}
ClientCommand::PetitionRename { item, name } => w.petition_rename(item, &name),
ClientCommand::PetitionDecline { item } => w.petition_decline(item),
ClientCommand::TaxiNodeStatusQuery { guid } => w.taxi_node_status_query(guid),
ClientCommand::TaxiQueryNodes { guid } => w.taxi_query_available_nodes(guid),
ClientCommand::ActivateTaxi {
+6 -3
View File
@@ -146,9 +146,12 @@ impl WorldCursor {
/// Vanilla `UNIT_NPC_FLAGS` bits (vmangos `UnitDefines.h`, 1.12 values — later expansions differ).
/// REPAIR (0x4000) exists but the classifier never consults it (falls `je 0x4826cb`).
/// `pub(super)` so the right-click dispatch ([`super::click`]) reuses BANKER to split the shared
/// Buy cursor kind (banker vs auctioneer) without a duplicate table (decision 0604).
pub(super) mod npc_flags {
/// `pub(crate)` so the right-click dispatch ([`super::click`]) reuses BANKER to split the shared
/// Buy cursor kind (banker vs auctioneer) without a duplicate table (decision 0604), and so a live
/// probe scans for a service NPC by the same bits the cursor classifies with instead of keeping a
/// private copy ([`crate::capture::ProbeCharterPlugin`] and PETITIONER) — a duplicated flag table
/// is how B249's icon map went stale.
pub(crate) mod npc_flags {
pub const GOSSIP: u32 = 0x1;
pub const QUESTGIVER: u32 = 0x2;
pub const VENDOR: u32 = 0x4;
@@ -340,6 +340,9 @@ pub(super) fn drain_action_uses(
spell_index,
use_spell: template.use_spell.map(|u| u.spell_id),
on_object: None,
is_charter: template.flags
& benilla_protocol::messages::ITEM_FLAG_CHARTER
!= 0,
},
&targeting.context(),
&mut ladder,
+9 -2
View File
@@ -95,7 +95,7 @@ pub(super) fn drain_inventory_uses(
let slot = (id - 1) as u8;
// The doll's own slot ids ARE the wire slots (`wire_pos`'s EQUIPMENT_BAG law), so the
// equipped instance resolves off the player's INV array directly.
let (guid, start_quest, spell_index, use_spell, entry) = self_q
let (guid, start_quest, spell_index, use_spell, entry, is_charter) = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &ladder.items))
@@ -110,9 +110,10 @@ pub(super) fn drain_inventory_uses(
t.use_spell_index().unwrap_or(0),
t.use_spell.map(|u| u.spell_id),
Some(entry),
t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0,
))
})
.unwrap_or((None, 0, 0, None, None));
.unwrap_or((None, 0, 0, None, None, false));
debug!("ui_items: use equipped item, lua slot {id} (wire 255/{slot})");
super::send_item_use(
super::ItemUse {
@@ -124,6 +125,7 @@ pub(super) fn drain_inventory_uses(
spell_index,
use_spell,
on_object: None,
is_charter,
},
&targeting.context(),
&mut ladder,
@@ -255,6 +257,7 @@ pub(super) fn drain_container_uses(
unwraps_gift: t.unwraps_gift(inst_flags),
opens_loot: t.opens_loot(),
page_text: t.page_text,
is_charter: t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0,
})
});
@@ -332,6 +335,7 @@ pub(super) fn drain_container_uses(
spell_index: c.spell_index,
use_spell: c.use_spell,
on_object: None,
is_charter: c.is_charter,
},
&targeting.context(),
&mut ladder,
@@ -442,6 +446,7 @@ pub(super) fn drain_container_uses(
spell_index: clicked.map_or(0, |c| c.spell_index),
use_spell: clicked.and_then(|c| c.use_spell),
on_object: None,
is_charter: clicked.is_some_and(|c| c.is_charter),
},
&targeting.context(),
&mut ladder,
@@ -475,6 +480,8 @@ struct Clicked {
/// book. The reader re-reads the head (and the material) off the template itself as it paints,
/// like the reference, so only the fork's predicate is carried here.
page_text: u32,
/// The template's `ITEM_FLAG_CHARTER` — a guild petition (decision 1672).
is_charter: bool,
}
/// Drain the pick/place/swap/split moves `PickupContainerItem`/`SplitContainerItem` queued and
+89
View File
@@ -611,6 +611,9 @@ pub(crate) struct ItemUse {
/// The GameObject this use is aimed at — the key-in-a-lock arm (decision 0769), which is
/// `CGItem::Use`'s own target argument. `None` for every ordinary click.
pub(crate) on_object: Option<u64>,
/// The template's `ITEM_FLAG_CHARTER` (`0x2000`) — a signable guild petition
/// (decision 1672). Diverts to [`ItemUseRoute::ShowPetition`].
pub(crate) is_charter: bool,
}
/// **The item-use fork** — our `CGItem::Use` (`0x5d8d00`): the one place that decides what "using"
@@ -653,6 +656,31 @@ pub(crate) enum ItemUseRoute {
/// with **nothing sent**. (Before decision 0914 we sent anyway, and vmangos answered
/// `EQUIP_ERR_ITEM_NOT_FOUND` — a red "Item not found." the reference never shows.)
Nothing,
/// **The charter arm** (decision 1672): using an item whose template flags carry
/// `ITEM_FLAG_CHARTER` (`0x2000`) opens the petition window — it sends
/// `CMSG_PETITION_SHOW_SIGNATURES` for the instance, not `CMSG_USE_ITEM`.
///
/// **INFERRED, and this is the whole of the evidence.** wow-re has not carved `CGItem::Use`'s
/// charter branch, so the *position* of this arm and the opcode it sends are reasoned rather
/// than read:
///
/// - 1.12's shipped FrameXML special-cases a charter **nowhere** — `UseContainerItem` and
/// `UseAction` are entirely generic — so whatever opens the window is engine-side.
/// - The charter template (entry 5863) has no ON_USE spell, no `StartQuest`, and
/// `InventoryType = 0`, so *every* other arm of this fork declines it and the click currently
/// reaches [`Self::Nothing`] and sends nothing at all. Right-clicking a charter in 1.12
/// plainly does something, so an arm must exist.
/// - `CMSG_PETITION_SHOW_SIGNATURES` is the only opcode that opens the window, and vmangos's
/// `HandleUseItemOpcode` has no charter branch — a `CMSG_USE_ITEM` here would be answered
/// with nothing.
/// - The reference already keys other charter behaviour on this exact flag bit: the item
/// tooltip's `ITEM_SIGNABLE` line and its enchant-line suppression both test `0x2000`
/// (wow-re `system/ui/scratch/tooltip-content-law.md:485-505`).
///
/// The arm sits directly after the quest fork. Its **order relative to the quest fork is
/// unobservable** — no 1.12 item is both a charter and a quest starter — so nothing here rests
/// on it; a byte read of `0x5d8d00` settles both the position and the opcode.
ShowPetition { item: u64 },
}
/// [`ItemUseRoute`]'s decision. `guid: None` (the instance never resolved) cannot address a
@@ -669,6 +697,12 @@ pub(crate) fn item_use_route(it: ItemUse, aura_cancels: impl Fn(u32) -> bool) ->
quest: it.start_quest,
};
}
// The charter arm (decision 1672) — see `ItemUseRoute::ShowPetition` for its evidence and for
// why its position here is unobservable. A charter with no resolved instance cannot be
// addressed, so it falls through to the ordinary path exactly as the quest fork does.
if let Some(item) = it.guid.filter(|_| it.is_charter) {
return ItemUseRoute::ShowPetition { item };
}
match it.use_spell {
Some(spell) if aura_cancels(spell) => ItemUseRoute::ToggleCancel(spell),
Some(spell) => ItemUseRoute::Cast(spell),
@@ -750,6 +784,13 @@ pub(crate) fn send_item_use(
.send(crate::net::ClientCommand::QuestgiverQuery { npc, quest });
true
}
ItemUseRoute::ShowPetition { item } => {
let _ = ladder
.commands
.0
.send(crate::net::ClientCommand::PetitionShowSignatures { item });
true
}
ItemUseRoute::Nothing => {
debug!(
"ui_items: the item at wire {}/{} has no ON_USE block — nothing sent (TryCast's null-rec bail)",
@@ -1098,6 +1139,7 @@ mod tests {
spell_index: 0,
use_spell,
on_object: None,
is_charter: false,
};
let never = |_| false;
assert_eq!(
@@ -1125,6 +1167,52 @@ mod tests {
);
}
/// **The charter arm** (decision 1672): an item whose template carries `ITEM_FLAG_CHARTER`
/// opens the petition window instead of taking the cast tail.
///
/// This is the arm's whole justification as a test: the live charter template (entry 5863) has
/// **no** ON_USE spell, **no** `StartQuest` and `InventoryType = 0`, so without this arm the
/// click reaches `Nothing` and sends absolutely nothing — a charter you can buy and never open.
/// The `Nothing` case below is that pre-1672 behaviour, pinned beside the fix so the two are
/// visibly one decision.
#[test]
fn a_charter_click_opens_the_petition_instead_of_casting() {
let charter = 0x4000_0000_0000_5863_u64;
let it = |guid, is_charter, use_spell| ItemUse {
entry: 5863,
guid,
start_quest: 0,
bag_index: 255,
slot: 23,
spell_index: 0,
use_spell,
on_object: None,
is_charter,
};
let never = |_| false;
assert_eq!(
item_use_route(it(Some(charter), true, None), never),
ItemUseRoute::ShowPetition { item: charter },
"the charter opens its petition window"
);
// Without the flag, the very same item is the reference's silent no-op — which is what a
// charter click did before this arm existed.
assert_eq!(
item_use_route(it(Some(charter), false, None), never),
ItemUseRoute::Nothing
);
// No resolved instance: nothing to address the show-signatures with, so it falls through
// exactly as the quest fork does on the same condition.
assert_eq!(
item_use_route(it(None, true, None), never),
ItemUseRoute::Nothing
);
assert_eq!(
item_use_route(it(None, true, Some(8690)), never),
ItemUseRoute::Cast(8690)
);
}
/// **The mount click, both ways** (the director, 08-03: "clicking the mount while mounted
/// should dismount"). `CGItem::Use`'s toggle scan sits above the cast tail, so an item whose
/// ON_USE spell is already live on the caster cancels its aura and never casts — and the same
@@ -1143,6 +1231,7 @@ mod tests {
spell_index: 0,
use_spell,
on_object: None,
is_charter: false,
};
let mounted = |spell: u32| spell == SUMMON_HORSE;
assert_eq!(
+329
View File
@@ -0,0 +1,329 @@
//! The charter VM feed/drain — the systems half of [`super`]: resolve the wire's guids and ids into
//! the snapshot the two charter windows read, fire the four events on their edges, drain the
//! composed lines onto their two channels, and turn the Lua-side [`PetitionRequest`] intents into
//! their sends.
//!
//! **`PETITION_SHOW` is DEFERRED, and that is the load-bearing behaviour of this file.** The real
//! client fires it only when *no signer name is still resolving* **and** *the petition record has
//! arrived* (`0x4f419b`-`0x4f41ad`), and it fires exactly once however many names were outstanding
//! (`0x4f4320` decrements the pending counter and fires only on the transition to zero). wow-re's
//! note puts it flatly: *"A client that fires `PETITION_SHOW` straight off the packet paints a
//! window with blank signer names."* That is precisely what this file did before the carve — it
//! opened immediately and repainted as each lookup landed, which is a visibly different window.
//!
//! Two things follow, and they are why the deferral is a simplification rather than a cost:
//!
//! - the window never paints a partial state, so `PetitionFrame`'s `format(GUILD_CHARTER_TEMPLATE,
//! title)` can never see a nil title;
//! - the *getters* still answer honestly at any moment (an addon may call them whenever it likes),
//! which is why `benilla_ui::script::petition` keeps its no-record leg and its nil names. The
//! deferral is about the **event**, not about the data.
//!
//! **The owner's name is deliberately NOT part of the defer condition.** The pending counter is
//! incremented only for *signers* (`0x4f4186`/`0x4f42e8`), so a charter whose owner name has not
//! resolved still opens, with `GetPetitionInfo`'s fifth return reading nil. Folding the owner in
//! would look tidier and would hold the window shut in a case the client does not.
//!
//! **Lines go to two different channels**, and which one is not guessable from the key names — see
//! [`super::lines`]. The red `UI_ERROR_MESSAGE` needs the `script` handle, which is why they are
//! composed at apply time and fired here.
use benilla_ui::script::{
PetitionRecordView, PetitionRequest, PetitionState as VmPetition, ScriptValue, UiScript,
PETITION_TYPE_CHARTER, PETITION_TYPE_PETITION,
};
use bevy::prelude::*;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfGuid, SelfPlayer};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_items::{find_item, ItemSearch};
use crate::ui_session::{npc_switched, NpcSession};
use super::{lines, GuildRegistrarState, PetitionState};
/// What the feed last announced, so the events fire on edges rather than every frame.
#[derive(Default)]
pub(super) struct FedPetition {
/// The registrar's NPC, if the window was open last frame.
registrar: Option<u64>,
/// The charter item the petition window was **showing** last frame — set only once the charter
/// became READY (module doc). A charter that is open but still resolving is `None` here, which
/// is what makes the deferral a plain edge rather than a second flag.
shown_charter: Option<u64>,
/// The last pushed view, so a change while shown re-fires `PETITION_SHOW`.
shown: Option<PetitionRecordView>,
/// …and the signer names beside it, which change independently of the record.
shown_signers: Vec<Option<String>>,
}
/// Build the snapshot, push it, drain the queued lines, and fire the four events on their edges.
#[allow(clippy::too_many_arguments)]
pub(super) fn feed_petition(
script: Option<NonSendMut<UiScript>>,
registrar: Res<GuildRegistrarState>,
mut petition: ResMut<PetitionState>,
mut names: ResMut<NameCache>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
self_guid: Res<SelfGuid>,
commands: Res<NetCommands>,
mut chat_log: ResMut<ChatLog>,
mut fed: Local<crate::ui_script::VmMemo<FedPetition>>,
) {
let Some(mut script) = script else {
return;
};
let fed = fed.get(&script);
// The composed lines, onto their two channels. Drained before the snapshot so a refusal shows
// in the same frame as the state change that caused it.
for line in std::mem::take(&mut petition.lines) {
match line {
lines::Line::Chat(text) => {
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
lines::Line::Error(text) => {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
}
}
// **Rebuilt every frame, deliberately.** Half of what this window shows arrives from a CACHE,
// not a packet, and reading that cache is what ISSUES its query (decision 0660's law). So a
// name landing sets no flag anywhere — the only evidence is that rebuilding differs. This is
// also what drives the deferral: `ready` below can only become true on a rebuild.
// A price and at most nine names, so the honest version is the cheap one too.
let open = petition.open.clone();
let me = self_guid.0.unwrap_or(0);
let signers: Vec<Option<String>> = open
.as_ref()
.map(|o| {
o.signers
.iter()
.map(|s| names.resolve(*s, &commands).map(str::to_string))
.collect()
})
.unwrap_or_default();
let record = open.as_ref().and_then(|o| {
let r = petition.records.get(&o.petition_id)?;
Some(PetitionRecordView {
petition_type: if r.is_charter {
PETITION_TYPE_CHARTER
} else {
PETITION_TYPE_PETITION
}
.to_string(),
title: r.name.clone(),
body_text: r.body_text.clone(),
max_signatures: r.required,
// Nil until the cache has it — and NOT part of the defer condition (module doc).
originator: names.resolve(o.owner, &commands).map(str::to_string),
is_originator: o.owner == me,
})
});
// `CanSignPetition`, the four refusals of `0x4f45e0` — and its no-record leg, which answers
// **1**. That asymmetry is the reference's own: with no record cached it jumps past the three
// record-dependent tests straight into the signer scan, over an array the close path has
// already zeroed.
let in_guild = self_q
.iter()
.next()
.is_some_and(|s| s.0.player_guild_id() != 0);
let can_sign = match (open.as_ref(), record.as_ref()) {
(Some(o), Some(r)) => {
// (a)/(b) are gated on the charter bit, so a non-charter petition is refused for
// neither guild membership nor a full list.
let charter_refusal = r.petition_type == PETITION_TYPE_CHARTER
&& (in_guild || o.signers.len() as i32 >= r.max_signatures);
!charter_refusal && !r.is_originator && !o.signers.contains(&me)
}
// No record: only the signer scan runs, and it is over an empty array.
(Some(o), None) => !o.signers.contains(&me),
(None, _) => true,
};
script.set_petition(VmPetition {
charter_cost: registrar.cost(),
signers: signers.clone(),
record: record.clone(),
can_sign,
});
// ── The registrar's edges. A registrar→registrar switch is a real close+open in the
// reference, because `ShowUIPanel` early-returns on a visible frame and the open sound
// would otherwise never replay (`crate::ui_session::npc_switched`).
let now_registrar = registrar.npc();
if npc_switched(fed.registrar, now_registrar) {
script.fire_event("GUILD_REGISTRAR_CLOSED", vec![]);
script.fire_event("GUILD_REGISTRAR_SHOW", vec![]);
// …then consume the close `OnHide` just queued, or the drain would apply it to the
// registrar we have this instant re-opened (decision 0096).
let _ = script.drop_petition_close_intents();
} else {
match (fed.registrar, now_registrar) {
(None, Some(_)) => script.fire_event("GUILD_REGISTRAR_SHOW", vec![]),
(Some(_), None) => script.fire_event("GUILD_REGISTRAR_CLOSED", vec![]),
_ => {}
}
}
fed.registrar = now_registrar;
// ── The petition window's edges, **behind the deferral**.
//
// `ready` is the client's two conditions: the record has arrived, and no signer name is still
// resolving. A charter that is open but not ready is invisible to every arm below — which is
// exactly the point, and is why an unresolved name cannot reach the screen.
let ready = record.is_some() && signers.iter().all(Option::is_some);
let now_charter = open.as_ref().map(|o| o.item).filter(|_| ready);
match (fed.shown_charter, now_charter) {
(Some(a), Some(b)) if a != b => {
// A charter arriving while another is shown — somebody offering us theirs while ours is
// up. Close then open, then eat the close `OnHide` queued: without that last line the
// drain clears the session this branch just opened, AND puts a `MSG_PETITION_DECLINE`
// on the wire for a charter we did not decline.
script.fire_event("PETITION_CLOSED", vec![]);
script.fire_event("PETITION_SHOW", vec![]);
let _ = script.drop_petition_close_intents();
}
// The deferral's own edge: the record landed, or the last name resolved. Fires ONCE.
(None, Some(_)) => script.fire_event("PETITION_SHOW", vec![]),
(Some(_), None) => script.fire_event("PETITION_CLOSED", vec![]),
// A rename echo, or a name resolving after the window was already up.
(Some(_), Some(_)) if fed.shown != record || fed.shown_signers != signers => {
script.fire_event("PETITION_SHOW", vec![])
}
_ => {}
}
fed.shown_charter = now_charter;
fed.shown = record;
fed.shown_signers = signers;
}
/// Turn the Era API's charter intents into their sends.
///
/// Most need the app because Lua has no way to name what they act on: buying needs the latched
/// registrar's NPC, turning in needs the charter *in the bags*, offering needs the current target,
/// and the item verbs need the open charter's item guid.
///
/// **`ClosePetition` is not local-only, and that was this file's sharpest correction.** `0x4f3f60`'s
/// decline leg puts `MSG_PETITION_DECLINE` on the wire whenever a petition was open, no sign is in
/// flight, a record is cached, and we are **not** its owner. Closing somebody else's charter tells
/// them so. `CloseGuildRegistrar`, by contrast, really does send nothing — verified by a closure
/// walk that found no `CDataStore` build on its whole path.
#[allow(clippy::too_many_arguments)]
pub(super) fn drain_petition(
script: Option<NonSendMut<UiScript>>,
mut registrar: ResMut<GuildRegistrarState>,
mut petition: ResMut<PetitionState>,
mut names: ResMut<NameCache>,
items: Res<Items>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
self_guid: Res<SelfGuid>,
selection: Res<crate::target::Selection>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
let requests = script.take_petition_requests();
if requests.is_empty() {
return;
}
let me = self_guid.0.unwrap_or(0);
for request in requests {
match request {
PetitionRequest::Buy(name) => {
// The NPC is the petition module's own latched registrar (`[0xbdceb0]`), never the
// current target and never `CGGameUI`'s interaction pair — the send site's only
// caller passes that cell by address.
let Some(npc) = registrar.npc() else {
debug!("ui_petition: BuyGuildCharter with no registrar open — dropped");
continue;
};
let _ = commands.0.send(ClientCommand::PetitionBuy { npc, name });
}
PetitionRequest::TurnIn => {
// A **bag scan**, verified: `0x5ef2b0` walks the sixteen backpack slots then the
// four bags on every call and latches nothing. Ours is the same walk through the
// reference's own inventory search; `max_count = 1` means there is at most one.
let charter = self_q.iter().next().and_then(|store| {
find_item(
&store.0,
&items,
benilla_protocol::messages::CHARTER_ITEM_ENTRY,
ItemSearch::default(),
)
});
match charter {
Some((_, _, item)) => {
let _ = commands.0.send(ClientCommand::TurnInPetition { item });
}
// Nothing to address the send with, so none is built — and the refusal is a
// RED line, not a chat one (`0x5ef49a` emits id `0x7c`, kind 2).
None => petition.lines.push(lines::no_charter_line()),
}
}
// Verified send-free.
PetitionRequest::CloseRegistrar => registrar.close(),
PetitionRequest::ClosePetition => {
petition.decline_on_close(me, &commands);
petition.close();
}
PetitionRequest::Sign(byte) => {
let Some(item) = petition.open_item() else {
continue;
};
let _ = commands.0.send(ClientCommand::PetitionSign { item, byte });
// The in-flight latch, whose one reader is the decline leg above: a close while our
// own signature is outstanding must not read as a decline.
petition.signing = true;
}
PetitionRequest::Offer => {
let Some(item) = petition.open_item() else {
continue; // guard 1 — silent
};
// `OfferPetition()` carries no argument, and the guid it needs is `CGGameUI`'s
// **selection** pair — the current target — not the interaction pair the registrar
// latches. With no target the reference is silent too (guard 2).
let Some(player) = selection.guid else {
debug!("ui_petition: OfferPetition with no target — dropped");
continue;
};
if player == me {
// Guard 6, and the only one of the eight that is both reachable here and has a
// message. Guards 3/4/5/7 (unit resolution, is-a-player, faction) are the
// server's to enforce and are not built; guard 8 (target already guilded) needs
// the target's descriptor and is likewise left to the refusal.
petition.lines.push(lines::self_offer_line());
continue;
}
let _ = commands
.0
.send(ClientCommand::OfferPetition { item, player });
// Emitted **optimistically on the send**, with no server confirmation — the target
// is the one the server answers, so this echo is the offerer's only feedback.
let name = names
.resolve(player, &commands)
.unwrap_or_default()
.to_string();
petition.lines.push(lines::offered_line(&name));
}
PetitionRequest::Rename(name) => {
let Some(item) = petition.open_item() else {
continue;
};
let _ = commands
.0
.send(ClientCommand::PetitionRename { item, name });
}
// The client's own name validator refused it; no packet was ever built.
PetitionRequest::NameRefused(key) => {
petition.lines.push(lines::name_refused_line(key));
}
}
}
}
+310
View File
@@ -0,0 +1,310 @@
//! The charter session's **system lines** — the messages the engine composes, and *which of the
//! client's two message channels each one goes to*.
//!
//! The split is the part a re-implementation cannot guess, and we did guess it wrong first: by the
//! key names, `ERR_PETITION_SIGNED_S` and `ERR_PETITION_ALREADY_SIGNED` look like the same kind of
//! thing. They are not. wow-re
//! `system/object-layer/scratch/petition-wire-law.md` §4 read the message catalog at `0xb4b498`
//! (stride `0x14`, field `+4` = kind, routed through the four-entry table at `0x496888`) and swept
//! every row:
//!
//! - the **four** `_S`/success ids `0x141`-`0x144` carry kind `0` → the shared chat chokepoint
//! `0x49a870` with the row's own `+0x10` chat type, which is `0xa` → **`CHAT_MSG_SYSTEM`**, the
//! same channel every `ERR_GUILD_*` line uses;
//! - the **five** refusals `0x145`-`0x149`, and `ERR_NO_GUILD_CHARTER` (`0x7c`), carry kind `2` →
//! `UI_ERROR_MESSAGE` (`0xe0`), the **red UIErrorsFrame line**.
//!
//! Two more facts from the same sweep that shape the tables below:
//!
//! - **`PETITION_SIGN_NEED_MORE` (4) has no user-visible message on the SIGN path at all.** It
//! falls into the default arm with `>= 6` and reaches only the debug console
//! (`0x63cb50("Petition error")`). It *does* have one on the turn-in path. Same code, two
//! packets, two behaviours.
//! - **A successful turn-in emits no text whatsoever** (`0x5ef166`). It fires
//! `GUILD_REGISTRAR_CLOSED` and says nothing. This file first shipped an `ERR_GUILD_FOUNDER_S`
//! line there, reasoned from the fact that vmangos never sends the guild-command result that
//! would produce one; the binary settles it — founding a guild really is silent, and the player
//! learns of it from the guild pane lighting up.
//!
//! Every one of these rows also carries a named sound cue (`+0xc = 0x44`): `0x142`/`0x143` play
//! `igPlayerInviteAccept` and `0x144`-`0x149` play `igPlayerInviteDecline`, while `0x141` has none.
//! Recorded here; wiring the cue is [`super`]'s job, not this module's.
use benilla_protocol::messages::petition_result;
// ── The templates, quoted verbatim from the reference's own patch chain; the `GlobalStrings.lua`
// line is on each ──────────────────────────────────────────────────────────────────────────
const ERR_PETITION_ALREADY_SIGNED: &str = "You have already signed that guild charter."; // :1762
const ERR_PETITION_CREATOR: &str = "You can't sign your own guild charter."; // :1763
const ERR_PETITION_DECLINED_S: &str = "%s has declined your guild invitation."; // :1764
const ERR_PETITION_IN_GUILD: &str = "You are already in a guild."; // :1765
const ERR_PETITION_NOT_ENOUGH_SIGNATURES: &str = "You need more signatures."; // :1766
const ERR_PETITION_NOT_SAME_SERVER: &str = "That player is not from your server"; // :1767
const ERR_PETITION_OFFERED_S: &str = "You have requested %s's signature."; // :1768
const ERR_PETITION_SIGNED: &str = "Guild charter signed."; // :1769
const ERR_PETITION_SIGNED_S: &str = "%s has signed your guild charter."; // :1770
const ERR_NO_GUILD_CHARTER: &str = "You don't have a guild charter."; // :1745
// The guild-name validator's messages (`0x4f5160`'s seven arms). Only the three
// `benilla_ui::script::validate_guild_name` implements can be produced; the rest are here so the
// key→text map is complete and a later carve of `0x6c9b70` only has to add the branch.
const ERR_GUILD_ENTER_NAME: &str = "Enter a name for your guild."; // :1603
const ERR_GUILD_NAME_INVALID: &str = "That name contains invalid characters, Enter a new name."; // :1616
const ERR_GUILD_NAME_INVALID_SPACE: &str =
"Guild names cannot start or end with a space. Enter a new name."; // :1617
const ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES: &str =
"Consecutive spaces are not allowed. Enter a new name."; // :1619
const ERR_GUILD_NAME_TOO_SHORT: &str = "That name is too short. Enter a new name."; // :1622
/// Which of the client's two channels a line goes to — the `+4 kind` field, as a type.
///
/// Modelled rather than left implicit because the split does not follow the key names, and a
/// `String` return would let a future edit put a refusal in the chat log without anything noticing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Line {
/// Kind `0` → the `CHAT_MSG_SYSTEM` chat line.
Chat(String),
/// Kind `2` → the red `UI_ERROR_MESSAGE` frame.
Error(String),
}
/// One `%s` substitution.
fn fill(template: &str, arg: &str) -> String {
template.replacen("%s", arg, 1)
}
/// The line one `SMSG_PETITION_SIGN_RESULTS` prints **when the signer is us** — the switch at
/// `0x5eeff5`'s "I signed" leg.
///
/// The owner's copy of the same packet is a different path entirely: it appends the signer and
/// emits `ERR_PETITION_SIGNED_S`, and never inspects the result at all ([`signed_by_other`]).
///
/// `NEED_MORE` and anything `>= 6` return `None` — the default arm reaches only the debug console.
pub(super) fn my_sign_line(result: u32) -> Option<Line> {
Some(match result {
petition_result::OK => Line::Chat(ERR_PETITION_SIGNED.to_string()),
petition_result::ALREADY_SIGNED => Line::Error(ERR_PETITION_ALREADY_SIGNED.to_string()),
petition_result::ALREADY_IN_GUILD => Line::Error(ERR_PETITION_IN_GUILD.to_string()),
petition_result::CANT_SIGN_OWN => Line::Error(ERR_PETITION_CREATOR.to_string()),
petition_result::NOT_SERVER => Line::Error(ERR_PETITION_NOT_SAME_SERVER.to_string()),
// `NEED_MORE` (4) and >= 6: the default arm, debug console only.
_ => return None,
})
}
/// The line the **owner** gets when somebody else signs — `ERR_PETITION_SIGNED_S`, a chat line, and
/// emitted **only when the signer's name is already cached** (`0x4f42f6`); on a cache miss the
/// client increments its pending-name counter instead and says nothing until the name lands.
pub(super) fn signed_by_other(name: &str) -> Line {
Line::Chat(fill(ERR_PETITION_SIGNED_S, name))
}
/// The line one `SMSG_TURN_IN_PETITION_RESULTS` prints. **Success prints nothing** — see the module
/// doc; it is not an omission here.
pub(super) fn turn_in_line(result: u32) -> Option<Line> {
Some(match result {
petition_result::ALREADY_IN_GUILD => Line::Error(ERR_PETITION_IN_GUILD.to_string()),
petition_result::NEED_MORE => Line::Error(ERR_PETITION_NOT_ENOUGH_SIGNATURES.to_string()),
_ => return None,
})
}
/// The line an inbound `MSG_PETITION_DECLINE` prints to the charter's owner — a chat line, and
/// **only if the declining player's name is already cached**. There is no query and no retry: an
/// uncached name means the owner is told nothing at all (`0x5ef12a`/`0x5ef139`).
pub(super) fn declined_line(name: &str) -> Line {
Line::Chat(fill(ERR_PETITION_DECLINED_S, name))
}
/// The local echo for a charter we just offered — emitted **optimistically on the send**, with no
/// server confirmation (`0x4f48fa`). Nothing comes back on success; the *target* is the one the
/// server answers.
pub(super) fn offered_line(name: &str) -> Line {
Line::Chat(fill(ERR_PETITION_OFFERED_S, name))
}
/// Offering a charter to yourself — guard 6 of `OfferPetition`'s eight (`0x4f4839`), which emits
/// `ERR_PETITION_CREATOR`, the same red line signing your own charter gets.
pub(super) fn self_offer_line() -> Line {
Line::Error(ERR_PETITION_CREATOR.to_string())
}
/// The local refusal when `TurnInGuildCharter()`'s bag scan finds no charter — a **red** line
/// (`0x5ef49a` emits id `0x7c`, kind 2), and no packet is built.
pub(super) fn no_charter_line() -> Line {
Line::Error(ERR_NO_GUILD_CHARTER.to_string())
}
/// The line for a name the client's own validator refused
/// ([`benilla_ui::script::validate_guild_name`]) — a red line, by the class every other
/// `ERR_GUILD_NAME_*` row in the catalog belongs to.
///
/// **The kind of these five rows is INFERRED**, unlike the nine petition rows above: that round
/// swept the petition ids and `0x7c`, not the `0x75`-`0x7b` band. They are refusals, and every
/// refusal in the swept set is kind 2. An unknown key falls back to the generic invalid-name text
/// rather than showing a raw key.
pub(super) fn name_refused_line(key: &str) -> Line {
Line::Error(
match key {
"ERR_GUILD_ENTER_NAME" => ERR_GUILD_ENTER_NAME,
"ERR_GUILD_NAME_INVALID_SPACE" => ERR_GUILD_NAME_INVALID_SPACE,
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES" => ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
"ERR_GUILD_NAME_TOO_SHORT" => ERR_GUILD_NAME_TOO_SHORT,
_ => ERR_GUILD_NAME_INVALID,
}
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
/// **The channel split, which does not follow the key names.** Success to chat, refusal to the
/// red frame — swept out of the message catalog, not guessed. This test is the one that would
/// fail if somebody "tidied" the two into a single `String` return.
#[test]
fn successes_go_to_chat_and_refusals_go_to_the_red_line() {
assert_eq!(
my_sign_line(petition_result::OK),
Some(Line::Chat("Guild charter signed.".into()))
);
assert_eq!(
signed_by_other("Bob"),
Line::Chat("Bob has signed your guild charter.".into())
);
assert_eq!(
declined_line("Bob"),
Line::Chat("Bob has declined your guild invitation.".into())
);
assert_eq!(
offered_line("Bob"),
Line::Chat("You have requested Bob's signature.".into())
);
for (code, text) in [
(
petition_result::ALREADY_SIGNED,
"You have already signed that guild charter.",
),
(
petition_result::ALREADY_IN_GUILD,
"You are already in a guild.",
),
(
petition_result::CANT_SIGN_OWN,
"You can't sign your own guild charter.",
),
(
petition_result::NOT_SERVER,
"That player is not from your server",
),
] {
assert_eq!(
my_sign_line(code),
Some(Line::Error(text.into())),
"code {code} is a RED line"
);
}
assert_eq!(
no_charter_line(),
Line::Error("You don't have a guild charter.".into())
);
}
/// **`NEED_MORE` is silent on the sign path and loud on the turn-in path** — one result code,
/// two packets, two behaviours. Reading the enum and giving both packets the same table is the
/// mistake this pins, and it is the shape the first version of this file had.
#[test]
fn need_more_is_silent_when_signing_and_loud_when_turning_in() {
assert_eq!(
my_sign_line(petition_result::NEED_MORE),
None,
"the sign switch's default arm reaches only the debug console"
);
assert_eq!(
turn_in_line(petition_result::NEED_MORE),
Some(Line::Error("You need more signatures.".into()))
);
}
/// A successful turn-in says **nothing**. The window closing is the whole of the feedback.
#[test]
fn a_successful_turn_in_prints_no_line_at_all() {
assert_eq!(turn_in_line(petition_result::OK), None);
// …and the codes the turn-in packet cannot carry stay silent rather than borrowing the
// sign path's wording.
assert_eq!(turn_in_line(petition_result::CANT_SIGN_OWN), None);
assert_eq!(turn_in_line(petition_result::ALREADY_SIGNED), None);
}
/// **Every string in this file, checked against the shipped `GlobalStrings.lua`.**
///
/// Three of the five validator lines shipped here as *paraphrases* — "Names may not begin or
/// end with a space." where the real client says "Guild names cannot start or end with a
/// space. Enter a new name." — because they were written from the key name instead of copied.
/// Nothing else could catch that: the text is never compared to anything at runtime, so a
/// paraphrase renders, reads plausibly, and is simply not what the game says. This test is the
/// oracle, and it is `ui_items::equip_error`'s, applied one table over.
#[test]
fn every_line_matches_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 s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| {
s.lua()
.globals()
.get::<String>(key)
.unwrap_or_else(|e| panic!("{key} missing from GlobalStrings: {e}"))
};
for (key, ours) in [
("ERR_PETITION_ALREADY_SIGNED", ERR_PETITION_ALREADY_SIGNED),
("ERR_PETITION_CREATOR", ERR_PETITION_CREATOR),
("ERR_PETITION_DECLINED_S", ERR_PETITION_DECLINED_S),
("ERR_PETITION_IN_GUILD", ERR_PETITION_IN_GUILD),
(
"ERR_PETITION_NOT_ENOUGH_SIGNATURES",
ERR_PETITION_NOT_ENOUGH_SIGNATURES,
),
("ERR_PETITION_NOT_SAME_SERVER", ERR_PETITION_NOT_SAME_SERVER),
("ERR_PETITION_OFFERED_S", ERR_PETITION_OFFERED_S),
("ERR_PETITION_SIGNED", ERR_PETITION_SIGNED),
("ERR_PETITION_SIGNED_S", ERR_PETITION_SIGNED_S),
("ERR_NO_GUILD_CHARTER", ERR_NO_GUILD_CHARTER),
("ERR_GUILD_ENTER_NAME", ERR_GUILD_ENTER_NAME),
("ERR_GUILD_NAME_INVALID", ERR_GUILD_NAME_INVALID),
("ERR_GUILD_NAME_INVALID_SPACE", ERR_GUILD_NAME_INVALID_SPACE),
(
"ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES",
ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES,
),
("ERR_GUILD_NAME_TOO_SHORT", ERR_GUILD_NAME_TOO_SHORT),
] {
assert_eq!(g(key), ours, "{key} is not what the client says");
}
}
/// The validator's keys resolve to their own text, and an unknown key degrades to the generic
/// invalid-name line rather than surfacing a raw key to the player.
#[test]
fn refused_names_resolve_their_key_and_degrade_safely() {
assert_eq!(
name_refused_line("ERR_GUILD_ENTER_NAME"),
Line::Error("Enter a name for your guild.".into())
);
assert_eq!(
name_refused_line("ERR_GUILD_NAME_INVALID_SPACE"),
Line::Error("Guild names cannot start or end with a space. Enter a new name.".into())
);
assert_eq!(
name_refused_line("ERR_SOMETHING_UNCARVED"),
Line::Error("That name contains invalid characters, Enter a new name.".into()),
"never a raw key on screen"
);
}
}
+683
View File
@@ -0,0 +1,683 @@
//! The guild-charter session — the registrar window, the petition window, and the verbs that found
//! a guild (decision 1672).
//!
//! [`crate::ui_guild`] is *being* in a guild; this is *making* one, the slice 1257 §2 deliberately
//! left out and named as the next. It mirrors the wire the way that module does: the seven server
//! packets replace or patch [`PetitionState`] / [`GuildRegistrarState`], and [`feed`] turns them
//! into the display-ready snapshot `benilla_ui::script::petition` reads.
//!
//! **Two windows, two sessions, and only one of them is an NPC session.**
//!
//! - [`GuildRegistrarState`] is the NPC half: it opens on `SMSG_PETITION_SHOWLIST` (which the
//! server pushes when the "How do I form a guild?" gossip row is selected — vmangos
//! `Player.cpp:12428-12431` closes the gossip menu and calls `SendPetitionShowList`), it knows a
//! price and an NPC guid, and it closes when the player walks away like every other NPC window
//! ([`crate::ui_session`]). wow-re corroborates the shape rather than merely permitting it: the
//! function that fires `GUILD_REGISTRAR_SHOW` (`0x4f4fb0`, firing at `0x4f4fff`) is one of the
//! fourteen callers of `CGGameUI::SetInteractNPC 0x4930d0`
//! (`system/object-layer/scratch/interaction-facing.md:177-178`), which is the *same* latch every
//! other NPC window arms.
//! - [`PetitionState`] is the item half: it opens on `SMSG_PETITION_SHOW_SIGNATURES`, is bound to a
//! charter **item guid**, and has no NPC at all. Walking away from the registrar must not close
//! it, which is why the two are separate resources rather than one with a half-meaning `close()`.
//!
//! They meet at exactly one place: `TurnInGuildCharter()` is a *registrar* button that acts on a
//! charter in the *bags*, found by [`crate::ui_items::find_item`].
//!
//! **The gossip handoff needs no special case, and that is worth saying because the banker's did**
//! (decision 0607). vmangos's `GOSSIP_OPTION_PETITIONER` arm calls `PlayerTalkClass->CloseGossip()`
//! *before* `SendPetitionShowList` (`Player.cpp:12428-12431`), and `CloseGossip` really does send
//! `SMSG_GOSSIP_COMPLETE` (`GossipDef.cpp:231-236`) — so the menu closes itself, and the registrar
//! takes the left panel slot the gossip window has already released. The bank needed
//! `npc::show_bank` to force that close precisely because its option sends no such packet; this one
//! must NOT, or the close would be done twice.
//!
//! **The laws that are not what the obvious design would do:**
//!
//! - **The window opens before it knows what it is showing.**
//! `SMSG_PETITION_SHOW_SIGNATURES` carries an item guid, an owner guid, a petition id and a list
//! of signer guids — and no text whatsoever. The proposed guild's name and the signature
//! requirement come only from `SMSG_PETITION_QUERY_RESPONSE`, keyed by petition id, and the
//! names come from [`crate::names::NameCache`]. So the petition window's first paint is
//! deliberately partial and repaints as each lookup lands. This is the identical two-caches shape
//! `SMSG_GUILD_ROSTER` has with `SMSG_GUILD_QUERY_RESPONSE` ([`crate::ui_guild`]'s own module
//! doc), and the cache is lazy for the same reason: nothing queries a petition we are not looking
//! at.
//! - **One inbound packet, two meanings.** `SMSG_PETITION_SHOW_SIGNATURES` answers our own
//! `CMSG_PETITION_SHOW_SIGNATURES` *and* is what arrives when somebody offers us their charter
//! (`Handlers/PetitionsHandler.cpp:390-397`). Nothing distinguishes them but whether `owner` is
//! us — so there is no "an offer arrived" path separate from "a charter opened", and building one
//! would be inventing a distinction the wire does not make.
//! - **A signature does not push a new list.** vmangos answers a successful sign with
//! `SMSG_PETITION_SIGN_RESULTS` to both parties and **no fresh signatures packet**
//! (`:299`, `:312-316`), while the reference's `PetitionFrame` registers only `PETITION_SHOW` and
//! `PETITION_CLOSED`. Nothing would ever repaint the name rows. So an `OK` result naming the open
//! charter re-sends `CMSG_PETITION_SHOW_SIGNATURES` ([`apply::sign_results`]) — INFERRED, and
//! flagged as such below.
//! - **Two success paths are silent on the wire and speak locally.** Offering a charter answers the
//! *target*, not us, and finding no charter to turn in is refused before any packet is built —
//! so `ERR_PETITION_OFFERED_S` and `ERR_NO_GUILD_CHARTER` are composed here. Both strings exist
//! in `GlobalStrings.lua` and nothing on the wire can produce either, which is the same test
//! [`crate::ui_guild::lines`] uses to identify an engine-composed line.
//!
//! ## What is INFERRED, and what would settle it
//!
//! wow-re had **not carved a single one of this subsystem's contracts** when this landed — it holds
//! the eight binding addresses (`system/ui/scratch/bindings.md:455-462`, all classified
//! ORCHESTRATION: located, contract never derived) and the four event ids
//! (`guild-api-carve.md:603-624`), and nothing about the wire or the handlers. Every claim below is
//! read off the *demand* side (the shipped `PetitionFrame.lua` / `GuildRegistrarFrame.lua`) plus
//! vmangos, and is named here so none of it is mistaken for byte law:
//!
//! 1. **`SMSG_PETITION_SHOWLIST` → `GUILD_REGISTRAR_SHOW`** (no args). The firer `0x4f4fb0` sits in
//! the petition band with `0x5eeb80` as its sole caller; the byte read of that handler settles
//! it.
//! 2. **`SMSG_PETITION_SHOW_SIGNATURES` → `PETITION_SHOW`** (no args), everything painted from
//! getters. The reference's own OnEvent uses no `arg1`, which makes this weak-but-consistent.
//! 3. **`ClosePetition()` / `CloseGuildRegistrar()` send nothing.** Chosen because inventing
//! traffic 1.12 never sends is the worse failure, and because wow-re already recorded exactly
//! that verdict for the neighbouring `CloseGuildRoster` (`xor eax,eax; ret`).
//! 4. **Re-requesting the signature list on an `OK` sign result** (above).
//! 5. **`ERR_GUILD_FOUNDER_S` on a successful turn-in.** vmangos declares `GUILD_FOUNDER_S = 0x0E`
//! and **never sends it** — `Guild::Create` broadcasts no event at all on the founding path
//! (`Guild/Guild.cpp:104-119`), so without a local line, founding a guild is completely silent.
//! The string is routed through [`crate::ui_guild::lines`]'s existing constant so there is one
//! copy; a server that *does* send the command result would double the line, which is visible
//! and cheap to undo.
//! 6. **Using a charter item opens it** — see [`crate::ui_items::ItemUseRoute::ShowPetition`],
//! where the evidence and its limits are written down.
use std::collections::{HashMap, HashSet};
use benilla_protocol::messages::{
petition_result, PetitionQueryResponse, PetitionRename, PetitionShowList,
PetitionShowSignatures, PetitionSignResults,
};
use bevy::prelude::*;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_script::UiInput;
use crate::ui_session::NpcSession;
mod feed;
mod lines;
/// The open guild registrar — an NPC window, and nothing more than a price.
///
/// Deliberately separate from [`PetitionState`]: only this half is bound to an NPC, so only this
/// half may be closed by the walk-away guard. A single resource implementing [`NpcSession`] would
/// have a `close()` that closed half of itself, which is the kind of thing that reads as correct
/// for a year.
#[derive(Resource, Default)]
pub(crate) struct GuildRegistrarState {
open: Option<Registrar>,
}
/// What one open registrar knows.
#[derive(Clone, Debug)]
struct Registrar {
/// The NPC — what `BuyGuildCharter` addresses (the petition module's own latched guid
/// `[0xbdceb0]`, *not* `CGGameUI`'s interaction pair), and what the range guard watches.
npc: u64,
/// The charter's price in copper. **Unsigned**, because the binding pushes it through the
/// zero-extending `fild QWORD` idiom.
cost: u32,
}
/// The two `UNIT_NPC_FLAGS` bits the client requires on a showlist's NPC before it will open the
/// registrar — bit 9 **and** bit 10, `PETITIONER | TABARDDESIGNER`
/// (`0x5eec33`/`0x5eec3b`, two separate `test`/`je` pairs).
///
/// vmangos gates its *send* on `PETITIONER` alone, so the two disagree — but not observably on this
/// world's data: all six petitioner templates carry both bits, and the eight tabard-only ones never
/// send a showlist at all. Implemented anyway, because it is three lines and a silent-drop gate is
/// exactly the kind of thing that matters the day the data changes.
const REGISTRAR_NPC_FLAGS: u32 = crate::target::cursor_mode::npc_flags::PETITIONER
| crate::target::cursor_mode::npc_flags::TABARDDESIGNER;
impl GuildRegistrarState {
/// `SMSG_PETITION_SHOWLIST` — open (or re-open) the registrar, **if it passes all three gates**.
///
/// `npc_flags` is the NPC's live `UNIT_NPC_FLAGS`, or `None` when the guid does not resolve to a
/// streamed unit — which fails the gate, as the client's own `0x5eec1a` resolve does.
///
/// The three gates, from `system/object-layer/scratch/petition-wire-law.md` §2:
/// 1. the NPC resolves as a unit and carries [`REGISTRAR_NPC_FLAGS`];
/// 2. **entry\[0\]**'s `entryFlags & 1` is set (`0x5eec42`, and it is entry\[0\]'s fifth dword
/// specifically — no other entry's flags are tested anywhere on this path);
/// 3. there is an entry\[0\] at all.
///
/// **Entry\[0\] and no other.** The handler parses every entry the count declares — a
/// re-implementation must too, to stay in sync with the stream — but a whole-image census of the
/// ten-entry table found exactly four references, all inside the handler, and only entry\[0\] is
/// ever consumed. This file first took "the first row with the visible flag", which reads as the
/// same thing on a one-row packet and is a different rule.
///
/// A packet that fails any gate is **parsed and silently dropped** — no window, no error line.
fn open(&mut self, list: &PetitionShowList, npc_flags: Option<u32>) -> bool {
if npc_flags.is_none_or(|f| f & REGISTRAR_NPC_FLAGS != REGISTRAR_NPC_FLAGS) {
debug!(
"ui_petition: showlist from {:#x} without both registrar npc flags — dropped",
list.npc
);
return false;
}
let Some(first) = list.entries.first().filter(|e| e.entry_flags & 1 != 0) else {
debug!("ui_petition: showlist entry[0] not flagged visible — dropped");
return false;
};
self.open = Some(Registrar {
npc: list.npc,
cost: first.charter_cost as u32,
});
true
}
/// The charter's price in copper, `0` when no registrar is open — `GetGuildCharterCost()`.
fn cost(&self) -> u32 {
self.open.as_ref().map_or(0, |r| r.cost)
}
}
impl NpcSession for GuildRegistrarState {
fn npc(&self) -> Option<u64> {
self.open.as_ref().map(|r| r.npc)
}
fn close(&mut self) {
self.open = None;
}
}
/// The open charter and the petition-record cache.
#[derive(Resource, Default)]
pub(crate) struct PetitionState {
open: Option<OpenCharter>,
/// The lazy record cache, keyed by petition id — the guild-identity cache's twin. Session
/// state: a petition id means nothing across a reconnect.
records: HashMap<u32, Record>,
/// Petition ids with a `CMSG_PETITION_QUERY` in flight, so the ask happens once per id per
/// connection ([`crate::names::NameCache`]'s rule).
querying: HashSet<u32>,
/// `[0xbdce1c]` — set while a sign we sent is outstanding, cleared when its result lands.
///
/// It exists for one reader, and that reader is easy to miss: the decline leg of a close is
/// gated on it (`0x4f3f89`), so closing a window while your own signature is in flight must
/// **not** put `MSG_PETITION_DECLINE` on the wire.
signing: bool,
/// Lines composed at apply time, waiting for the feed — which is where the `script` handle
/// lives, and so the only place a red `UI_ERROR_MESSAGE` can be fired ([`crate::ui_bank`]'s
/// `BankErrors` shape).
lines: Vec<lines::Line>,
}
/// The charter the petition window is showing.
#[derive(Clone, Debug)]
struct OpenCharter {
/// The charter **item's** guid — the handle every verb in the family takes.
item: u64,
/// Its owner. Whether this is us decides which half of the window shows.
owner: u64,
/// The petition id — the key into [`PetitionState::records`].
petition_id: u32,
/// The signer guids, in wire order. Names are resolved at feed time and are deliberately not
/// stored: a name that lands later must change the display without any packet re-arriving.
signers: Vec<u64>,
}
/// One petition's record, from `SMSG_PETITION_QUERY_RESPONSE`.
#[derive(Clone, Debug, Default)]
struct Record {
/// The proposed guild's name.
name: String,
/// Free text — always empty on a 1.12 server.
body_text: String,
/// How many signatures it **requires** — the record's `+0x1118`, and a **signed** i32 because
/// the binding pushes it with `fild DWORD`. What the Request-Signature button disables against,
/// and what `CanSignPetition` compares the live count to (`0x4f4634`); distinct from the nine
/// name *rows*.
required: i32,
/// The record's `+0x1110` bit 0 — set for a guild charter, clear for a plain petition. It picks
/// `GetPetitionInfo`'s first return, and it **gates two of `CanSignPetition`'s refusals**, so a
/// non-charter petition is never refused for guild membership or for a full list.
is_charter: bool,
}
impl PetitionState {
/// `SMSG_PETITION_SHOW_SIGNATURES` — open the window on this charter, and fire the record
/// query if we have never seen this petition.
///
/// Takes the command channel rather than holding one: the same seam
/// [`crate::ui_guild::GuildState::request_identity`] has, so the state stays plain data.
fn show(&mut self, sigs: PetitionShowSignatures, commands: &NetCommands) {
let petition_id = sigs.petition_id;
self.open = Some(OpenCharter {
item: sigs.item,
owner: sigs.owner,
petition_id,
signers: sigs.signatures.into_iter().map(|s| s.signer).collect(),
});
self.request_record(petition_id, sigs.item, commands);
}
/// The lazy record fill: ask once per petition id, and answer nothing for the call that missed.
/// The arrival simply lands in the cache; the feed's next rebuild picks it up (see
/// [`feed`]'s note on why there is no flag).
fn request_record(&mut self, petition_id: u32, item: u64, commands: &NetCommands) {
if self.records.contains_key(&petition_id) || !self.querying.insert(petition_id) {
return;
}
let _ = commands
.0
.send(ClientCommand::PetitionQuery { petition_id, item });
}
/// `SMSG_PETITION_QUERY_RESPONSE` — fill the cache.
fn apply_record(&mut self, response: PetitionQueryResponse) {
self.querying.remove(&response.petition_id);
self.records.insert(
response.petition_id,
Record {
name: response.name,
body_text: response.body_text,
required: response.min_signatures as i32,
is_charter: response.flags & 1 != 0,
},
);
}
/// Append one signer to the open charter — the owner's half of a successful sign
/// (`0x4f41c0`), and it is an **append, not a re-request**.
///
/// The server sends no fresh signature list when somebody signs, and the reference's
/// `PetitionFrame` registers only `PETITION_SHOW`/`PETITION_CLOSED`. This file first re-sent
/// `CMSG_PETITION_SHOW_SIGNATURES` to make the rows repaint, which is a round trip the real
/// client does not make: it grows its own array and fires the event itself.
///
/// Returns the signer's name when the cache already had it — the condition on emitting
/// `ERR_PETITION_SIGNED_S`. On a miss the name is left unresolved in the list and the feed's own
/// deferral holds the repaint until it lands, which is the same effect as the client's
/// pending-name counter.
///
/// A result for a charter we do not have open is not ours to apply.
fn append_signer(
&mut self,
item: u64,
signer: u64,
names: &mut NameCache,
commands: &NetCommands,
) -> Option<String> {
let open = self.open.as_mut().filter(|o| o.item == item)?;
if !open.signers.contains(&signer) {
open.signers.push(signer);
}
names.resolve(signer, commands).map(str::to_string)
}
/// The decline `ClosePetition()` can put on the wire — `0x4f3f60`'s leg at `0x4f3fb5`.
///
/// **This is the half of `ClosePetition` that is not a local reset**, and it is guarded by four
/// conditions, every one of which matters:
///
/// 1. a petition is open (`0x4f3f7c`) — otherwise there is nothing to decline;
/// 2. **no sign of ours is in flight** (`0x4f3f89`) — closing the window while your own
/// signature is on the wire must not read as a refusal of the same charter;
/// 3. a record is cached (`0x4f3f95`) — without one the ownership test below cannot run;
/// 4. we are **not** the owner (`0x4f3fa5`/`0x4f3fae`) — you do not decline your own charter.
///
/// The same leg runs when a *different* petition replaces the open one, which is why the feed's
/// switch arm consumes the close intent it causes: without that, being offered a second charter
/// would silently decline the first.
fn decline_on_close(&mut self, me: u64, commands: &NetCommands) {
let Some(open) = self.open.as_ref() else {
return;
};
if self.signing || !self.records.contains_key(&open.petition_id) || open.owner == me {
return;
}
let _ = commands
.0
.send(ClientCommand::PetitionDecline { item: open.item });
}
/// The window's client-side close — no packet (module doc, INFERRED #3).
fn close(&mut self) {
self.open = None;
}
/// The open charter's item guid — what `SignPetition` / `OfferPetition` / `RenamePetition`
/// address. `None` = nothing open, and the verb is dropped rather than sent against a guess.
fn open_item(&self) -> Option<u64> {
self.open.as_ref().map(|o| o.item)
}
}
/// The plugin: two resources, the walk-away guard on the registrar half, and the feed/drain pair.
pub(crate) struct UiPetitionPlugin;
impl Plugin for UiPetitionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GuildRegistrarState>()
.init_resource::<PetitionState>()
.add_systems(
Update,
(
// Ahead of the feed, so a walk-away turns into GUILD_REGISTRAR_CLOSED the same
// frame — every other NPC window's ordering.
crate::ui_session::close_npc_session_out_of_range::<GuildRegistrarState>
.before(feed::feed_petition),
feed::feed_petition.before(UiInput),
feed::drain_petition.after(UiInput),
),
);
}
}
/// The inbound arms — one per packet, each beside the state it drives.
///
/// **Nothing here fires an event or writes a chat line directly.** Composed lines go onto
/// [`PetitionState::lines`] and the feed drains them, because the red `UI_ERROR_MESSAGE` channel
/// needs the `script` handle and apply has none (`crate::ui_bank`'s `BankErrors` shape).
pub(crate) mod apply {
use super::*;
/// `SMSG_PETITION_SHOWLIST` — the registrar's charter list, subject to its three gates.
///
/// `npc_flags` is the NPC's live `UNIT_NPC_FLAGS`; the caller reads it because only the apply
/// pass holds the object store.
pub(crate) fn show_list(
registrar: &mut GuildRegistrarState,
list: PetitionShowList,
npc_flags: Option<u32>,
) {
registrar.open(&list, npc_flags);
}
/// `SMSG_PETITION_SHOW_SIGNATURES` — a charter opened, ours or one offered to us.
///
/// **An ignored owner suppresses the whole update** (`0x5eeefe`-`0x5eef0b`): no record fetch, no
/// signer list, no event, no error line. The player is simply never told. That gate is checked
/// by the caller, which holds the ignore list.
pub(crate) fn show_signatures(
petition: &mut PetitionState,
sigs: PetitionShowSignatures,
owner_ignored: bool,
commands: &NetCommands,
) {
if owner_ignored {
debug!(
"ui_petition: charter from ignored owner {:#x} — whole update dropped",
sigs.owner
);
return;
}
petition.show(sigs, commands);
}
/// `SMSG_PETITION_QUERY_RESPONSE` — the record cache fill.
pub(crate) fn query_response(petition: &mut PetitionState, response: PetitionQueryResponse) {
petition.apply_record(response);
}
/// `SMSG_PETITION_SIGN_RESULTS` — and it is **two different code paths in one packet**.
///
/// The server sends a byte-identical copy to the signer and to the charter's owner, and the
/// client tells them apart by comparing the packet's player guid against its own
/// (`0x5eefee`). What each does could hardly be less alike:
///
/// - **somebody else signed**: the result is *never inspected*. The signer is **appended to the
/// local list** (`0x4f41c0`) — no re-request, which is what this file first did — the count
/// rises, and a cached name emits `ERR_PETITION_SIGNED_S` while an uncached one just raises
/// the pending counter and stays quiet until it lands.
/// - **we signed**: the result is switched on for a line, the in-flight latch clears, and a
/// success additionally **closes the window** (`0x5ef037` fires `PETITION_CLOSED`).
pub(crate) fn sign_results(
petition: &mut PetitionState,
names: &mut NameCache,
self_guid: u64,
results: PetitionSignResults,
commands: &NetCommands,
) {
if results.player != self_guid {
let cached = petition.append_signer(results.item, results.player, names, commands);
if let Some(name) = cached {
petition.lines.push(lines::signed_by_other(&name));
}
return;
}
petition.signing = false;
if let Some(line) = lines::my_sign_line(results.result) {
petition.lines.push(line);
}
if results.result == petition_result::OK {
petition.close();
}
}
/// `SMSG_TURN_IN_PETITION_RESULTS` — the verdict on a turn-in.
///
/// **Success says nothing and closes the REGISTRAR** (`0x5ef166` fires
/// `GUILD_REGISTRAR_CLOSED`), not the charter window — which the destroyed item closes on its
/// own. Only the two refusals print, and both are red.
pub(crate) fn turn_in_results(
petition: &mut PetitionState,
registrar: &mut GuildRegistrarState,
result: u32,
) {
if result == petition_result::OK {
// The server has destroyed the charter, so the item-bound window has nothing left to
// point at either.
petition.close();
registrar.close();
return;
}
if let Some(line) = lines::turn_in_line(result) {
petition.lines.push(line);
}
}
/// `MSG_PETITION_DECLINE` inbound — somebody turned our charter down.
///
/// **Only if their name is already cached** (`0x5ef12a`/`0x5ef139`). There is no query and no
/// retry: an uncached name means the owner is told nothing at all, ever. So this arm
/// deliberately does *not* take the command channel — it must not be able to ask.
pub(crate) fn declined(petition: &mut PetitionState, names: &NameCache, player: u64) {
if let Some(name) = names.peek(player) {
let line = lines::declined_line(name);
petition.lines.push(line);
}
}
/// `MSG_PETITION_RENAME` inbound — the echo of a rename that took.
///
/// Patches the cached title **in place** (`0x5ef292` overwrites the record's inline `char[0x100]`
/// and fires `PETITION_SHOW`), rather than re-querying: the echo carries the new name.
pub(crate) fn renamed(petition: &mut PetitionState, rename: PetitionRename) {
let Some(id) = petition
.open
.as_ref()
.filter(|o| o.item == rename.item)
.map(|o| o.petition_id)
else {
return;
};
petition.records.entry(id).or_default().name = rename.name;
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{PetitionShowListEntry, PetitionSignature};
use crossbeam_channel::unbounded;
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = unbounded();
(NetCommands(tx), rx)
}
fn show_list(npc: u64, rows: &[(i32, i32)]) -> PetitionShowList {
PetitionShowList {
npc,
entries: rows
.iter()
.enumerate()
.map(|(i, (cost, flags))| PetitionShowListEntry {
index: i as u32 + 1,
charter_entry: benilla_protocol::messages::CHARTER_ITEM_ENTRY,
charter_display_id: benilla_protocol::messages::CHARTER_DISPLAY_ID,
charter_cost: *cost,
entry_flags: *flags,
})
.collect(),
}
}
/// The open charter's cached title — what the feed reads to fill `GetPetitionInfo`'s second
/// return, spelled once here rather than at three assertion sites.
fn open_title(p: &PetitionState) -> Option<&str> {
let open = p.open.as_ref()?;
p.records.get(&open.petition_id).map(|r| r.name.as_str())
}
fn signatures(item: u64, owner: u64, id: u32, signers: &[u64]) -> PetitionShowSignatures {
PetitionShowSignatures {
item,
owner,
petition_id: id,
signatures: signers
.iter()
.map(|s| PetitionSignature {
signer: *s,
unknown: 0,
})
.collect(),
}
}
/// **The registrar's three gates**, and the price is entry\[0\]'s — not the first *visible*
/// row's, which is what this file shipped first.
///
/// The two readings agree on every packet vmangos sends (one row, flag set), which is exactly
/// why the wrong one survives: it takes a two-row fixture whose first row is hidden to tell
/// them apart, and the real client would drop that packet where a first-visible reading quotes
/// the second row's price.
#[test]
fn the_registrar_gates_on_entry_zero_and_on_both_npc_flags() {
let both = Some(REGISTRAR_NPC_FLAGS);
let mut r = GuildRegistrarState::default();
assert!(r.open(&show_list(0x2a1f, &[(1000, 1)]), both));
assert_eq!(r.cost(), 1000, "entry[0]'s price, in copper");
assert_eq!(r.npc(), Some(0x2a1f));
// Entry[0] hidden: the WHOLE packet is dropped. A "first visible row" reading would open
// the window here and quote 1000.
let mut r = GuildRegistrarState::default();
assert!(!r.open(&show_list(0x2a1f, &[(9999, 0), (1000, 1)]), both));
assert_eq!(r.npc(), None, "no window at all — entry[0] is not visible");
// Each NPC-flag gate alone is not enough; the client tests both, separately.
for flags in [
None,
Some(0),
Some(crate::target::cursor_mode::npc_flags::PETITIONER),
Some(crate::target::cursor_mode::npc_flags::TABARDDESIGNER),
] {
let mut r = GuildRegistrarState::default();
assert!(
!r.open(&show_list(0x2a1f, &[(1000, 1)]), flags),
"flags {flags:?} must not open the registrar"
);
}
}
/// The registrar is an NPC session and the petition window is not: closing the first must not
/// touch the second. This is the whole reason they are two resources.
#[test]
fn walking_away_from_the_registrar_leaves_an_open_charter_alone() {
let (commands, _rx) = commands();
let mut registrar = GuildRegistrarState::default();
let mut petition = PetitionState::default();
registrar.open(&show_list(0x2a1f, &[(1000, 1)]), Some(REGISTRAR_NPC_FLAGS));
petition.show(signatures(0x99, 0xaa, 7, &[0xbb]), &commands);
registrar.close();
assert_eq!(registrar.npc(), None);
assert_eq!(
petition.open_item(),
Some(0x99),
"the charter window is item-bound and survives the walk-away"
);
}
/// The record query fires once per petition id and not again — the guild-identity cache's
/// ask-once rule, which is what stops a window that repaints every frame from hammering the
/// server.
#[test]
fn the_record_query_is_asked_once_per_petition() {
let (commands, rx) = commands();
let mut petition = PetitionState::default();
petition.show(signatures(0x99, 0xaa, 7, &[]), &commands);
petition.show(signatures(0x99, 0xaa, 7, &[0xbb]), &commands);
let sent: Vec<_> = rx.try_iter().collect();
assert_eq!(
sent.len(),
1,
"one query for two opens of the same petition"
);
assert!(matches!(
sent[0],
ClientCommand::PetitionQuery {
petition_id: 7,
item: 0x99
}
));
// The answer landing does not re-arm the ask.
petition.apply_record(PetitionQueryResponse {
petition_id: 7,
name: "Legacy".into(),
min_signatures: 9,
..Default::default()
});
petition.show(signatures(0x99, 0xaa, 7, &[0xbb, 0xcc]), &commands);
assert!(rx.try_iter().next().is_none(), "still no second query");
assert_eq!(open_title(&petition), Some("Legacy"));
}
/// A rename echo patches the cached record in place. Re-querying instead would be a round trip
/// for a value the echo already carries — and would leave the window showing the old name for
/// as long as it took.
#[test]
fn a_rename_echo_patches_the_cached_record() {
let (commands, _rx) = commands();
let mut petition = PetitionState::default();
petition.show(signatures(0x99, 0xaa, 7, &[]), &commands);
petition.apply_record(PetitionQueryResponse {
petition_id: 7,
name: "First".into(),
min_signatures: 9,
..Default::default()
});
apply::renamed(
&mut petition,
PetitionRename {
item: 0x99,
name: "Second".into(),
},
);
assert_eq!(open_title(&petition), Some("Second"));
// An echo for a charter we do not have open is not ours to apply.
apply::renamed(
&mut petition,
PetitionRename {
item: 0xdead,
name: "Wrong".into(),
},
);
assert_eq!(open_title(&petition), Some("Second"));
}
}
+6
View File
@@ -923,6 +923,12 @@ mod guild_tests;
#[cfg(test)]
mod help_frame_tests;
/// The two guild-charter windows — the registrar and the petition sheet (decision 1672). Its own
/// module for `guild_tests`' reason: it stands the charter engine API in for in Lua before the XML
/// loads, so what is under test is the window and not `script::petition`'s plumbing.
#[cfg(test)]
mod petition_tests;
/// The social window's fourth tab — the raid pane and its grid (decision 1549). Its own module for
/// `guild_tests`' reason: every test in it pushes a RAID roster first, which a file about the
/// friends list must not be in the business of.
@@ -0,0 +1,454 @@
//! The two guild-charter windows (decision 1672): the guild registrar's two panels, and the
//! charter itself with its two faces.
//!
//! What these guard that the Rust-side unit tests structurally cannot: the windows are Lua over an
//! engine snapshot, so a `GetPetitionInfo` destructured in the wrong order, a name row wired to the
//! wrong index, the leader/signer swap tested the wrong way round, or a button pointed at the wrong
//! verb are all invisible to `script::petition`'s own tests and green in the parse sweep. Each test
//! below fails on exactly one of those.
//!
//! **The engine API is STOOD IN FOR here, deliberately** — `guild_tests`' convention, and for its
//! reasons: what is under test is the window and nothing else, the tests need no app feed seated,
//! and a change to the engine's plumbing cannot quietly turn one green. Two fixture shapes are the
//! ones `script::petition` promises and are easy to get wrong:
//!
//! - **era booleans are `1`/`nil`, never `true`/`false`** — `isOriginator` and `CanSignPetition`;
//! - **`GetPetitionInfo` returns SIX values in a fixed order**
//! (`petitionType, title, bodyText, maxSignatures, originatorName, isOriginator`), and the
//! fourth is the wire's *requirement*, not `MAX_PETITION_SIGNATURES`.
use benilla_ui::script::UiScript;
/// Load one shipped `assets/ui/<file>`, panicking on any loader error **and on any
/// unknown-template warning** — `guild_tests`' loader, and the decision-0672 reason is the same:
/// `inherits=` a template this house doesn't ship is a *warning*, the frame still loads, and a
/// bare Button still clicks. Both windows here inherit from `UIPanelTemplates.xml` and
/// `MoneyFrame.xml`, and the registrar defines one local template of its own.
fn load_xml(s: &UiScript, file: &str) {
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
);
let missing: Vec<&String> = report
.warnings
.iter()
.filter(|w| w.contains("unknown template"))
.collect();
assert!(
missing.is_empty(),
"{file}: inherits a template this house does not ship (the frame loads, its ART does \
not): {missing:?}"
);
}
/// The charter engine API, stood in for in Lua.
///
/// One mutable table, `BenillaPetitionFixture`, is the whole model; every verb appends to `.calls`,
/// which a test drains with `BenillaPetitionCalls()`. Tests mutate the table's FIELDS — never
/// replace the table, since the closures below hold it as an upvalue.
///
/// Seeded as a **signer's** view of a charter with two of nine signatures: the case where every
/// distinction this window makes is live at once (the Sign face showing, rows both filled and
/// empty, Request still enabled).
const PETITION_FIXTURE: &str = r#"
BenillaPetitionFixture = {
petitionType = "charter",
title = "Legacy of Steel",
bodyText = "",
-- The WIRE's requirement, deliberately not 9, so a test can tell it apart from
-- MAX_PETITION_SIGNATURES.
maxSignatures = 4,
originator = "Tigole",
isOriginator = nil,
canSign = 1,
signers = { "Furor", "Kaplan" },
charterCost = 1000,
calls = {},
}
local F = BenillaPetitionFixture
function BenillaPetitionCalls()
local out = table.concat(F.calls, "|")
F.calls = {}
return out
end
local function record(call)
table.insert(F.calls, call)
end
function GetPetitionInfo()
return F.petitionType, F.title, F.bodyText, F.maxSignatures, F.originator, F.isOriginator
end
function GetNumPetitionNames() return table.getn(F.signers) end
function GetPetitionNameInfo(i) return F.signers[i] end
function CanSignPetition() return F.canSign end
function GetGuildCharterCost() return F.charterCost end
function SignPetition() record("SignPetition") end
function OfferPetition() record("OfferPetition") end
function ClosePetition() record("ClosePetition") end
function CloseGuildRegistrar() record("CloseGuildRegistrar") end
function TurnInGuildCharter() record("TurnInGuildCharter") end
function BuyGuildCharter(name) record("BuyGuildCharter:" .. name) end
function RenamePetition(name) record("RenamePetition:" .. name) end
"#;
/// The windows' manifest slice, in `benilla.toc` order, with the fixture seated first.
///
/// `MoneyFrame.xml` is load-bearing rather than incidental: `GuildRegistrarMoneyFrame` declares
/// `inherits="MoneyFrameTemplate"`, which resolves at LOAD (decision 1580), so without it the
/// registrar's price row would be a bare frame and the unknown-template guard above would fire.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(PETITION_FIXTURE).unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "GuildRegistrarFrame.xml");
load_xml(&s, "PetitionFrame.xml");
s
}
fn text(s: &UiScript, expr: &str) -> String {
s.eval::<String>(&format!("return {expr}:GetText() or \"\""))
.unwrap_or_else(|e| panic!("{expr}:GetText() — {e}"))
}
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap_or_else(|e| panic!("{frame}:IsVisible() — {e}"))
}
fn calls(s: &UiScript) -> String {
s.eval::<String>("return BenillaPetitionCalls()").unwrap()
}
/// Open the charter window the way the engine does.
fn show_petition(s: &mut UiScript) {
s.fire_event("PETITION_SHOW", vec![]);
assert!(s.errors().is_empty(), "PETITION_SHOW: {:?}", s.errors());
}
fn show_registrar(s: &mut UiScript) {
s.fire_event("GUILD_REGISTRAR_SHOW", vec![]);
assert!(
s.errors().is_empty(),
"GUILD_REGISTRAR_SHOW: {:?}",
s.errors()
);
}
/// A signer's charter paints the header, the signed rows, the unsigned rows, and the member
/// instructions — and shows Sign, not Request/Rename.
///
/// The row assertion is the load-bearing half: rows 1..2 carry the two signers and rows 3..9 must
/// read `<not yet signed>`. A `GetPetitionNameInfo` bound 0-based would put "Kaplan" in row 1 and
/// blank row 2 while every other assertion here still passed.
#[test]
fn a_signers_charter_shows_the_sign_face_and_nine_rows() {
let mut s = setup();
show_petition(&mut s);
assert!(visible(&s, "PetitionFrame"));
assert_eq!(
text(&s, "PetitionFrameNpcNameText"),
"Legacy of Steel Guild Charter",
"GUILD_CHARTER_TEMPLATE filled with the title"
);
assert_eq!(text(&s, "PetitionFrameCharterName"), "Legacy of Steel");
assert_eq!(text(&s, "PetitionFrameMasterName"), "Tigole");
// The three static labels come from GlobalStrings via `text=` and are never painted.
assert_eq!(text(&s, "PetitionFrameCharterTitle"), "Guild Name");
assert_eq!(text(&s, "PetitionFrameMasterTitle"), "Guild Master");
assert_eq!(text(&s, "PetitionFrameMemberTitle"), "Members");
assert_eq!(text(&s, "PetitionFrameMemberName1"), "Furor");
assert_eq!(text(&s, "PetitionFrameMemberName2"), "Kaplan");
for i in 3..=9 {
assert_eq!(
text(&s, &format!("PetitionFrameMemberName{i}")),
"<not yet signed>",
"row {i} is an empty seat"
);
}
assert!(visible(&s, "PetitionFrameSignButton"));
assert!(!visible(&s, "PetitionFrameRequestButton"));
assert!(
!visible(&s, "PetitionFrameRenameButton"),
"only the charter's owner may rename it"
);
assert_eq!(
text(&s, "PetitionFrameInstructions"),
"Click the <Sign Charter> button to become a charter member of this guild."
);
}
/// The owner's charter is the same window inside out: Request + Rename replace Sign, and the
/// instructions change.
///
/// Both halves are asserted because the two buttons share one anchor — a Show/Hide written the
/// wrong way round stacks them and the top one wins, which looks correct from a screenshot of
/// either view alone.
#[test]
fn the_owners_charter_swaps_sign_for_request_and_rename() {
let mut s = setup();
s.run("BenillaPetitionFixture.isOriginator = 1").unwrap();
show_petition(&mut s);
assert!(visible(&s, "PetitionFrameRequestButton"));
assert!(visible(&s, "PetitionFrameRenameButton"));
assert!(!visible(&s, "PetitionFrameSignButton"));
assert_eq!(
text(&s, "PetitionFrameInstructions"),
"Select a player you wish to invite and click <request signature>. To create this \
guild, turn it in to the guild registrar when you have filled the charter."
);
}
/// Request Signature disables against the **wire's** requirement, never against the nine rows.
///
/// The fixture requires 4. At three signatures the button is live; at four it is not — and nine
/// rows are still painted either way. A window that compared against `MAX_PETITION_SIGNATURES`
/// would leave Request enabled on a full charter, and every other assertion in this file would
/// still pass.
#[test]
fn request_signature_disables_at_the_wires_requirement_not_at_nine() {
let mut s = setup();
s.run(
r#"
BenillaPetitionFixture.isOriginator = 1
BenillaPetitionFixture.signers = { "A", "B", "C" }
"#,
)
.unwrap();
show_petition(&mut s);
assert!(
s.eval::<bool>("return PetitionFrameRequestButton:IsEnabled() and true or false")
.unwrap(),
"three of four signatures — still asking"
);
s.run(r#"BenillaPetitionFixture.signers = { "A", "B", "C", "D" }"#)
.unwrap();
show_petition(&mut s);
assert!(
!s.eval::<bool>("return PetitionFrameRequestButton:IsEnabled() and true or false")
.unwrap(),
"four of four — the charter is full, though only four of nine rows are used"
);
assert_eq!(
text(&s, "PetitionFrameMemberName5"),
"<not yet signed>",
"the nine rows are unaffected by the requirement"
);
}
/// `CanSignPetition` gates the Sign button independently of which face is showing: a charter can be
/// a signer's view and still be unsignable (already signed, already guilded, full).
#[test]
fn the_sign_button_follows_can_sign_petition() {
let mut s = setup();
show_petition(&mut s);
assert!(s
.eval::<bool>("return PetitionFrameSignButton:IsEnabled() and true or false")
.unwrap());
s.run("BenillaPetitionFixture.canSign = nil").unwrap();
show_petition(&mut s);
assert!(
!s.eval::<bool>("return PetitionFrameSignButton:IsEnabled() and true or false")
.unwrap(),
"nil, the era false, disables it"
);
assert!(
visible(&s, "PetitionFrameSignButton"),
"disabled, not hidden — hiding would expose the Request button beneath it"
);
}
/// Each button reaches its own verb, and closing the window closes the engine's session.
///
/// The `ClosePetition` half is the one worth pinning: it rides `OnHide`, so it fires however the
/// window closes, and without it the engine keeps a charter session for a window nobody can see.
#[test]
fn the_charter_buttons_reach_their_verbs_and_the_close_clears_the_session() {
let mut s = setup();
show_petition(&mut s);
let _ = calls(&s);
s.run("PetitionFrameSignButton:Click()").unwrap();
assert_eq!(calls(&s), "SignPetition");
s.run("BenillaPetitionFixture.isOriginator = 1").unwrap();
show_petition(&mut s);
let _ = calls(&s);
s.run("PetitionFrameRequestButton:Click()").unwrap();
assert_eq!(calls(&s), "OfferPetition");
s.run("HideUIPanel(PetitionFrame)").unwrap();
assert!(!visible(&s, "PetitionFrame"));
assert_eq!(calls(&s), "ClosePetition", "OnHide clears the session");
}
/// The rename dialog is registered, takes the box's text, and is capped at the server's own
/// 24-character charter-name limit.
#[test]
fn rename_guild_sends_the_box_text_and_caps_at_twenty_four() {
let mut s = setup();
s.run("BenillaPetitionFixture.isOriginator = 1").unwrap();
show_petition(&mut s);
let _ = calls(&s);
s.run("PetitionFrameRenameButton:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(
s.eval::<i64>("return StaticPopupDialogs[\"RENAME_GUILD\"].maxLetters")
.unwrap(),
24,
"the server's MAX_CHARTER_NAME"
);
assert!(
visible(&s, "StaticPopup1"),
"the popup engine raised the dialog"
);
s.run(
r#"
StaticPopup1EditBox:SetText("Second Legacy")
StaticPopup1Button1:Click()
"#,
)
.unwrap();
assert_eq!(calls(&s), "RenamePetition:Second Legacy");
}
/// The registrar opens on its services list, and Purchase swaps panels **locally** — no verb, no
/// packet. Getting that wrong would send a buy on the first click of "Purchase a Guild Charter",
/// before the player has typed a name.
#[test]
fn the_registrar_opens_on_services_and_purchase_is_a_local_panel_swap() {
let mut s = setup();
show_registrar(&mut s);
assert!(visible(&s, "GuildRegistrarFrame"));
assert!(visible(&s, "GuildRegistrarGreetingFrame"));
assert!(!visible(&s, "GuildRegistrarPurchaseFrame"));
assert_eq!(
text(&s, "GuildRegistrarButton1"),
"Purchase a Guild Charter"
);
assert_eq!(
text(&s, "GuildRegistrarButton2"),
"Register a Guild Charter"
);
let _ = calls(&s);
s.run("GuildRegistrarButton1:Click()").unwrap();
assert!(visible(&s, "GuildRegistrarPurchaseFrame"));
assert!(!visible(&s, "GuildRegistrarGreetingFrame"));
assert_eq!(calls(&s), "", "the swap sends nothing");
}
/// Buying sends the box's text and closes the window; registering sends the no-argument turn-in.
///
/// The close-after-buy is the reference's own behaviour and is asserted because it looks like a bug
/// otherwise: a buy is one-shot, and its answer is an item arriving rather than anything this
/// window will be told.
#[test]
fn purchase_sends_the_typed_name_and_register_turns_the_charter_in() {
let mut s = setup();
show_registrar(&mut s);
s.run("GuildRegistrarButton1:Click()").unwrap();
let _ = calls(&s);
s.run(
r#"
GuildRegistrarFrameEditBox:SetText("Legacy of Steel")
GuildRegistrarFramePurchaseButton:Click()
"#,
)
.unwrap();
assert_eq!(
calls(&s),
"BuyGuildCharter:Legacy of Steel|CloseGuildRegistrar",
"the buy, then the window's own OnHide close"
);
assert!(!visible(&s, "GuildRegistrarFrame"));
show_registrar(&mut s);
let _ = calls(&s);
s.run("GuildRegistrarButton2:Click()").unwrap();
assert_eq!(
calls(&s),
"TurnInGuildCharter",
"no argument — the engine finds the charter in the bags"
);
}
/// The price row is filled at panel-swap time from `GetGuildCharterCost()`, in copper.
///
/// Reading it at *show* time instead would paint a 0: the cost belongs to the open registrar's
/// charter list, and the greeting panel never displays one.
#[test]
fn the_charter_price_is_read_when_the_purchase_panel_opens() {
let mut s = setup();
show_registrar(&mut s);
s.run("GuildRegistrarButton1:Click()").unwrap();
// 1000 copper = 10 silver: the gold slot is empty, the silver slot reads 10.
assert_eq!(text(&s, "GuildRegistrarMoneyFrameSilverButtonText"), "10");
assert_eq!(text(&s, "GuildRegistrarCostLabel"), "Cost:");
}
/// Re-opening the registrar always lands on the services list, never on a half-filled purchase
/// panel left over from last time.
#[test]
fn reopening_the_registrar_returns_to_the_services_list() {
let mut s = setup();
show_registrar(&mut s);
s.run("GuildRegistrarButton1:Click()").unwrap();
assert!(visible(&s, "GuildRegistrarPurchaseFrame"));
s.fire_event("GUILD_REGISTRAR_CLOSED", vec![]);
assert!(!visible(&s, "GuildRegistrarFrame"));
show_registrar(&mut s);
assert!(visible(&s, "GuildRegistrarGreetingFrame"));
assert!(!visible(&s, "GuildRegistrarPurchaseFrame"));
}
/// Both windows are registered UIPanels — without a row, `ShowUIPanel` degrades to a bare `Show()`
/// in no slot and two left-slot windows paint over each other (B288's shape, decision 1507).
#[test]
fn both_charter_windows_are_registered_left_slot_panels() {
let mut s = setup();
for frame in ["GuildRegistrarFrame", "PetitionFrame"] {
assert_eq!(
s.eval::<String>(&format!("return UIPanelWindows[\"{frame}\"].area"))
.unwrap(),
"left",
"{frame} must hold a panel row"
);
}
// And they are rivals, not neighbours: opening one seats it where the other was.
show_registrar(&mut s);
show_petition(&mut s);
assert!(visible(&s, "PetitionFrame"));
assert!(
!visible(&s, "GuildRegistrarFrame"),
"one left slot, both pushable 0"
);
}
+16 -2
View File
@@ -100,7 +100,7 @@ pub(crate) fn close_npc_session_out_of_range<T: NpcSession>(
/// window open, so the booth empties; the ring is hidden with its window then, so the dark disc
/// never shows.
#[derive(Resource, Default)]
pub(crate) struct InteractNpc(pub(crate) Option<Entity>);
pub(crate) struct InteractNpc(pub(crate) Option<Entity>, pub(crate) Option<u64>);
/// Collapse the portrait-bound sessions into [`InteractNpc`] each frame: the open one's guid,
/// resolved to its entity. One deterministic writer (not one publish-system per session), so there is
@@ -123,6 +123,13 @@ pub(crate) fn feed_interact_npc(
// here is what left `AuctionPortraitTexture` a black disc: the window asks for `"npc"` in its
// OnShow like every other NPC window, and nothing was answering.
auction: Option<Res<crate::ui_auction::AuctionOpen>>,
// The guild registrar's, while the charter window is open (decision 1672) — same booth path,
// and it is NOT covered by the gossip arm above: the server closes the gossip menu before it
// sends `SMSG_PETITION_SHOWLIST` (`Player.cpp:12428-12431` — `CloseGossip()` then
// `SendPetitionShowList`), so by the time `GuildRegistrar_OnShow` asks for `"npc"` the gossip
// session is already gone. Without this arm the window's portrait is the auctioneer's black
// disc all over again and its name banner is blank.
registrar: Option<Res<crate::ui_petition::GuildRegistrarState>>,
index: Option<Res<GuidIndex>>,
mut out: ResMut<InteractNpc>,
) {
@@ -134,8 +141,15 @@ pub(crate) fn feed_interact_npc(
.or_else(|| taxi.and_then(|s| s.npc()))
.or_else(|| trade.and_then(|s| s.npc()))
.or_else(|| bank.and_then(|s| s.npc()))
.or_else(|| auction.and_then(|s| s.npc()));
.or_else(|| auction.and_then(|s| s.npc()))
.or_else(|| registrar.and_then(|s| s.npc()));
// Field 0 is the entity the portrait booth bakes and the facing chain steers by; field 1 is
// the same NPC's **guid**, which `crate::ui_unit`'s feed needs to resolve the `"npc"` unit
// token's name (a name lives in the `NameCache`, keyed by guid — there is no way back to one
// from an entity). Both are set together and cleared together; a guid whose entity is not
// streamed still names the unit, which is why they are two fields rather than one lookup.
out.0 = guid.and_then(|g| index.and_then(|i| i.0.get(&g).copied()));
out.1 = guid;
}
#[cfg(test)]
+64
View File
@@ -1041,6 +1041,9 @@ fn feed_units(
// (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>,
// The NPC the player is interacting with, for the `"npc"` token below. `Option` for the same
// reason `index` and `factions` are: a UI-only harness runs this feed with no session plugin.
interact: Option<Res<crate::ui_session::InteractNpc>>,
) {
let Some(mut script) = script else {
return;
@@ -1272,6 +1275,67 @@ fn feed_units(
script.set_unit("targettarget", tot.clone());
}
// `"npc"` — the unit the player is interacting with, resolved through the SAME interaction guid
// the real client's token reads (`CGGameUI`'s `[0xb4e2d0]`, what `CGGameUI::SetInteractNPC`
// writes; wow-re confirms the guild registrar's own opener is one of that function's fourteen
// callers). `crate::ui_session::InteractNpc` is benilla's model of exactly that cell, so the
// token and the portrait booth cannot disagree about who "npc" is.
//
// **It had no feed at all until decision 1678**, and the gap was invisible because nothing
// called it: `TaxiFrame.xml` and `TradeFrame.xml` both take the NPC's name from an event
// argument instead and say in their own comments that they are deviating from the reference's
// `UnitName("NPC")` to do it. `GuildRegistrarFrame.xml` is the first window here to ship the
// reference's line unchanged — and it painted a blank name banner, because
// `GUILD_REGISTRAR_SHOW` is verified to carry no arguments, so there is nothing else for it to
// read. The probe caught it; a one-frame-lag theory was the first (wrong) explanation.
//
// No `guild` leg, for `"targettarget"`'s reason: nothing asks an NPC for its guild, and the
// lookup is the lazy cache whose miss SENDS a query.
let npc = interact
.as_deref()
.and_then(|i| Some((i.0?, i.1?)))
.and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
let reaction = ring_reaction(
factions.as_deref(),
&reputations,
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction);
s.guid = guid;
s.is_connected = true;
s.raid_target = group.raid_target_index(guid);
s.faction_group = faction_group(store, factions.as_deref());
s.can_attack = crate::target::can_attack(
Some(store),
factions.as_deref(),
&reputations,
self_pair.map(|(s, _)| s),
);
enrich_unit(
&mut s,
guid,
&names,
store,
factions.as_deref(),
self_pair.map(|(s, _)| s),
);
Some(s)
});
// "Absence IS data" again: closing an NPC window must clear the token, or the next window's
// first frame paints the last NPC's name.
let npc_dirty = match (&npc, memo.last.get("npc")) {
(Some(cur), Some(prev)) => cur != prev,
(None, None) => false,
_ => true,
};
if npc_dirty {
gate.audit("feed_units", "the interaction-NPC snapshot");
script.set_unit("npc", npc.clone());
}
// The XP bar's feed: push our own avatar's PLAYER_XP / PLAYER_NEXT_LEVEL_XP (both PRIVATE, only
// ever streamed for self) and fire PLAYER_XP_UPDATE when either changes — the coinage feed's
// shape. Absent fields read 0 (a fresh descriptor's zero default; the bar shows empty until XP
+33 -4
View File
@@ -17,10 +17,11 @@ use crate::messages::{
GuildQueryResponse, GuildRoster, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
MirrorTimerStart, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog,
PetMode, PetSpells, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss,
StabledPet, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose,
VendorItem, WhoResults, XpGain,
PetMode, PetSpells, PetitionQueryResponse, PetitionRename, PetitionShowList,
PetitionShowSignatures, PetitionSignResults, PvpCredit, QuestComplete, QuestDetails,
QuestGiverList, QuestOfferReward, QuestRequestItems, QuestTemplate, SpellDamageLog,
SpellEnergizeLog, SpellHealLog, SpellLogMiss, StabledPet, TaxiMask, TradeStatus,
TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
};
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
@@ -1180,6 +1181,34 @@ pub enum SessionEvent {
/// 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),
/// A guild registrar's charter list (`SMSG_PETITION_SHOWLIST`) — what opens the registrar
/// window. Always one row at 1.12; `npc` is the registrar every later verb names.
PetitionShowList(PetitionShowList),
/// Who has signed a charter (`SMSG_PETITION_SHOW_SIGNATURES`). **Two meanings in one packet**:
/// the answer to our own ask, or somebody offering us theirs — a consumer tells them apart by
/// whether `owner` is us, and nothing else does.
///
/// It carries neither the proposed guild's name nor the signature requirement. Those live only
/// on [`Self::PetitionQueryResponse`], keyed by `petition_id` — the same two-caches shape the
/// guild roster has with [`Self::GuildQueryResponse`].
PetitionShowSignatures(PetitionShowSignatures),
/// The verdict on one signature (`SMSG_PETITION_SIGN_RESULTS`). Sent to **both** the signer and
/// the charter's owner on success, and both copies name the *signer*, so a consumer cannot tell
/// which copy it holds from the packet alone.
PetitionSignResults(PetitionSignResults),
/// A petition's record (`SMSG_PETITION_QUERY_RESPONSE`) — the proposed guild name and the
/// signature requirement. The lazy cache fill behind the charter window's title.
PetitionQueryResponse(PetitionQueryResponse),
/// The verdict on a turn-in (`SMSG_TURN_IN_PETITION_RESULTS`) — a bare code, with no charter
/// named. **Silence is a real outcome**: a name collision answers with an
/// [`Self::GuildCommandResult`] and no results packet at all.
TurnInPetitionResults { result: u32 },
/// Somebody declined our charter (`MSG_PETITION_DECLINE`) — their guid, delivered to the
/// charter's owner only.
PetitionDeclined { player: u64 },
/// A charter rename that took (`MSG_PETITION_RENAME`) — the server's echo, sent only on
/// success. A rejected name arrives as a [`Self::GuildCommandResult`] instead.
PetitionRenamed(PetitionRename),
/// 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
@@ -567,6 +567,23 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
}
ServerPacket::GuildDecline { name } => vec![SessionEvent::GuildDecline { name }],
ServerPacket::GuildInfo(info) => vec![SessionEvent::GuildInfo(info)],
ServerPacket::PetitionShowList(list) => vec![SessionEvent::PetitionShowList(list)],
ServerPacket::PetitionShowSignatures(sigs) => {
vec![SessionEvent::PetitionShowSignatures(sigs)]
}
ServerPacket::PetitionSignResults(results) => {
vec![SessionEvent::PetitionSignResults(results)]
}
ServerPacket::PetitionQueryResponse(response) => {
vec![SessionEvent::PetitionQueryResponse(response)]
}
ServerPacket::TurnInPetitionResults { result } => {
vec![SessionEvent::TurnInPetitionResults { result }]
}
ServerPacket::PetitionDeclined { player } => {
vec![SessionEvent::PetitionDeclined { player }]
}
ServerPacket::PetitionRenamed(rename) => vec![SessionEvent::PetitionRenamed(rename)],
ServerPacket::DestroyObject { guid } => vec![SessionEvent::ObjectDestroyed(guid)],
ServerPacket::TriggerCinematic { cinematic_id } => {
vec![SessionEvent::CinematicTriggered { cinematic_id }]
@@ -45,6 +45,7 @@ mod packet;
mod page_text;
mod parse;
mod pet;
mod petition;
mod pose;
mod progression;
mod pvp;
@@ -172,6 +173,13 @@ pub use pet::{
PET_REACT_DEFENSIVE, PET_REACT_PASSIVE, PET_STATE_BAR_DISABLED, PET_TYPE_SPELL_FIRST,
PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
};
pub use petition::{
offer_petition, petition_buy, petition_decline, petition_query, petition_rename,
petition_result, petition_show_list, petition_show_signatures, petition_sign, turn_in_petition,
PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowListEntry,
PetitionShowSignatures, PetitionSignResults, PetitionSignature, CHARTER_DISPLAY_ID,
CHARTER_ITEM_ENTRY, CHARTER_NAME_MAX_LENGTH, ITEM_FLAG_CHARTER, MAX_PETITION_SIGNATURES,
};
pub use pose::{set_sheathed, stand_state_change};
pub use progression::{
learn_talent, talent_wipe_confirm, ExplorationXp, LevelUpInfo, TalentWipeConfirm, XpGain,
+35 -1
View File
@@ -942,7 +942,8 @@ pub const CMSG_DEL_IGNORE: u16 = 0x006D; // 109
// 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.
// charter/petition opcodes that *found* a guild are their own family, below; the tabard
// opcodes that dress one are not built.
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
@@ -975,6 +976,39 @@ 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 petition family — the guild-charter flow that FOUNDS a guild (VERIFIED vmangos
// `Opcodes_1_12_1.h:444-456`, `:705-706` + `Server/Packets/Petition.{h,cpp}`,
// `Handlers/PetitionsHandler.cpp`; the handler table is `Opcodes.cpp:528-540` and `:800-801`).
// Bodies in [`super::petition`]; decision 1672.
//
// Two shapes to know. The three `MSG_` opcodes are genuinely bidirectional with *different*
// bodies each way — decline sends an item guid and receives a player guid — so each has its own
// reader and its own builder rather than one shared body. And `MSG_DELETE_GUILD_CHARTER` (0x2C0)
// is deliberately **absent**: vmangos registers it `INVALID_PACKET(…, Unhandled)`
// (`Opcodes.cpp:800`) and has no handler at all, because destroying a charter runs through the
// ordinary item-destroy path, which cascades into deleting the petition (`Player.cpp:10811-10817`,
// `Item.cpp:515-516`). Declaring it would model traffic that nothing on either end sends.
pub const CMSG_PETITION_SHOWLIST: u16 = 0x01BB; // 443
pub const SMSG_PETITION_SHOWLIST: u16 = 0x01BC; // 444
pub const CMSG_PETITION_BUY: u16 = 0x01BD; // 445
pub const CMSG_PETITION_SHOW_SIGNATURES: u16 = 0x01BE; // 446
/// The answer to **two** different asks — our own `CMSG_PETITION_SHOW_SIGNATURES`, and someone
/// else's `CMSG_OFFER_PETITION` aimed at us. Only its `owner` field tells the two apart.
pub const SMSG_PETITION_SHOW_SIGNATURES: u16 = 0x01BF; // 447
pub const CMSG_PETITION_SIGN: u16 = 0x01C0; // 448
pub const SMSG_PETITION_SIGN_RESULTS: u16 = 0x01C1; // 449
/// Bidirectional with different bodies: we send the charter **item's** guid, the owner receives
/// the declining **player's** guid.
pub const MSG_PETITION_DECLINE: u16 = 0x01C2; // 450
pub const CMSG_OFFER_PETITION: u16 = 0x01C3; // 451
pub const CMSG_TURN_IN_PETITION: u16 = 0x01C4; // 452
pub const SMSG_TURN_IN_PETITION_RESULTS: u16 = 0x01C5; // 453
pub const CMSG_PETITION_QUERY: u16 = 0x01C6; // 454
pub const SMSG_PETITION_QUERY_RESPONSE: u16 = 0x01C7; // 455
/// Bidirectional, and here the two bodies agree: `u64 item` + the new name, both ways. The echo
/// comes back only on success.
pub const MSG_PETITION_RENAME: u16 = 0x02C1; // 705
// 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
+39 -6
View File
@@ -12,12 +12,13 @@ use super::{
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, PvpCredit,
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems,
QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown,
SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, StabledPet,
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
WhoResults, XpGain,
MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
PetitionQueryResponse, PetitionRename, PetitionShowList, PetitionShowSignatures,
PetitionSignResults, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind,
SpellChainTargets, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog,
SpellLogMiss, SpellStart, StabledPet, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell,
TransportPose, VendorItem, WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -1133,6 +1134,31 @@ pub enum ServerPacket {
/// `SMSG_GUILD_INFO` — the "founded on / N members / N accounts" summary; a separate ask from
/// the roster, with no overlapping fields.
GuildInfo(GuildInfo),
/// `SMSG_PETITION_SHOWLIST` — a guild registrar's charter list. In 1.12 always one row, and
/// the packet that opens the registrar window.
PetitionShowList(PetitionShowList),
/// `SMSG_PETITION_SHOW_SIGNATURES` — who has signed a charter. Answers **two** different asks
/// (our own show-signatures, and someone else's offer aimed at us); only `owner` tells them
/// apart.
PetitionShowSignatures(PetitionShowSignatures),
/// `SMSG_PETITION_SIGN_RESULTS` — the verdict on one signature. Sent to *both* parties on
/// success, and both copies name the signer.
PetitionSignResults(PetitionSignResults),
/// `SMSG_PETITION_QUERY_RESPONSE` — a petition's record: the proposed guild name and the
/// signature requirement, neither of which is on any other packet.
PetitionQueryResponse(PetitionQueryResponse),
/// `SMSG_TURN_IN_PETITION_RESULTS` — a bare result code. A name collision produces no packet
/// at all, so silence is a real outcome.
TurnInPetitionResults {
result: u32,
},
/// `MSG_PETITION_DECLINE` inbound — the guid of the player who declined our charter. Sent only
/// to the charter's owner.
PetitionDeclined {
player: u64,
},
/// `MSG_PETITION_RENAME` inbound — the server's echo of a rename that took. Only on success.
PetitionRenamed(PetitionRename),
/// 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).
@@ -1527,6 +1553,13 @@ impl ServerPacket {
ServerPacket::GuildInvite { .. } => "SMSG_GUILD_INVITE".into(),
ServerPacket::GuildDecline { .. } => "SMSG_GUILD_DECLINE".into(),
ServerPacket::GuildInfo(..) => "SMSG_GUILD_INFO".into(),
ServerPacket::PetitionShowList(..) => "SMSG_PETITION_SHOWLIST".into(),
ServerPacket::PetitionShowSignatures(..) => "SMSG_PETITION_SHOW_SIGNATURES".into(),
ServerPacket::PetitionSignResults(..) => "SMSG_PETITION_SIGN_RESULTS".into(),
ServerPacket::PetitionQueryResponse(..) => "SMSG_PETITION_QUERY_RESPONSE".into(),
ServerPacket::TurnInPetitionResults { .. } => "SMSG_TURN_IN_PETITION_RESULTS".into(),
ServerPacket::PetitionDeclined { .. } => "MSG_PETITION_DECLINE".into(),
ServerPacket::PetitionRenamed(..) => "MSG_PETITION_RENAME".into(),
ServerPacket::SplineSpeedChange { kind, .. } => {
format!("SMSG_SPLINE_SET_{kind:?}_SPEED")
}
+27 -2
View File
@@ -13,8 +13,8 @@ use crate::wire::{
use super::{
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
duel, gameobject, gm_ticket, gossip, group, guild, items, loot, mail, mirror_timer,
monster_move, movement, opcode, page_text, pet, progression, pvp, quest, social, spellbook,
spells, stable, taxi, trade, trainer, update_object, vendor, world_state, Character,
monster_move, movement, opcode, page_text, pet, petition, progression, pvp, quest, social,
spellbook, spells, stable, taxi, trade, trainer, update_object, vendor, world_state, Character,
CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
};
@@ -1071,6 +1071,31 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
name: guild::read_guild_decline(&mut r)?,
},
opcode::SMSG_GUILD_INFO => ServerPacket::GuildInfo(guild::read_guild_info(&mut r)?),
// The petition family (bodies in `petition`) — founding a guild, which at 1.12 is an
// entirely different wire from the guild family above. Note `SMSG_PETITION_SHOW_SIGNATURES`
// answers two different asks, and the two `MSG_` opcodes read a *different* body from the
// one they write (see `petition`'s own docs).
opcode::SMSG_PETITION_SHOWLIST => {
ServerPacket::PetitionShowList(petition::read_petition_show_list(&mut r)?)
}
opcode::SMSG_PETITION_SHOW_SIGNATURES => {
ServerPacket::PetitionShowSignatures(petition::read_petition_show_signatures(&mut r)?)
}
opcode::SMSG_PETITION_SIGN_RESULTS => {
ServerPacket::PetitionSignResults(petition::read_petition_sign_results(&mut r)?)
}
opcode::SMSG_PETITION_QUERY_RESPONSE => {
ServerPacket::PetitionQueryResponse(petition::read_petition_query_response(&mut r)?)
}
opcode::SMSG_TURN_IN_PETITION_RESULTS => ServerPacket::TurnInPetitionResults {
result: petition::read_turn_in_petition_results(&mut r)?,
},
opcode::MSG_PETITION_DECLINE => ServerPacket::PetitionDeclined {
player: petition::read_petition_decline(&mut r)?,
},
opcode::MSG_PETITION_RENAME => {
ServerPacket::PetitionRenamed(petition::read_petition_rename(&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.
@@ -0,0 +1,562 @@
//! The petition family — the guild-charter flow that *founds* a guild (opcodes `0x1BB`–`0x1C7`
//! plus `0x2C1`).
//!
//! [`super::guild`] is about *being* in a guild; this is about *making* one. At 1.12 those are
//! entirely different wires: `CMSG_GUILD_CREATE` is registered `STATUS_NEVER` on vmangos, and the
//! only path to a new guild is buy a charter → collect nine signatures → turn it in.
//!
//! The server side is pinned at the bytes by vmangos `Server/Packets/Petition.{h,cpp}` (the
//! `ReadFromWorldPacket` / `AppendBodyTo` pairs) and `Handlers/PetitionsHandler.cpp` (which
//! packets each handler answers with, and the order of its refusals). **Two of the five
//! server→client packets are not built through the packet classes at all** —
//! `SMSG_PETITION_SHOW_SIGNATURES` is hand-assembled at `PetitionsHandler.cpp:160-168` and
//! `:390-397` — so their layout is read off the handler, not off `Petition.cpp`.
//!
//! Four shapes of this family are worth knowing before touching any of it:
//!
//! - **The charter is an ITEM, and the petition id rides in its enchantment slot.** Buying stores
//! item [`CHARTER_ITEM_ENTRY`] and writes the petition id into `ITEM_FIELD_ENCHANTMENT` slot 0's
//! `id` dword (`PetitionsHandler.cpp:126`). That is not a hack the client is unaware of: the
//! reference's item tooltip **forces enchantment slot 0 to zero and skips the enchant lines
//! entirely** for any item whose template flags carry [`ITEM_FLAG_CHARTER`]
//! (`test ah,0x20 / jne 0x52c9ee`, wow-re `system/ui/scratch/tooltip-content-law.md:485-505`).
//! So slot 0 on a charter is a petition id by contract on both ends, and nothing may read it as
//! a `SpellItemEnchantment.dbc` row.
//! - **Almost nothing is acked positively.** A successful buy sends **no confirmation packet** —
//! the client learns of it only from the `SMSG_ITEM_PUSH_RESULT` for the new item
//! (`PetitionsHandler.cpp:130`). Rename echoes only on success (`:209-215`). The refusals come
//! back as `SMSG_GUILD_COMMAND_RESULT`, the *guild* family's error channel, reused here.
//! - **`SMSG_PETITION_SHOW_SIGNATURES` is the answer to two different questions.** It answers our
//! own `CMSG_PETITION_SHOW_SIGNATURES` (`:160-168`), and it is also what a *third party* is sent
//! when someone offers us their charter (`:390-397`, the `CMSG_OFFER_PETITION` path). One
//! inbound packet, two meanings, distinguished only by whether its `owner` is us.
//! - **The signature limit is enforced three times with three different numbers.** vmangos's
//! `MinPetitionSigns` config (clamped 0..=9, `World.cpp:665`) decides when a petition
//! `IsComplete()` — with `==`, not `>=` (`GuildMgr.h:124`); the sign handler *separately*
//! hard-caps at nine with the comment "Client hard limit at 9 signatures"
//! (`PetitionsHandler.cpp:269-271`); and `SMSG_PETITION_QUERY_RESPONSE` reports min = max = 9
//! hardcoded (`:182-183`), regardless of the config. [`MAX_PETITION_SIGNATURES`] is that client
//! limit; a consumer wanting "how many does this petition need" reads the wire's
//! `max_signatures`, not the constant, because on a server with `MinPetitionSigns` lowered the
//! two disagree.
use std::io::{self, Read};
use crate::wire::{read_cstring, read_i32_le, read_u16_le, read_u32_le, read_u64_le, read_u8};
/// The one item that is a guild charter — vmangos `GUILD_CHARTER` (`PetitionsHandler.cpp:37`),
/// also `Item::IsCharter`'s literal (`Item.h:158`, `GetEntry() == 5863u`). Confirmed against this
/// deployment's world DB: entry 5863 "Guild Charter", display 16161, flags `0x2000`.
pub const CHARTER_ITEM_ENTRY: u32 = 5863;
/// The charter's inventory display id — vmangos `CHARTER_DISPLAY_ID` (`PetitionsHandler.cpp:38`).
/// It rides in [`PetitionShowListEntry::charter_display_id`] as well as in the item template, and
/// the two agree; the packet's copy exists so a vendor list can be drawn before the item is owned.
pub const CHARTER_DISPLAY_ID: u32 = 16161;
/// `ITEM_FLAG_CHARTER` — the template-flag bit that makes an item a signable petition
/// (vmangos `ItemPrototype.h:77`). **The reference client keys on this same bit** to print the
/// green `ITEM_SIGNABLE` tooltip line and to suppress the enchantment lines
/// (wow-re `system/ui/scratch/tooltip-content-law.md:485-505`), so it is a shared contract rather
/// than a server-side convenience.
pub const ITEM_FLAG_CHARTER: u32 = 0x0000_2000;
/// The most signatures a 1.12 charter can hold — vmangos's own "Client hard limit at 9
/// signatures" (`PetitionsHandler.cpp:269-271`), and the reference FrameXML's
/// `MAX_PETITION_SIGNATURES = 9` (`PetitionFrame.lua:1`), which sizes the window's name rows.
///
/// This is the **row count**, not the requirement: how many a petition needs to be turned in is
/// [`PetitionQueryResponse::min_signatures`] off the wire, which a server may configure lower.
pub const MAX_PETITION_SIGNATURES: usize = 9;
/// Charter-name cap, in *characters* — vmangos `MAX_CHARTER_NAME` (`ObjectMgr.h:401`), enforced by
/// `ObjectMgr::IsValidCharterName` (`ObjectMgr.cpp:9322-9334`) as a UTF-8 length. The reference's
/// own edit boxes agree: `GuildRegistrarFrameEditBox letters="24"` and the `RENAME_GUILD` popup's
/// `maxLetters = 24`. Same number as [`super::GUILD_NAME_MAX_LENGTH`], and not a coincidence — a
/// charter name becomes a guild name.
pub const CHARTER_NAME_MAX_LENGTH: usize = 24;
/// `SMSG_PETITION_SIGN_RESULTS`' and `SMSG_TURN_IN_PETITION_RESULTS`' shared result code —
/// vmangos `PetitionSigns` (`Guild/Guild.h:147-154`). One enum, two packets, and the two use
/// **disjoint subsets** of it, which is why the arms are documented by packet.
pub mod petition_result {
/// Signed, or turned in. Used by both packets.
pub const OK: u32 = 0;
/// Sign only — this account has already signed this charter. vmangos checks uniqueness **per
/// account first**, then per character (`GuildMgr.cpp:388-400`), so a second character on one
/// account gets this, not a second signature.
pub const ALREADY_SIGNED: u32 = 1;
/// Turn-in only — the would-be founder is already in a guild (`PetitionsHandler.cpp:427`).
/// Never sent by the sign path, which answers an already-guilded *signer* with an
/// `SMSG_GUILD_COMMAND_RESULT` instead (`:258`).
pub const ALREADY_IN_GUILD: u32 = 2;
/// Sign only — you cannot sign your own charter (`PetitionsHandler.cpp:237`).
pub const CANT_SIGN_OWN: u32 = 3;
/// Turn-in only — not enough signatures yet (`PetitionsHandler.cpp:438`).
pub const NEED_MORE: u32 = 4;
/// Not on the same realm. vmangos never sends it (cross-realm charters cannot arise on a
/// single-realm server); modelled because the reference has a string for it
/// (`ERR_PETITION_NOT_SAME_SERVER`) and the client's switch must have an arm.
pub const NOT_SERVER: u32 = 5;
}
/// One row of `SMSG_PETITION_SHOWLIST` — a charter a petitioner NPC will sell.
///
/// vmangos always sends exactly one of these, and its own header says **"only 1 element is
/// supported in the client"** (`Petition.h:176`). We parse the counted list faithfully anyway: a
/// count is a count, and a reader that assumes one would desynchronise rather than degrade if it
/// were ever wrong.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionShowListEntry {
/// Row index, 1-based (`PetitionsHandler.cpp:501`, a literal `1`).
pub index: u32,
/// The item entry sold — [`CHARTER_ITEM_ENTRY`] in practice.
pub charter_entry: u32,
/// Its display id — [`CHARTER_DISPLAY_ID`] in practice.
pub charter_display_id: u32,
/// Price in **copper** (1000 = 10 silver on vmangos, `PetitionsHandler.cpp:39`). Signed on the
/// wire (`int32`), which is why it is signed here: a reader that narrows it to `u32` would be
/// asserting a range the sender does not.
pub charter_cost: i32,
/// vmangos sends `1` and its header says the row **"must be `&1` to show it in the UI"**
/// (`Petition.h:169`). Kept raw rather than folded into a `bool`: that claim is a *comment*
/// about the reference client, not something vmangos itself enforces, and a consumer that
/// wants the filter can apply it knowingly.
pub entry_flags: i32,
}
/// `SMSG_PETITION_SHOWLIST` — a petitioner NPC's "here is what I sell" list. In 1.12 that is
/// always the single guild charter, and this packet is what puts the guild registrar's window on
/// screen.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionShowList {
/// The NPC we are talking to. Every later verb in the flow that names an NPC uses this guid.
pub npc: u64,
/// The rows, in wire order. Normally exactly one; see [`PetitionShowListEntry`].
pub entries: Vec<PetitionShowListEntry>,
}
/// Read `SMSG_PETITION_SHOWLIST` (VERIFIED vmangos `Server/Packets/Petition.cpp:115-127`
/// `PetitionShowList::AppendBodyTo`, filled at `Handlers/PetitionsHandler.cpp:482-507`):
/// `u64 npcGuid`, `u8 count`, then per row `u32 index`, `u32 charterEntry`, `u32 charterDisplayId`,
/// `i32 charterCost`, `i32 entryFlags`.
///
/// The send is gated on `GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_PETITIONER)`
/// (`:484`; the flag is `0x200`, `Objects/UnitDefines.h:666`), so a list arriving at all is the
/// server's confirmation that we are in range of a real registrar.
pub(super) fn read_petition_show_list(r: &mut impl Read) -> io::Result<PetitionShowList> {
let npc = read_u64_le(r)?;
let count = read_u8(r)?;
let mut entries = Vec::with_capacity(count as usize);
for _ in 0..count {
entries.push(PetitionShowListEntry {
index: read_u32_le(r)?,
charter_entry: read_u32_le(r)?,
charter_display_id: read_u32_le(r)?,
charter_cost: read_i32_le(r)?,
entry_flags: read_i32_le(r)?,
});
}
Ok(PetitionShowList { npc, entries })
}
/// One signature on a charter — a signer's guid, and the dword that always follows it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionSignature {
/// The signing character's guid. Turning it into a *name* is the client's job: the packet
/// carries no names at all.
pub signer: u64,
/// The trailing dword, which vmangos writes as a literal `0`
/// (`Guild/GuildMgr.cpp:358-366`, `data << 0`). Kept rather than skipped so the field is
/// visible in a dump and a non-zero value from another server would be *noticed* rather than
/// silently consumed.
pub unknown: u32,
}
/// `SMSG_PETITION_SHOW_SIGNATURES` — who has signed a charter. **The answer to two different
/// questions** (module doc): our own `CMSG_PETITION_SHOW_SIGNATURES`, and someone else's
/// `CMSG_OFFER_PETITION` aimed at us.
///
/// Note what is *not* here: the guild name, the body text, and the signature requirement. Those
/// live only in `SMSG_PETITION_QUERY_RESPONSE`, keyed by [`Self::petition_id`] — the same
/// two-caches shape [`super::guild`] has, where the roster carries no guild name.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionShowSignatures {
/// The charter **item's** guid — the handle every later verb takes (sign, offer, rename, turn
/// in, decline). Not the petition id.
pub item: u64,
/// The charter owner's character guid. Whether this is us is what makes the window the
/// leader's view or a signer's view.
pub owner: u64,
/// The petition id — the key `CMSG_PETITION_QUERY` takes, and the same value the charter
/// item's enchantment slot 0 carries (module doc).
pub petition_id: u32,
/// The signatures so far, in wire order.
pub signatures: Vec<PetitionSignature>,
}
/// Read `SMSG_PETITION_SHOW_SIGNATURES` (VERIFIED vmangos `Handlers/PetitionsHandler.cpp:160-168`
/// and `:390-397`, which build it **by hand** rather than through a packet class):
/// `u64 itemGuid`, `u64 ownerGuid`, `u32 petitionId`, `u8 signatureCount`, then per signature
/// `u64 signerGuid` + `u32 0`.
///
/// The 12-byte stride is corroborated by the sender's own size hint `8+8+4+1 + signs*12` and by
/// `Guild/GuildMgr.cpp:358-366` (`Petition::BuildSignatureData`).
pub(super) fn read_petition_show_signatures(
r: &mut impl Read,
) -> io::Result<PetitionShowSignatures> {
let item = read_u64_le(r)?;
let owner = read_u64_le(r)?;
let petition_id = read_u32_le(r)?;
let count = read_u8(r)?;
let mut signatures = Vec::with_capacity(count as usize);
for _ in 0..count {
signatures.push(PetitionSignature {
signer: read_u64_le(r)?,
unknown: read_u32_le(r)?,
});
}
Ok(PetitionShowSignatures {
item,
owner,
petition_id,
signatures,
})
}
/// `SMSG_PETITION_SIGN_RESULTS` — the verdict on one signature attempt.
///
/// **It is sent to both parties on success**: once to the signer (`PetitionsHandler.cpp:299`) and
/// again, identical, to the charter's owner if they are online (`:312-316`). Both copies name the
/// *signer* in [`Self::player`], so a receiver cannot tell the two apart from this packet alone —
/// only from whether it holds that charter.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionSignResults {
/// The charter item's guid.
pub item: u64,
/// The **signer's** guid — in both copies of the packet, per the doc above.
pub player: u64,
/// A [`petition_result`] code.
pub result: u32,
}
/// Read `SMSG_PETITION_SIGN_RESULTS` (VERIFIED vmangos `Server/Packets/Petition.cpp:69-74`
/// `PetitionSignResults::AppendBodyTo`): `u64 itemGuid`, `u64 playerGuid`, `u32 result`.
pub(super) fn read_petition_sign_results(r: &mut impl Read) -> io::Result<PetitionSignResults> {
Ok(PetitionSignResults {
item: read_u64_le(r)?,
player: read_u64_le(r)?,
result: read_u32_le(r)?,
})
}
/// `SMSG_PETITION_QUERY_RESPONSE` — a petition's own record, keyed by petition id.
///
/// Sixteen fields, of which vmangos fills three with anything but a constant, and whose own header
/// says *"all those fields below don't really change anything in the UI"* (`Petition.h:126`). They
/// are parsed in full regardless: the trailing `choice` list is length-prefixed, so skipping the
/// middle would make the tail unreadable, and a field that is constant on this server is not
/// thereby constant on the wire.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionQueryResponse {
/// The petition id we asked about.
pub petition_id: u32,
/// The charter owner's character guid.
pub owner: u64,
/// The proposed guild's name. This is the only place it exists on the wire.
pub name: String,
/// Free text on the charter — always empty on vmangos (`PetitionsHandler.cpp:178`).
pub body_text: String,
/// vmangos sends `1` (`:179`). Meaning unknown; kept because it is a real wire field.
pub flags: u32,
/// How many signatures are **required**. vmangos hardcodes 9 here even when its
/// `MinPetitionSigns` config is lower (module doc), so this is the number the reference's
/// Request-Signature button disables against, and it need not equal
/// [`MAX_PETITION_SIGNATURES`].
pub min_signatures: u32,
/// How many signatures fit. vmangos hardcodes 9 (`:183`).
pub max_signatures: u32,
/// Deadline timestamp — `0` on vmangos.
pub deadline: u32,
/// Creation timestamp — `0` on vmangos.
pub creation: u32,
/// Restricting guild id — `0` on vmangos.
pub allowed_guild_id: u32,
/// Class mask restriction — `0` on vmangos.
pub allowed_classes: u32,
/// Race mask restriction — `0` on vmangos.
pub allowed_races: u32,
/// Gender restriction — **`u16` on the wire, not `u32`** (`Petition.cpp:94`). The one odd
/// width in the packet, and misreading it as a dword shifts every field after it by two bytes.
pub allowed_gender: u16,
/// Minimum level — `0` on vmangos.
pub allowed_min_level: u32,
/// Maximum level — `0` on vmangos.
pub allowed_max_level: u32,
/// The multiple-choice options this petition offers — always empty on vmangos, and the reason
/// the tail cannot be skipped: the count is what says where `default_choice` starts.
pub choices: Vec<String>,
/// Which choice is pre-selected — `0` on vmangos.
pub default_choice: u32,
}
/// Read `SMSG_PETITION_QUERY_RESPONSE` (VERIFIED vmangos `Server/Packets/Petition.cpp:81-102`
/// `PetitionQueryResponse::AppendBodyTo`, filled at `Handlers/PetitionsHandler.cpp:177-184`):
/// `u32 petitionId`, `u64 ownerGuid`, cstring name, cstring bodyText, `u32 flags`,
/// `u32 minSignatures`, `u32 maxSignatures`, `u32 deadline`, `u32 creation`, `u32 allowedGuildID`,
/// `u32 allowedClasses`, `u32 allowedRaces`, **`u16 allowedGender`**, `u32 allowedMinLevel`,
/// `u32 allowedMaxLevel`, `u32 choiceCount`, `choiceCount ×` cstring, `u32 defaultChoice`.
///
/// The `u16` in the middle is the trap: see [`PetitionQueryResponse::allowed_gender`].
pub(super) fn read_petition_query_response(r: &mut impl Read) -> io::Result<PetitionQueryResponse> {
let petition_id = read_u32_le(r)?;
let owner = read_u64_le(r)?;
let name = read_cstring(r)?;
let body_text = read_cstring(r)?;
let flags = read_u32_le(r)?;
let min_signatures = read_u32_le(r)?;
let max_signatures = read_u32_le(r)?;
let deadline = read_u32_le(r)?;
let creation = read_u32_le(r)?;
let allowed_guild_id = read_u32_le(r)?;
let allowed_classes = read_u32_le(r)?;
let allowed_races = read_u32_le(r)?;
let allowed_gender = read_u16_le(r)?;
let allowed_min_level = read_u32_le(r)?;
let allowed_max_level = read_u32_le(r)?;
let choice_count = read_u32_le(r)?;
let mut choices = Vec::with_capacity(choice_count.min(64) as usize);
for _ in 0..choice_count {
choices.push(read_cstring(r)?);
}
Ok(PetitionQueryResponse {
petition_id,
owner,
name,
body_text,
flags,
min_signatures,
max_signatures,
deadline,
creation,
allowed_guild_id,
allowed_classes,
allowed_races,
allowed_gender,
allowed_min_level,
allowed_max_level,
choices,
default_choice: read_u32_le(r)?,
})
}
/// `MSG_PETITION_RENAME` — the server's echo of a successful rename.
///
/// Sent **only on success** (`PetitionsHandler.cpp:209-215`); a rejected name comes back as an
/// `SMSG_GUILD_COMMAND_RESULT` instead. Note the handler does **no ownership check** — anyone
/// holding the item may rename it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionRename {
/// The charter item's guid.
pub item: u64,
/// Its new name.
pub name: String,
}
/// Read `MSG_PETITION_RENAME` (VERIFIED vmangos `Server/Packets/Petition.cpp:104-108`
/// `PetitionRename::AppendBodyTo`): `u64 itemGuid`, cstring newName. The same two fields the
/// client sends, in the same order — which is what makes it an `MSG_` rather than a pair.
pub(super) fn read_petition_rename(r: &mut impl Read) -> io::Result<PetitionRename> {
Ok(PetitionRename {
item: read_u64_le(r)?,
name: read_cstring(r)?,
})
}
/// Read `SMSG_TURN_IN_PETITION_RESULTS` (VERIFIED vmangos `Server/Packets/Petition.cpp:76-79`
/// `TurnInPetitionResults::AppendBodyTo`): one `u32`, a [`petition_result`] code — and **only** the
/// code. Which charter it refers to is implicit; there can be only one in flight.
///
/// The three codes vmangos actually sends here are [`petition_result::OK`] (`:473`),
/// [`petition_result::ALREADY_IN_GUILD`] (`:427`) and [`petition_result::NEED_MORE`] (`:438`). A
/// name collision is reported as `SMSG_GUILD_COMMAND_RESULT` instead and produces no results
/// packet at all (`:445`), so "no answer" is a real outcome of a turn-in.
pub(super) fn read_turn_in_petition_results(r: &mut impl Read) -> io::Result<u32> {
read_u32_le(r)
}
/// Read `MSG_PETITION_DECLINE`'s **inbound** form (VERIFIED vmangos
/// `Server/Packets/Petition.cpp:110-113` `PetitionDecline::AppendBodyTo`): one `u64`, the guid of
/// the player who declined. Delivered only to the charter's owner
/// (`Handlers/PetitionsHandler.cpp:321-338`).
///
/// The outbound form of the same opcode carries the *item* guid instead
/// ([`petition_decline`]) — one opcode, two different bodies by direction, which is what `MSG_`
/// means here.
pub(super) fn read_petition_decline(r: &mut impl Read) -> io::Result<u64> {
read_u64_le(r)
}
/// Body of `CMSG_PETITION_SHOWLIST` (VERIFIED vmangos `Server/Packets/Petition.cpp:3-6`
/// `PetitionShowListRequest::ReadFromWorldPacket`): one `u64`, the petitioner NPC's guid.
///
/// Answered by `SMSG_PETITION_SHOWLIST`, or by nothing at all if the NPC is out of range or lacks
/// `UNIT_NPC_FLAG_PETITIONER` (`Handlers/PetitionsHandler.cpp:484`). The server also sends that
/// answer unasked when a `GOSSIP_OPTION_PETITIONER` row is selected (`Player.cpp:12428-12431`), so
/// this request is the *second* way in, not the only one.
pub fn petition_show_list(npc: u64) -> Vec<u8> {
npc.to_le_bytes().to_vec()
}
/// Body of `CMSG_PETITION_BUY` (VERIFIED vmangos `Server/Packets/Petition.cpp:47-67`
/// `PetitionBuy::ReadFromWorldPacket`) — **72 bytes, of which the server reads exactly two
/// fields**: the NPC guid at offset 0 and the name at offset 20. Everything else is skipped.
///
/// The shape is a generic multi-choice petition struct that never shipped: `u64 npcGuid`,
/// `u32 0`, `u64 0`, cstring name, `10 × u32 0`, `u16 0`, `u8 0`, `u32 index`, `u32 0`. We send
/// zeros for every skipped field — including `index`, which the server's own comment calls
/// "unused" — because a field nobody reads has no correct non-zero value, and a zero is the one
/// choice that cannot encode an accident.
///
/// The refusal ladder is worth knowing, because none of it is a petition packet: an already-taken
/// or invalid name answers `SMSG_GUILD_COMMAND_RESULT(GUILD_CREATE_S, name, …)`
/// (`Handlers/PetitionsHandler.cpp:78`, `:83`, `:93`), too little money answers `SMSG_BUY_FAILED`
/// (`:108`), a full bag answers `SMSG_INVENTORY_CHANGE_FAILURE` (`:116`) — and **success answers
/// nothing but the new item** (`:130`).
pub fn petition_buy(npc: u64, name: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(72 + name.len());
body.extend_from_slice(&npc.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
body.extend_from_slice(&0u64.to_le_bytes());
push_cstring(&mut body, name);
body.extend_from_slice(&[0u8; 40]); // 10 × u32
body.extend_from_slice(&0u16.to_le_bytes());
body.push(0);
body.extend_from_slice(&0u32.to_le_bytes()); // index — the server's own "unused"
body.extend_from_slice(&0u32.to_le_bytes());
body
}
/// Body of `CMSG_PETITION_SHOW_SIGNATURES` (VERIFIED vmangos `Server/Packets/Petition.cpp:8-11`
/// `PetitionShowSignaturesRequest::ReadFromWorldPacket`): one `u64`, the charter **item's** guid.
///
/// Answered with `SMSG_PETITION_SHOW_SIGNATURES` — or silently dropped if we are already in a
/// guild (`Handlers/PetitionsHandler.cpp:140`) or do not hold that item (`:143`).
pub fn petition_show_signatures(item: u64) -> Vec<u8> {
item.to_le_bytes().to_vec()
}
/// Body of `CMSG_PETITION_SIGN` (VERIFIED vmangos `Server/Packets/Petition.cpp:35-39`
/// `PetitionSign::ReadFromWorldPacket`): `u64 itemGuid` then one `i8` the server **skips**, whose
/// own vmangos comment is *"argument of `/run SignPetition(123)` is never used in the official
/// interface"*.
///
/// **The client's default for that byte is `1`, not `0`** — `SignPetition`'s optional Lua argument
/// initialises `edi = 1` at `0x4f46d9` and the byte goes out through `Put8` at `0x4f4749` (wow-re
/// `system/ui/scratch/petition-charter-api.md`). Since the server reads and discards it, **nothing
/// observable depends on the value**, which is exactly why it shipped here as `0` and why only a
/// golden can tell the difference.
pub fn petition_sign(item: u64, arg: i8) -> Vec<u8> {
let mut body = Vec::with_capacity(9);
body.extend_from_slice(&item.to_le_bytes());
body.push(arg as u8);
body
}
/// Body of `CMSG_OFFER_PETITION` (VERIFIED vmangos `Server/Packets/Petition.cpp:41-45`
/// `OfferPetition::ReadFromWorldPacket`): `u64 itemGuid`, `u64 playerGuid` — the charter, and who
/// to show it to.
///
/// On success the server sends `SMSG_PETITION_SHOW_SIGNATURES` **to the target**, not back to us
/// (`Handlers/PetitionsHandler.cpp:390-397`); we hear only about refusals, as
/// `SMSG_GUILD_COMMAND_RESULT`.
pub fn offer_petition(item: u64, player: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(16);
body.extend_from_slice(&item.to_le_bytes());
body.extend_from_slice(&player.to_le_bytes());
body
}
/// Body of `CMSG_TURN_IN_PETITION` (VERIFIED vmangos `Server/Packets/Petition.cpp:24-27`
/// `TurnInPetition::ReadFromWorldPacket`): one `u64`, the charter item's guid.
///
/// Only the petition's **owner** may turn it in; anyone else is refused silently
/// (`Handlers/PetitionsHandler.cpp:432`). On success the guild is created with every signer as a
/// member at the lowest rank, the petition row is deleted, and *then* the item is destroyed — in
/// that order, because destroying a charter cascades into deleting its petition
/// (`Player.cpp:10811-10817`).
pub fn turn_in_petition(item: u64) -> Vec<u8> {
item.to_le_bytes().to_vec()
}
/// Body of `CMSG_PETITION_QUERY` (VERIFIED vmangos `Server/Packets/Petition.cpp:13-17`
/// `PetitionQuery::ReadFromWorldPacket`): `u32 petitionId`, `u64 itemGuid`.
///
/// The item guid is **read and then ignored** by the handler (`Handlers/PetitionsHandler.cpp:171-185`),
/// which looks the petition up by id alone. Sent anyway, at its true value: a field the current
/// server does not use is not a field to fill with garbage.
pub fn petition_query(petition_id: u32, item: u64) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&petition_id.to_le_bytes());
body.extend_from_slice(&item.to_le_bytes());
body
}
/// Body of `MSG_PETITION_RENAME`'s **outbound** form (VERIFIED vmangos
/// `Server/Packets/Petition.cpp:29-33` `PetitionRename::ReadFromWorldPacket`): `u64 itemGuid`,
/// cstring newName.
///
/// Echoed back as the same two fields on success ([`read_petition_rename`]); rejected names come
/// back as `SMSG_GUILD_COMMAND_RESULT` and no echo. The name is capped at
/// [`CHARTER_NAME_MAX_LENGTH`] characters by the server's own validator — not truncated here, for
/// the reason [`super::guild`]'s caps give: silently shortening sends the wrong text.
pub fn petition_rename(item: u64, name: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(9 + name.len());
body.extend_from_slice(&item.to_le_bytes());
push_cstring(&mut body, name);
body
}
/// Body of `MSG_PETITION_DECLINE`'s **outbound** form (VERIFIED vmangos
/// `Server/Packets/Petition.cpp:19-22` `PetitionDecline::ReadFromWorldPacket`): one `u64`, the
/// charter item's guid. The server forwards our guid to the charter's owner
/// ([`read_petition_decline`]).
pub fn petition_decline(item: u64) -> Vec<u8> {
item.to_le_bytes().to_vec()
}
/// 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::*;
/// `CMSG_PETITION_BUY`'s body is a fixed 72 bytes plus the name — the shape vmangos's reader
/// walks field by field. A body one byte short leaves the reader mid-field and the buy is
/// silently dropped, which is exactly the failure this pins.
#[test]
fn petition_buy_body_is_seventy_two_bytes_plus_the_name() {
let body = petition_buy(0x1234_5678_9abc_def0, "Test");
// 8 npc + 4 + 8 + (4+1) name + 40 + 2 + 1 + 4 + 4
assert_eq!(body.len(), 72 + 4, "72 fixed bytes + a 4-char name");
assert_eq!(
petition_buy(0, "").len(),
72,
"the empty name still costs its NUL"
);
}
/// The charter constants are a shared contract between server and reference client, not
/// vmangos policy — the tooltip's signable branch keys on the same `0x2000`.
#[test]
fn charter_constants_match_the_world_data() {
assert_eq!(CHARTER_ITEM_ENTRY, 5863);
assert_eq!(CHARTER_DISPLAY_ID, 16161);
assert_eq!(ITEM_FLAG_CHARTER, 0x2000);
assert_eq!(CHARTER_NAME_MAX_LENGTH, super::super::GUILD_NAME_MAX_LENGTH);
}
}
@@ -76,6 +76,7 @@ mod loot;
mod mail;
mod names;
mod pet;
mod petition;
mod player_flags;
mod pose;
mod progression;
@@ -0,0 +1,153 @@
//! The petition family's `WorldWriter` sends — buying a guild charter, showing it, signing it,
//! offering it, renaming it and turning it in (decision 1672).
//!
//! [`super::guild`] is *being* in a guild; this is *founding* one, and at 1.12 the two share only
//! their error channel. Three shapes bind every caller here:
//!
//! - **A charter is addressed by its ITEM guid**, not by a petition id — sign, offer, rename,
//! decline and turn-in all take the item. The petition *id* appears in exactly one place,
//! [`WorldWriter::petition_query`], and it is not derivable from the item without either a
//! preceding `SMSG_PETITION_SHOW_SIGNATURES` or a read of the item's enchantment slot 0
//! (see [`crate::messages::petition`]'s module doc).
//! - **Success is mostly silent.** Buying answers with nothing but the new item; offering answers
//! the *target*, not us; renaming echoes only if it took. What comes back on a refusal is an
//! `SMSG_GUILD_COMMAND_RESULT`, borrowed from the guild family — so a caller that only listens
//! for petition opcodes will see a failed verb as a hang.
//! - **Every one of these can be refused silently** by a range or ownership check the client
//! cannot see (`GetNPCIfCanInteractWith`, "do we hold that item", "are we the owner"). None of
//! them is safe to treat as applied at the send.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Ask a petitioner NPC what charters it sells (`CMSG_PETITION_SHOWLIST`), answered by
/// `SMSG_PETITION_SHOWLIST`.
///
/// This is the **second** way that answer arrives: selecting the NPC's
/// `GOSSIP_OPTION_PETITIONER` row makes the server close the gossip menu and push the same
/// packet unasked (`Player.cpp:12428-12431`). So a client that only ever talks through gossip
/// never needs this send — it is here for the direct re-open, and because the opcode exists.
pub fn petition_show_list(&mut self, npc: u64) -> Result<()> {
self.send(
opcode::CMSG_PETITION_SHOWLIST,
&messages::petition_show_list(npc),
)
}
/// Buy a guild charter from a petitioner NPC (`CMSG_PETITION_BUY`) under the given name.
///
/// **Nothing acknowledges a successful buy.** The server takes the money, stores item
/// [`messages::CHARTER_ITEM_ENTRY`] and sends only the ordinary `SMSG_ITEM_PUSH_RESULT`
/// (`Handlers/PetitionsHandler.cpp:130`). A refusal, by contrast, is loud and arrives on three
/// *different* opcodes depending on which check failed: `SMSG_GUILD_COMMAND_RESULT` for a
/// taken or invalid name, `SMSG_BUY_FAILED` for too little money, `SMSG_INVENTORY_CHANGE_FAILURE`
/// for a full bag.
///
/// The name is capped at [`messages::CHARTER_NAME_MAX_LENGTH`] characters by the server's own
/// validator; the caller enforces that at the edit box, as the reference does, rather than
/// truncating here.
pub fn petition_buy(&mut self, npc: u64, name: &str) -> Result<()> {
self.send(
opcode::CMSG_PETITION_BUY,
&messages::petition_buy(npc, name),
)
}
/// Open a charter and see who has signed it (`CMSG_PETITION_SHOW_SIGNATURES`), answered by
/// `SMSG_PETITION_SHOW_SIGNATURES`.
///
/// Dropped **silently** if we are already in a guild or do not hold that item
/// (`Handlers/PetitionsHandler.cpp:140`, `:143`), so a caller must not wait on the answer as
/// though it were guaranteed.
pub fn petition_show_signatures(&mut self, item: u64) -> Result<()> {
self.send(
opcode::CMSG_PETITION_SHOW_SIGNATURES,
&messages::petition_show_signatures(item),
)
}
/// Sign somebody's charter (`CMSG_PETITION_SIGN`), answered by `SMSG_PETITION_SIGN_RESULTS` —
/// which the server sends to the **owner too**, identical, with the signer's guid in both
/// copies.
///
/// Not every refusal comes back on that opcode: an already-guilded or cross-faction signer is
/// answered with an `SMSG_GUILD_COMMAND_RESULT` instead
/// (`Handlers/PetitionsHandler.cpp:252`, `:258`).
/// `arg` is the byte the optional Lua argument rides on; the client's own default is `1`.
pub fn petition_sign(&mut self, item: u64, arg: i8) -> Result<()> {
self.send(
opcode::CMSG_PETITION_SIGN,
&messages::petition_sign(item, arg),
)
}
/// Show our charter to another player so they can sign it (`CMSG_OFFER_PETITION`).
///
/// The success path answers **them**, not us: the server sends *the target* an
/// `SMSG_PETITION_SHOW_SIGNATURES` (`Handlers/PetitionsHandler.cpp:390-397`). We hear only
/// about refusals, as `SMSG_GUILD_COMMAND_RESULT`. So there is nothing local to update on the
/// send, and no answer to wait for.
pub fn offer_petition(&mut self, item: u64, player: u64) -> Result<()> {
self.send(
opcode::CMSG_OFFER_PETITION,
&messages::offer_petition(item, player),
)
}
/// Turn a completed charter in to a guild registrar (`CMSG_TURN_IN_PETITION`), answered by
/// `SMSG_TURN_IN_PETITION_RESULTS`.
///
/// Only the charter's **owner** may do this; anyone else is refused silently
/// (`Handlers/PetitionsHandler.cpp:432`). And a guild-name collision answers with an
/// `SMSG_GUILD_COMMAND_RESULT` and **no results packet at all** (`:445`) — so the absence of a
/// result is itself an outcome, not a dropped packet.
///
/// On success the guild is created with every signer as a member, and the charter item is
/// destroyed — which arrives as an ordinary inventory update, plus the guild family's own
/// roster push.
pub fn turn_in_petition(&mut self, item: u64) -> Result<()> {
self.send(
opcode::CMSG_TURN_IN_PETITION,
&messages::turn_in_petition(item),
)
}
/// Ask for a petition's record by id (`CMSG_PETITION_QUERY`), answered by
/// `SMSG_PETITION_QUERY_RESPONSE` — the **only** packet that carries the proposed guild's name
/// and the signature requirement.
///
/// The `item` field is read and then ignored by vmangos, which looks the petition up by id
/// alone (`Handlers/PetitionsHandler.cpp:171-185`); it is sent at its true value anyway.
pub fn petition_query(&mut self, petition_id: u32, item: u64) -> Result<()> {
self.send(
opcode::CMSG_PETITION_QUERY,
&messages::petition_query(petition_id, item),
)
}
/// Rename a charter (`MSG_PETITION_RENAME`), echoed back **only if it took**; a rejected name
/// comes back as an `SMSG_GUILD_COMMAND_RESULT` and no echo.
///
/// vmangos does **no ownership check** here — anyone holding the item may rename it
/// (`Handlers/PetitionsHandler.cpp:187-216`).
pub fn petition_rename(&mut self, item: u64, name: &str) -> Result<()> {
self.send(
opcode::MSG_PETITION_RENAME,
&messages::petition_rename(item, name),
)
}
/// Decline a charter someone offered us (`MSG_PETITION_DECLINE`). We send the charter's item
/// guid; the server forwards **our** guid to its owner, which is why one opcode has two
/// bodies.
pub fn petition_decline(&mut self, item: u64) -> Result<()> {
self.send(
opcode::MSG_PETITION_DECLINE,
&messages::petition_decline(item),
)
}
}
+386
View File
@@ -0,0 +1,386 @@
//! Golden tests for the petition opcode family — the guild-charter flow that founds a guild
//! (decision 1672). 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()` helper and methodology note.
//!
//! Four of the tests exist for a specific failure mode rather than for coverage, and each was
//! mutation-checked — the code was broken that exact way and the test observed to fail:
//!
//! - `petition_buy_body_is_the_full_seventy_two_byte_frame` — vmangos's reader walks 72 fixed bytes
//! field by field and never checks the remainder, so a body one field short leaves it reading
//! garbage and the buy is dropped **silently**. There is no error packet for a malformed buy.
//! - `petition_query_response_gender_is_sixteen_bit` — the one odd width in the family. Read as a
//! `u32`, every field after it shifts by two bytes and the choice count becomes a huge number.
//! - `petition_show_signatures_stride_is_twelve_bytes` — the trailing zero dword per signature is
//! easy to omit; a nine-signature charter then reads eight signers and a truncation error, and a
//! two-signature fixture would still pass.
//! - `msg_petition_opcodes_read_a_different_body_than_they_write` — the two `MSG_` opcodes are
//! genuinely asymmetric (decline sends an item guid and receives a player guid), which is exactly
//! the shape a shared body type would paper over.
mod common;
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages::{
self, opcode, petition_result, CHARTER_DISPLAY_ID, CHARTER_ITEM_ENTRY, MAX_PETITION_SIGNATURES,
};
use benilla_protocol::ServerPacket;
use common::hx;
/// Every CMSG builder in the family, byte-exact.
#[test]
fn cmsg_bodies_golden() {
// CMSG_PETITION_SHOWLIST (vmangos Server/Packets/Petition.cpp:3-6): one u64, the NPC.
assert_eq!(
messages::petition_show_list(0x0000_0000_0000_2a1f),
hx("1f2a000000000000"),
"CMSG_PETITION_SHOWLIST body"
);
// CMSG_PETITION_SHOW_SIGNATURES (Petition.cpp:8-11): one u64, the charter ITEM.
assert_eq!(
messages::petition_show_signatures(0x0000_0000_0001_0203),
hx("0302010000000000"),
"CMSG_PETITION_SHOW_SIGNATURES body"
);
// CMSG_TURN_IN_PETITION (Petition.cpp:24-27): one u64, the item.
assert_eq!(
messages::turn_in_petition(0x0000_0000_0001_0203),
hx("0302010000000000"),
"CMSG_TURN_IN_PETITION body"
);
// MSG_PETITION_DECLINE outbound (Petition.cpp:19-22): one u64 — the ITEM, not the player.
assert_eq!(
messages::petition_decline(0x0000_0000_0001_0203),
hx("0302010000000000"),
"MSG_PETITION_DECLINE body"
);
// CMSG_PETITION_SIGN (Petition.cpp:35-39): u64 item, then an i8 the server reads and skips.
// The client's own default for that byte is 1, not 0 (`0x4f46d9`) — and because the server
// discards it, this golden is the ONLY thing that can tell the two apart.
assert_eq!(
messages::petition_sign(0x0000_0000_0001_0203, 1),
hx("030201000000000001"),
"CMSG_PETITION_SIGN body, the client's default byte"
);
assert_eq!(
messages::petition_sign(0x0000_0000_0001_0203, -1),
hx("0302010000000000ff"),
"…and a negative argument rides as its two's complement"
);
// CMSG_OFFER_PETITION (Petition.cpp:41-45): u64 item, u64 target player.
assert_eq!(
messages::offer_petition(0x0000_0000_0001_0203, 0x0000_0000_0000_00aa),
hx("0302010000000000aa00000000000000"),
"CMSG_OFFER_PETITION body"
);
// CMSG_PETITION_QUERY (Petition.cpp:13-17): u32 petitionId, u64 itemGuid.
assert_eq!(
messages::petition_query(7, 0x0000_0000_0001_0203),
hx("070000000302010000000000"),
"CMSG_PETITION_QUERY body"
);
// MSG_PETITION_RENAME outbound (Petition.cpp:29-33): u64 item, cstring newName.
assert_eq!(
messages::petition_rename(0x0000_0000_0001_0203, "Legacy"),
hx("03020100000000004c656761637900"),
"MSG_PETITION_RENAME body"
);
}
/// `CMSG_PETITION_BUY` is 72 fixed bytes plus the name's, laid out exactly as vmangos's reader
/// walks them (`Server/Packets/Petition.cpp:47-67`): `u64 npc`, `u32 0`, `u64 0`, cstring name,
/// `10 × u32 0`, `u16 0`, `u8 0`, `u32 index`, `u32 0`.
///
/// **Mutation-checked.** Dropping any one of the skipped fields still compiles, still sends, and is
/// answered by nothing at all — the server reads past the end, `IsValidCharterName` sees garbage or
/// the handler returns, and no error packet exists for a malformed buy. This test is the only thing
/// between that and a "the Purchase button does nothing" report.
#[test]
fn petition_buy_body_is_the_full_seventy_two_byte_frame() {
let body = messages::petition_buy(0x0000_0000_0000_2a1f, "Legacy");
assert_eq!(
body,
hx(concat!(
"1f2a000000000000", // u64 npcGuid
"00000000", // u32 skipped
"0000000000000000", // u64 skipped
"4c656761637900", // cstring "Legacy"
// 10 × u32 skipped
"00000000000000000000000000000000000000000000000000000000000000000000000000000000",
"0000", // u16 skipped
"00", // u8 skipped
"00000000", // u32 index — the server's own "unused"
"00000000", // u32 skipped
)),
"CMSG_PETITION_BUY body"
);
assert_eq!(body.len(), 72 + "Legacy".len(), "72 fixed bytes + the name");
// The name is the only variable-length part, and it sits at offset 20 — where the server's
// reader arrives after skipping 8 + 4 + 8 bytes.
assert_eq!(
&body[20..27],
b"Legacy\0",
"the name starts at offset 20, NUL-terminated"
);
}
/// `SMSG_PETITION_SHOWLIST` — the packet that opens the guild registrar. Layout from
/// `Server/Packets/Petition.cpp:115-127`, values from `Handlers/PetitionsHandler.cpp:482-507`.
///
/// The list is **counted**, and parsed as one even though vmangos's own header says the reference
/// client supports only one row: a reader that assumed one would desynchronise rather than degrade
/// if a server ever sent two. The fixture sends two for exactly that reason.
#[test]
fn petition_show_list_parses_a_counted_list() {
let body = hx(concat!(
"1f2a000000000000", // u64 npcGuid
"02", // u8 count — deliberately not vmangos's 1
// row 1: index 1, entry 5863, display 16161, cost 1000, flags 1
"01000000",
"e7160000",
"213f0000",
"e8030000",
"01000000",
// row 2: index 2, entry 5863, display 16161, cost 2000, flags 0 (the "hidden" case)
"02000000",
"e7160000",
"213f0000",
"d0070000",
"00000000",
));
let packet = messages::parse_server(opcode::SMSG_PETITION_SHOWLIST, &body).unwrap();
let ServerPacket::PetitionShowList(list) = &packet else {
panic!("expected PetitionShowList, got {}", packet.name());
};
assert_eq!(list.npc, 0x2a1f);
assert_eq!(
list.entries.len(),
2,
"both rows parsed, not just the first"
);
assert_eq!(list.entries[0].index, 1);
assert_eq!(list.entries[0].charter_entry, CHARTER_ITEM_ENTRY);
assert_eq!(list.entries[0].charter_display_id, CHARTER_DISPLAY_ID);
assert_eq!(list.entries[0].charter_cost, 1000, "10 silver, in copper");
assert_eq!(list.entries[0].entry_flags, 1);
assert_eq!(list.entries[1].charter_cost, 2000);
assert_eq!(
list.entries[1].entry_flags, 0,
"the flag is kept raw, not folded into a bool"
);
let events = decode(packet);
assert!(
matches!(events.as_slice(), [SessionEvent::PetitionShowList(l)] if l.npc == 0x2a1f),
"one PetitionShowList event, got {events:?}"
);
}
/// `SMSG_PETITION_SHOW_SIGNATURES` — `u64 item`, `u64 owner`, `u32 petitionId`, `u8 count`, then
/// **12 bytes per signature**: the signer's guid and a dword vmangos writes as a literal zero
/// (`Handlers/PetitionsHandler.cpp:160-168`, `Guild/GuildMgr.cpp:358-366`).
///
/// **Mutation-checked** against omitting that dword. The fixture carries a full nine signatures
/// because that is the only size at which the drift is unmistakable — with two, an 8-byte stride
/// reads one signer and then a truncation error, which is easy to misread as a short packet.
#[test]
fn petition_show_signatures_stride_is_twelve_bytes() {
let mut body = hx(concat!(
"0302010000000000", // u64 itemGuid
"aa00000000000000", // u64 ownerGuid
"07000000", // u32 petitionId
"09", // u8 signatureCount
));
for signer in 1..=MAX_PETITION_SIGNATURES as u64 {
body.extend_from_slice(&signer.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
}
let packet = messages::parse_server(opcode::SMSG_PETITION_SHOW_SIGNATURES, &body).unwrap();
let ServerPacket::PetitionShowSignatures(sigs) = &packet else {
panic!("expected PetitionShowSignatures, got {}", packet.name());
};
assert_eq!(sigs.item, 0x0001_0203);
assert_eq!(sigs.owner, 0xaa);
assert_eq!(sigs.petition_id, 7);
assert_eq!(sigs.signatures.len(), MAX_PETITION_SIGNATURES);
// The LAST signer is the one that proves the stride: an 8-byte stride would have walked into
// the padding long before here.
assert_eq!(
sigs.signatures[MAX_PETITION_SIGNATURES - 1].signer,
MAX_PETITION_SIGNATURES as u64,
"the ninth signer, at stride 12"
);
assert!(sigs.signatures.iter().all(|s| s.unknown == 0));
let events = decode(packet);
assert!(
matches!(events.as_slice(), [SessionEvent::PetitionShowSignatures(s)] if s.petition_id == 7),
"one PetitionShowSignatures event, got {events:?}"
);
}
/// `SMSG_PETITION_QUERY_RESPONSE` — sixteen fields, one of which is sixteen bits.
///
/// **Mutation-checked** against reading `allowedGender` as a `u32`. That shifts everything after it
/// by two bytes: `allowedMinLevel` picks up half of `allowedMaxLevel`, and the choice count reads a
/// large number, so the parse fails on a truncated cstring — far from the cause. The fixture gives
/// every trailing field a distinct value so the shift cannot land on a lucky zero.
#[test]
fn petition_query_response_gender_is_sixteen_bit() {
let body = hx(concat!(
"07000000", // u32 petitionId
"aa00000000000000", // u64 ownerGuid
"4c656761637900", // cstring name "Legacy"
"00", // cstring bodyText ""
"01000000", // u32 flags
"09000000", // u32 minSignatures
"09000000", // u32 maxSignatures
"11111111", // u32 deadline
"22222222", // u32 creation
"33333333", // u32 allowedGuildID
"44444444", // u32 allowedClasses
"55555555", // u32 allowedRaces
"6666", // u16 allowedGender <-- sixteen bits
"77777777", // u32 allowedMinLevel
"88888888", // u32 allowedMaxLevel
"01000000", // u32 choiceCount
"59657300", // cstring "Yes"
"99999999", // u32 defaultChoice
));
let packet = messages::parse_server(opcode::SMSG_PETITION_QUERY_RESPONSE, &body).unwrap();
let ServerPacket::PetitionQueryResponse(r) = &packet else {
panic!("expected PetitionQueryResponse, got {}", packet.name());
};
assert_eq!(r.petition_id, 7);
assert_eq!(r.owner, 0xaa);
assert_eq!(r.name, "Legacy");
assert_eq!(r.body_text, "", "vmangos always sends this empty");
assert_eq!(r.min_signatures, 9);
assert_eq!(r.max_signatures, 9);
assert_eq!(r.allowed_gender, 0x6666, "sixteen bits, not thirty-two");
// These two are what a 32-bit gender read would corrupt first.
assert_eq!(r.allowed_min_level, 0x7777_7777);
assert_eq!(r.allowed_max_level, 0x8888_8888);
assert_eq!(r.choices, vec!["Yes".to_string()], "the counted tail");
assert_eq!(r.default_choice, 0x9999_9999);
let events = decode(packet);
assert!(
matches!(events.as_slice(), [SessionEvent::PetitionQueryResponse(q)] if q.name == "Legacy"),
"one PetitionQueryResponse event, got {events:?}"
);
}
/// `SMSG_PETITION_SIGN_RESULTS` (`Petition.cpp:69-74`) and `SMSG_TURN_IN_PETITION_RESULTS`
/// (`:76-79`) share the `PetitionSigns` enum but not a layout: the first carries two guids and a
/// code, the second carries **only** the code.
#[test]
fn the_two_results_packets_share_an_enum_but_not_a_layout() {
let body = hx(concat!(
"0302010000000000", // u64 itemGuid
"bb00000000000000", // u64 playerGuid — the SIGNER, in both copies of this packet
"03000000", // u32 result = CANT_SIGN_OWN
));
let packet = messages::parse_server(opcode::SMSG_PETITION_SIGN_RESULTS, &body).unwrap();
let ServerPacket::PetitionSignResults(r) = &packet else {
panic!("expected PetitionSignResults, got {}", packet.name());
};
assert_eq!(r.item, 0x0001_0203);
assert_eq!(r.player, 0xbb);
assert_eq!(r.result, petition_result::CANT_SIGN_OWN);
// The turn-in answer is a bare u32 with no charter named at all.
let packet = messages::parse_server(
opcode::SMSG_TURN_IN_PETITION_RESULTS,
&hx("04000000"), // NEED_MORE
)
.unwrap();
assert!(
matches!(
packet,
ServerPacket::TurnInPetitionResults { result } if result == petition_result::NEED_MORE
),
"expected TurnInPetitionResults, got {}",
packet.name()
);
let events = decode(packet);
assert!(
matches!(
events.as_slice(),
[SessionEvent::TurnInPetitionResults { result: 4 }]
),
"one TurnInPetitionResults event, got {events:?}"
);
}
/// The two `MSG_` opcodes are genuinely bidirectional, and **decline reads a different body than it
/// writes**: we send the charter's item guid, the owner receives the declining player's guid. Both
/// are eight bytes, so nothing but this test distinguishes a correct implementation from one that
/// reuses a single body type and is wrong half the time.
///
/// Rename, by contrast, is symmetric — the same `u64 item` + cstring both ways — which is why the
/// two are asserted together: the family is not uniformly one or the other.
#[test]
fn msg_petition_opcodes_read_a_different_body_than_they_write() {
// Decline: outbound is the ITEM, inbound is the PLAYER. Same width, opposite meaning.
assert_eq!(
messages::petition_decline(0x0001_0203),
hx("0302010000000000"),
"outbound MSG_PETITION_DECLINE carries the item guid"
);
let packet =
messages::parse_server(opcode::MSG_PETITION_DECLINE, &hx("bb00000000000000")).unwrap();
assert!(
matches!(packet, ServerPacket::PetitionDeclined { player } if player == 0xbb),
"inbound MSG_PETITION_DECLINE carries the player guid, got {}",
packet.name()
);
// Rename: symmetric, and the echo arrives only on success.
let body = hx("03020100000000004c656761637900");
assert_eq!(
messages::petition_rename(0x0001_0203, "Legacy"),
body,
"outbound MSG_PETITION_RENAME"
);
let packet = messages::parse_server(opcode::MSG_PETITION_RENAME, &body).unwrap();
let ServerPacket::PetitionRenamed(r) = &packet else {
panic!("expected PetitionRenamed, got {}", packet.name());
};
assert_eq!(r.item, 0x0001_0203);
assert_eq!(r.name, "Legacy");
let events = decode(packet);
assert!(
matches!(events.as_slice(), [SessionEvent::PetitionRenamed(p)] if p.name == "Legacy"),
"one PetitionRenamed event, got {events:?}"
);
}
/// An empty charter — no signatures yet, which is what the owner sees the moment they buy one.
/// The count byte is zero and the packet simply ends; nothing may read a signature record that is
/// not there.
#[test]
fn a_freshly_bought_charter_has_no_signatures() {
let body = hx(concat!(
"0302010000000000", // item
"aa00000000000000", // owner
"07000000", // petitionId
"00", // no signatures
));
let packet = messages::parse_server(opcode::SMSG_PETITION_SHOW_SIGNATURES, &body).unwrap();
let ServerPacket::PetitionShowSignatures(sigs) = &packet else {
panic!("expected PetitionShowSignatures, got {}", packet.name());
};
assert!(sigs.signatures.is_empty());
assert_eq!(sigs.owner, 0xaa, "the owner is still named");
}
+7
View File
@@ -98,6 +98,7 @@ mod net_stats;
mod object;
mod party;
mod pet;
mod petition;
mod pointer;
mod pvp;
mod quest;
@@ -175,6 +176,11 @@ pub use guild::{
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
MAX_RANKS, MIN_RANKS, RANK_RIGHT_BITS,
};
pub use petition::{
validate_guild_name, PetitionRecordView, PetitionRequest, PetitionState, PETITION_TYPE_CHARTER,
PETITION_TYPE_PETITION,
};
pub use inspect::{InspectView, UnitReach};
pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState};
pub use item_text::ItemTextState;
@@ -544,6 +550,7 @@ impl UiScript {
party::install(&lua)?;
social::install(&lua)?;
guild::install(&lua)?;
petition::install(&lua)?;
binder::install(&lua)?;
gm_ticket::install(&lua)?;
duel::install(&lua)?;
+16 -3
View File
@@ -6,9 +6,10 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
auction, backdrop, bank, char_stats, chat_window, colorselect, container, craft, cursor, death,
duel, follow, gossip, guild, inspect, item_text, loot, loot_roll, macros, mail, merchant,
party, pvp, quest, quest_log, reputation, session, simplehtml, skills, slider, social,
spellbook, stable, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot, AuraState,
FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
party, petition, pvp, quest, quest_log, reputation, session, simplehtml, skills, slider,
social, spellbook, stable, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot,
AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest,
UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -452,6 +453,16 @@ pub(crate) struct Model {
/// the app's last [`super::UiScript::take_guild_requests`] drain — the outbound seam
/// ([`guild`]).
pub(crate) guild_requests: Vec<guild::GuildRequest>,
/// The guild-charter snapshot the app pushes (the registrar's price and the open petition —
/// decision 1672): `GetPetitionInfo`/`GetPetitionNameInfo`/`GetGuildCharterCost`/… read it
/// ([`petition`]). Names and the proposed guild's title are already resolved, because in the
/// real client too they come from caches the engine owns and not from the packet that opens
/// the window.
pub(crate) petition: petition::PetitionState,
/// Charter intents (`BuyGuildCharter`/`SignPetition`/`TurnInGuildCharter`/…) queued since the
/// app's last [`super::UiScript::take_petition_requests`] drain — the outbound seam
/// ([`petition`]).
pub(crate) petition_requests: Vec<petition::PetitionRequest>,
/// 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).
@@ -1472,6 +1483,8 @@ impl Model {
guild: guild::GuildState::default(),
guild_control: guild::GuildRankEdit::default(),
guild_requests: Vec::new(),
petition: petition::PetitionState::default(),
petition_requests: Vec::new(),
tell_requests: Vec::new(),
open_chat_requests: Vec::new(),
chat_window_looks: [chat_window::ChatWindowLook::DEFAULT;
+692
View File
@@ -0,0 +1,692 @@
//! The guild-charter **Era API surface** — the guild registrar and the petition window
//! (decision 1672, **re-derived against the byte law in 1678**).
//!
//! [`super::guild`] is *being* in a guild; this is *founding* one, which 1257 §2 deliberately left
//! out and named as the next slice. Fifteen registered globals across two windows (the three tabard
//! ones are the adjacent family and are not built), and the same shape as every other domain here:
//! the app pushes a [`PetitionState`] snapshot ([`UiScript::set_petition`]) and the getters read
//! that plain data; every verb queues a [`PetitionRequest`] the app drains
//! ([`UiScript::take_petition_requests`]). No ECS or net reach from the engine (decision 0068 §3).
//!
//! Every contract below is **VERIFIED at the bytes** — wow-re
//! `system/ui/scratch/petition-charter-api.md`, a §5 round commissioned by this slice, which carved
//! the whole `PetitionInfo.cpp` TU (`0x84cfb8`). Where it corrected what this file first shipped,
//! the correction is named on the binding.
//!
//! **The snapshot mirrors the module's own `.data` state, and that is why it looks split.** The
//! real client keeps the signature list (`[0xbdce20]` + the `0x10`-stride signer array) and the
//! cached `CGPetition` record (`[0xbdce28]`) as two independent things, because the bindings read
//! them independently: `GetNumPetitionNames`/`GetPetitionNameInfo` answer off the *packet's* list
//! while `GetPetitionInfo` answers off the *record*, and either can exist without the other. So
//! [`PetitionState`] carries them separately rather than as one "is the window open" struct — a
//! collapsed model cannot express `CanSignPetition`'s no-record leg below, and gets it wrong.
//!
//! **This client has no `lua_pushboolean`.** Every predicate is the number `1` or `nil`
//! (`0x6f3810` / `0x6f37f0`), and a name the `NameCache` has not resolved is **`nil`**, not `""`.
//!
//! **`PETITION_SHOW` is deferred, which is what makes the partial states below unreachable in
//! practice.** The event fires only when no signer name is still resolving *and* the record has
//! arrived (`0x4f419b`-`0x4f41ad`), so the window never paints a blank title or a blank row. The
//! deferral lives app-side in `crate::ui_petition`; the getters here still answer honestly at any
//! moment, because an addon may call them whenever it likes.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// `GetPetitionInfo`'s first return when the record's `"charter"` bit is SET — `0x84cf8c`, selected
/// by `0x4f43fb test BYTE PTR [edi+0x1110],0x1`. The literal the reference compares against
/// (`PetitionFrame.lua:22`).
pub const PETITION_TYPE_CHARTER: &str = "charter";
/// …and when it is clear — `0x84b8f8`. **Reachable**, not dead: the bit is the record's own, so a
/// server that sent a non-charter petition would land here, and `CanSignPetition`'s guild-membership
/// and full-charter refusals are *both* gated on the same bit. 1.12 servers only ever send charters,
/// which is why the reference's `else` arm merely writes the bare word.
pub const PETITION_TYPE_PETITION: &str = "petition";
/// The cached `CGPetition` record, as [`GetPetitionInfo`] reads it — `[0xbdce28]`'s six fields.
///
/// `None` on [`PetitionState::record`] is the no-record leg, which is a real state with its own
/// return tuple; see [`GetPetitionInfo`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionRecordView {
/// [`PETITION_TYPE_CHARTER`] or [`PETITION_TYPE_PETITION`] — the record's `+0x1110` bit 0.
pub petition_type: String,
/// The record's inline `char[0x100]` title (`+0x10`) — the proposed guild's name.
pub title: String,
/// The record's inline `char[0x1000]` body text (`+0x110`). Empty on every 1.12 server.
pub body_text: String,
/// The record's `+0x1118`, pushed as a **signed** i32 (`fild DWORD`, not the zero-extending
/// `fild QWORD` the two counters use).
///
/// **That this field is the signature cap is settled inside the binary rather than inferred
/// from the packet's field order**: `CanSignPetition` refuses at `0x4f4634 cmp ecx,[esi+0x1118]`
/// against the live signature count. It is *not* the nine name rows — see the module doc of
/// `crate::ui_petition`.
pub max_signatures: i32,
/// The owner's name through the `NameCache` — **`nil` when uncached** (`0x4f446d`), never `""`.
pub originator: Option<String>,
/// Whether the active player's guid equals the record's owner (`0x4f447a`/`0x4f4481`).
pub is_originator: bool,
}
/// What the two charter windows read — the module's `.data` state, in the same three pieces.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionState {
/// `GetGuildCharterCost()` — showlist **entry\[0\]**'s fourth dword (`[0xbdce50]`), in copper.
///
/// **Unsigned**, because the binding pushes it through the `fild QWORD` idiom with the high
/// dword forced to zero (`0x4f5245`): a negative `charterCost` on the wire surfaces in Lua as
/// ~4.29e9, not as a negative number. `0` before any showlist has arrived, and after the
/// world-enter clear.
///
/// That it is **copper** is settled without going through the UI: `0x4f50ed` compares this cell
/// directly against `PLAYER_FIELD_COINAGE` and refuses with `ERR_NOT_ENOUGH_MONEY`.
pub charter_cost: u32,
/// The open petition's signers in wire order, each resolved through the `NameCache` — `None`
/// where the name has not landed. Its length is `GetNumPetitionNames()`, which counts
/// **signatures only**: the petition's owner is not among them.
///
/// Independent of [`Self::record`] on purpose (module doc): the packet fills this, the cache
/// fills that, and the bindings read one each.
pub signers: Vec<Option<String>>,
/// The cached record, or `None` — the no-record leg.
pub record: Option<PetitionRecordView>,
/// `CanSignPetition()`, computed app-side because three of its four refusals need state the
/// engine does not hold. See [`PetitionState::can_sign`]'s own note in `crate::ui_petition`;
/// the shape to remember here is that **it is `1` when nothing is open at all**.
pub can_sign: bool,
}
/// A charter intent queued from Lua, drained by the app into its send.
///
/// **Almost every one is fire-and-forget, and that is the wire's shape**: buying is answered only by
/// the item appearing, offering is answered to the *target*, and a refusal comes back on the guild
/// family's own error channel. Nothing here may update local state optimistically.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PetitionRequest {
/// `BuyGuildCharter(name)` — the registrar's Purchase button, **after** the name passed
/// [`validate_guild_name`]. The app supplies the NPC guid from the latched registrar, which is
/// the petition module's own `[0xbdceb0]` and *not* `CGGameUI`'s interaction pair.
Buy(String),
/// `TurnInGuildCharter()` — no argument. The app scans the bags, because the real client does
/// (`0x5ef2b0`, and it latches nothing: it re-scans on every call).
TurnIn,
/// `CloseGuildRegistrar()` — **sends nothing of its own**, VERIFIED by a closure walk over
/// `0x4f5010` that finds no `CDataStore` build and no send, with this TU's own four sends as
/// the positive control.
CloseRegistrar,
/// `SignPetition([n])` — the optional argument is a **byte on the wire** and defaults to `1`
/// (`0x4f46d9`), not to `0`. vmangos skips it, so only a golden can tell the difference.
Sign(i8),
/// `OfferPetition()` — no argument; the app resolves the **current target** (`CGGameUI`'s
/// *selection* pair, not its interaction pair) and runs the eight guards.
Offer,
/// `RenamePetition(name)` — after [`validate_guild_name`].
Rename(String),
/// `ClosePetition()` — **and this one can put bytes on the wire.** `0x4f3f60`'s decline leg
/// sends `MSG_PETITION_DECLINE` whenever a petition was open, no sign is in flight, a record is
/// cached, and we are **not** its owner. The app decides that; the verb just says "close".
ClosePetition,
/// A name the client itself refused ([`validate_guild_name`]) — carries the GlobalStrings key
/// of the message to show. **No packet is built**: the real client's validator runs before the
/// send and emits through the message catalog on its own.
NameRefused(&'static str),
}
impl super::UiScript {
/// Push the charter snapshot, replacing whatever was there. A bare setter — firing the four
/// events on their edges (and *deferring* `PETITION_SHOW`) is the app's job.
pub fn set_petition(&mut self, state: PetitionState) {
self.model_mut().petition = state;
}
/// Take the charter intents queued since the last drain.
pub fn take_petition_requests(&mut self) -> Vec<PetitionRequest> {
std::mem::take(&mut self.model_mut().petition_requests)
}
/// Drop any queued close intent, reporting how many went — **the close-intent consumption
/// decision 0096 named**, and the one thing a window switch cannot work without.
///
/// Firing `PETITION_CLOSED` runs the frame's `OnHide` synchronously, and that handler calls
/// `ClosePetition()`, which queues a close. On a *switch* — a charter offered to us while ours
/// is open — the feed fires `PETITION_CLOSED` then `PETITION_SHOW` for the new charter, and the
/// close queued by the first would be drained onto the session the second just opened: the new
/// charter's window flashes and shuts. **And here it would also put a `MSG_PETITION_DECLINE` on
/// the wire for a charter we did not decline**, which is the sharper reason it must go.
///
/// It cannot eat a *user's* close by mistake, and the ordering is what guarantees that rather
/// than a flag: the feed runs `before(UiInput)`, so a click's own `OnHide` queues its close
/// after this has already run.
pub fn drop_petition_close_intents(&mut self) -> usize {
let requests = &mut self.model_mut().petition_requests;
let before = requests.len();
requests.retain(|r| {
!matches!(
r,
PetitionRequest::CloseRegistrar | PetitionRequest::ClosePetition
)
});
before - requests.len()
}
/// Queue one charter intent directly — the test seam.
#[cfg(test)]
pub fn queue_petition_request(&mut self, request: PetitionRequest) {
self.model_mut().petition_requests.push(request);
}
}
/// The client-side guild-name check `BuyGuildCharter` and `RenamePetition` **share** — `0x4f5160`,
/// which runs the name through the locale-aware string checker `0x6c9b70` and maps its code to one
/// of seven messages, accepting **only** code `0xd`. `Ok(())` is that acceptance; `Err(key)` is the
/// GlobalStrings key of the line to show, and **no packet is built**.
///
/// **Partial, deliberately, and here is exactly how far it goes.** The code space belongs to
/// `0x6c9b70`, which that round did not carve, so only the checks whose meaning is unambiguous from
/// the message keys are implemented — empty, a leading or trailing space, and consecutive spaces.
/// The three that need data we do not have (`ERR_GUILD_NAME_TOO_SHORT`'s minimum,
/// `ERR_GUILD_NAME_PROFANE`'s word list, `ERR_GUILD_NAME_MIXED_LANGUAGES`' script rules) **pass**
/// rather than guess: refusing a name the server would have accepted is the worse failure, and the
/// server re-checks every one of them anyway (`ObjectMgr::IsValidCharterName`). What this buys over
/// sending blindly is the case the reference makes loudest and the server answers with silence —
/// clicking Purchase with an empty box.
///
/// The full table, from the note, with what each maps to:
///
/// | code | key | implemented |
/// |---|---|---|
/// | 0 | `ERR_GUILD_ENTER_NAME` | **yes** — the empty string |
/// | 1 | `ERR_GUILD_NAME_TOO_SHORT` | no — the minimum is inside `0x6c9b70` |
/// | 4 | `ERR_GUILD_NAME_MIXED_LANGUAGES` | no |
/// | 5 | `ERR_GUILD_NAME_PROFANE` | no |
/// | 6 | `ERR_GUILD_NAME_RESERVED` | no |
/// | 10 | `ERR_GUILD_NAME_INVALID_SPACE` | **yes** — a leading or trailing space |
/// | 11 | `ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES` | **yes** |
/// | 2,3,7,8,9,>0xb | `ERR_GUILD_NAME_INVALID` | no |
pub fn validate_guild_name(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("ERR_GUILD_ENTER_NAME");
}
if name.starts_with(' ') || name.ends_with(' ') {
return Err("ERR_GUILD_NAME_INVALID_SPACE");
}
if name.contains(" ") {
return Err("ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES");
}
Ok(())
}
/// 1.12's `1`/`nil`, never `true`/`false` — this client has no `lua_pushboolean`.
fn era_bool(on: bool) -> Value {
if on {
Value::Integer(1)
} else {
Value::Nil
}
}
/// A cached name pushes as a string; an uncached one pushes **nil**.
fn name_value(lua: &Lua, name: Option<&String>) -> mlua::Result<Value> {
match name {
Some(n) => Ok(Value::String(lua.create_string(n)?)),
None => Ok(Value::Nil),
}
}
/// Register the charter globals against the snapshot store.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// ── The petition window ──────────────────────────────────────────────────────────────────
// GetPetitionInfo() (`0x4f43d0`) — **exactly six values on BOTH legs** (`mov eax,0x6` at
// `0x4f4493` and `0x4f44d4`), in the order the reference destructures them
// (`PetitionFrame.lua:10`).
//
// The no-record leg is NOT "return nothing": it is `nil, nil, nil, 0, nil, nil`, with the
// fourth pushed as the *number* zero (`0x4f44b4 push 0; push 0; call 0x6f3810`). This file
// first shipped an empty return, which reads identically through the reference's own
// destructure and differently to anything that counts its arguments.
g.set(
"GetPetitionInfo",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(r) = model.petition.record.as_ref() else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Nil,
Value::Integer(0),
Value::Nil,
Value::Nil,
]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&r.petition_type)?),
Value::String(lua.create_string(&r.title)?),
Value::String(lua.create_string(&r.body_text)?),
Value::Integer(i64::from(r.max_signatures)),
name_value(lua, r.originator.as_ref())?,
era_bool(r.is_originator),
]))
})?,
)?;
// GetNumPetitionNames() (`0x4f44e0`, 44 bytes — one field read) — `[0xbdce20]`, pushed
// UNSIGNED. It counts **signatures only**; the owner is painted separately by the window and is
// never in this list.
g.set(
"GetNumPetitionNames",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.petition.signers.len() as i64)
})?,
)?;
// GetPetitionNameInfo(index) (`0x4f4510`) — **1-based** (`0x4f4569 dec eax`), one value on
// every leg. The bound test `0x4f456c jae` is UNSIGNED, so index `< 1` wraps to `0xffffffff`
// and fails the same comparison — which is why a zero or negative index answers nil rather
// than the last row. An uncached name is **nil**, not `""`.
//
// A non-numeric argument is `luaL_error("Usage: GetPetitionNameInfo(index)")`, which longjmps;
// mlua's coercion raises for us on the same input, so the shape is preserved.
g.set(
"GetPetitionNameInfo",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(name) = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.petition.signers.get(i))
else {
return Ok(Value::Nil);
};
name_value(lua, name.as_ref())
})?,
)?;
// CanSignPetition() (`0x4f45e0`) — the Sign button's only gate.
//
// **It returns `1` with NO petition open**, and that is the reference's own asymmetry rather
// than a reading error: `0x4f45f7 je 0x4f4655` jumps past the three record-dependent refusals
// straight into the signer scan, over an array the close path has already zeroed. A client that
// treats this as a sufficient precondition lets the user click Sign with nothing to sign — so
// the window's own `isOriginator` branch is what actually keeps the button off screen.
g.set(
"CanSignPetition",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(era_bool(model.petition.can_sign))
})?,
)?;
// ── The registrar window ─────────────────────────────────────────────────────────────────
// GetGuildCharterCost() (`0x4f5230`) — copper, unsigned, `0` before any showlist.
g.set(
"GetGuildCharterCost",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.petition.charter_cost))
})?,
)?;
// ── The verbs ────────────────────────────────────────────────────────────────────────────
// The four that carry nothing and push nothing. None may touch the snapshot: none of them is
// acknowledged, so a local update would show a charter as signed that the server refused.
for (global, request) in [
("TurnInGuildCharter", PetitionRequest::TurnIn),
("CloseGuildRegistrar", PetitionRequest::CloseRegistrar),
("OfferPetition", PetitionRequest::Offer),
("ClosePetition", PetitionRequest::ClosePetition),
] {
g.set(
global,
lua.create_function(move |lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.petition_requests.push(request.clone());
Ok(())
})?,
)?;
}
// SignPetition([n]) (`0x4f46d0`) — the argument is OPTIONAL and rides the wire as a byte,
// defaulting to **1**, not 0 (`0x4f46d9 edi = 1`, `0x4f4749 Put8`). The server skips the byte,
// so nothing observable depends on it — which is exactly why it is easy to ship as 0 and never
// find out.
g.set(
"SignPetition",
lua.create_function(|lua, n: Option<f64>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// `trunc` toward zero, then narrowed the way the `Put8` does.
let byte = n.map_or(1i8, |v| v.trunc() as i64 as i8);
model.petition_requests.push(PetitionRequest::Sign(byte));
Ok(())
})?,
)?;
// BuyGuildCharter(guildName) (`0x4f5260`) — **returns the number `1` or `nil`**, and what it
// reports is NAME VALIDITY, not that a packet was sent: `0x4f5294` runs the shared validator and
// only its non-zero return reaches the action, which then has five silent refusals of its own.
// Nothing in the shipped FrameXML reads the return; an addon can.
g.set(
"BuyGuildCharter",
lua.create_function(|lua, name: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match validate_guild_name(&name) {
Ok(()) => {
model.petition_requests.push(PetitionRequest::Buy(name));
Ok(Value::Integer(1))
}
Err(key) => {
model
.petition_requests
.push(PetitionRequest::NameRefused(key));
Ok(Value::Nil)
}
}
})?,
)?;
// RenamePetition(name) (`0x4f4930`) — **0 values**, the SAME validator, and silent on refusal
// beyond the validator's own message. (Its usage string is literally
// `Usage(RenamePetition("name")` in the binary — Blizzard's own unbalanced parenthesis. It is
// unreachable from our binding, which raises through mlua's coercion instead, and is recorded
// here rather than reproduced.)
g.set(
"RenamePetition",
lua.create_function(|lua, name: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
match validate_guild_name(&name) {
Ok(()) => model.petition_requests.push(PetitionRequest::Rename(name)),
Err(key) => model
.petition_requests
.push(PetitionRequest::NameRefused(key)),
}
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
fn record() -> PetitionRecordView {
PetitionRecordView {
petition_type: PETITION_TYPE_CHARTER.into(),
title: "Legacy".into(),
body_text: String::new(),
max_signatures: 9,
originator: Some("Founder".into()),
is_originator: false,
}
}
fn open(script: &mut UiScript, r: PetitionRecordView, signers: Vec<Option<String>>) {
script.set_petition(PetitionState {
charter_cost: 1000,
signers,
record: Some(r),
can_sign: true,
});
}
/// The six returns, in the reference's own destructuring order.
#[test]
fn get_petition_info_returns_the_reference_six_in_order() {
let mut s = UiScript::new().unwrap();
open(&mut s, record(), vec![]);
let (kind, title, body, max, originator, is_originator) = s
.eval::<(String, String, String, i32, String, Option<u32>)>("return GetPetitionInfo()")
.unwrap();
assert_eq!(kind, "charter");
assert_eq!(title, "Legacy");
assert_eq!(body, "");
assert_eq!(max, 9);
assert_eq!(originator, "Founder");
assert_eq!(is_originator, None, "era nil, not false");
open(
&mut s,
PetitionRecordView {
is_originator: true,
..record()
},
vec![],
);
assert_eq!(
s.eval::<Option<u32>>("local _,_,_,_,_,o = GetPetitionInfo(); return o")
.unwrap(),
Some(1),
"era 1, not true"
);
}
/// **The no-record leg is six values, not none** — `nil, nil, nil, 0, nil, nil`, with the
/// fourth the *number* zero.
///
/// This file first shipped an empty return, which the reference's own six-way destructure
/// cannot tell apart (both give six nils… except the fourth, which is `0` here and `nil`
/// there). `select("#", …)` is what separates them, and it is the whole point of the test.
#[test]
fn the_no_record_leg_is_six_values_with_a_numeric_zero() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select(\"#\", GetPetitionInfo())")
.unwrap(),
6,
"six values even with nothing open"
);
assert_eq!(
s.eval::<i64>("local _,_,_,m = GetPetitionInfo(); return m")
.unwrap(),
0,
"the fourth is the number 0, not nil"
);
assert_eq!(
s.eval::<Option<String>>("return (GetPetitionInfo())")
.unwrap(),
None,
"…and the first really is nil"
);
}
/// The name list is 1-based and bounded, an uncached name is **nil**, and the count excludes
/// the owner. The zero/negative cases matter because the binary's bound test is *unsigned*.
#[test]
fn petition_names_are_one_based_and_uncached_reads_nil() {
let mut s = UiScript::new().unwrap();
open(
&mut s,
record(),
vec![Some("Aaa".into()), None, Some("Ccc".into())],
);
assert_eq!(
s.eval::<i64>("return GetNumPetitionNames()").unwrap(),
3,
"three signers — the owner is not one of them"
);
assert_eq!(
s.eval::<String>("return GetPetitionNameInfo(1)").unwrap(),
"Aaa"
);
assert_eq!(
s.eval::<Option<String>>("return GetPetitionNameInfo(2)")
.unwrap(),
None,
"an unresolved name is nil, never an empty string"
);
assert_eq!(
s.eval::<String>("return GetPetitionNameInfo(3)").unwrap(),
"Ccc",
"and it keeps its ROW — the list does not close up around it"
);
for out_of_range in ["0", "4", "-1"] {
assert_eq!(
s.eval::<Option<String>>(&format!("return GetPetitionNameInfo({out_of_range})"))
.unwrap(),
None,
"index {out_of_range} answers nil"
);
}
}
/// `CanSignPetition()` is era-boolean **and answers `1` with nothing open** — the reference's
/// own asymmetry, pinned so nobody "fixes" it into a nil.
#[test]
fn can_sign_petition_answers_one_with_nothing_open() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<Option<u32>>("return CanSignPetition()").unwrap(),
None,
"the app's own default is a refusal…"
);
let mut s = UiScript::new().unwrap();
s.set_petition(PetitionState {
can_sign: true,
..PetitionState::default()
});
assert_eq!(
s.eval::<Option<u32>>("return CanSignPetition()").unwrap(),
Some(1),
"…but the binding reports whatever the app computed, including the no-record 1"
);
}
/// The cost is copper, unsigned, and belongs to the registrar rather than to any petition.
#[test]
fn charter_cost_is_unsigned_copper_and_the_registrars() {
let mut s = UiScript::new().unwrap();
s.set_petition(PetitionState {
charter_cost: 1000,
..PetitionState::default()
});
assert_eq!(s.eval::<i64>("return GetGuildCharterCost()").unwrap(), 1000);
// A negative wire cost surfaces as ~4.29e9, not as a negative — the `fild QWORD` idiom.
s.set_petition(PetitionState {
charter_cost: (-1i32) as u32,
..PetitionState::default()
});
assert_eq!(
s.eval::<i64>("return GetGuildCharterCost()").unwrap(),
4_294_967_295
);
s.set_petition(PetitionState::default());
assert_eq!(s.eval::<i64>("return GetGuildCharterCost()").unwrap(), 0);
}
/// `SignPetition`'s optional argument rides the wire as a byte and **defaults to 1**.
#[test]
fn sign_petition_defaults_its_wire_byte_to_one() {
let mut s = UiScript::new().unwrap();
s.run("SignPetition(); SignPetition(7); SignPetition(3.9)")
.unwrap();
assert_eq!(
s.take_petition_requests(),
vec![
PetitionRequest::Sign(1),
PetitionRequest::Sign(7),
PetitionRequest::Sign(3),
],
"absent = 1, present = truncated toward zero"
);
}
/// `BuyGuildCharter` reports **name validity** as `1`/`nil`, and a refused name queues its
/// message instead of a packet. `RenamePetition` shares the validator and returns nothing.
#[test]
fn the_name_validator_gates_both_verbs_and_only_buy_reports_it() {
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<Option<u32>>("return BuyGuildCharter(\"Legacy\")")
.unwrap(),
Some(1)
);
assert_eq!(
s.eval::<Option<u32>>("return BuyGuildCharter(\"\")")
.unwrap(),
None,
"an empty name is refused locally — the server answers it with silence"
);
assert_eq!(
s.eval::<i64>("return select(\"#\", RenamePetition(\"Legacy\"))")
.unwrap(),
0,
"rename pushes nothing at all"
);
s.run("RenamePetition(\" spaced \")").unwrap();
assert_eq!(
s.take_petition_requests(),
vec![
PetitionRequest::Buy("Legacy".into()),
PetitionRequest::NameRefused("ERR_GUILD_ENTER_NAME"),
PetitionRequest::Rename("Legacy".into()),
PetitionRequest::NameRefused("ERR_GUILD_NAME_INVALID_SPACE"),
]
);
}
/// The validator's three implemented codes, and — just as load-bearing — the names it lets
/// through rather than guessing at.
#[test]
fn the_validator_refuses_only_what_it_can_prove() {
assert_eq!(validate_guild_name(""), Err("ERR_GUILD_ENTER_NAME"));
assert_eq!(
validate_guild_name(" Legacy"),
Err("ERR_GUILD_NAME_INVALID_SPACE")
);
assert_eq!(
validate_guild_name("Legacy "),
Err("ERR_GUILD_NAME_INVALID_SPACE")
);
assert_eq!(
validate_guild_name("Legacy of Steel"),
Err("ERR_GUILD_NAME_NAME_CONSECUTIVE_SPACES")
);
// Passed through on purpose: the minimum length, the profanity list and the script rules
// all live inside a function that round did not carve, and refusing a name the server would
// accept is the worse failure. The server re-checks every one of them.
for ok in ["Legacy of Steel", "A", "Ab", "Éclair", "x y z"] {
assert_eq!(
validate_guild_name(ok),
Ok(()),
"{ok:?} passes to the server"
);
}
}
/// The close-intent consumption removes exactly the two close verbs and preserves the order of
/// everything else.
#[test]
fn dropping_close_intents_leaves_the_other_verbs_untouched() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
SignPetition()
ClosePetition()
BuyGuildCharter("Legacy")
CloseGuildRegistrar()
OfferPetition()
"#,
)
.unwrap();
assert_eq!(s.drop_petition_close_intents(), 2);
assert_eq!(
s.take_petition_requests(),
vec![
PetitionRequest::Sign(1),
PetitionRequest::Buy("Legacy".into()),
PetitionRequest::Offer,
],
);
assert_eq!(s.drop_petition_close_intents(), 0);
}
}