Files
benilla-octo/crates/benilla-ui/src/script/guild.rs
T
SamandClaude Opus 5.5 ef0fcc327c a Vanish clears your target at once, and a painted road covers the grass
- a Vanish, a stealth or a unit leaving range clears your target at once, not two seconds later, and TAB reaches a mob behind you (2345)
- a fully painted terrain layer covers fully: roads and rock no longer let about 6% of the grass underneath show through (2345)
- companion pets show their names, the saved camera pitch survives a login, and a missed resurrect offer no longer blocks later ones (2345)
- ESC on a quest giver closes it, browsing the auction house by category searches the right items, and abandon removes the quest you chose (2345)
- a profession window open at logout no longer reopens for the next character (2345)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:22:10 +07:00

1143 lines
56 KiB
Rust

//! The guild **Era API surface** — roster, ranks, notes, MOTD, and the membership verbs
//! (decision 1257).
//!
//! [`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.
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 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.
pub const RANK_RIGHT_BITS: [u32; 13] = [
0x0000_0001, // 1 Guildchat Listen
0x0000_0002, // 2 Guildchat Speak
0x0000_0004, // 3 Officerchat Listen
0x0000_0008, // 4 Officerchat Speak
0x0000_0080, // 5 Promote
0x0000_0100, // 6 Demote
0x0000_0010, // 7 Invite Member
0x0000_0020, // 8 Remove Member
0x0000_1000, // 9 Set MOTD
0x0000_2000, // 10 Edit Public Note
0x0000_4000, // 11 View Officer Note
0x0000_8000, // 12 Edit Officer Note
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.
mod right {
pub(super) const PROMOTE: usize = 5;
pub(super) const DEMOTE: usize = 6;
pub(super) const INVITE: usize = 7;
pub(super) const REMOVE: usize = 8;
pub(super) const SET_MOTD: usize = 9;
pub(super) const EDIT_PUBLIC_NOTE: usize = 10;
pub(super) const VIEW_OFFICER_NOTE: usize = 11;
pub(super) const EDIT_OFFICER_NOTE: usize = 12;
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`).
pub const MIN_RANKS: usize = 5;
/// See [`MIN_RANKS`].
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.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct LastOnline {
pub years: u32,
pub months: u32,
pub days: u32,
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.
#[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.
pub name: String,
/// The unit's rank name within that guild.
pub rank_name: String,
/// The unit's rank, **0-based**, `0` = guild master.
pub rank_index: u32,
}
/// One guild rank: its name (from the query response) and its rights word (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.
pub name: String,
/// The rank's permission bits — test 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`).
#[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.
pub name: String,
/// The member's rank *name*, e.g. "Officer" — `GetGuildRosterInfo`'s second return.
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.
pub rank_index: u32,
pub level: u32,
/// Localized class name ("Warrior"). Populated for offline members too.
pub class: String,
/// Zone name ("Elwynn Forest"), 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).
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.
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.
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.
#[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.
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.
pub name: String,
/// The player's own rank name.
pub rank_name: String,
/// The player's own rank, **0-based**, `0` = guild master.
pub rank_index: u32,
/// Whether the player is the guild master — `IsGuildLeader`.
pub is_leader: bool,
/// The player's own rank's rights word, which every `Can*` predicate tests.
pub rights: u32,
/// The message of the day.
pub motd: String,
/// The guild info text (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.
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** (wow-re
/// `system/ui/scratch/guild-api-carve.md`, correcting our claim A4).
///
/// 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.
pub num_members: usize,
/// Every rank, index 0 = guild master. Length is the guild's real rank count, which is what
/// `GuildControlGetNumRanks` reports.
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`.
pub selection: u32,
/// Whether the UI is showing offline members. Affects [`Self::num_members`] only — never
/// [`Self::roster`]'s contents.
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.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GuildRankEdit {
/// The **1-based** rank `GuildControlSetRank` last loaded; `0` = none loaded yet.
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.
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.
#[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.
Roster,
/// `GuildInfo()` — the `/ginfo` query (`CMSG_GUILD_INFO`), whose answer is a chat line.
Info,
/// `GuildInviteByName(name)`.
Invite(String),
/// `GuildUninviteByName(name)` — remove a member (`CMSG_GUILD_REMOVE`).
Uninvite(String),
/// `GuildPromoteByName(name)`.
Promote(String),
/// `GuildDemoteByName(name)`.
Demote(String),
/// `GuildSetLeaderByName(name)` — hand over the guild.
SetLeader(String),
/// `AcceptGuild()` — the `GUILD_INVITE` popup's Accept.
Accept,
/// `DeclineGuild()` — its Decline.
Decline,
/// `GuildLeave()`.
Leave,
/// `GuildDisband()`.
Disband,
/// `GuildSetMOTD(text)`. An empty string is a real value meaning "clear 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.
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.
SaveRank {
rank_index: u32,
rights: u32,
name: String,
},
/// `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.
DelRank,
/// `SetGuildRosterSelection(index)` — mirrored into the app so the next push agrees.
Select(u32),
/// `SetGuildRosterShowOffline(flag)` — the app re-filters and pushes back.
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.
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.
pub fn set_guild(&mut self, state: GuildState) {
self.model_mut().guild = state;
}
/// Drain the guild intents queued since the last call.
pub fn take_guild_requests(&mut self) -> Vec<GuildRequest> {
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.
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.
fn member_at(roster: &[GuildMemberInfo], index: i64) -> Option<&GuildMemberInfo> {
usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.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.
fn truthy(value: &Value) -> bool {
match value {
Value::Nil | Value::Boolean(false) => false,
Value::Integer(0) => false,
Value::Number(n) => *n != 0.0,
_ => true,
}
}
/// The Era boolean: `1` for true, `nil` for false. See the module doc — this is not `Boolean`.
fn era_bool(on: bool) -> Value {
if on {
Value::Integer(1)
} else {
Value::Nil
}
}
/// Whether the player's own rank holds the permission at `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.
fn live_rights(model: &Model, rank_one_based: u32) -> u32 {
(rank_one_based as usize)
.checked_sub(1)
.and_then(|i| model.guild.ranks.get(i))
.map(|r| r.rights)
.unwrap_or(0)
}
/// 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.
fn gated_rank_name(name: &str) -> Option<&str> {
let c_name = name.split('\0').next().unwrap_or_default();
(1..=16)
.contains(&c_name.encode_utf16().count())
.then_some(c_name)
}
/// Register the guild globals against the snapshot store.
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.
g.set(
"IsInGuild",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(era_bool(model.guild.in_guild))
})?,
)?;
// IsGuildLeader() (`0x516e40`) — takes no argument; it reports whether the PLAYER is the
// guild master, which is rank 0.
g.set(
"IsGuildLeader",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(era_bool(model.guild.is_leader))
})?,
)?;
// 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)"
// (wow-re `system/ui` ledger, `0x4c9330`, VERIFIED, `scratch/guild-api-carve.md`).
//
// 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.
g.set(
"GetGuildInfo",
lua.create_function(|lua, unit: Option<String>| {
crate::script::unit::check_unit_token(&unit)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(state) = unit.as_deref().and_then(|u| model.unit(u)) else {
return Ok((Value::Nil, Value::Nil, Value::Integer(0)));
};
let Some(guild) = state.guild.as_ref() else {
return Ok((Value::Nil, Value::Nil, Value::Integer(0)));
};
Ok((
Value::String(lua.create_string(&guild.name)?),
Value::String(lua.create_string(&guild.rank_name)?),
Value::Integer(i64::from(guild.rank_index)),
))
})?,
)?;
// ── 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.
g.set(
"GetNumGuildMembers",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.guild.num_members as i64)
})?,
)?;
// 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`.
g.set(
"GetGuildRosterInfo",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(m) = member_at(&model.guild.roster, index) else {
return Ok(MultiValue::from_vec(vec![Value::Nil; 10]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(&m.name)?),
Value::String(lua.create_string(&m.rank)?),
Value::Integer(i64::from(m.rank_index)),
Value::Integer(i64::from(m.level)),
Value::String(lua.create_string(&m.class)?),
Value::String(lua.create_string(&m.zone)?),
Value::String(lua.create_string(&m.note)?),
Value::String(lua.create_string(&m.officer_note)?),
era_bool(m.online),
Value::String(lua.create_string(&m.status)?),
]))
})?,
)?;
// 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".
g.set(
"GetGuildRosterLastOnline",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(m) = member_at(&model.guild.roster, index) else {
return Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil));
};
let last = m.last_online;
Ok((
Value::Integer(i64::from(last.years)),
Value::Integer(i64::from(last.months)),
Value::Integer(i64::from(last.days)),
Value::Integer(i64::from(last.hours)),
))
})?,
)?;
// GetGuildRosterMOTD() / GetGuildInfoText() — the two free-text blocks the roster carries.
g.set(
"GetGuildRosterMOTD",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
lua.create_string(&model.guild.motd)
})?,
)?;
g.set(
"GetGuildInfoText",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
lua.create_string(&model.guild.info_text)
})?,
)?;
// 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.
g.set(
"GetGuildRosterSelection",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.guild.selection))
})?,
)?;
g.set(
"SetGuildRosterSelection",
lua.create_function(|lua, index: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let len = model.guild.roster.len();
let index = u32::try_from(index.clamp(0, len as i64)).unwrap_or(0);
model.guild.selection = index;
model.guild_requests.push(GuildRequest::Select(index));
Ok(())
})?,
)?;
// GetGuildRosterShowOffline() (`0x4d1e30`) / SetGuildRosterShowOffline(flag). Same
// mutate-and-mirror shape: the checkbox reads its own state back through the getter.
g.set(
"GetGuildRosterShowOffline",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
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`.
g.set(
"SetGuildRosterShowOffline",
lua.create_function(|lua, args: MultiValue| {
let on = match args.into_iter().next() {
None => true,
Some(flag) => truthy(&flag),
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild.show_offline = on;
model.guild_requests.push(GuildRequest::SetShowOffline(on));
Ok(())
})?,
)?;
// 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.
g.set(
"SortGuildRoster",
lua.create_function(|lua, field: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_requests.push(GuildRequest::Sort(field));
Ok(())
})?,
)?;
// 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.
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.
for (global, request) in [
("GuildRoster", GuildRequest::Roster),
("GuildInfo", GuildRequest::Info),
("AcceptGuild", GuildRequest::Accept),
("DeclineGuild", GuildRequest::Decline),
("GuildLeave", GuildRequest::Leave),
("GuildDisband", GuildRequest::Disband),
] {
g.set(
global,
lua.create_function(move |lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_requests.push(request.clone());
Ok(())
})?,
)?;
}
// ── 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).
for (global, make) in [
("GuildInviteByName", GuildRequest::Invite as fn(String) -> _),
(
"GuildUninviteByName",
GuildRequest::Uninvite as fn(String) -> _,
),
(
"GuildPromoteByName",
GuildRequest::Promote as fn(String) -> _,
),
("GuildDemoteByName", GuildRequest::Demote as fn(String) -> _),
(
"GuildSetLeaderByName",
GuildRequest::SetLeader as fn(String) -> _,
),
] {
g.set(
global,
lua.create_function(move |lua, name: String| {
if !name.trim().is_empty() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_requests.push(make(name));
}
Ok(())
})?,
)?;
}
// 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.
for (global, make) in [
("GuildSetMOTD", GuildRequest::SetMotd as fn(String) -> _),
(
"SetGuildInfoText",
GuildRequest::SetInfoText as fn(String) -> _,
),
] {
g.set(
global,
lua.create_function(move |lua, text: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_requests.push(make(text));
Ok(())
})?,
)?;
}
// 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.
for (global, make) in [
(
"GuildRosterSetPublicNote",
(|index, note| GuildRequest::SetPublicNote { index, note }) as fn(u32, String) -> _,
),
(
"GuildRosterSetOfficerNote",
(|index, note| GuildRequest::SetOfficerNote { index, note }) as fn(u32, String) -> _,
),
] {
g.set(
global,
lua.create_function(move |lua, (index, note): (i64, String)| {
let Ok(index) = u32::try_from(index) else {
return Ok(());
};
if index >= 1 {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_requests.push(make(index, note));
}
Ok(())
})?,
)?;
}
// ── 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`.
for (global, index) in [
("CanGuildPromote", right::PROMOTE),
("CanGuildDemote", right::DEMOTE),
("CanGuildInvite", right::INVITE),
("CanGuildRemove", right::REMOVE),
("CanEditMOTD", right::SET_MOTD),
("CanEditPublicNote", right::EDIT_PUBLIC_NOTE),
("CanViewOfficerNote", right::VIEW_OFFICER_NOTE),
("CanEditOfficerNote", right::EDIT_OFFICER_NOTE),
("CanEditGuildInfo", right::MODIFY_GUILD_INFO),
] {
g.set(
global,
lua.create_function(move |lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(era_bool(has_right(&model, index)))
})?,
)?;
}
// ── 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`).
g.set(
"GuildControlGetNumRanks",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.guild.ranks.len() as i64)
})?,
)?;
// 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.
g.set(
"GuildControlGetRankName",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let name = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.and_then(|i| model.guild.ranks.get(i))
.map(|r| r.name.as_str())
.unwrap_or_default();
lua.create_string(name)
})?,
)?;
// 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.
g.set(
"GuildControlSetRank",
lua.create_function(|lua, index: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let rank = u32::try_from(index).ok().filter(|i| *i >= 1).unwrap_or(0);
let rights = live_rights(&model, rank);
let mut staged = [false; 13];
for (slot, bit) in staged.iter_mut().zip(RANK_RIGHT_BITS) {
*slot = rights & bit != 0;
}
model.guild_control = GuildRankEdit { rank, staged };
Ok(())
})?,
)?;
// 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.
g.set(
"GuildControlGetRankFlags",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let rights = live_rights(&model, model.guild_control.rank);
Ok(MultiValue::from_vec(
RANK_RIGHT_BITS
.iter()
.map(|bit| era_bool(rights & bit != 0))
.collect(),
))
})?,
)?;
// 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.
g.set(
"GuildControlSetRankFlag",
lua.create_function(|lua, (index, enabled): (i64, Value)| {
let Some(slot) = usize::try_from(index)
.ok()
.and_then(|i| i.checked_sub(1))
.filter(|i| *i < RANK_RIGHT_BITS.len())
else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.guild_control.staged[slot] = truthy(&enabled);
Ok(())
})?,
)?;
// 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.
g.set(
"GuildControlSaveRank",
lua.create_function(|lua, name: String| {
let Some(name) = gated_rank_name(&name).map(str::to_string) else {
return Ok(());
};
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
// past the rank count.
if edit.rank >= 1 && (edit.rank as usize) <= model.guild.ranks.len() {
let mut rights = live_rights(&model, edit.rank);
for (i, bit) in RANK_RIGHT_BITS.iter().enumerate() {
if edit.staged[i] {
rights |= bit;
} else {
rights &= !bit;
}
}
model.guild_requests.push(GuildRequest::SaveRank {
rank_index: edit.rank - 1,
rights,
name,
});
}
Ok(())
})?,
)?;
// 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.
g.set(
"GuildControlAddRank",
lua.create_function(|lua, name: String| {
let Some(name) = gated_rank_name(&name).map(str::to_string) else {
return Ok(());
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.guild.ranks.len() < MAX_RANKS {
model.guild_requests.push(GuildRequest::AddRank(name));
}
Ok(())
})?,
)?;
// 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.
g.set(
"GuildControlDelRank",
lua.create_function(|lua, _ignored: MultiValue| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.guild.ranks.len() > MIN_RANKS {
model.guild_requests.push(GuildRequest::DelRank);
}
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// **`GetGuildInfo` splits three ways, not two** — wow-re `0x4c9330` (VERIFIED,
/// `scratch/guild-api-carve.md`): 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.
#[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).
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");
assert_eq!(RANK_RIGHT_BITS[right::PROMOTE - 1], 0x80);
assert_eq!(RANK_RIGHT_BITS[right::DEMOTE - 1], 0x100);
assert_eq!(RANK_RIGHT_BITS[right::INVITE - 1], 0x10);
assert_eq!(RANK_RIGHT_BITS[right::REMOVE - 1], 0x20);
assert!(
RANK_RIGHT_BITS[right::PROMOTE - 1] > RANK_RIGHT_BITS[right::INVITE - 1],
"promote's bit is above invite's while its index is below — the whole reason this is \
a table and not a shift"
);
for (i, bit) in RANK_RIGHT_BITS.iter().enumerate() {
assert_eq!(bit.count_ones(), 1, "flag {} is not a single bit", i + 1);
}
let mut seen = 0u32;
for bit in RANK_RIGHT_BITS {
assert_eq!(seen & bit, 0, "duplicate bit {bit:#x}");
seen |= bit;
}
}
/// **`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.
#[test]
fn saving_a_rank_preserves_bits_outside_the_thirteen_and_can_clear() {
// Live: Invite (0x10) + Set MOTD (0x1000), plus the untouchable 0x40.
let live = 0x0000_0040 | RANK_RIGHT_BITS[right::INVITE - 1] | RANK_RIGHT_BITS[8];
// Staged: the user unticked Set MOTD and ticked Promote.
let mut staged = [false; 13];
staged[right::INVITE - 1] = true;
staged[right::PROMOTE - 1] = true;
let mut folded = live;
for (i, bit) in RANK_RIGHT_BITS.iter().enumerate() {
if staged[i] {
folded |= bit;
} else {
folded &= !bit;
}
}
assert_eq!(
folded & 0x0000_0040,
0x0000_0040,
"0x40 is outside the thirteen and must ride through untouched"
);
assert_ne!(folded & RANK_RIGHT_BITS[right::PROMOTE - 1], 0, "ticked");
assert_eq!(
folded & RANK_RIGHT_BITS[8],
0,
"unticked Set MOTD must actually clear — a plain OR could never do this"
);
}
/// 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 {
in_guild: true,
ranks: (0..n)
.map(|i| GuildRankInfo {
name: format!("Rank{i}"),
rights: 0,
})
.collect(),
..Default::default()
});
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.
#[test]
fn saving_a_rank_refuses_what_the_reference_refuses() {
let mut s = guild_with_ranks(5);
let saves = |s: &mut crate::script::UiScript| {
s.take_guild_requests()
.into_iter()
.filter(|r| matches!(r, GuildRequest::SaveRank { .. }))
.count()
};
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank("")"#)
.unwrap();
assert_eq!(saves(&mut s), 0, "an empty name is a silent no-op");
// Sixteen UTF-16 units is the ceiling; seventeen is refused.
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(string.rep("a", 16))"#)
.unwrap();
assert_eq!(saves(&mut s), 1);
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(string.rep("a", 17))"#)
.unwrap();
assert_eq!(saves(&mut s), 0, "seventeen units");
// Units, not bytes: sixteen two-byte letters are sixteen units.
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(string.rep("é", 16))"#)
.unwrap();
assert_eq!(saves(&mut s), 1, "32 bytes, 16 units");
// Whitespace is not empty to the reference.
s.run(r#"GuildControlSetRank(2); GuildControlSaveRank(" ")"#)
.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.
let mut fresh = guild_with_ranks(5);
fresh.run(r#"GuildControlSaveRank("Officer")"#).unwrap();
assert_eq!(saves(&mut fresh), 0, "nothing selected");
s.run(r#"GuildControlSetRank(6); GuildControlSaveRank("Officer")"#)
.unwrap();
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.
#[test]
fn adding_a_rank_runs_the_same_name_gate() {
let mut s = guild_with_ranks(5);
s.run(r#"GuildControlAddRank(""); GuildControlAddRank(string.rep("a", 17))"#)
.unwrap();
assert!(s.take_guild_requests().is_empty());
s.run(r#"GuildControlAddRank(" ")"#).unwrap();
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.
#[test]
fn member_lookup_is_one_based_and_tolerates_zero() {
let roster = vec![
GuildMemberInfo {
name: "Alice".into(),
..Default::default()
},
GuildMemberInfo {
name: "Bob".into(),
..Default::default()
},
];
assert_eq!(
member_at(&roster, 1).map(|m| m.name.as_str()),
Some("Alice")
);
assert_eq!(member_at(&roster, 2).map(|m| m.name.as_str()), Some("Bob"));
assert!(member_at(&roster, 0).is_none(), "0 = nothing selected");
assert!(member_at(&roster, 3).is_none(), "past the end");
assert!(member_at(&roster, -1).is_none(), "negative");
}
}