Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)

Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one.
  - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`.
  - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale.
  - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent.
  - A StatusBar fills from its internal normalised fraction, not from `GetValue()`.
  - The addresses of `StartDuel` and `StartDuelUnit` were swapped.
  - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default.
  - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference.

Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-25 01:58:49 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8505187668
commit fd386e75ae
233 changed files with 14108 additions and 42094 deletions
+175 -402
View File
@@ -1,76 +1,25 @@
//! The guild **Era API surface** — roster, ranks, notes, MOTD, and the membership verbs
//! (decision 1257).
//! The guild bindings: roster, ranks, notes, MOTD and the membership verbs. The app pushes a
//! display-ready [`GuildState`] ([`UiScript::set_guild`]) that joins two caches: the roster (MOTD,
//! info text, rank rights, members) and the guild query (the name and ten rank names). Each verb
//! queues a [`GuildRequest`] the app drains ([`UiScript::take_guild_requests`]).
//!
//! [`super::social`]'s shape exactly: the app pushes a [`GuildState`] snapshot built from its own
//! wire mirror ([`UiScript::set_guild`]) and the getters here read that plain data; every verb
//! queues a [`GuildRequest`] the app drains ([`UiScript::take_guild_requests`]) into the matching
//! `CMSG_*` send. No ECS or net reach from the engine (decision 0068 §3).
//!
//! **The snapshot is already display-ready** — member names, localized class names, zone names,
//! rank names, and the last-online decomposition — because every one of those is a *lookup the
//! engine owns* in the real client too. Unlike the friend list and `/who`, the guild roster
//! carries level/class/zone for **offline** members as well (the server keeps them in the guild
//! table), which is why those columns stay populated and merely grey out.
//!
//! **Guild identity and guild roster are two different caches, and this file joins them.**
//! `SMSG_GUILD_ROSTER` carries the MOTD, the info text, the rank *rights* and the members — but
//! **not** the guild's name and **not** the rank *names*. Those come only from
//! `SMSG_GUILD_QUERY_RESPONSE`, which answers a `CMSG_GUILD_QUERY(guildId)` with the name plus a
//! fixed array of ten rank names. The app does the joining before it pushes; [`GuildState`] is
//! the joined result.
//!
//! **Two index bases coexist, and callers must convert.** A roster row's `rankIndex` (from
//! `GetGuildRosterInfo`) is **0-based**, `0` = guild master — the reference computes
//! `maxRankIndex = GuildControlGetNumRanks() - 1` and compares roster indices against it
//! (`FriendsFrame.lua:338`, `:393-398`). The whole `GuildControl*` family is **1-based**, driven
//! straight off dropdown IDs `1..GuildControlGetNumRanks()` (`:820-822`, `:855-857`, `:866-868`).
//! Mixing them silently promotes the wrong rank, so every conversion here is spelled out.
//!
//! **Rank-control edits are staged, and the staging is write-only.** `GuildControlSetRank(i)`
//! loads rank `i` into [`GuildRankEdit`], `GuildControlSetRankFlag(idx, on)` writes one staged
//! slot, and `GuildControlSaveRank(name)` folds the thirteen slots over a freshly re-read *live*
//! baseline and queues one intent. The buffer is deliberately *not* part of the pushed snapshot:
//! it is scratch the popup owns between open and Accept, and a snapshot push mid-edit would
//! otherwise discard the user's unsaved checkbox clicks.
//!
//! The counter-intuitive half, and it is verified: **`GuildControlGetRankFlags` reads the LIVE
//! rights, never the staging buffer.** A checkbox click is invisible through the getter until the
//! save round-trips and a fresh roster lands. Reading the buffer back would feel more responsive
//! and would be a different client.
//!
//! **The roster is never filtered.** `GetGuildRosterInfo`, `GetGuildRosterLastOnline` and
//! `SetGuildRosterSelection` all address the *whole* member list; only `GetNumGuildMembers`
//! respects the show-offline toggle, and indices past it are valid and return real offline
//! members. See [`GuildState::num_members`] — this is the opposite of the obvious design, and
//! collapsing the two is the mistake it exists to prevent.
//!
//! **Era booleans are `1`/`nil`, never `true`/`false`** — `IsInGuild`, `IsGuildLeader` and every
//! `Can*` predicate below. The FrameXML branches on truthiness and stores the result, so a
//! `false` where the reference gives `nil` survives into places a `nil` would not.
//! Two index bases: a roster `rankIndex` is 0-based with 0 the guild master
//! (`FriendsFrame.lua:338`, `:393-398`), while the `GuildControl*` family is 1-based, driven by
//! the dropdown IDs (`FriendsFrame.lua:820-822`, `:855-857`, `:866-868`).
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// The thirteen guild rank permissions, **in `GuildControlGetRankFlags`' return order** — which is
/// also `GuildControlPopupFrameCheckbox1..13`'s order and the index `GuildControlSetRankFlag`
/// takes. That ordering *is* the API contract: `GuildControlCheckboxUpdate` loops
/// `for i=1, arg.n` over the returns and drives `getglobal("GuildControlPopupFrameCheckbox"..i)`
/// (`FriendsFrame.lua:874-884`), so nothing but position connects a flag to its checkbox.
/// The thirteen rank rights in `GuildControlGetRankFlags`' return order, which is also the
/// checkbox order and `GuildControlSetRankFlag`'s index: only position ties a flag to its checkbox
/// (`FriendsFrame.lua:874-884`). The labels are `GUILDCONTROL_OPTION1..13`
/// (`GlobalStrings.lua:2036-2048`).
///
/// The labels, in this order, are `GUILDCONTROL_OPTION1..13`
/// (`GlobalStrings.lua:2036-2048`): Guildchat Listen · Guildchat Speak · Officerchat Listen ·
/// Officerchat Speak · Promote · Demote · Invite Member · Remove Member · Set MOTD ·
/// Edit Public Note · View Officer Note · Edit Officer Note · Modify Guild Info.
///
/// **The map is not monotonic, and that is the whole reason it is a table rather than a shift.**
/// Promote (index 5) is `0x80` and Demote (6) is `0x100`, both *above* Invite (7) `0x10` and
/// Remove (8) `0x20` in bit value while *below* them in checkbox order. `1 << (i - 1)` is wrong
/// for nine of the thirteen. Bits `0x40`, `0x400` and `0x800` are unused.
///
/// Bits from vmangos `src/game/Guild/Guild.h:56-74` (`GuildRankRights`), with that enum's
/// `GR_RIGHT_EMPTY` (`0x40`) stripped — it is OR'd into every one of its entries, so it carries no
/// per-permission information.
/// Not bit order: Promote and Demote (5, 6) are `0x80` and `0x100`, above Invite and Remove
/// (7, 8) at `0x10` and `0x20`, so `1 << (i - 1)` is wrong for nine of the thirteen. The bits are
/// vmangos `GuildRankRights` (`Guild/Guild.h:56-74`) without the `GR_RIGHT_EMPTY` `0x40` it ORs
/// into each; `0x40`, `0x400` and `0x800` are none of the thirteen.
pub const RANK_RIGHT_BITS: [u32; 13] = [
0x0000_0001, // 1 Guildchat Listen
0x0000_0002, // 2 Guildchat Speak
@@ -87,9 +36,7 @@ pub const RANK_RIGHT_BITS: [u32; 13] = [
0x0001_0000, // 13 Modify Guild Info
];
/// Named indices into [`RANK_RIGHT_BITS`], for the `Can*` predicates. One-based, matching the
/// checkbox numbering the reference's strings use, so a reader can check them against
/// `GUILDCONTROL_OPTION<n>` without arithmetic.
/// One-based indices into [`RANK_RIGHT_BITS`], numbered as `GUILDCONTROL_OPTION<n>`.
mod right {
pub(super) const PROMOTE: usize = 5;
pub(super) const DEMOTE: usize = 6;
@@ -102,24 +49,16 @@ mod right {
pub(super) const MODIFY_GUILD_INFO: usize = 13;
}
/// The lowest and highest rank counts a guild can have — the reference's own guard rails, which
/// it enforces client-side before the server ever sees the request:
/// `GuildControlPopupFrameAddRankButton_OnUpdate` disables Add at `>= 10`
/// (`FriendsFrame.lua:887`) and `..RemoveRankButton_OnUpdate` only shows Remove when the selected
/// rank is the last *and* the count exceeds 5 (`:908`). Same numbers as vmangos'
/// `GUILD_RANKS_MIN_COUNT` / `GUILD_RANKS_MAX_COUNT` (`Guild.h:31-32`).
/// The fewest ranks: `GuildControlDelRank` refuses at it and the Remove button shows only above it
/// (`FriendsFrame.lua:908`); vmangos `GUILD_RANKS_MIN_COUNT` (`Guild/Guild.h:31`).
pub const MIN_RANKS: usize = 5;
/// See [`MIN_RANKS`].
/// The most ranks: `GuildControlAddRank` refuses at it and the Add button disables there
/// (`FriendsFrame.lua:887`); vmangos `GUILD_RANKS_MAX_COUNT` (`Guild/Guild.h:32`).
pub const MAX_RANKS: usize = 10;
/// How long ago a member was last seen, already decomposed into the four units
/// `GetGuildRosterLastOnline` returns. The wire carries one `f32` — days since last login, sent
/// only for *offline* members — and the decomposition is engine math, so the app does it once and
/// the VM just reports it.
///
/// The reference reads these largest-unit-first and stops at the first non-zero
/// (`GuildFrame_GetLastOnline`, `FriendsFrame.lua:957-978`), so the contract that matters is that
/// exactly the largest applicable unit is non-zero.
/// `GetGuildRosterLastOnline`'s four units, decomposed by the app from the wire's one `f32` of
/// days offline. `GuildFrame_GetLastOnline` shows the largest non-zero unit, reading nil as 0
/// (`FriendsFrame.lua:957-978`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LastOnline {
pub years: u32,
@@ -128,190 +67,139 @@ pub struct LastOnline {
pub hours: u32,
}
/// A unit's guild membership, as `GetGuildInfo(unit)` reports it — hung off
/// [`super::unit::UnitState`] because the binding is per-unit and the descriptor is public.
///
/// `PLAYER_GUILDID` (field 191) and `PLAYER_GUILDRANK` (field 192) are PUBLIC, so these arrive
/// for *every visible player*, not just us (vmangos `Objects/UpdateFields_1_12_1.h:121-122`;
/// UNIT_END = 188, the same arithmetic that pins `PLAYER_FLAGS` = 190). The app turns the id into
/// a name through its guild-identity cache and the rank field into a rank name through that
/// identity's ten rank names — which is exactly why the query response carries a fixed ten.
/// A unit's guild as `GetGuildInfo(unit)` reports it. `PLAYER_GUILDID` (191) and
/// `PLAYER_GUILDRANK` (192) are public descriptor fields (vmangos
/// `Objects/UpdateFields_1_12_1.h:121-122`), so every visible player carries one; the app names
/// them through its guild query cache.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct UnitGuild {
/// The guild's name — **never empty**.
///
/// A cache miss does not produce a blank-named `UnitGuild`: it produces `None`. The binding
/// takes the *same* nil path on the cache-miss leg (`0x4c93d7 je 0x4c943c`) as on the
/// guildless leg, so "we have not resolved this id yet" and "this unit has no guild" are
/// indistinguishable to Lua by design. That matters — `UnitPopup.lua:575` passes the result
/// straight into a StaticPopup format, and an empty name would render `<>` there.
/// Never empty: an unresolved guild is `None`, as the binding takes the same nil path on a
/// cache miss as for no guild (`0x4c93d7 je 0x4c943c`).
pub name: String,
/// The unit's rank name within that guild.
pub rank_name: String,
/// The unit's rank, **0-based**, `0` = guild master.
/// 0-based, 0 the guild master.
pub rank_index: u32,
}
/// One guild rank: its name (from the query response) and its rights word (from the roster).
/// One guild rank: its name from the guild query, its rights from the roster.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GuildRankInfo {
/// The rank's display name, e.g. "Guild Master". Empty for a rank the query response has not
/// named yet.
/// Empty until the guild query names it.
pub name: String,
/// The rank's permission bits — test with [`RANK_RIGHT_BITS`].
/// Tested with [`RANK_RIGHT_BITS`].
pub rights: u32,
}
/// One roster row, already resolved for display. Mirrors `GetGuildRosterInfo`'s nine returns in
/// field order, which is how `FriendsFrame.lua` destructures them (`:344`, `:447`, `:479`).
/// One roster row resolved for display: `GetGuildRosterInfo`'s ten returns in order, then the
/// last-online time.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GuildMemberInfo {
/// The character's name. Carried on the wire in the roster itself, so unlike a friend row
/// this never waits on a name query.
/// On the wire in the roster itself, so it never waits on a name query.
pub name: String,
/// The member's rank *name*, e.g. "Officer" — `GetGuildRosterInfo`'s second return.
/// The rank's name.
pub rank: String,
/// The member's rank as a **0-based** index, `0` = guild master. See the module doc: this is
/// the opposite base from the `GuildControl*` family.
/// 0-based, 0 the guild master; the `GuildControl*` family is 1-based.
pub rank_index: u32,
pub level: u32,
/// Localized class name ("Warrior"). Populated for offline members too.
/// The localized class name. The roster carries class, level and zone for offline members too.
pub class: String,
/// Zone name ("Elwynn Forest"), empty when the id has no `AreaTable` row.
/// The zone name, empty when the id has no `AreaTable` row.
pub zone: String,
/// The public note.
pub note: String,
/// The officer note. The server sends this empty when the *viewer* lacks
/// `View Officer Note`, so an empty string here is "not permitted" and "not set" alike —
/// which is exactly what the reference can distinguish too (it gates on `CanViewOfficerNote`
/// separately rather than on emptiness).
/// Empty both when unset and when the server blanks it for a viewer without View Officer
/// Note; the frame gates on `CanViewOfficerNote` instead.
pub officer_note: String,
pub online: bool,
/// The away tag, exactly as the friends list spells it: `""`, `"<AFK>"` or `"<DND>"` —
/// `GetGuildRosterInfo`'s **tenth** return.
///
/// It is easy to miss that there is a tenth. Six of the reference's seven call sites
/// destructure only nine (`FriendsFrame.lua:344`, `:447`, `:479`, `:709`,
/// `StaticPopup.lua:1008`, `:1045`) — but the player-status view takes ten
/// (`FriendsFrame.lua:541`) and then branches on `status == ""` to choose between the
/// "Online" label and the tag itself (`:548-551`). An engine that answered nil there would
/// blank that column silently, and every nine-value call site would keep working.
///
/// This is also what the wire's presence byte is *for*: `GRF_ONLINE`/`GRF_AFK`/`GRF_DND`
/// (0x01/0x02/0x04) would not need three bits if the API only exposed a boolean.
/// The tenth return: `""`, `"<AFK>"` or `"<DND>"`. Only the status view reads it, to choose
/// between the Online label and the tag (`FriendsFrame.lua:541`, `:548-551`).
pub status: String,
/// How long since this member was last seen — meaningful only when `online` is false. The
/// wire omits it entirely for online members.
/// Meaningful only offline; the wire omits it for online members.
pub last_online: LastOnline,
}
/// The guild snapshot, pushed whole by the app whenever it changes ([`UiScript::set_guild`]).
/// `default()` is the guildless shape, which is also the fresh-login shape.
/// The guild snapshot the app pushes whole when it changes ([`UiScript::set_guild`]); the default
/// is guildless.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GuildState {
/// Whether the player is in a guild at all — `IsInGuild`. Driven by the player's own
/// `PLAYER_GUILDID` descriptor field (191), so it is true from the moment the descriptor
/// streams, *before* any roster has arrived.
/// `IsInGuild`, from the player's own `PLAYER_GUILDID` (191), so true before any roster.
pub in_guild: bool,
/// The guild's name. Only `SMSG_GUILD_QUERY_RESPONSE` carries it, so it stays empty in the
/// window between `in_guild` going true and that query answering.
/// Only `SMSG_GUILD_QUERY_RESPONSE` carries it, so it is empty until that query answers.
pub name: String,
/// The player's own rank name.
pub rank_name: String,
/// The player's own rank, **0-based**, `0` = guild master.
/// The player's own rank, 0-based, 0 the guild master.
pub rank_index: u32,
/// Whether the player is the guild master — `IsGuildLeader`.
/// `IsGuildLeader`: the player is the guild master.
pub is_leader: bool,
/// The player's own rank's rights word, which every `Can*` predicate tests.
/// The player's own rank's rights, which every `Can*` predicate tests.
pub rights: u32,
/// The message of the day.
pub motd: String,
/// The guild info text (the `GuildInfoFrame` body).
/// The `GuildInfoFrame` body.
pub info_text: String,
/// The **whole** roster, sorted — never filtered. `GetGuildRosterInfo` indexes this directly,
/// 1-based, and so do `GetGuildRosterLastOnline` and `SetGuildRosterSelection`.
///
/// **It is NOT filtered by [`Self::show_offline`], and its length is NOT
/// `GetNumGuildMembers()`.** See [`Self::num_members`] — this asymmetry is verified and it is
/// the opposite of what the obvious design would do.
/// The whole roster, sorted and never filtered: `GetGuildRosterInfo`,
/// `GetGuildRosterLastOnline` and `SetGuildRosterSelection` index it 1-based.
pub roster: Vec<GuildMemberInfo>,
/// What `GetNumGuildMembers()` reports: the count the UI loops to, which **does** respect
/// [`Self::show_offline`].
///
/// **This is an advisory loop bound, not the addressable range.** In the real client the three
/// index-taking bindings all bound against the *full* member count (`[0xb73118]`) and never
/// read the show-offline flag at all, while `GetNumGuildMembers` answers a different number
/// entirely — so **indices past it are valid and return real offline members**.
///
/// Collapsing the two — filtering the vector and reporting its length — is the natural design
/// and it is wrong: it would make a selection that survives a show-offline toggle address a
/// different member, or none.
/// `GetNumGuildMembers()`, the UI's loop bound, which respects [`Self::show_offline`]. The
/// index-taking bindings bound against the full count (`[0xb73118]`) and never read the flag,
/// so an index past this still returns a real offline member.
pub num_members: usize,
/// Every rank, index 0 = guild master. Length is the guild's real rank count, which is what
/// `GuildControlGetNumRanks` reports.
/// Every rank, 0 the guild master; the length is `GuildControlGetNumRanks`.
pub ranks: Vec<GuildRankInfo>,
/// The selected roster row as a **1-based** index into [`Self::roster`], `0` = none — the
/// scale `GetGuildRosterSelection` reports and `FriendsFrame.lua:347` tests with `> 0`.
/// The selected row, 1-based into [`Self::roster`], 0 for none (`FriendsFrame.lua:347` tests
/// `> 0`).
pub selection: u32,
/// Whether the UI is showing offline members. Affects [`Self::num_members`] only — never
/// [`Self::roster`]'s contents.
/// Changes [`Self::num_members`] only, never [`Self::roster`].
pub show_offline: bool,
}
/// The rank-control popup's staging buffer — the rank it has loaded and the rights word it has
/// been editing. See the module doc on why this is not part of the pushed snapshot.
/// The rank-control popup's staging buffer, kept out of [`GuildState`] so a push mid-edit does not
/// discard unsaved checkbox clicks.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GuildRankEdit {
/// The **1-based** rank `GuildControlSetRank` last loaded; `0` = none loaded yet.
/// The 1-based rank `GuildControlSetRank` last loaded, 0 for none.
pub rank: u32,
/// The thirteen staged checkbox states, positionally matching [`RANK_RIGHT_BITS`].
///
/// Held per-flag rather than as a packed word because that is what the binding does — it
/// stages `mask[k] & rights` into thirteen separate slots and `SetRankFlag` writes exactly
/// one of them. Keeping a word here instead would work only as long as nothing outside the
/// thirteen bits mattered, and `0x40` is precisely such a bit.
/// The thirteen staged checkboxes in [`RANK_RIGHT_BITS`] order, one slot per flag as the
/// binding stages them, so the save leaves every other bit of the live rights alone.
pub staged: [bool; 13],
}
/// Outbound guild intents queued by the Era API, drained by the app
/// ([`UiScript::take_guild_requests`]). Plain data — [`super::social::SocialRequest`]'s twin.
/// Outbound guild intents, drained by the app ([`UiScript::take_guild_requests`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuildRequest {
/// `GuildRoster()` — ask the server for a fresh roster (`CMSG_GUILD_ROSTER`). Throttled
/// app-side to one per 10 s, as the reference is; the `GUILD_ROSTER_UPDATE` arg1 path clears
/// that throttle first so the FrameXML's response request is never the one swallowed.
/// `GuildRoster()` (`CMSG_GUILD_ROSTER`). The app throttles it to one per 10 s as the reference
/// does; the `GUILD_ROSTER_UPDATE` arg1 path clears the throttle first, so the FrameXML's own
/// request is never the one dropped.
Roster,
/// `GuildInfo()` — the `/ginfo` query (`CMSG_GUILD_INFO`), whose answer is a chat line.
/// `GuildInfo()`, the `/ginfo` query (`CMSG_GUILD_INFO`), answered as a chat line.
Info,
/// `GuildInviteByName(name)`.
Invite(String),
/// `GuildUninviteByName(name)` — remove a member (`CMSG_GUILD_REMOVE`).
/// `GuildUninviteByName(name)` (`CMSG_GUILD_REMOVE`).
Uninvite(String),
/// `GuildPromoteByName(name)`.
Promote(String),
/// `GuildDemoteByName(name)`.
Demote(String),
/// `GuildSetLeaderByName(name)` — hand over the guild.
/// `GuildSetLeaderByName(name)`.
SetLeader(String),
/// `AcceptGuild()` — the `GUILD_INVITE` popup's Accept.
/// `AcceptGuild()`.
Accept,
/// `DeclineGuild()` — its Decline.
/// `DeclineGuild()`.
Decline,
/// `GuildLeave()`.
Leave,
/// `GuildDisband()`.
Disband,
/// `GuildSetMOTD(text)`. An empty string is a real value meaning "clear it".
/// `GuildSetMOTD(text)`; an empty string clears it.
SetMotd(String),
/// `SetGuildInfoText(text)`.
SetInfoText(String),
/// `GuildRosterSetPublicNote(index, note)` — the index is the 1-based display row, which the
/// app resolves to the name the wire wants.
/// `GuildRosterSetPublicNote(index, note)`: a 1-based display row the app resolves to the name
/// the wire takes.
SetPublicNote { index: u32, note: String },
/// `GuildRosterSetOfficerNote(index, note)`.
SetOfficerNote { index: u32, note: String },
/// `GuildControlSaveRank(name)` — the staged buffer flushed as one `CMSG_GUILD_RANK`. The
/// rank is carried **0-based**, converted here once, because that is the base the wire uses.
/// `GuildControlSaveRank(name)` as one `CMSG_GUILD_RANK`; the rank is 0-based, as on the wire.
SaveRank {
rank_index: u32,
rights: u32,
@@ -319,23 +207,21 @@ pub enum GuildRequest {
},
/// `GuildControlAddRank(name)`.
AddRank(String),
/// `GuildControlDelRank()` — delete the last rank. **Carries nothing**: the binding reads no
/// argument and the packet (`0x233`) is body-less; the server infers which rank.
/// `GuildControlDelRank()`: no argument and an empty `0x233`; the server deletes the last rank.
DelRank,
/// `SetGuildRosterSelection(index)` — mirrored into the app so the next push agrees.
/// `SetGuildRosterSelection(index)`, mirrored so the next push agrees.
Select(u32),
/// `SetGuildRosterShowOffline(flag)` — the app re-filters and pushes back.
/// `SetGuildRosterShowOffline(flag)`, mirrored so the next push agrees.
SetShowOffline(bool),
/// `SortGuildRoster(field)` — one of `"name"`, `"zone"`, `"level"`, `"class"`, `"rank"`,
/// `"note"`, `"online"` (the seven the reference's two column sets set as `this.sortType`,
/// `FriendsFrame.xml:1895-1940` and `:2086-2131`). Sorting is client-side; the app re-orders
/// its own roster and pushes it back.
/// `SortGuildRoster(field)`: `"name"`, `"zone"`, `"level"`, `"class"`, `"rank"`, `"note"` or
/// `"online"`, the headers' `sortType`s (`FriendsFrame.xml:1895-1940`, `:2086-2131`). Sorting
/// is client-side: the app re-orders its roster and pushes it back.
Sort(String),
}
impl super::UiScript {
/// Push the guild snapshot, replacing whatever was there. A bare setter — firing
/// `GUILD_ROSTER_UPDATE`/`PLAYER_GUILD_UPDATE`/`GUILD_MOTD` on the edges is the app's job.
/// Push the guild snapshot, replacing it. The app fires `GUILD_ROSTER_UPDATE`,
/// `PLAYER_GUILD_UPDATE` and `GUILD_MOTD` on the edges.
pub fn set_guild(&mut self, state: GuildState) {
self.model_mut().guild = state;
}
@@ -345,16 +231,14 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().guild_requests)
}
/// Queue an intent from the app side — the slash commands (`/ginvite`, `/gquit`, `/gpromote`,
/// …). In the reference these ARE Lua (`SlashCmdList["GUILDINVITE"]` calls
/// `GuildInviteByName`); benilla parses slash lines in Rust, so the same intents enter the
/// same queue here. [`super::social::SocialRequest`]'s `queue_social_request` twin.
/// Queue an intent from a slash command (`/ginvite`, `/gquit`, `/gpromote`, ...). The
/// reference's slash commands are Lua calling the same bindings; benilla parses them in Rust.
pub fn queue_guild_request(&mut self, request: GuildRequest) {
self.model_mut().guild_requests.push(request);
}
}
/// A roster row by 1-based display index, or `None` past the end.
/// A roster row by 1-based index.
fn member_at(roster: &[GuildMemberInfo], index: i64) -> Option<&GuildMemberInfo> {
usize::try_from(index)
.ok()
@@ -362,9 +246,9 @@ fn member_at(roster: &[GuildMemberInfo], index: i64) -> Option<&GuildMemberInfo>
.and_then(|i| roster.get(i))
}
/// The Era truthiness coercion for a flag argument — `nil`, `false` and `0` are off, everything
/// else is on. [`super::social`]'s `SetWhoToUI` does the same, and for the same reason: the
/// FrameXML passes a checkbox's `GetChecked()`, which is `1`/`nil`, but addons pass booleans.
/// A flag argument: `nil`, `false` and `0` are off, anything else on. The reference reads both
/// flags with `GetBoolOrDefault` (`0x6f1c10`, `binding_abi::bool_or_default`), which differs on
/// strings and fractions; the FrameXML's checkbox states, `1` or `nil`, read the same in both.
fn truthy(value: &Value) -> bool {
match value {
Value::Nil | Value::Boolean(false) => false,
@@ -374,7 +258,8 @@ fn truthy(value: &Value) -> bool {
}
}
/// The Era boolean: `1` for true, `nil` for false. See the module doc — this is not `Boolean`.
/// The 1.12 boolean, `1` or `nil`: the FrameXML stores what it tests, so a `false` would survive
/// where the reference's `nil` does not.
fn era_bool(on: bool) -> Value {
if on {
Value::Integer(1)
@@ -383,17 +268,13 @@ fn era_bool(on: bool) -> Value {
}
}
/// Whether the player's own rank holds the permission at `index` (1-based into
/// [`RANK_RIGHT_BITS`]).
/// Whether the player's own rank holds right `index`, 1-based into [`RANK_RIGHT_BITS`].
fn has_right(model: &Model, index: usize) -> bool {
model.guild.in_guild && model.guild.rights & RANK_RIGHT_BITS[index - 1] != 0
}
/// The **live** rights word of a 1-based rank, `0` for "no rank loaded" or out of range.
///
/// The rank editor reads live rights, never its own staging buffer — see
/// `GuildControlGetRankFlags`. Factored out because `GuildControlSaveRank` needs the same value as
/// its fold baseline, and the two reading different things is exactly how a save would drop bits.
/// The live rights of a 1-based rank, 0 for none or out of range. `GuildControlGetRankFlags` and
/// `GuildControlSaveRank`'s fold both read this, never the staging buffer.
fn live_rights(model: &Model, rank_one_based: u32) -> u32 {
(rank_one_based as usize)
.checked_sub(1)
@@ -403,11 +284,9 @@ fn live_rights(model: &Model, rank_one_based: u32) -> u32 {
}
/// The rank-name gate `GuildControlSaveRank` (`0x4d20d0`) and `GuildControlAddRank` (`0x4d2210`)
/// both run before any packet, byte-for-byte the same: the C string (so up to the first NUL) must
/// be non-empty and `0x65b250(name) - 1` — its UTF-16 code-unit count — must lie in `1..=16`.
/// Nothing else: no trim, so a whitespace name passes. The name the gate measured is the name
/// sent. vmangos's own cap is 15 code points and it kicks over it; that server-side limit is the
/// app's to enforce at the send (`ui_guild::feed::capped_rank_name`), not the binding's.
/// run before any packet: the name up to the first NUL must be 1 to 16 UTF-16 code units
/// (`0x65b250`), untrimmed, and is sent as measured. vmangos kicks a name over 15 code points; the
/// app caps that at the send (`ui_guild::feed::capped_rank_name`).
fn gated_rank_name(name: &str) -> Option<&str> {
let c_name = name.split('\0').next().unwrap_or_default();
(1..=16)
@@ -415,13 +294,13 @@ fn gated_rank_name(name: &str) -> Option<&str> {
.then_some(c_name)
}
/// Register the guild globals against the snapshot store.
/// Register the guild globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// ── Membership and identity ──────────────────────────────────────────────────────────────
// IsInGuild() (`0x516de0`) — reads the player's own PLAYER_GUILDID, so it is answerable
// before any roster arrives. `InGuildCheck` gates the whole tab on it.
// IsInGuild() (`0x516de0`) reads the player's own `PLAYER_GUILDID`, so it answers before any
// roster arrives.
g.set(
"IsInGuild",
lua.create_function(|lua, ()| {
@@ -430,8 +309,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// IsGuildLeader() (`0x516e40`) — takes no argument; it reports whether the PLAYER is the
// guild master, which is rank 0.
// IsGuildLeader() (`0x516e40`) takes no argument: whether the player is the guild master.
g.set(
"IsGuildLeader",
lua.create_function(|lua, ()| {
@@ -440,30 +318,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetGuildInfo(unit) → guildName, rankName, rankIndex (`0x4c9330`). Every call site in 1.12's
// FrameXML passes "player" (`FriendsFrame.lua:114`, `:337`, `PaperDollFrame.lua:117`,
// `TabardFrame.lua:96`, `UnitPopup.lua:575`), but the binding is per-unit and the wire
// supports it: PLAYER_GUILDID (191) and PLAYER_GUILDRANK (192) are PUBLIC descriptor fields,
// and the query response's fixed ten rank names exist precisely so another unit's rank field
// can be turned into a rank name. So it reads the unit snapshot, not the player's guild.
//
// A guildless unit answers `nil, nil, 0` — three values, and the third is the NUMBER zero, not
// nil. The binding pushes 3 on both legs (`mov eax,3`), so a caller destructuring the third
// gets `0`. Returning nil there would be a different contract.
//
// **RECOGNISED and UNRESOLVABLE are two different things**, and this binding conflated them
// until the stock character sheet came off the chain (decision 1751). It resolves its argument
// through the GENERAL unit-token resolver `0x515940`, so an *unrecognised* token raises via the
// L-less `luaL_error 0x7040e0` at `0x515c1a` — but "a recognised-but-unresolvable token, a
// non-player, a guildless player and a not-yet-arrived cache record ALL return (nil, nil, 0)"
// (`0x4c9330`).
//
// Ours raised on any token the model held no unit for, which includes `"player"` before the
// app has pushed the first snapshot. Stock `PaperDollFrame_SetGuild` calls
// `GetGuildInfo("player")` UNGUARDED out of `PaperDollFrame_OnShow` (`PaperDollFrame.lua:117`,
// reached from `:569`), so opening the character window in the frames before that push raised
// where the reference prints an empty guild line. [`check_unit_token`] is the resolver's own
// three-way split and every other unit binding already goes through it.
// GetGuildInfo(unit) → guildName, rankName, rankIndex (`0x4c9330`), per unit because the rank
// fields are public: it reads the unit snapshot, not the player's guild. The general resolver
// (`0x515940`) raises on an unrecognised token (`0x515c1a`); a recognised but unresolvable
// token, a non-player, a guildless player and an unarrived guild record all answer
// `nil, nil, 0`, the third the number 0 (`mov eax,3` on both legs). Stock
// `PaperDollFrame_SetGuild` calls it for `"player"` unguarded (`PaperDollFrame.lua:117`).
g.set(
"GetGuildInfo",
lua.create_function(|lua, unit: Option<String>| {
@@ -484,9 +344,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ── The roster ───────────────────────────────────────────────────────────────────────────
// GetNumGuildMembers() (`0x4d1190`) — the loop bound `GuildStatus_Update` iterates to
// (`FriendsFrame.lua:446`), NOT the addressable range. It respects show-offline;
// GetGuildRosterInfo does not. See `GuildState::num_members` for why the two differ.
// GetNumGuildMembers() (`0x4d1190`): the loop bound (`FriendsFrame.lua:446`), which respects
// show-offline, not the addressable range (`GuildState::num_members`).
g.set(
"GetNumGuildMembers",
lua.create_function(|lua, ()| {
@@ -496,17 +355,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetGuildRosterInfo(index) → name, rank, rankIndex, level, class, zone, note, officernote,
// online, status (`0x4d1200`). TEN returns, in that order.
//
// The tenth is easy to miss and expensive to miss: six of the reference's seven call sites
// take only nine (`FriendsFrame.lua:344`, `:447`, `:479`, `:709`, `StaticPopup.lua:1008`,
// `:1045`), and only the player-status view takes `status` (`:541`) to choose between the
// "Online" label and the `<AFK>`/`<DND>` tag (`:548-551`). Answering nine would leave that one
// column permanently blank while every other caller looked correct.
//
// An out-of-range index answers all-nils rather than erroring: the reference calls it with
// `GetGuildRosterSelection()`, which is 0 when nothing is selected, on every single
// `GuildStatus_Update` (`:344`) and only afterwards checks `> 0`.
// online, status (`0x4d1200`). Ten returns: only the status view takes the tenth
// (`FriendsFrame.lua:541`), every other call site nine. Out of range answers ten nils rather
// than raising: `GuildStatus_Update` passes the selection, 0 when none, before testing it
// (`FriendsFrame.lua:344`, `:347`).
g.set(
"GetGuildRosterInfo",
lua.create_function(|lua, index: i64| {
@@ -529,10 +381,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetGuildRosterLastOnline(index) → years, months, days, hours (`0x4d14a0`). The reference
// reads them largest-first and stops at the first non-zero (`GuildFrame_GetLastOnline`,
// `FriendsFrame.lua:957-978`), and treats nil exactly as 0, so an online member — for whom
// the wire sends no value at all — correctly falls through to "online now".
// GetGuildRosterLastOnline(index) → years, months, days, hours (`0x4d14a0`), all 0 for an
// online member, whom the wire sends no time for.
g.set(
"GetGuildRosterLastOnline",
lua.create_function(|lua, index: i64| {
@@ -550,7 +400,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetGuildRosterMOTD() / GetGuildInfoText() — the two free-text blocks the roster carries.
// GetGuildRosterMOTD() / GetGuildInfoText(): the two free-text blocks the roster carries.
g.set(
"GetGuildRosterMOTD",
lua.create_function(|lua, ()| {
@@ -566,11 +416,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetGuildRosterSelection() (`0x4d1890`) / SetGuildRosterSelection(index) (`0x4d1820`).
// 1-based, 0 = nothing selected. The setter mutates the snapshot in place *and* queues the
// intent, for [`super::social`]'s stated reason: the same Lua tick reads the new value back —
// `GuildStatus_Update` calls `GetGuildRosterSelection()` four times in one pass — and the
// app's own state has to follow so the next push agrees.
// GetGuildRosterSelection() (`0x4d1890`) / SetGuildRosterSelection(index) (`0x4d1820`):
// 1-based, 0 for none. The setter writes the snapshot and queues the intent: the same Lua pass
// reads it back, and the app's state must follow so the next push agrees.
g.set(
"GetGuildRosterSelection",
lua.create_function(|lua, ()| {
@@ -590,8 +438,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetGuildRosterShowOffline() (`0x4d1e30`) / SetGuildRosterShowOffline(flag). Same
// mutate-and-mirror shape: the checkbox reads its own state back through the getter.
// GetGuildRosterShowOffline() (`0x4d1e30`) / SetGuildRosterShowOffline(flag): written in
// place and mirrored, as the selection is.
g.set(
"GetGuildRosterShowOffline",
lua.create_function(|lua, ()| {
@@ -599,10 +447,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(era_bool(model.guild.show_offline))
})?,
)?;
//
// **Called with NO argument it defaults to TRUE**, not false — verified, and the opposite of
// what `truthy(nil)` would give. So an absent argument and an explicit `nil` differ here, and
// the binding must be able to tell them apart: hence `MultiValue` rather than `Value`.
// With no argument it turns show-offline on, unlike an explicit `nil`, hence `MultiValue`.
g.set(
"SetGuildRosterShowOffline",
lua.create_function(|lua, args: MultiValue| {
@@ -617,11 +462,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SortGuildRoster(field) (`0x4d1cb0`) — the column headers' only action: their OnClick is
// `if (this.sortType) then SortGuildRoster(this.sortType) end` and nothing else
// (`FriendsFrame.xml:442-446`). No GuildStatus_Update, no GuildRoster — so the engine both
// re-orders and fires GUILD_ROSTER_UPDATE itself, and plainly must not re-request the roster
// from the server because a user clicked a header. The app does both on the drain.
// SortGuildRoster(field) (`0x4d1cb0`): the header's OnClick calls no `GuildStatus_Update` and
// no `GuildRoster` (`FriendsFrame.xml:442-446`), so the app re-orders and fires
// `GUILD_ROSTER_UPDATE` on the drain, without asking the server.
g.set(
"SortGuildRoster",
lua.create_function(|lua, field: String| {
@@ -631,14 +474,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// CloseGuildRoster() is `xor eax,eax; ret` in the real client — a TOTAL no-op that sends
// nothing and touches nothing. It exists as a symbol and does not do anything. Wiring it to a
// packet would invent traffic 1.12 never sends, so it is registered and does nothing here too.
// CloseGuildRoster() is `xor eax,eax; ret` in the reference: a no-op that sends nothing.
g.set("CloseGuildRoster", lua.create_function(|_, ()| Ok(()))?)?;
// GuildRoster() — ask the server for a fresh roster. Note the app throttles this to one
// request per 10 s, as the reference does, and the `GUILD_ROSTER_UPDATE` arg1 path clears that
// throttle so the FrameXML's own response request is never the one swallowed.
// The argument-less verbs; the app throttles `GuildRoster()` (`GuildRequest::Roster`).
for (global, request) in [
("GuildRoster", GuildRequest::Roster),
("GuildInfo", GuildRequest::Info),
@@ -658,10 +497,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
// ── The by-name verbs ────────────────────────────────────────────────────────────────────
// Five globals of one shape (`0x48aef0`, `0x48afb0`, `0x48b050`, `0x48b0f0`, `0x48b190`): take
// a character name, queue the matching CMSG. An empty name is dropped rather than sent — the
// server answers a blank lookup with nothing at all, so it would look like a hang
// ([`super::social`]'s `AddFriend` note).
// Five name-taking verbs (`0x48aef0`, `0x48afb0`, `0x48b050`, `0x48b0f0`, `0x48b190`). An empty
// or whitespace name is not sent. vmangos ignores an empty one without a reply, except an
// invite, which it answers with `ERR_GUILD_PLAYER_NOT_FOUND_S`
// (`Handlers/GuildHandler.cpp:77-83`).
for (global, make) in [
("GuildInviteByName", GuildRequest::Invite as fn(String) -> _),
(
@@ -690,9 +529,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
// GuildSetMOTD(text) (`0x48b270`) / SetGuildInfoText(text) (`0x4d2380`). Unlike the by-name
// verbs, an EMPTY string is a real value here: it clears the block, and the wire has an
// explicit empty-body case for it. So these do not drop empties.
// GuildSetMOTD(text) (`0x48b270`) / SetGuildInfoText(text) (`0x4d2380`): an empty string is
// sent, and clears the text.
for (global, make) in [
("GuildSetMOTD", GuildRequest::SetMotd as fn(String) -> _),
(
@@ -710,9 +548,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
}
// GuildRosterSetPublicNote(index, note) (`0x4d15e0`) / ...SetOfficerNote (`0x4d1700`) — the
// note dialogs address the roster ROW, not a name; the app resolves the row to the name the
// wire carries, because only it knows the display order the index refers to.
// GuildRosterSetPublicNote(index, note) (`0x4d15e0`) / GuildRosterSetOfficerNote (`0x4d1700`)
// take a roster row, which the app resolves to the name the wire carries.
for (global, make) in [
(
"GuildRosterSetPublicNote",
@@ -739,10 +576,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
// ── The permission predicates ────────────────────────────────────────────────────────────
// Nine one-line globals, each testing one bit of the player's own rank's rights word
// (`0x4d18c0`, `0x4d1930`, `0x4d19a0`, `0x4d1a10`, `0x4d1c40` and the four whose names lack
// "Guild"). CanEditMOTD/CanEditPublicNote/CanViewOfficerNote/CanEditOfficerNote are called at
// `FriendsFrame.lua:427`, `:359`, `:370`, `:371`.
// Nine predicates, each one bit of the player's own rank's rights (`0x4d18c0`, `0x4d1930`,
// `0x4d19a0`, `0x4d1a10`, `0x4d1c40` among them).
for (global, index) in [
("CanGuildPromote", right::PROMOTE),
("CanGuildDemote", right::DEMOTE),
@@ -764,9 +599,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
// ── The rank-control popup ───────────────────────────────────────────────────────────────
// GuildControlGetNumRanks() (`0x4d1e60`) — the guild's real rank count, which is what
// `maxRankIndex = GuildControlGetNumRanks() - 1` turns into the 0-based bottom rank
// (`FriendsFrame.lua:338`).
// GuildControlGetNumRanks() (`0x4d1e60`): the guild's rank count.
g.set(
"GuildControlGetNumRanks",
lua.create_function(|lua, ()| {
@@ -775,8 +608,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GuildControlGetRankName(index) (`0x4d1e90`) — ONE-BASED, straight off the dropdown's ID
// (`FriendsFrame.lua:857`, `:868`, `:895`, `:899`). See the module doc on the two bases.
// GuildControlGetRankName(index) (`0x4d1e90`): 1-based, the dropdown's ID.
g.set(
"GuildControlGetRankName",
lua.create_function(|lua, index: i64| {
@@ -791,14 +623,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GuildControlSetRank(index) (`0x4d1fa0`) — load a rank into the staging buffer. ONE-based;
// it stores `index - 1` internally and seeds each of the thirteen staged slots from that
// rank's live rights.
//
// Passing 0 computes -1 and **silently clears the staged selection** — verified, and the
// reason this is `checked_sub` rather than a clamp: a caller that handed a raw 0-based roster
// `rankIndex` straight in (the standing trap, see the module doc) gets nothing loaded rather
// than rank 1 quietly loaded in its place.
// GuildControlSetRank(index) (`0x4d1fa0`): load a 1-based rank into the staging buffer, each
// slot seeded from its live rights. 0 clears the selection rather than loading rank 1, so a
// 0-based `rankIndex` passed by mistake loads nothing.
g.set(
"GuildControlSetRank",
lua.create_function(|lua, index: i64| {
@@ -814,20 +641,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GuildControlGetRankFlags() (`0x4d1fe0`) — THIRTEEN returns, one per checkbox, in
// RANK_RIGHT_BITS order. `GuildControlCheckboxUpdate` loops `for i=1, arg.n` over them
// (`FriendsFrame.lua:874-884`), so both the count and the order are load-bearing.
//
// It is called at `GuildControlPopupFrame_OnLoad` (`:801`) — BEFORE `_OnShow` ever calls
// `GuildControlSetRank`, so it must answer thirteen well-formed values with nothing loaded.
// An unloaded buffer reads as all-off, which leaves every checkbox unchecked: the same thing
// the reference shows on a popup that has not selected a rank yet.
//
// **It reads the LIVE rank's rights, NOT the staging buffer** — verified. A checkbox click
// stages a change that is invisible through this getter until `GuildControlSaveRank`
// round-trips through the server and a fresh roster lands. Reading the buffer here would look
// more responsive and would be a different client: the reference's checkboxes genuinely do
// not "stick" until the save comes back.
// GuildControlGetRankFlags() (`0x4d1fe0`): thirteen returns in `RANK_RIGHT_BITS` order, from
// the loaded rank's live rights, never the staging buffer, so a click shows only once the save
// round-trips and a new roster lands. `GuildControlPopupFrame_OnLoad` calls it before any rank
// is loaded (`FriendsFrame.lua:801`), when all thirteen are nil.
g.set(
"GuildControlGetRankFlags",
lua.create_function(|lua, ()| {
@@ -842,9 +659,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GuildControlSetRankFlag(index, enabled) (`0x4d2070`) — toggle one bit in the staging
// buffer. The index is the checkbox's, 1..13, and RANK_RIGHT_BITS maps it to the bit. This is
// the mapping that a `1 << (index - 1)` would get wrong for nine of the thirteen.
// GuildControlSetRankFlag(index, enabled) (`0x4d2070`): stage one checkbox, 1 to 13.
g.set(
"GuildControlSetRankFlag",
lua.create_function(|lua, (index, enabled): (i64, Value)| {
@@ -861,21 +676,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GuildControlSaveRank(name) (`0x4d20d0`) — flush the staged edits as one CMSG_GUILD_RANK.
// The argument is the rank's (possibly renamed) NAME, taken from the popup's edit box
// (`FriendsFrame.lua:839`); the rank it applies to is whichever GuildControlSetRank loaded.
// Converted to the wire's 0-based rank id here, once. A name that fails
// [`gated_rank_name`] — empty above all, which vmangos would otherwise persist as the rank's
// name guild-wide — is a silent no-op, before any packet.
//
// **The fold is a masked set-or-clear over a freshly re-read LIVE baseline** — verified: the
// binding does `or mask[k]` / `and ~mask[k]` per flag against the current rights, never a
// plain OR of a staged word. Two consequences, both of which a naive "send the staged word"
// implementation gets wrong:
// · a plain OR could never turn a permission OFF, only on;
// · bits outside the thirteen — notably `0x40`, which is tested NOWHERE in the image and is
// a genuine gap rather than an always-set flag — must ride through untouched from whatever
// the server last sent. Sending the staged word wholesale would clear them.
// GuildControlSaveRank(name) (`0x4d20d0`): the edit box's name (`FriendsFrame.lua:839`) and the
// staged flags as one `CMSG_GUILD_RANK` for the loaded rank. A name failing `gated_rank_name`
// is a silent no-op; vmangos stores an empty one (`Handlers/GuildHandler.cpp:586`). The fold
// sets or clears each staged bit over the freshly read live rights (`or mask[k]` /
// `and ~mask[k]`), so a right can be turned off and bits outside the thirteen, such as `0x40`,
// which the reference never tests, ride through as the server sent them.
g.set(
"GuildControlSaveRank",
lua.create_function(|lua, name: String| {
@@ -884,7 +690,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let edit = model.guild_control.clone();
// `[0x84966c] >= [0xb73120]`: no rank loaded (the reference's -1, our 0) or one at or
// The reference's `[0x84966c] >= [0xb73120]`: no rank loaded (its -1, our 0) or one
// past the rank count.
if edit.rank >= 1 && (edit.rank as usize) <= model.guild.ranks.len() {
let mut rights = live_rights(&model, edit.rank);
@@ -905,10 +711,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GuildControlAddRank(name) (`0x4d2210`) — append a rank, behind the same silent name gate
// as SaveRank ([`gated_rank_name`]). The client refuses at
// `numRanks >= MAX_RANKS` **silently**, before any packet, which is why the reference's Add
// button greys itself at ten (`FriendsFrame.lua:887`) rather than relying on a refusal.
// GuildControlAddRank(name) (`0x4d2210`): the same name gate, and a silent refusal at
// `MAX_RANKS`, both before any packet.
g.set(
"GuildControlAddRank",
lua.create_function(|lua, name: String| {
@@ -923,15 +727,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GuildControlDelRank() (`0x4d22e0`) — **reads no Lua argument at all**, and its packet
// (`0x233`) is body-less: the server deletes the LAST rank and infers which that is. The
// reference passes a name (`FriendsFrame.lua:895`) and the binding discards it, which is why
// the reference only offers the button when the selected rank IS the last (`:908`).
//
// Accepting-and-ignoring is the faithful shape. Taking the name and sending it would invent a
// wire field; refusing to compile against the reference's call would be worse.
//
// The client also refuses at `numRanks <= MIN_RANKS`, silently, before any packet.
// GuildControlDelRank() (`0x4d22e0`) reads no argument and sends an empty `0x233`: the server
// deletes the last rank. The FrameXML passes a name (`FriendsFrame.lua:895`), which is ignored.
// It refuses silently at `MIN_RANKS`, before any packet.
g.set(
"GuildControlDelRank",
lua.create_function(|lua, _ignored: MultiValue| {
@@ -950,37 +748,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
mod tests {
use super::*;
/// **`GetGuildInfo` splits three ways, not two** — `0x4c9330`: only an *unrecognised* token
/// raises; a **recognised** token that resolves to nothing answers `(nil, nil, 0)` like a
/// guildless player does.
///
/// The distinction is not academic. Stock `PaperDollFrame_SetGuild` calls
/// `GetGuildInfo("player")` unguarded from `PaperDollFrame_OnShow`, so before this split
/// existed, opening the character window in the frames before the app pushed its first player
/// snapshot raised — found by putting the reference's own `PaperDollFrame.xml` on the chain
/// (decision 1751). `"player"` with no unit behind it is the whole test.
/// `0x4c9330`: only an unrecognised token raises; `"player"` with no unit behind it, the paper
/// doll's case at world entry, answers `nil, nil, 0`.
#[test]
fn a_recognised_but_unresolved_unit_answers_nils_and_only_an_unknown_token_raises() {
let s = crate::script::UiScript::new().unwrap();
// Recognised, resolves to nothing — the paper doll's own case at world entry.
let (name, rank, idx): (Option<String>, Option<String>, i64) = s
.eval(r#"return GetGuildInfo("player")"#)
.expect("a recognised token must not raise");
assert_eq!((name, rank, idx), (None, None, 0));
// …and the third value is the NUMBER zero on that leg, not nil (`mov eax,3` on both).
// The third value is the number 0, not nil (`mov eax,3` on both legs).
assert!(s
.eval::<bool>(r#"local a,b,c = GetGuildInfo("party3") return c == 0"#)
.unwrap());
// Unrecognised: the resolver's own raise, unchanged.
assert!(s
.eval::<i64>(r#"return GetGuildInfo("nosuchunit")"#)
.is_err());
}
/// The thirteen-bit table is the API contract, and its non-monotonicity is the trap: Promote
/// and Demote sit at indices 5 and 6 with bits ABOVE Invite and Remove at 7 and 8. A
/// `1 << (i - 1)` implementation passes indices 1-4 and fails the other nine, so this asserts
/// the whole table rather than a property of it.
#[test]
fn the_rank_right_table_is_not_a_shift() {
assert_eq!(RANK_RIGHT_BITS.len(), 13, "one bit per GUILDCONTROL_OPTION");
@@ -1003,15 +788,8 @@ mod tests {
}
}
/// **`SaveRank` folds over the live baseline, so bits outside the thirteen survive.**
///
/// `0x40` is tested nowhere in the 1.12 image — it is a genuine gap in the bit space, not an
/// always-set flag — and `SaveRank`'s fold neither sets nor clears it, so whatever the server
/// last sent rides through. An implementation that sent the staged word wholesale would clear
/// it (and any other unknown bit) on the first rank edit, silently, forever.
///
/// The fold must also be able to turn a permission OFF, which a plain `OR` of a staged word
/// cannot.
/// `0x40`, vmangos's `GR_RIGHT_EMPTY`, is none of the thirteen and never tested by the
/// reference.
#[test]
fn saving_a_rank_preserves_bits_outside_the_thirteen_and_can_clear() {
// Live: Invite (0x10) + Set MOTD (0x1000), plus the untouchable 0x40.
@@ -1043,7 +821,6 @@ mod tests {
);
}
/// A guild with `n` named ranks.
fn guild_with_ranks(n: usize) -> crate::script::UiScript {
let mut s = crate::script::UiScript::new().unwrap();
s.set_guild(GuildState {
@@ -1059,10 +836,8 @@ mod tests {
s
}
/// **`0x4d20d0` refuses a bad name silently, before any packet** — NULL, empty, or a length
/// outside 1..16 UTF-16 code units (`0x65b250` counts units plus the terminator), and a rank
/// index at or past the rank count. vmangos persists whatever name arrives — an empty one
/// blanks the rank guild-wide — so this gate is the only thing standing in the way.
/// `0x4d20d0` refuses silently: a name outside 1 to 16 UTF-16 units (`0x65b250` counts them
/// plus the NUL), no rank loaded, or a rank past the count.
#[test]
fn saving_a_rank_refuses_what_the_reference_refuses() {
let mut s = guild_with_ranks(5);
@@ -1093,8 +868,8 @@ mod tests {
.unwrap();
assert_eq!(saves(&mut s), 1);
// No rank loaded (a fresh popup — the selection persists across saves, as
// `[0x84966c]` does), or one past the ladder.
// No rank loaded in a fresh popup (the selection persists across saves, as `[0x84966c]`
// does), or one past the count.
let mut fresh = guild_with_ranks(5);
fresh.run(r#"GuildControlSaveRank("Officer")"#).unwrap();
assert_eq!(saves(&mut fresh), 0, "nothing selected");
@@ -1103,8 +878,7 @@ mod tests {
assert_eq!(saves(&mut s), 0, "rank index 5 >= 5 ranks");
}
/// `0x4d2210` runs the same name gate as `SaveRank` — and nothing more: a whitespace name is
/// not empty to it.
/// `0x4d2210` runs the same name gate; a whitespace name passes it.
#[test]
fn adding_a_rank_runs_the_same_name_gate() {
let mut s = guild_with_ranks(5);
@@ -1115,8 +889,7 @@ mod tests {
assert_eq!(s.take_guild_requests().len(), 1);
}
/// A 1-based index past the end, and the 0 the reference passes on every `GuildStatus_Update`
/// before it checks the selection, both have to answer `None` rather than panic.
/// `GuildStatus_Update` passes 0 before it tests the selection.
#[test]
fn member_lookup_is_one_based_and_tolerates_zero() {
let roster = vec![