Files
benilla-octo/crates/benilla-ui/src/script/party.rs
T
SamandClaude Fable 5 acbae000a6 an addon loop reports instead of hanging the whole survey
- every addon VM runs under an instruction bound, so a non-terminating loop becomes a report — and the bound promptly caught a landed fix that had been left switched off
- an unrecognised unit token raises the client's own "Unknown unit name" error instead of answering nil
- GetBindLocation answers from a real bind point, and RequestTimePlayed round-trips with TIME_PLAYED_MSG — both halves or neither

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:02:01 +07:00

1035 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The party/raid **Era API surface** (decision 0434 §2, phase 2) — the engine-free seam mirroring
//! [`super::unit`]: the app pushes a roster **snapshot** ([`UiScript::set_party`]) built from its own
//! `GroupState` wire mirror, and the `GetNumPartyMembers`/`GetPartyLeaderIndex`/`GetLootMethod`/…
//! globals here read that plain data. The invite/uninvite/promote/loot-config calls are the outbound
//! half: they queue a [`PartyRequest`] the app drains ([`UiScript::take_party_requests`]) and turns
//! into the matching `CMSG_GROUP_*`/`CMSG_LOOT_METHOD` send — no ECS/net reach from the engine
//! (decision 0068 §3), exactly [`super::unit`]'s split.
//!
//! Per-member game state (health/mana/level/reaction/…) does **not** live here — it rides the
//! existing per-unit snapshots under the `"party1"`..`"party4"` tokens (decision 0434 §3), the same
//! feed `"player"`/`"target"` use ([`super::unit::UnitState`]). This module owns only the
//! roster-level facts a unit snapshot can't carry: how many members, who leads, the loot
//! configuration. `PartyState::default()` is "not in a group" — every getter then answers the
//! solo-player shape a fresh client reports (`GetNumPartyMembers()` `0`, `GetLootMethod()`
//! `("group", nil)`, …).
//!
//! The **raid roster** ([`PartyState::raid`]) is the same shape one level out: the app pushes the
//! whole array — the player included, which is why `UnitInRaid("player")` needs no token special
//! case the way `UnitInParty` does — and `GetNumRaidMembers`/`GetRaidRosterInfo`/`UnitInRaid` read
//! it. It is deliberately ONE list rather than a count beside a list: the reference indexes the
//! array `0xb712a8` bounded by the count `0xb713e0`, and two of the three bindings walk exactly
//! that pair, so a client whose count and array can disagree hands an addon looping
//! `for i = 1, GetNumRaidMembers()` a miss tuple it will then index. The per-member grid/UI is
//! still a later arc (decision 0434 §6); the *data* is not.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::number_arg;
use super::Model;
/// One roster member's engine-owned facts (decision 0434 §2/§3) — deliberately thin: everything else
/// (health, class, reaction, …) is the unit snapshot under its `"partyN"` token.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PartyMemberInfo {
pub name: String,
/// The member's GUID — the identity `UnitInParty` matches arbitrary tokens (the target)
/// against (decision 0434 §5's popup menu pick). `0` = unknown, never matches.
pub guid: u64,
}
/// One **raid roster** row — `GetRaidRosterInfo`'s nine returns, as the app resolved them
/// (wow-re `ui/scratch/raid-roster-bindings.md` §2, §5-cross-checked with orchestrator
/// byte-arbitration). Field order is the push order of the reference's success tuple.
///
/// The row is the *record*, not the answer: two of the nine are computed at the binding
/// ([`Self::subgroup`]'s 1-based exposure, [`Self::zone`]'s offline substitution), because both
/// adjustments live in the binding's own bytes and doing them at the feed would put a Lua-facing
/// convention in the app.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RaidMemberInfo {
/// Return 1 — the member's name, from the guid-keyed name cache (`0x4bb5ee`).
///
/// **Empty means "not cached yet", and that is not a cosmetic difference**: the reference
/// sends a cache miss to the *same* arm an out-of-range index takes (`0x4bb5f8 je 0x4bb7b9`),
/// so an occupied slot whose name has not arrived is indistinguishable from no slot at all.
/// Reproduced — [`raid_roster_info`] answers the miss tuple for an empty name.
pub name: String,
/// The member's GUID — what `UnitInRaid` matches an arbitrary token against. `0` = unknown,
/// never matches (the reference's membership helper `0x4baee0` short-circuits GUID `0:0` to
/// false at its entry).
pub guid: u64,
/// Return 2 — the rank, **exposed exactly as stored** (`0x4bb607 fild [edi+0xc]`, no
/// adjustment). wow-re could not derive the numeric convention from the binding, and does not
/// claim one; the corpus does, consistently: `ChatLog.lua:351-353` prints `@` for `2` and `*`
/// for `1`, i.e. **0 member · 1 assistant · 2 leader**, which is what the app fills.
pub rank: u32,
/// Return 3 — the subgroup **as stored, 0-based**. The binding exposes `subgroup + 1`
/// (`0x4bb61a inc eax`, a VERIFIED adjustment); keeping the record 0-based means the wire's
/// own value (`GroupMemberEntry::flags` bits 0-2) lands here unconverted and the +1 has one
/// home.
pub subgroup: u32,
/// Return 4 — the member's level. `0` when neither a streamed object nor a stats packet has
/// carried one (the reference's own third arm pushes `0` there too).
pub level: u32,
/// Return 5 — the **localized** class name ("Warrior"), `None` → `nil`.
pub class: Option<String>,
/// Return 6 — the class file/token ("WARRIOR"), the one non-localized string in the tuple.
pub class_file: Option<String>,
/// Return 7 — the member's zone name while [`Self::online`]; `None` → `nil`. **When offline
/// this field is not read at all**: the reference's third arm pushes the localized global
/// `PLAYER_OFFLINE` in the zone slot rather than `nil`, and the binding does that itself.
pub zone: Option<String>,
/// Return 8 — connected. Also the switch for return 7's two arms, because it is in the
/// reference: return 8 is `1` on exactly the two arms that produced a real zone and `nil` on
/// the `PLAYER_OFFLINE` one.
pub online: bool,
/// Return 9 — **deliberately unlabelled.** wow-re verified the mechanism and refused the
/// name: a streamed member answers `1` iff `[obj+0x110 +0x40] <= 0` (UNIT_FIELD_HEALTH by the
/// descriptor line this repo already anchors), and an unstreamed one iff the roster record's
/// `[+0x18]` carries **both** bits `0x4` and `0x1`. Two independent workers guessed "isDead"
/// positionally and the note declined to adopt it (§5 Open).
///
/// Three things corroborate that guess without settling it, recorded here so whoever settles
/// it starts ahead: the whole corpus destructures position 9 as `isDead`
/// (`ChatLog.lua:347`, `CT_RaidAssist/CT_RAOptions.lua:89`, `oRA2/Leader/Ready.lua:241`);
/// health ≤ 0 is this client's own dead test; and our wire's status byte —
/// `benilla_protocol::messages::member_status`, from vmangos `Group.cpp:45-63` — spells
/// `ONLINE = 0x01` and `DEAD = 0x04`, which is *exactly* the bit pair the second arm tests.
/// The app fills this from that pair, so we reproduce the mechanism whatever it turns out to
/// be called.
pub ninth: bool,
}
/// The party/raid roster snapshot, pushed whole by the app each frame it changes
/// ([`UiScript::set_party`]) — the `GroupState` merged view's roster-level facts (decision 0434 §2).
/// `PartyState::default()` = not in a group.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PartyState {
/// The other party members, `"party1"`..`"party4"` order (the recipient never appears in its own
/// list, matching `SMSG_GROUP_LIST`); empty = not in a group. Never more than 4 — in a raid
/// this is our own subgroup's slice and the whole roster is [`Self::raid`].
pub members: Vec<PartyMemberInfo>,
/// The party leader, on the Lua `GetPartyLeaderIndex` scale: `0` the player leads, `1..=4` that
/// `members` slot (1-based) leads.
pub leader_index: u32,
/// The **whole raid roster**, `GetRaidRosterInfo`'s 1-based array — empty outside a raid,
/// and **including the player** (the reference's array does; it is why `UnitInRaid("player")`
/// answers `1` in a raid and why this list is not `members`'s recipient-excluded shape).
/// `GetNumRaidMembers()` is its length: one list, no count beside it (module doc).
pub raid: Vec<RaidMemberInfo>,
/// `GetLootMethod`'s method string: `"freeforall"` | `"roundrobin"` | `"master"` | `"group"` |
/// `"needbeforegreed"`. `Default::default()` is `""` — the *native* reports it as `"group"` (the
/// live shape for a solo/fresh player) when empty; the app is expected to always push a real
/// method once grouped.
pub loot_method: String,
/// The master looter, as a party index (`0` the player, `1..=4` that `members` slot) — the loot
/// method's second return, `masterlooterPartyID`. `None` = no master looter (any method but
/// `"master"`, or a master-loot group with none assigned yet).
pub master_looter: Option<u32>,
/// `GetLootThreshold`'s quality floor (`2..=4`) below which non-leader loot isn't round-robin/
/// master gated. `0` (the default) is fine while ungrouped — the getter has nothing to floor.
pub loot_threshold: u32,
}
/// Outbound party/loot intents queued by the Era API's action calls, drained by the app
/// ([`UiScript::take_party_requests`]) into the matching `CMSG_*` send. Plain data — no mlua/ECS
/// types, [`super::unit::UnitState`]'s `TargetUnit` seam's twin.
#[derive(Clone, Debug, PartialEq)]
pub enum PartyRequest {
/// `AcceptGroup()` — accept the pending invite.
Accept,
/// `DeclineGroup()` — decline the pending invite.
Decline,
/// `LeaveParty()` — leave the current group (no confirmation popup, decision 0434 §4).
Leave,
/// `InviteByName(name)` — invite by character name.
InviteName(String),
/// `InviteToParty(unit)` — invite by unit TOKEN (e.g. `"target"`); the app resolves it to a name.
InviteUnit(String),
/// `UninviteFromParty(unit)` — kick a roster member, addressed by unit token (e.g. `"party2"`).
UninviteUnit(String),
/// `PromoteToPartyLeader(unit)` — hand leadership to a roster member, by unit token.
PromoteUnit(String),
/// `SetLootMethod(method[, masterName])` — the master-looter argument is a character NAME (the
/// reference's own shape); the app resolves it to a roster member for the send.
LootMethod {
method: String,
master_name: Option<String>,
},
/// `SetLootThreshold(n)` — the new quality floor.
LootThreshold(u32),
/// `SetRaidTargetIcon(unit, index)` — mark (1..=8) or clear (0) the raid-target icon on a
/// unit, addressed by token; the app resolves the token to a guid for the
/// `MSG_RAID_TARGET_UPDATE` send (decision 0434 §5's submenu, §6's board law).
SetRaidTarget { unit: String, index: u8 },
}
impl super::UiScript {
/// Push the roster snapshot, replacing whatever was there. A bare setter (the `spellbook`/
/// `action` shape) — firing any `PARTY_*`/roster-changed event is the app's own diff-and-fire
/// job, never auto-fired here.
pub fn set_party(&mut self, state: PartyState) {
self.model_mut().party = state;
}
/// Drain the party/loot intents queued since the last call.
pub fn take_party_requests(&mut self) -> Vec<PartyRequest> {
std::mem::take(&mut self.model_mut().party_requests)
}
/// Drain the whisper targets `ChatFrame_SendTell` queued since the last call — the app opens
/// its chat edit box prefilled `/w <name> ` for each (in practice the popup queues one).
pub fn take_tell_requests(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().tell_requests)
}
}
/// **The leader predicate — ONE function, because the reference has one.**
///
/// `IsRaidLeader 0x4bb8c0` opens `mov esi,[0xbc75f8]` · `mov edi,[0xbc75fc]` and compares the pair
/// against the local player's own GUID (`0x468550`). `IsPartyLeader 0x4e9130` opens
/// `mov eax,[0xbc75fc]` · `mov esi,[0xbc75f8]` and compares **the same two globals** against the
/// same GUID; its only extra instruction is an `or ecx,eax; je` short-circuit on a `0:0` cached
/// leader, which is behaviourally inert (a zero GUID can never equal a live player's). wow-re's
/// orchestrator re-read both prologues and they are byte-identical in the globals they load
/// (`raid-roster-bindings.md` §3, VERIFIED).
///
/// **So `IsRaidLeader()` is TRUE for an ordinary 5-man party leader**, and its body reads no
/// raid-vs-party flag and never touches the roster array/count the other two raid bindings use.
/// Stubbing it `nil` "because there is no raid" would be a divergence — which is exactly why the
/// two registrations below share this function rather than each growing their own rule.
fn leads_the_group(model: &Model) -> bool {
// Our stand-in for "the cached leader GUID equals mine": the app's leader index is `0` when
// the player leads. The `members.is_empty()` guard is the reference's `0:0` case — ungrouped,
// the cached pair is zero and matches nobody, whereas our `leader_index` defaults to `0`.
!model.party.members.is_empty() && model.party.leader_index == 0
}
/// `GetRaidRosterInfo`'s nine values for a **1-based** index — the whole binding's behaviour,
/// pulled out of the registration so the arity and the miss tuple are testable as one unit.
///
/// **Nine values on every path** (`0x4bb560`, every return site `mov eax,9`): there is no arm that
/// returns fewer and none that returns nothing. Out of range, index ≤ 0, a null slot and a
/// name-cache miss all converge on `0x4bb7b9`, which pushes — in order — `nil`, the number `0`,
/// the number `1`, the number `1`, then five `nil`s. A client that models "no such member" as zero
/// values breaks `local name = GetRaidRosterInfo(i)` differently from the real client, and one
/// that models it as `nil, nil, …` breaks `ChatLog.lua:346`'s `for i = 1, MAX_RAID_MEMBERS` sweep
/// in the subgroup slot.
///
/// Takes the row **by value**, not the model: every arm below re-enters Lua (`create_string`, and
/// the `PLAYER_OFFLINE` global read), and this crate's rule is that a callback drops its `app_data`
/// borrow before it does (the module doc's MAXCSTACK/borrow discipline).
fn raid_roster_info(lua: &Lua, row: Option<RaidMemberInfo>) -> mlua::Result<MultiValue> {
let Some(m) = row else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Integer(0),
Value::Integer(1),
Value::Integer(1),
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
]));
};
let opt_str = |s: &Option<String>| match s {
Some(s) => lua.create_string(s).map(Value::String),
None => Ok(Value::Nil),
};
// Return 7: a real zone while online, else the **localized global** `PLAYER_OFFLINE` — read
// out of the VM exactly as `0x703bf0` reads it, so a translated GlobalStrings.lua translates
// this too. A missing global lands on `0x6f3890`'s NULL guard, which pushes `nil` and still
// reports one value; `Value::Nil` here is that.
let zone = if m.online {
opt_str(&m.zone)?
} else {
match lua.globals().get::<Value>("PLAYER_OFFLINE") {
Ok(v @ Value::String(_)) => v,
_ => Value::Nil,
}
};
let flag = |b: bool| if b { Value::Integer(1) } else { Value::Nil };
Ok(MultiValue::from_vec(vec![
lua.create_string(&m.name).map(Value::String)?,
Value::Integer(i64::from(m.rank)),
// `0x4bb61a inc eax` — stored 0-based, exposed 1-based (VERIFIED adjustment).
Value::Integer(i64::from(m.subgroup) + 1),
Value::Integer(i64::from(m.level)),
opt_str(&m.class)?,
opt_str(&m.class_file)?,
zone,
flag(m.online),
flag(m.ninth),
]))
}
/// Register the party/raid globals reading the roster snapshot store (the same style/place `unit`
/// registers the `Unit*` globals).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetNumPartyMembers() → the roster's member count (0 = not in a group; never counts the player
// themself, matching SMSG_GROUP_LIST's recipient-excluded array).
g.set(
"GetNumPartyMembers",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.party.members.len() as i64)
})?,
)?;
// GetNumRaidMembers() → the raid roster's length (0 outside a raid). The count the reference
// reads (`0xb713e0`) bounds the very array `GetRaidRosterInfo` indexes (`0xb712a8`), so it is
// the same list here, not a number beside one.
g.set(
"GetNumRaidMembers",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.party.raid.len() as i64)
})?,
)?;
// GetRaidRosterInfo(index) → name, rank, subgroup, level, class, fileName, zone, online, <9th>
//
// 1-BASED, and **exactly nine values on every path** — see [`raid_roster_info`] for the arity
// law and the fixed miss tuple. The ONLY raise in `0x4bb560` is a non-number argument
// (`0x4bb582 call 0x6f34d0` → `0x4bb591 call 0x6f4940`, usage string `0x8474a0`); an index of
// 0, of −1, of 500, or of a live-but-uncached member all return the tuple.
g.set(
"GetRaidRosterInfo",
lua.create_function(|lua, index: Value| {
let index = number_arg(lua, index, "Usage: GetRaidRosterInfo(index)")?;
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// `0x4bb5bb dec eax` then `0x4bb5be jae` — an UNSIGNED compare after the
// decrement, so ONE branch catches index ≤ 0 and index > count together (and the
// `dec` wraps rather than trapping, which `wrapping_sub` keeps true of `i32::MIN`).
usize::try_from(index.wrapping_sub(1))
.ok()
.and_then(|i| model.party.raid.get(i))
// A name-cache miss shares that same arm (`0x4bb5f8 je 0x4bb7b9`).
.filter(|m| !m.name.is_empty())
.cloned()
};
raid_roster_info(lua, row)
})?,
)?;
// IsRaidLeader() → 1 / nil, and TRUE FOR A PARTY LEADER — the shared predicate's whole point
// ([`leads_the_group`]). No arguments, never raises.
g.set(
"IsRaidLeader",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if leads_the_group(&model) {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetPartyMember(id) → 1 if id is a live 1-based roster slot, else nil (era 1/nil shape).
g.set(
"GetPartyMember",
lua.create_function(|lua, id: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let n = model.party.members.len() as i64;
Ok(if id >= 1 && id <= n {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetPartyLeaderIndex() → 0 (the player leads) or 1..4 (that party slot leads).
g.set(
"GetPartyLeaderIndex",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.party.leader_index))
})?,
)?;
// IsPartyLeader() → 1 iff we're grouped AND lead it, else nil (a solo player doesn't "lead").
// Shares [`leads_the_group`] with `IsRaidLeader` because the reference shares the globals.
g.set(
"IsPartyLeader",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if leads_the_group(&model) {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// GetLootMethod() → lootmethod, masterlooterPartyID (the era 2-tuple return; later clients add
// a raid index third return we don't carry). An unset method (never pushed) reports the
// fresh-player shape: "group", nil.
g.set(
"GetLootMethod",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let method = if model.party.loot_method.is_empty() {
"group"
} else {
model.party.loot_method.as_str()
};
let master = match model.party.master_looter {
Some(idx) => Value::Integer(i64::from(idx)),
None => Value::Nil,
};
Ok((Value::String(lua.create_string(method)?), master))
})?,
)?;
// GetLootThreshold() → the quality floor (0 while ungrouped is fine — nothing to floor).
g.set(
"GetLootThreshold",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.party.loot_threshold))
})?,
)?;
// The outbound half: each call queues a PartyRequest, the app drains and sends. No-return, era
// shape (fire-and-forget, like TargetUnit/CastSpell).
g.set(
"AcceptGroup",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::Accept);
Ok(())
})?,
)?;
g.set(
"DeclineGroup",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::Decline);
Ok(())
})?,
)?;
g.set(
"LeaveParty",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::Leave);
Ok(())
})?,
)?;
g.set(
"InviteByName",
lua.create_function(|lua, name: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::InviteName(name));
Ok(())
})?,
)?;
g.set(
"InviteToParty",
lua.create_function(|lua, unit: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::InviteUnit(unit));
Ok(())
})?,
)?;
g.set(
"UninviteFromParty",
lua.create_function(|lua, unit: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::UninviteUnit(unit));
Ok(())
})?,
)?;
g.set(
"PromoteToPartyLeader",
lua.create_function(|lua, unit: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::PromoteUnit(unit));
Ok(())
})?,
)?;
g.set(
"SetLootMethod",
lua.create_function(|lua, (method, master_name): (String, Option<String>)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::LootMethod {
method,
master_name,
});
Ok(())
})?,
)?;
g.set(
"SetLootThreshold",
lua.create_function(|lua, n: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.party_requests.push(PartyRequest::LootThreshold(n));
Ok(())
})?,
)?;
g.set(
"SetRaidTargetIcon",
lua.create_function(|lua, (unit, index): (String, u8)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.party_requests
.push(PartyRequest::SetRaidTarget { unit, index });
Ok(())
})?,
)?;
// IsRaidOfficer() → nil, always: a 1.12 PARTY has no officer rank (the assistant flag is a
// raid concept). The popup's leader-or-assistant gates read this and fall back to the leader
// half. The roster now carries the assistant rank ([`RaidMemberInfo::rank`], `1`), so the
// *data* to answer this exists — what does not is a carve of `IsRaidOfficer`'s own body, and
// "it is probably rank ≥ 1 for the player's row" is a guess, not a mechanism. Left nil until
// someone reads the bytes.
g.set(
"IsRaidOfficer",
lua.create_function(|_, ()| Ok(Value::Nil))?,
)?;
// ChatFrame_SendTell(name) — the popup's WHISPER action. In the ref this is ChatFrame.lua
// filling the edit box with "/w name "; our chat edit is app-side (ui_chat), so the call
// queues the name for the app to open the edit box prefilled (UiScript::take_tell_requests
// drains — the PartyRequest seam's chat sibling).
g.set(
"ChatFrame_SendTell",
lua.create_function(|lua, name: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.tell_requests.push(name);
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::{PartyMemberInfo, PartyRequest, PartyState, UiScript};
fn two_member_party() -> PartyState {
PartyState {
members: vec![
PartyMemberInfo {
name: "Alice".into(),
guid: 0xA11CE,
},
PartyMemberInfo {
name: "Bob".into(),
guid: 0xB0B,
},
],
leader_index: 1, // Alice (party1) leads
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
}
}
#[test]
fn read_natives_report_the_pushed_roster() {
let mut s = UiScript::new().unwrap();
s.set_party(two_member_party());
assert_eq!(s.eval::<i64>("return GetNumPartyMembers()").unwrap(), 2);
assert_eq!(s.eval::<i64>("return GetNumRaidMembers()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetPartyMember(1)").unwrap(), 1);
assert_eq!(s.eval::<i64>("return GetPartyMember(2)").unwrap(), 1);
assert!(s.eval::<bool>("return GetPartyMember(3) == nil").unwrap());
assert!(s.eval::<bool>("return GetPartyMember(0) == nil").unwrap());
assert_eq!(s.eval::<i64>("return GetPartyLeaderIndex()").unwrap(), 1);
// Alice (party1) leads, not us.
assert!(s.eval::<bool>("return IsPartyLeader() == nil").unwrap());
let (method, master) = s
.eval::<(String, Option<i64>)>("return GetLootMethod()")
.unwrap();
assert_eq!(method, "group");
assert_eq!(master, None);
assert_eq!(s.eval::<i64>("return GetLootThreshold()").unwrap(), 2);
}
#[test]
fn is_party_leader_reports_when_the_player_leads() {
let mut s = UiScript::new().unwrap();
let mut party = two_member_party();
party.leader_index = 0; // the player leads
s.set_party(party);
assert_eq!(s.eval::<i64>("return IsPartyLeader()").unwrap(), 1);
}
#[test]
fn get_loot_method_reports_the_assigned_master() {
let mut s = UiScript::new().unwrap();
let mut party = two_member_party();
party.loot_method = "master".into();
party.master_looter = Some(2); // Bob (party2)
s.set_party(party);
let (method, master) = s.eval::<(String, i64)>("return GetLootMethod()").unwrap();
assert_eq!(method, "master");
assert_eq!(master, 2);
}
#[test]
fn empty_state_reports_the_solo_player_shape() {
let s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumPartyMembers()").unwrap(), 0);
assert!(s.eval::<bool>("return GetPartyMember(1) == nil").unwrap());
assert!(s.eval::<bool>("return IsPartyLeader() == nil").unwrap());
let (method, master) = s
.eval::<(String, Option<i64>)>("return GetLootMethod()")
.unwrap();
assert_eq!(method, "group");
assert_eq!(master, None);
}
// ── The raid trio (wow-re `ui/scratch/raid-roster-bindings.md`) ─────────────────────────────
//
// One test per return-shape trap, in `item_stats`'s `get_item_info_tests` style: the ARITY
// assertion is half of each, because a signature regression is the failure every individual
// read still survives.
fn raider(name: &str, guid: u64) -> crate::script::RaidMemberInfo {
crate::script::RaidMemberInfo {
name: name.into(),
guid,
rank: 0,
subgroup: 0,
level: 60,
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
zone: Some("Molten Core".into()),
online: true,
ninth: false,
}
}
fn ten_player_raid() -> PartyState {
let mut raid: Vec<crate::script::RaidMemberInfo> = (0u64..10)
.map(|i| raider(&format!("Raider{i}"), 0x100 + i))
.collect();
raid[0].rank = 2; // the leader
raid[0].name = "Me".into();
raid[3].subgroup = 1; // stored 0-based — the binding exposes 2
PartyState {
raid,
..Default::default()
}
}
/// **Nine values on every path**, including the paths that have no member to describe. Every
/// return site in `0x4bb560` is `mov eax,9`; the out-of-range arm `0x4bb7b9` pushes nine
/// values of its own rather than returning nothing.
#[test]
fn get_raid_roster_info_returns_nine_values_on_every_path() {
let mut s = UiScript::new().unwrap();
s.set_party(ten_player_raid());
for index in ["1", "10", "0", "-1", "41", "9999"] {
assert_eq!(
s.eval::<i64>(&format!("return select('#', GetRaidRosterInfo({index}))"))
.unwrap(),
9,
"index {index} must still be a nine-value answer"
);
}
// And with no raid at all — the shape does not depend on being in one.
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select('#', GetRaidRosterInfo(1))")
.unwrap(),
9
);
}
/// The miss tuple is `(nil, 0, 1, 1, nil, nil, nil, nil, nil)` — **not** nine nils, and not
/// nothing. `0x4bb7b9` pushes nil, then the number 0, then 1, then 1, then five nils.
#[test]
fn get_raid_roster_info_misses_with_the_fixed_tuple() {
let mut s = UiScript::new().unwrap();
s.set_party(ten_player_raid());
let miss = "local a,b,c,d,e,f,g,h,i = GetRaidRosterInfo(99) \
return a == nil, b, c, d, e == nil, f == nil, g == nil, h == nil, i == nil";
let (a, b, c, d, e, f, g, h, i): (bool, i64, i64, i64, bool, bool, bool, bool, bool) =
s.eval(miss).unwrap();
assert!(a, "name is nil");
assert_eq!(
(b, c, d),
(0, 1, 1),
"rank 0, subgroup 1, level 1 — numbers"
);
assert!(e && f && g && h && i, "the last five are nil");
// An in-range member whose NAME has not been cached takes the same arm — the reference's
// `0x4bb5f8 je 0x4bb7b9`, which is why a client must not model this as "returns nothing".
let mut party = ten_player_raid();
party.raid[1].name = String::new();
s.set_party(party);
let (name_nil, rank): (bool, i64) = s
.eval("local a,b = GetRaidRosterInfo(2) return a == nil, b")
.unwrap();
assert!(name_nil && rank == 0, "an uncached name is a full miss");
}
/// The success tuple, destructured positionally exactly as `ChatLog.lua:347` and
/// `oRA2/Leader/Ready.lua:241` destructure it.
#[test]
fn get_raid_roster_info_reports_the_pushed_row() {
let mut s = UiScript::new().unwrap();
s.set_party(ten_player_raid());
let (name, rank, subgroup, level, class, file, zone, online, ninth): (
String,
i64,
i64,
i64,
String,
String,
String,
i64,
Option<i64>,
) = s
.eval("local a,b,c,d,e,f,g,h,i = GetRaidRosterInfo(1) return a,b,c,d,e,f,g,h,i")
.unwrap();
assert_eq!(name, "Me");
assert_eq!(rank, 2, "as stored, no adjustment");
assert_eq!(subgroup, 1, "stored 0 → exposed 1 (`0x4bb61a inc eax`)");
assert_eq!(level, 60);
assert_eq!((class.as_str(), file.as_str()), ("Warrior", "WARRIOR"));
assert_eq!(zone, "Molten Core");
assert_eq!(online, 1);
assert_eq!(ninth, None, "1/nil, never true/false");
// Subgroup is the one adjusted field: member 4 is stored in subgroup 1 and reads 2.
assert_eq!(
s.eval::<i64>("local _,_,g = GetRaidRosterInfo(4) return g")
.unwrap(),
2
);
}
/// An OFFLINE member's zone slot carries the localized `PLAYER_OFFLINE` global, never nil —
/// and return 8 goes nil on that same arm (`0x4bb705`).
#[test]
fn get_raid_roster_info_puts_player_offline_in_the_zone_slot() {
let mut s = UiScript::new().unwrap();
let mut party = ten_player_raid();
party.raid[1].online = false;
party.raid[1].zone = Some("Molten Core".into()); // ignored on the offline arm
s.set_party(party);
// GlobalStrings.lua is the app's to load; the binding reads whatever the VM has.
s.run(r#"PLAYER_OFFLINE = "Offline""#).unwrap();
let (zone, online): (String, Option<i64>) = s
.eval("local _,_,_,_,_,_,g,h = GetRaidRosterInfo(2) return g, h")
.unwrap();
assert_eq!(zone, "Offline");
assert_eq!(online, None);
}
/// A non-number argument is the ONLY raise in the whole function (`0x4bb591`), and it raises
/// rather than returning nil or nothing — `0x6f4940` never returns.
#[test]
fn get_raid_roster_info_raises_only_on_a_non_number() {
let s = UiScript::new().unwrap();
let err = s
.eval::<i64>("return select('#', GetRaidRosterInfo({}))")
.unwrap_err();
assert!(
format!("{err}").contains("Usage: GetRaidRosterInfo(index)"),
"got {err}"
);
// A numeric string coerces (Lua 5.1's `lua_isnumber`) and does NOT raise.
assert_eq!(
s.eval::<i64>(r#"return select('#', GetRaidRosterInfo("3"))"#)
.unwrap(),
9
);
}
/// `UnitInRaid` answers the **constant 1**, not a roster index — the value is the hard-coded
/// double at `0x51637e`, and the helper underneath never exposes a counter.
#[test]
fn unit_in_raid_answers_one_not_an_index() {
let mut s = UiScript::new().unwrap();
s.set_party(ten_player_raid());
s.set_unit("player", Some(unit(true, 0x100)));
s.set_unit("target", Some(unit(true, 0x105))); // roster row 6
s.set_unit("mouseover", Some(unit(true, 0xDEAD)));
assert_eq!(s.eval::<i64>(r#"return UnitInRaid("player")"#).unwrap(), 1);
assert_eq!(
s.eval::<i64>(r#"return UnitInRaid("target")"#).unwrap(),
1,
"row 6 still answers 1 — this is not an index"
);
assert!(s
.eval::<bool>(r#"return UnitInRaid("mouseover") == nil"#)
.unwrap());
// **The input partition, and it is NOT "never raises".** This test used to assert that a
// missing, wrong-typed or unknown token is all nil, on a wow-re finding that published "no
// error path". That claim was refuted by a later §5 cross-check and corrected at the source
// (`raid-roster-bindings.md` §1: *"The grammar is now enumerated and it settles the other
// way"*), which named `UnitInRaid` as one of exactly three verbs carrying the wrong claim.
//
// Quiet nil: `0x6f3690` returns NULL for a missing or uncoercible argument, `0x515970` maps
// NULL/empty to GUID `0:0`, and `0x4baee0` short-circuits `0:0` to false at entry.
for call in ["UnitInRaid()", "UnitInRaid(nil)", r#"UnitInRaid("party3")"#] {
assert!(
s.eval::<bool>(&format!("return {call} == nil")).unwrap(),
"{call} must answer nil"
);
}
// Raise: a token matching none of the nine prefixes reaches
// `luaL_error("Unknown unit name: %s")` and longjmps — and a NUMBER is coerced to a string
// first, so it raises too. `UnitInRaid("bogus")` never returns on the real client.
for call in ["UnitInRaid(7)", r#"UnitInRaid("nosuchtoken")"#] {
assert!(
s.run(call).is_err(),
"{call} must raise — the token resolves to no prefix at all"
);
}
}
/// **`IsRaidLeader()` is true for an ordinary 5-man party leader.** Its body reads the same
/// two leader-GUID globals `IsPartyLeader` does and carries no raid-vs-party flag at all
/// (`0x4bb8c0` vs `0x4e9130`, both prologues re-read by wow-re's orchestrator). Answering nil
/// "because there is no raid" would be the divergence.
#[test]
fn is_raid_leader_is_true_for_a_party_leader() {
let mut s = UiScript::new().unwrap();
let mut party = two_member_party();
party.leader_index = 0; // we lead — a PARTY, with an empty raid roster
s.set_party(party);
assert!(
s.eval::<i64>("return GetNumRaidMembers()").unwrap() == 0,
"no raid, and that is exactly the point"
);
assert_eq!(s.eval::<i64>("return IsRaidLeader()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return IsPartyLeader()").unwrap(), 1);
// Somebody else leads: both go nil, together.
s.set_party(two_member_party()); // leader_index 1 = Alice
assert!(s.eval::<bool>("return IsRaidLeader() == nil").unwrap());
assert!(s.eval::<bool>("return IsPartyLeader() == nil").unwrap());
// Ungrouped: nil (the reference's cached leader pair is `0:0` and matches nobody).
let s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return IsRaidLeader() == nil").unwrap());
}
/// One list, not a count beside one: `GetNumRaidMembers` and `GetRaidRosterInfo` cannot
/// disagree about how many members there are.
#[test]
fn get_num_raid_members_bounds_the_roster_it_indexes() {
let mut s = UiScript::new().unwrap();
s.set_party(ten_player_raid());
assert_eq!(s.eval::<i64>("return GetNumRaidMembers()").unwrap(), 10);
assert!(s
.eval::<bool>("return GetRaidRosterInfo(GetNumRaidMembers()) ~= nil")
.unwrap());
assert!(s
.eval::<bool>("return GetRaidRosterInfo(GetNumRaidMembers() + 1) == nil")
.unwrap());
}
#[test]
fn intent_natives_queue_the_exact_request_sequence() {
let mut s = UiScript::new().unwrap();
// Nothing queued until a call lands.
assert!(s.take_party_requests().is_empty());
s.run("AcceptGroup()").unwrap();
s.run("DeclineGroup()").unwrap();
s.run("LeaveParty()").unwrap();
s.run(r#"InviteByName("Bob")"#).unwrap();
s.run(r#"InviteToParty("target")"#).unwrap();
s.run(r#"UninviteFromParty("party2")"#).unwrap();
s.run(r#"PromoteToPartyLeader("party2")"#).unwrap();
s.run(r#"SetLootMethod("master", "Bob")"#).unwrap();
s.run(r#"SetLootThreshold(3)"#).unwrap();
assert_eq!(
s.take_party_requests(),
vec![
PartyRequest::Accept,
PartyRequest::Decline,
PartyRequest::Leave,
PartyRequest::InviteName("Bob".into()),
PartyRequest::InviteUnit("target".into()),
PartyRequest::UninviteUnit("party2".into()),
PartyRequest::PromoteUnit("party2".into()),
PartyRequest::LootMethod {
method: "master".into(),
master_name: Some("Bob".into()),
},
PartyRequest::LootThreshold(3),
]
);
// The drain is a take — a second read is empty.
assert!(s.take_party_requests().is_empty());
}
#[test]
fn set_loot_method_without_a_master_name_queues_none() {
let mut s = UiScript::new().unwrap();
s.run(r#"SetLootMethod("freeforall")"#).unwrap();
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::LootMethod {
method: "freeforall".into(),
master_name: None,
}]
);
}
#[test]
fn set_raid_target_icon_queues_the_token_and_index() {
let mut s = UiScript::new().unwrap();
s.run(r#"SetRaidTargetIcon("target", 8)"#).unwrap();
s.run(r#"SetRaidTargetIcon("party2", 0)"#).unwrap();
assert_eq!(
s.take_party_requests(),
vec![
PartyRequest::SetRaidTarget {
unit: "target".into(),
index: 8,
},
PartyRequest::SetRaidTarget {
unit: "party2".into(),
index: 0,
},
]
);
}
#[test]
fn is_raid_officer_is_nil_until_the_raid_arc() {
let s = UiScript::new().unwrap();
assert!(s.eval::<bool>("return IsRaidOfficer() == nil").unwrap());
}
#[test]
fn chat_frame_send_tell_queues_the_name() {
let mut s = UiScript::new().unwrap();
assert!(s.take_tell_requests().is_empty());
s.run(r#"ChatFrame_SendTell("Alice")"#).unwrap();
assert_eq!(s.take_tell_requests(), vec!["Alice".to_string()]);
assert!(s.take_tell_requests().is_empty());
}
// ── The identity predicates (decision 0434 §5 — the popup's menu pick + gating) ─────────────
fn unit(exists: bool, guid: u64) -> crate::script::UnitState {
crate::script::UnitState {
exists,
guid,
..Default::default()
}
}
#[test]
fn unit_is_unit_compares_guids_and_tokens() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(unit(true, 0x10)));
s.set_unit("target", Some(unit(true, 0x10)));
s.set_unit("party1", Some(unit(true, 0x20)));
// Same guid across tokens; same token trivially; different guids nil.
assert_eq!(
s.eval::<i64>(r#"return UnitIsUnit("target", "player")"#)
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsUnit("player", "player")"#)
.unwrap(),
1
);
assert!(s
.eval::<bool>(r#"return UnitIsUnit("party1", "player") == nil"#)
.unwrap());
// Zero guids never match across tokens (unknown identity is not identity).
s.set_unit("target", Some(unit(true, 0)));
s.set_unit("mouseover", Some(unit(true, 0)));
assert!(s
.eval::<bool>(r#"return UnitIsUnit("target", "mouseover") == nil"#)
.unwrap());
// A missing token is nil.
assert!(s
.eval::<bool>(r#"return UnitIsUnit("pet", "player") == nil"#)
.unwrap());
}
#[test]
fn unit_in_party_matches_roster_guids() {
let mut s = UiScript::new().unwrap();
s.set_party(two_member_party());
s.set_unit("player", Some(unit(true, 0x10)));
s.set_unit("party1", Some(unit(true, 0xA11CE)));
// The target IS Alice (guid match through an arbitrary token).
s.set_unit("target", Some(unit(true, 0xA11CE)));
assert_eq!(s.eval::<i64>(r#"return UnitInParty("target")"#).unwrap(), 1);
assert_eq!(s.eval::<i64>(r#"return UnitInParty("party1")"#).unwrap(), 1);
// A stranger's guid is nil.
s.set_unit("target", Some(unit(true, 0xDEAD)));
assert!(s
.eval::<bool>(r#"return UnitInParty("target") == nil"#)
.unwrap());
// Ungrouped: everything is nil, the player included.
s.set_party(crate::script::PartyState::default());
assert!(s
.eval::<bool>(r#"return UnitInParty("player") == nil"#)
.unwrap());
}
#[test]
fn unit_can_cooperate_needs_a_friendly_player() {
let mut s = UiScript::new().unwrap();
let mut friendly = unit(true, 0x30);
friendly.is_player = true;
friendly.reaction = 5;
s.set_unit("target", Some(friendly.clone()));
assert_eq!(
s.eval::<i64>(r#"return UnitCanCooperate("player", "target")"#)
.unwrap(),
1
);
// A hostile player, and a friendly NPC, both fail the gate.
let mut hostile = friendly.clone();
hostile.reaction = 2;
s.set_unit("target", Some(hostile));
assert!(s
.eval::<bool>(r#"return UnitCanCooperate("player", "target") == nil"#)
.unwrap());
let mut npc = friendly;
npc.is_player = false;
s.set_unit("target", Some(npc));
assert!(s
.eval::<bool>(r#"return UnitCanCooperate("player", "target") == nil"#)
.unwrap());
}
#[test]
fn get_raid_target_index_reads_the_fed_mark() {
let mut s = UiScript::new().unwrap();
let mut marked = unit(true, 0x40);
marked.raid_target = 8;
s.set_unit("target", Some(marked));
assert_eq!(
s.eval::<i64>(r#"return GetRaidTargetIndex("target")"#)
.unwrap(),
8
);
s.set_unit("target", Some(unit(true, 0x40)));
assert!(s
.eval::<bool>(r#"return GetRaidTargetIndex("target") == nil"#)
.unwrap());
}
}