- the world map speaks the reference's names: WorldMapTooltip is declared (six corpus addons index it; pfUI dies on it at file scope), the blackout frame takes the reference's spelling BlackoutWorld, and the four hand-written party blips become the reference's own unit-blip templates — all 44 blips as instances, each with its published $parentIcon, the name pfUI recolours group blips by class through; the 40 raid ones are declared but not positioned yet, named in the OUT list rather than faked
- the templates addons build their own buttons from are published: ItemButtonTemplate, ContainerFrameItemButtonTemplate and the bag-slot chain — most of the handler scripts were live the whole time (this client executes the reference's own container and paper-doll code off the player's patch chain), so what was missing was mostly declarations, not behaviour — the bank arm stays out and is named as the omission it is; OptionsFrameSliders becomes real, with the three Graphics rows reading it and re-reading on show, so an addon's write to a slider's bounds actually moves the slider; and the template census that had ranked these at "zero demand" learns to see a local bound to a literal — its table went from 3 rows to 7
- the bindings answer in 1.12's own shapes: Button:IsEnabled() answers the NUMBER 1 or 0 (0 is truthy in Lua, so the reference's own == 0 tests got false forever, and three of our files had hand-worked around the divergence rather than fixed it); the constructors' string arguments are four shapes, not one rule — pfUI passes a Button where a template name goes and the reference constructs the FontString and ignores it; SetInventoryItem returns THREE values, the reference's own paper doll destructuring repairCost; UnitIsVisible is object presence and nothing else (57 bytes, one branch, no distance — an out-of-range party member is exists-yes, visible-nil); the tapped pair reads the two dynamic-flags bits, and its repaint wire fires UNIT_FACTION on the tap bit — the half a passing predicate test never sees; UnitIsPartyLeader is two ORed legs covering disjoint failures (a held stranger leading their own party, and an out-of-range member with no object to read a flag from); and the four nameplate verbs take no argument and return zero values
- the ghost's wisps compose onto its body instead of over it — the four emitters drew at full strength over a body at alpha 0.5, and now inherit the instance alpha down the attachment chain the way the reference recomposes it per frame; the residual we had parked ("the wisps should inherit the tint too") is REFUTED at the bytes: a particle batch commits hardcoded white RGB, and even that never reaches the fragment — particles are never tinted, only the alpha crosses, applied to the alpha byte alone — so the shipped shader split was already right and no code changes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1429 lines
64 KiB
Rust
1429 lines
64 KiB
Rust
//! 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
|
||
//! the RaidFrame (decision 1549), which reads exactly this array.
|
||
//!
|
||
//! The **raid management verbs** (decision 1549) are the outbound half again, and they address
|
||
//! members three different ways because the reference's own bindings do: by **raid index**
|
||
//! (`SetRaidSubgroup`, `SwapRaidSubgroup`, `UninviteFromRaid` — the RaidFrame has the index in
|
||
//! hand), by **name** (`PromoteByName`, `PromoteToAssistant`, `DemoteAssistant` — UnitPopup
|
||
//! carries a name, never an index), and by **nothing at all** (`ConvertToRaid`, `DoReadyCheck`,
|
||
//! `RequestRaidInfo`). Resolving index/name to whatever the wire wants is the app's job at the
|
||
//! drain, exactly as `InviteToParty`'s token resolution already is: this side stays plain data.
|
||
//!
|
||
//! The **saved-instance list** ([`SavedInstanceInfo`]) is a second app-pushed snapshot, separate
|
||
//! from [`PartyState`] because it arrives on its own packet (`SMSG_RAID_INSTANCE_INFO`) and
|
||
//! outlives every roster change — folding it into the roster push would clear it every time the
|
||
//! group moved.
|
||
|
||
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 saved raid lockout — `GetSavedInstanceInfo`'s three returns (decision 1549). Pushed whole
|
||
/// by the app ([`UiScript::set_saved_instances`]) from `SMSG_RAID_INSTANCE_INFO`, with the map
|
||
/// **name** already resolved: the wire carries a `Map.dbc` id and the DBC is the app's to read.
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||
pub struct SavedInstanceInfo {
|
||
/// The instance's display name (`Map.dbc`'s own), e.g. `"Molten Core"`.
|
||
pub name: String,
|
||
/// The instance id — what the panel prints in its ID column.
|
||
pub instance: u32,
|
||
/// Seconds remaining until the lockout resets; the panel runs it through `SecondsToTime`.
|
||
pub reset: u32,
|
||
}
|
||
|
||
/// 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 cached group-leader GUID** — the reference's `[0xbc75f8]:[0xbc75fc]` pair, `0` when
|
||
/// ungrouped. Fed straight from the wire's leader GUID, not derived from
|
||
/// [`Self::leader_index`].
|
||
///
|
||
/// It exists because `UnitIsPartyLeader`'s second leg is a GUID compare and an index cannot
|
||
/// stand in for it: the leg has to answer for a member whose object the client does **not**
|
||
/// hold — an out-of-range `party3`, any `raidN` — where there is no descriptor to read the
|
||
/// flag from, and it has to answer for the *resolved token's* GUID rather than for a slot.
|
||
///
|
||
/// **Compared WITHOUT a zero guard**, which is the load-bearing part and reads like a bug:
|
||
/// `IsPartyLeader 0x4e9130` short-circuits on a `0:0` cached leader, and
|
||
/// `UnitIsPartyLeader 0x516210` does not. So an unresolvable-but-non-raising argument (none,
|
||
/// `nil`, `""`, `"target"` with no target) resolves to `0:0`, matches the zeroed leader, and
|
||
/// the reference answers **`1` while solo**. A client that answers `nil` there is not
|
||
/// reproducing 1.12 (wow-re `ui/scratch/party-leader-and-nameplate-verbs.md`).
|
||
pub leader_guid: u64,
|
||
/// 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>,
|
||
/// `SetLootMethod`'s optional THIRD argument. The binding reads it whatever the method is
|
||
/// (`0x4e92a0`, presence-checked via `0x6f34d0`), unlike the master-looter argument, which
|
||
/// it reads only for `"master"` (decision 1675).
|
||
threshold: Option<u32>,
|
||
},
|
||
/// `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 },
|
||
// ── The raid-management verbs (decision 1549's RaidFrame) ───────────────────────────────
|
||
/// `ConvertToRaid()` — the Raid tab's own button (`CMSG_GROUP_RAID_CONVERT`, leader only).
|
||
ConvertToRaid,
|
||
/// `SetRaidSubgroup(index, group)` — move raid row `index` (1-based) into subgroup `group`
|
||
/// (1-based). The wire (`CMSG_GROUP_CHANGE_SUB_GROUP`) takes a NAME and a 0-based subgroup;
|
||
/// resolving both is the app's, because only it holds the roster the index means.
|
||
SetSubgroup { index: u32, group: u32 },
|
||
/// `SwapRaidSubgroup(index, other)` — trade two raid rows' subgroups
|
||
/// (`CMSG_GROUP_SWAP_SUB_GROUP`, two names on the wire). The drag's "dropped on an occupied
|
||
/// slot" arm; [`Self::SetSubgroup`] is its "dropped on an empty one".
|
||
SwapSubgroup { index: u32, other: u32 },
|
||
/// `PromoteByName(name)` — hand leadership over, addressed by name rather than by token
|
||
/// (`CMSG_GROUP_SET_LEADER`, whose body is a guid — the app resolves it). UnitPopup's
|
||
/// RAID_LEADER row; [`Self::PromoteUnit`] is the same send from a party token.
|
||
PromoteName(String),
|
||
/// `PromoteToAssistant(name)` / `DemoteAssistant(name)` — the raid assistant flag
|
||
/// (`CMSG_GROUP_ASSISTANT_LEADER`: guid + grant byte).
|
||
AssistantLeader { name: String, grant: bool },
|
||
/// `UninviteFromRaid(index)` — kick raid row `index` (1-based). The reference's own row-index
|
||
/// form; `CMSG_GROUP_UNINVITE` takes a name, which the app resolves from the same roster the
|
||
/// index addresses.
|
||
UninviteRaid(u32),
|
||
/// `DoReadyCheck()` — start one (`MSG_RAID_READY_CHECK`, empty body; leader only).
|
||
ReadyCheckStart,
|
||
/// `ConfirmReadyCheck(ready)` — answer one (`MSG_RAID_READY_CHECK`, one byte).
|
||
ReadyCheckAnswer(bool),
|
||
/// `RequestRaidInfo()` — ask for the saved-instance list (`CMSG_REQUEST_RAID_INFO`).
|
||
RequestRaidInfo,
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// Push the saved raid-lockout list, replacing whatever was there (decision 1549). A bare
|
||
/// setter like [`Self::set_party`] — firing `UPDATE_INSTANCE_INFO` is the app's diff-and-fire
|
||
/// job, never auto-fired here.
|
||
pub fn set_saved_instances(&mut self, saved: Vec<SavedInstanceInfo>) {
|
||
self.model_mut().saved_instances = saved;
|
||
}
|
||
|
||
/// 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(())
|
||
})?,
|
||
)?;
|
||
// SetLootMethod("method" [,master] [,threshold]) — the reference's own usage string
|
||
// (`0x84c42c`). The master-looter argument is read ONLY for "master"; the threshold argument
|
||
// is optional for every method (decision 1675).
|
||
g.set(
|
||
"SetLootMethod",
|
||
lua.create_function(
|
||
|lua, (method, master_name, threshold): (String, Option<String>, Option<u32>)| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.party_requests.push(PartyRequest::LootMethod {
|
||
method,
|
||
master_name,
|
||
threshold,
|
||
});
|
||
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))?,
|
||
)?;
|
||
|
||
// ── The raid-management verbs (decision 1549) ────────────────────────────────────────────
|
||
//
|
||
// All nine are `ORCHESTRATION` in wow-re's own classification (`ui/scratch/bindings.md`:
|
||
// `ConvertToRaid 0x4bbc90`, `SetRaidSubgroup 0x4bb990`, `SwapRaidSubgroup 0x4bbb00`,
|
||
// `PromoteToAssistant 0x4bbd20`, `RequestRaidInfo 0x4a1850`, `GetSavedInstanceInfo 0x4a1920`;
|
||
// `UninviteFromRaid 0x48a580` and `SetRaidRosterSelection 0x4bb820` in
|
||
// `item17-frameapi-fullcarve.md`) — "marshals + delegates to a C++ method/net-send; no inline
|
||
// fidelity math". So the *binding* has no law of its own to reproduce: the law is the wire's,
|
||
// which `benilla-protocol`'s `group` family already carries byte-golden, and the marshalling
|
||
// is the queue below. Nothing here is a guess about a body nobody has read.
|
||
//
|
||
// They queue rather than send for the module doc's reason: this crate cannot reach the net.
|
||
g.set(
|
||
"ConvertToRaid",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.party_requests.push(PartyRequest::ConvertToRaid);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
// SetRaidSubgroup(index, group) / SwapRaidSubgroup(index, other) — the drag's two landings.
|
||
// Both take numbers through the shared argument gate, so a non-number raises the reference's
|
||
// usage error rather than silently queueing a zero.
|
||
g.set(
|
||
"SetRaidSubgroup",
|
||
lua.create_function(|lua, (index, group): (Value, Value)| {
|
||
let index = number_arg(lua, index, "Usage: SetRaidSubgroup(index, group)")?;
|
||
let group = number_arg(lua, group, "Usage: SetRaidSubgroup(index, group)")?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.party_requests.push(PartyRequest::SetSubgroup {
|
||
index: index.max(0) as u32,
|
||
group: group.max(0) as u32,
|
||
});
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"SwapRaidSubgroup",
|
||
lua.create_function(|lua, (index, other): (Value, Value)| {
|
||
let index = number_arg(lua, index, "Usage: SwapRaidSubgroup(index1, index2)")?;
|
||
let other = number_arg(lua, other, "Usage: SwapRaidSubgroup(index1, index2)")?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.party_requests.push(PartyRequest::SwapSubgroup {
|
||
index: index.max(0) as u32,
|
||
other: other.max(0) as u32,
|
||
});
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"PromoteByName",
|
||
lua.create_function(|lua, name: String| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.party_requests.push(PartyRequest::PromoteName(name));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
for (binding, grant) in [("PromoteToAssistant", true), ("DemoteAssistant", false)] {
|
||
g.set(
|
||
binding,
|
||
lua.create_function(move |lua, name: String| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model
|
||
.party_requests
|
||
.push(PartyRequest::AssistantLeader { name, grant });
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
}
|
||
g.set(
|
||
"UninviteFromRaid",
|
||
lua.create_function(|lua, index: Value| {
|
||
let index = number_arg(lua, index, "Usage: UninviteFromRaid(index)")?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model
|
||
.party_requests
|
||
.push(PartyRequest::UninviteRaid(index.max(0) as u32));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"DoReadyCheck",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.party_requests.push(PartyRequest::ReadyCheckStart);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
// ConfirmReadyCheck(ready) — the popup's two buttons, and the argument is read for TRUTH, not
|
||
// for a number: the reference's own No button calls it with no argument at all
|
||
// (`ConfirmReadyCheck()`) while Yes passes `1`, so "absent" has to mean "not ready".
|
||
g.set(
|
||
"ConfirmReadyCheck",
|
||
lua.create_function(|lua, ready: Value| {
|
||
let ready = !matches!(ready, Value::Nil | Value::Boolean(false));
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model
|
||
.party_requests
|
||
.push(PartyRequest::ReadyCheckAnswer(ready));
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"RequestRaidInfo",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.party_requests.push(PartyRequest::RequestRaidInfo);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// GetNumSavedInstances() / GetSavedInstanceInfo(index) — the Raid Info panel's pair, over the
|
||
// app-pushed list. 1-based like every other indexed getter here, and a miss answers a plain
|
||
// `nil` (the panel's own loop only ever indexes inside the count it just read).
|
||
g.set(
|
||
"GetNumSavedInstances",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(model.saved_instances.len() as i64)
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"GetSavedInstanceInfo",
|
||
lua.create_function(|lua, index: Value| {
|
||
let index = number_arg(lua, index, "Usage: GetSavedInstanceInfo(index)")?;
|
||
let row = {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
usize::try_from(index.wrapping_sub(1))
|
||
.ok()
|
||
.and_then(|i| model.saved_instances.get(i))
|
||
.cloned()
|
||
};
|
||
Ok(match row {
|
||
Some(r) => MultiValue::from_vec(vec![
|
||
Value::String(lua.create_string(&r.name)?),
|
||
Value::Integer(i64::from(r.instance)),
|
||
Value::Integer(i64::from(r.reset)),
|
||
]),
|
||
None => MultiValue::new(),
|
||
})
|
||
})?,
|
||
)?;
|
||
|
||
// SetRaidRosterSelection(index) / GetRaidRosterSelection() — a purely CLIENT-SIDE cursor (the
|
||
// reference's `0x4bb820` writes a global; nothing is sent). The RaidFrame sets it when a row
|
||
// is picked up so the menu and the drag agree on who is being acted on.
|
||
g.set(
|
||
"SetRaidRosterSelection",
|
||
lua.create_function(|lua, index: Value| {
|
||
let index = number_arg(lua, index, "Usage: SetRaidRosterSelection(index)")?;
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.raid_selection = i64::from(index);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"GetRaidRosterSelection",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(model.raid_selection)
|
||
})?,
|
||
)?;
|
||
|
||
// 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
|
||
leader_guid: 0xA11CE,
|
||
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()),
|
||
threshold: None,
|
||
},
|
||
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,
|
||
threshold: None,
|
||
}]
|
||
);
|
||
|
||
// The optional THIRD argument, which the real binding reads for every method — not only
|
||
// for "master", the way the master-looter argument is read (decision 1675).
|
||
s.run(r#"SetLootMethod("group", nil, 4)"#).unwrap();
|
||
assert_eq!(
|
||
s.take_party_requests(),
|
||
vec![PartyRequest::LootMethod {
|
||
method: "group".into(),
|
||
master_name: None,
|
||
threshold: Some(4),
|
||
}]
|
||
);
|
||
}
|
||
|
||
#[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,
|
||
},
|
||
]
|
||
);
|
||
}
|
||
|
||
/// `IsRaidOfficer()` still answers nil, and the raid arc landing (decision 1549) did NOT
|
||
/// change that — it is not waiting on a feature, it is waiting on a carve of `0x4bb910`'s own
|
||
/// body. The binding's doc carries the refusal; this pins the behaviour so a later "surely it
|
||
/// is just rank >= 1" edit has to argue with a test.
|
||
///
|
||
/// What it costs today, stated where someone will find it: `RaidFrameAddMemberButton` is
|
||
/// enabled for the raid LEADER only, where the reference also enables it for an assistant.
|
||
#[test]
|
||
fn is_raid_officer_is_still_nil_and_that_is_a_missing_carve_not_a_missing_feature() {
|
||
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());
|
||
}
|
||
|
||
/// Every raid-management verb queues the request it names, with its arguments in the order
|
||
/// the reference's binding takes them — the seam the whole pane acts through (decision 1549).
|
||
#[test]
|
||
fn the_raid_verbs_queue_what_they_name() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run(
|
||
r#"
|
||
ConvertToRaid()
|
||
SetRaidSubgroup(7, 3)
|
||
SwapRaidSubgroup(7, 12)
|
||
PromoteByName("Alice")
|
||
PromoteToAssistant("Bob")
|
||
DemoteAssistant("Bob")
|
||
UninviteFromRaid(9)
|
||
DoReadyCheck()
|
||
RequestRaidInfo()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.take_party_requests(),
|
||
vec![
|
||
PartyRequest::ConvertToRaid,
|
||
PartyRequest::SetSubgroup { index: 7, group: 3 },
|
||
PartyRequest::SwapSubgroup {
|
||
index: 7,
|
||
other: 12
|
||
},
|
||
PartyRequest::PromoteName("Alice".into()),
|
||
PartyRequest::AssistantLeader {
|
||
name: "Bob".into(),
|
||
grant: true
|
||
},
|
||
PartyRequest::AssistantLeader {
|
||
name: "Bob".into(),
|
||
grant: false
|
||
},
|
||
PartyRequest::UninviteRaid(9),
|
||
PartyRequest::ReadyCheckStart,
|
||
PartyRequest::RequestRaidInfo,
|
||
]
|
||
);
|
||
assert!(s.take_party_requests().is_empty(), "the drain empties");
|
||
}
|
||
|
||
/// `ConfirmReadyCheck` reads its argument for TRUTH, not for a number — the reference's No
|
||
/// button calls it with **no argument at all** while Yes passes `1`, so "absent" has to be the
|
||
/// not-ready answer or every declined ready check reads as accepted.
|
||
#[test]
|
||
fn confirm_ready_check_treats_an_absent_argument_as_not_ready() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.run("ConfirmReadyCheck(1) ConfirmReadyCheck() ConfirmReadyCheck(false) ConfirmReadyCheck(0)")
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.take_party_requests(),
|
||
vec![
|
||
PartyRequest::ReadyCheckAnswer(true),
|
||
PartyRequest::ReadyCheckAnswer(false),
|
||
PartyRequest::ReadyCheckAnswer(false),
|
||
// `0` is TRUTHY in Lua, and the binding is a truth test — so this is ready.
|
||
PartyRequest::ReadyCheckAnswer(true),
|
||
]
|
||
);
|
||
}
|
||
|
||
/// The saved-lockout pair: 1-based, three returns, and a miss is a plain nothing (the panel's
|
||
/// own loop only ever indexes inside the count it just read).
|
||
#[test]
|
||
fn saved_instance_info_reads_the_pushed_list() {
|
||
use crate::script::SavedInstanceInfo;
|
||
let mut s = UiScript::new().unwrap();
|
||
assert_eq!(s.eval::<i64>("return GetNumSavedInstances()").unwrap(), 0);
|
||
s.set_saved_instances(vec![
|
||
SavedInstanceInfo {
|
||
name: "Molten Core".into(),
|
||
instance: 1234,
|
||
reset: 86_400,
|
||
},
|
||
SavedInstanceInfo {
|
||
name: "Onyxia's Lair".into(),
|
||
instance: 77,
|
||
reset: 3_600,
|
||
},
|
||
]);
|
||
assert_eq!(s.eval::<i64>("return GetNumSavedInstances()").unwrap(), 2);
|
||
let (name, id, reset) = s
|
||
.eval::<(String, i64, i64)>("return GetSavedInstanceInfo(1)")
|
||
.unwrap();
|
||
assert_eq!((name.as_str(), id, reset), ("Molten Core", 1234, 86_400));
|
||
assert_eq!(
|
||
s.eval::<String>("return GetSavedInstanceInfo(2)").unwrap(),
|
||
"Onyxia's Lair"
|
||
);
|
||
for miss in ["0", "3", "-1"] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return GetSavedInstanceInfo({miss}) == nil"))
|
||
.unwrap(),
|
||
"index {miss} is a miss"
|
||
);
|
||
}
|
||
// A non-number still raises, like every other indexed getter here.
|
||
assert!(s
|
||
.eval::<i64>(r#"return GetSavedInstanceInfo("x")"#)
|
||
.is_err());
|
||
}
|
||
|
||
/// `SetRaidRosterSelection` is purely client-side: it moves a cursor and sends nothing.
|
||
#[test]
|
||
fn the_raid_roster_selection_is_a_local_cursor() {
|
||
let mut s = UiScript::new().unwrap();
|
||
assert_eq!(s.eval::<i64>("return GetRaidRosterSelection()").unwrap(), 0);
|
||
s.run("SetRaidRosterSelection(11)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<i64>("return GetRaidRosterSelection()").unwrap(),
|
||
11
|
||
);
|
||
assert!(
|
||
s.take_party_requests().is_empty(),
|
||
"nothing about a selection goes on the wire"
|
||
);
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|