the raid tab lands, and sounds take the reference's voice buses

- the social window's fourth tab, Raid: the eight-subgroup grid over the roster, drag a member between subgroups, Convert To Raid, promote / demote assistant, uninvite, Ready Check with its popup, and the Raid Info flyout on the new `CMSG_REQUEST_RAID_INFO` / `SMSG_RAID_INSTANCE_INFO` wire (byte-golden against vmangos, an empty answer decoding as an empty list); `raid1`..`raid40` unit tokens, and `/partytest raid` stages a roster for a solo look
- an imbue pressed with an empty weapon hand says "Your weapon hand is empty" instead of raising a cursor — the attribute bit has two readers in the reference and the second is the error (corrects the day's first cut)
- sounds route onto the reference's own voice buses with its per-bus caps (the 0x87ce60 table, pinned as bytes): weapon impacts and clangs share four voices, footsteps six, the attacker's grunt one, the victim's injury two — so a pack swinging together makes about a dozen voices where it made dozens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-25 18:22:17 +07:00
co-authored by Claude Fable 5
parent 838bc1fb0c
commit 0d7563b581
41 changed files with 3808 additions and 144 deletions
+53 -11
View File
@@ -24,11 +24,14 @@
is the contract addons bind to — but the code is ours. It did not start that way: the
guild landing transcribed those windows and said so here, which is the thing the hard
rule forbids outright, so they were rewritten in our own terms (decision 1334).
· Tab 4 RAID is NOT built. The ref's RaidFrame is the party/raid management pane, and
decision 0434 §6 defers the raid UI wholesale; an empty fourth panel would read as a
bug, so the tab is absent until that arc lands. That is why `PanelTemplates_SetNumTabs`
says 3 where the ref says 4 — guild is tab 3 of 3 here, tab 3 of 4 there — and why
`FriendsFrame_Update`/`FRIENDSFRAME_SUBFRAMES` have no `selectedTab == 4` arm.
· Tab 4 RAID is LIVE (decision 1549). It lives in its own file, `RaidFrame.xml`, which
is the reference's own split — `RaidFrame` is a child of this window but a separate
FrameXML file there too — and it is loaded straight after this one, because a child
cannot be declared before its parent. This file owns only the tab: the button, the
`selectedTab == 4` arm, the subframe list entry, and the OnHide that shuts
RaidInfoFrame with the window. `PanelTemplates_SetNumTabs` now says 4, as the ref's
does. (It said 3 for as long as the pane did not exist, which was honest; the note
that stood here said "the tab is absent until that arc lands" — this is that arc.)
· FOUNDING a guild is out of scope and deliberately so (1257's own boundary): no
GuildRegistrar / PetitionFrame / TabardFrame, no charter or emblem path. Nothing in it
is reachable until you are IN a guild, which is what this file now covers.
@@ -192,10 +195,16 @@
{ name = "Race", sortType = "race" },
}
-- Ref FRIENDSFRAME_SUBFRAMES (l.24) less "RaidFrame", which this house does not build
-- (header SCOPE). Drives both `FriendsFrame_ShowSubFrame` and `FriendsFrame_OnHide`.
-- Ref FRIENDSFRAME_SUBFRAMES (l.24), now including "RaidFrame" (decision 1549). Drives
-- both `FriendsFrame_ShowSubFrame` and `FriendsFrame_OnHide`.
--
-- RaidFrame is declared in the NEXT file loaded, so at the moment this table is built the
-- name resolves to nothing. That is fine and is the reference's own situation twice over
-- (its RaidFrame.xml loads after this file; its raid GRID loads on demand, hours later):
-- the table holds NAMES, and every consumer resolves them through `getglobal` at call
-- time, by which point the window has been shown at least once and everything exists.
BENILLA_FRIENDS_SUBFRAMES = {
"FriendsListFrame", "IgnoreListFrame", "WhoFrame", "GuildFrame",
"FriendsListFrame", "IgnoreListFrame", "WhoFrame", "GuildFrame", "RaidFrame",
}
-- Six of this window's buttons are live only while a row of the right kind is selected,
@@ -239,7 +248,10 @@
-- The guild pane's three satellites are TOPLEVEL frames of their own rather than children
-- of the pane, so nothing hides them implicitly — each has to be told. They are also
-- mutually exclusive: opening any one shuts the other two, which is the same call with the
-- one being opened named. (RaidInfoFrame is the ref's fourth; no raid arc here.)
-- one being opened named. RaidInfoFrame is the ref's fourth and is NOT in this list, which
-- is the reference's own arrangement rather than an omission: it belongs to a different
-- tab, so it can never be on screen at the same time as any of these three. It is closed
-- by `FriendsFrame_OnHide` with everything else.
BENILLA_GUILD_SATELLITES = {
"GuildControlPopupFrame", "GuildMemberDetailFrame", "GuildInfoFrame",
}
@@ -326,6 +338,17 @@
FriendsFrameTitleText:SetText(guildName)
FriendsFrame_ShowSubFrame("GuildFrame")
GuildStatus_Update()
elseif frame.selectedTab == 4 then
-- The raid pane (ref l.118-125, decision 1549). The only tab that reuses the
-- CHARACTER window's bottom art rather than art of its own — there is no
-- RaidFrame-BotLeft in the patch chain, because the pane is a grid on a plain
-- parchment rather than a list in a frame.
FriendsFrameTopLeft:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-TopLeft")
FriendsFrameTopRight:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-TopRight")
FriendsFrameBottomLeft:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-BottomLeft")
FriendsFrameBottomRight:SetTexture("Interface\PaperDollInfoFrame\UI-Character-General-BottomRight")
FriendsFrameTitleText:SetText(RAID)
FriendsFrame_ShowSubFrame("RaidFrame")
end
end
@@ -1230,7 +1253,7 @@
-- THREE tabs, where the ref says four: benilla builds no RAID pane (header SCOPE), so
-- guild is tab 3 of 3 here and tab 3 of 4 there. Everything else in this function is
-- ref l.49-69.
PanelTemplates_SetNumTabs(this, 3)
PanelTemplates_SetNumTabs(this, 4)
this.selectedTab = 1
this.showFriendsList = 1
this.selectedFriend = 0
@@ -1268,10 +1291,15 @@
PlaySound("igMainMenuClose")
-- Ref l.131-135: the three guild satellites are TOPLEVEL frames of their own, not
-- children of the pane, so hiding the window does not hide them — each has to be told.
-- (RaidInfoFrame is the ref's fourth; no raid arc here.)
SetGuildRosterSelection(0)
GuildFrame.selectedGuildMember = 0
BenillaGuild_HideSatellitesExcept()
-- RaidInfoFrame is the ref's fourth satellite (l.135, decision 1549). It is not in
-- BENILLA_GUILD_SATELLITES because it is not a guild one — nothing may hide it just
-- because a guild window opened — but the window closing does close it, and it is a
-- child of RaidFrame rather than a toplevel, so this is belt and braces the ref also
-- wears.
if RaidInfoFrame then RaidInfoFrame:Hide() end
for i = 1, table.getn(BENILLA_FRIENDS_SUBFRAMES) do
getglobal(BENILLA_FRIENDS_SUBFRAMES[i]):Hide()
end
@@ -3263,6 +3291,20 @@
</OnClick>
</Scripts>
</Button>
<!-- The RAID tab (decision 1549). Unlike Guild it carries no enable/disable rule at
all — the reference never greys it, because the pane means something in every
state: solo it explains what a raid is, in a party it offers the conversion, in a
raid it is the roster. -->
<Button name="FriendsFrameTab4" inherits="CharacterFrameTabButtonTemplate" id="4" text="Raid">
<Anchors><Anchor point="LEFT" relativeTo="FriendsFrameTab3" relativePoint="RIGHT"><Offset><AbsDimension x="-14" y="0"/></Offset></Anchor></Anchors>
<Scripts>
<OnClick>
PanelTemplates_SetTab(FriendsFrame, this:GetID())
FriendsFrame_OnShow()
PlaySound("igCharacterInfoTab")
</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>FriendsFrame_OnLoad()</OnLoad>
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -21,7 +21,10 @@
-->
<Frame name="UIParent" setAllPoints="true" frameStrata="MEDIUM">
<Scripts>
<OnLoad>this:RegisterEvent("PLAYER_ENTERING_WORLD")</OnLoad>
<OnLoad>
this:RegisterEvent("PLAYER_ENTERING_WORLD")
this:RegisterEvent("READY_CHECK")
</OnLoad>
<OnEvent>UIParent_OnEvent(event)</OnEvent>
</Scripts>
</Frame>
@@ -199,6 +202,16 @@
MultiActionBar_Update()
end
end
-- The ready-check popup's opener (decision 1549), ref UIParent.lua l.570-572. The
-- event is the engine's own `0x218`, fired by the `MSG_RAID_READY_CHECK` open
-- handler; the frame and `ShowReadyCheck` live in RaidFrame.xml, so this is the one
-- place the two meet. Existence-guarded for `MultiActionBar_Update`'s reason: the
-- per-window harnesses load this file without the raid pane.
if ( event == "READY_CHECK" ) then
if ( ShowReadyCheck ) then
ShowReadyCheck()
end
end
end
-- ── Everything that has to move when the bottom of the screen fills up ──────────────────
@@ -599,6 +612,27 @@
frame:SetFrameLevel(frame:GetFrameLevel() + 1)
end
-- ── The 1024x768 scale ratios (ref UIParent.lua l.1794-1802) ────────────────────────────
--
-- Two FrameXML helpers addons call by name (the raid pullouts and a dozen third-party
-- drag handlers), so they exist for the reason `MouseIsOver` and `RaiseFrameLevel` do:
-- missing, the caller dies on `attempt to call global`. The reference's constants are the
-- 1024x768 reference resolution, and its own callers multiply a `GetCursorPosition()`
-- reading by them to convert into pixel space.
--
-- **benilla's own drag code does NOT use them**, and that is deliberate rather than an
-- oversight: our UI space is 1:1 logical px and `GetCursorPosition` already reports in
-- the same space `SetPoint` offsets are read in (the same identity `MouseIsOver`'s
-- comment below records for `GetEffectiveScale`). Multiplying here would place a dragged
-- frame at 1.5x the cursor on a 1600-wide window. Kept for the addons, unused by us.
function GetScreenHeightScale()
return GetScreenHeight() / 768
end
function GetScreenWidthScale()
return GetScreenWidth() / 1024
end
-- ── MouseIsOver: is the cursor inside this frame, with optional per-edge padding ─────────
--
-- The idiom every hover-driven addon writes, and **9 corpus addons call it** by name.
+54 -11
View File
@@ -36,8 +36,9 @@
one predicate that forks them are below, its opener is UnitFrames.xml's PetFrameDropDown, and
the two dialogs behind Abandon and Rename are registered in PetActionBar.xml (with the pet
arc, like every other dialog in this codebase — this file must stay loadable without the
popup engine). The RAID_* rank rows stay in the tables but are unreachable (no raid roster) —
they carry a hide-always predicate, and no click handler at all.
popup engine). The RAID_* rank rows opened with decision 1549: their hide-always predicate is
gone, they carry the reference's own per-row rules and click handlers, and their opener is
RaidFrame.xml's row right-click.
- Lua 5.1: the ref's 5.0 `for i, v in t do` iteration is ipairs/pairs here; strsub is
string.sub. `TEXT` is kept because the reference publishes it as the localization hook every
displayed string passes through (it is the identity, and BasicControls.xml defines the real
@@ -386,15 +387,40 @@
return PetCanBeAbandoned();
end
-- The raid roster (GetRaidRosterInfo) is a later arc — no raid menu opens yet, so the
-- four rank rows stay declared and never show.
local function hideAlways()
return true;
-- ── The four raid rank rows (decision 1549, ref UnitPopup.lua l.382-401) ──────────────
--
-- Every one of them re-reads the CLICKED ROW's rank through `GetRaidRosterInfo(userData)`
-- — which is why the RAID menu is the only one in this file opened with a fourth
-- argument, and why that argument is the raid ROW INDEX rather than a name. The rules,
-- ref-verbatim:
--
-- New Leader leader only, and not on the leader themself (rank 2)
-- Promote leader only, and only on a plain member (rank 0)
-- Demote leader only, and only on an assistant (rank 1)
-- Remove leader OR assistant, and never on the leader
--
-- The first three also require `menu.name`: a row whose name has not resolved cannot be
-- addressed by the by-name bindings behind them. Remove does not, because it acts on the
-- index directly.
local function raidRank(menu)
local _, rank = GetRaidRosterInfo(menu.userData);
return rank;
end
-- `party.isLeader` is 0/1, not a boolean, and **0 is truthy in Lua** — so these compare
-- against 0 rather than saying `not party.isLeader`, exactly as every other predicate in
-- this file does and exactly as the reference's own `(isLeader == 0)` does.
HIDE_ROW["RAID_LEADER"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) == 2 or not menu.name;
end
HIDE_ROW["RAID_PROMOTE"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) ~= 0 or not menu.name;
end
HIDE_ROW["RAID_DEMOTE"] = function(menu, party)
return party.isLeader == 0 or raidRank(menu) ~= 1 or not menu.name;
end
HIDE_ROW["RAID_REMOVE"] = function(menu, party)
return (party.isLeader == 0 and party.isAssistant == 0) or raidRank(menu) == 2;
end
HIDE_ROW["RAID_LEADER"] = hideAlways;
HIDE_ROW["RAID_PROMOTE"] = hideAlways;
HIDE_ROW["RAID_DEMOTE"] = hideAlways;
HIDE_ROW["RAID_REMOVE"] = hideAlways;
-- The two GUILD rows, ref UnitPopup.lua l.323-330 (decision 1257's guild arc un-defers
-- them). Both gate on `GuildFrame:IsVisible()`, which is the whole point of them: these
@@ -659,7 +685,7 @@
-- ── What a row does when it is clicked ─────────────────────────────────────────────
-- One handler per row, keyed by the row's own name. A row with no handler does nothing —
-- which is how Cancel, the nested parent rows, and every DEFERRED row are expressed
-- (header: TARGET, GUILD_*, RESET_INSTANCES, RAID_LEADER/PROMOTE/DEMOTE/REMOVE).
-- (header: TARGET, RESET_INSTANCES).
local CLICK_ROW = { };
CLICK_ROW["WHISPER"] = function(menu)
@@ -797,6 +823,23 @@
StaticPopup_Show("CONFIRM_GUILD_LEAVE", GetGuildInfo("player"));
end
-- The four raid rank verbs (ref UnitPopup_OnClick l.624-631). Three go by NAME and one by
-- the row index — the reference's own split, and it is the wire's: `CMSG_GROUP_UNINVITE`
-- would take either, but the assistant and leader opcodes need a guid the engine resolves
-- from a name, and the menu never had one to hand for a raid row.
CLICK_ROW["RAID_LEADER"] = function(menu)
PromoteByName(menu.name);
end
CLICK_ROW["RAID_PROMOTE"] = function(menu)
PromoteToAssistant(menu.name);
end
CLICK_ROW["RAID_DEMOTE"] = function(menu)
DemoteAssistant(menu.name);
end
CLICK_ROW["RAID_REMOVE"] = function(menu)
UninviteFromRaid(menu.userData);
end
function UnitPopup_OnClick()
local clicked = this;
local key = clicked.value;
+4
View File
@@ -303,6 +303,10 @@ TalentFrame.xml
# GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) —
# all loaded above.
FriendsFrame.xml
# The social window's fourth tab, in its own file because RaidFrame is a child of FriendsFrame and
# a child cannot be declared before its parent — the reference splits it the same way and for the
# same reason (its FrameXML.toc has RaidFrame.xml on the very next line). Decision 1549.
RaidFrame.xml
GossipFrame.xml
MerchantFrame.xml
# The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
+15
View File
@@ -1457,6 +1457,21 @@ pub(crate) enum ClientCommand {
/// submenu, decision 0434 §5): wire `icon` 0..7, `guid` 0 clears that icon's slot. Leader/
/// assistant only server-side; echoes back as the delta form.
SetRaidTarget { icon: u8, guid: u64 },
// ── The raid-management family (decision 1549's Raid tab) ─────────────────────────────────
/// Move a raid member to another subgroup (`CMSG_GROUP_CHANGE_SUB_GROUP`): the member's NAME
/// and the **0-based** subgroup the wire wants. Leader/assistant only; echoes as a fresh
/// `SMSG_GROUP_LIST`.
GroupChangeSubGroup { name: String, group: u8 },
/// Trade two raid members' subgroups (`CMSG_GROUP_SWAP_SUB_GROUP`, two names).
GroupSwapSubGroup { name: String, other: String },
/// Grant or revoke the raid-assistant flag (`CMSG_GROUP_ASSISTANT_LEADER`, leader only).
GroupAssistantLeader { guid: u64, grant: bool },
/// Start a ready check (`MSG_RAID_READY_CHECK`, empty body — leader only).
ReadyCheckStart,
/// Answer a ready check (`MSG_RAID_READY_CHECK`, one byte).
ReadyCheckAnswer { ready: bool },
/// Ask for our saved raid lockouts (`CMSG_REQUEST_RAID_INFO`) — the Raid Info panel.
RequestRaidInfo,
// ── The duel family (decision 0633; writer bodies in benilla-protocol
// `world/writer/duel.rs`). Challenging is a `CastSpell` of the duel spell, not a verb here.
/// Accept a duel challenge (`CMSG_DUEL_ACCEPTED`) — the popup's Accept, and the challenger's
+10 -5
View File
@@ -900,11 +900,16 @@ pub(super) fn apply_net_updates(
}
SessionEvent::RaidTargetSet { icon, guid } => group.apply_raid_target(icon, guid),
SessionEvent::RaidTargetList { entries } => group.apply_raid_target_list(&entries),
// Ping + ready-check are removed for now (decision 0460); the protocol still decodes
// the wire, but the client ignores it until those features return.
SessionEvent::MinimapPing { .. }
| SessionEvent::ReadyCheckRequest
| SessionEvent::ReadyCheckAnswer { .. } => {}
// The ready check came back with the Raid tab (decision 1549): the open form bumps
// the ticket the feed turns into a `READY_CHECK` edge. The ANSWER form is still
// ignored — the reference has no per-member answer surface in 1.12 (the raid pane
// shows no ready column; only later clients do), so there is nothing to show and
// storing it would be state with no reader.
SessionEvent::ReadyCheckRequest => group.apply_ready_check(),
SessionEvent::RaidInstanceInfo { entries } => group.apply_raid_instance_info(entries),
// Ping is still removed (decision 0460); the protocol decodes the wire, the client
// ignores it until that feature returns.
SessionEvent::MinimapPing { .. } | SessionEvent::ReadyCheckAnswer { .. } => {}
// ── The duel family (decision 0633): the session mirror + the two DisplayError
// lines the handlers emit inline; the Era events fire off the mirror's edges in
// `ui_duel::feed_duel`, and the countdown ticks in its own system ──
+12
View File
@@ -972,6 +972,18 @@ fn writer_loop(
threshold,
} => w.loot_method(method, master, threshold),
ClientCommand::SetRaidTarget { icon, guid } => w.raid_target_set(icon, guid),
ClientCommand::GroupChangeSubGroup { name, group } => {
w.group_change_sub_group(&name, group)
}
ClientCommand::GroupSwapSubGroup { name, other } => {
w.group_swap_sub_group(&name, &other)
}
ClientCommand::GroupAssistantLeader { guid, grant } => {
w.group_assistant_leader(guid, grant)
}
ClientCommand::ReadyCheckStart => w.ready_check_start(),
ClientCommand::ReadyCheckAnswer { ready } => w.ready_check_answer(ready),
ClientCommand::RequestRaidInfo => w.request_raid_info(),
ClientCommand::DuelAccepted { arbiter } => w.duel_accepted(arbiter),
ClientCommand::DuelCancelled { arbiter } => w.duel_cancelled(arbiter),
ClientCommand::TogglePvp => w.toggle_pvp(),
+84 -36
View File
@@ -9,10 +9,17 @@
//! kits 7080/7081 `Combat Miss 1H/2H` by weapon handedness — exactly the two ids the client
//! caches by name at startup (wow-re `0x4575b0`, `_DONOTRENAME_` kits).
//! - **`$CAH`** — the attacker's exertion vocal (`CreatureSoundData.Exertion[Critical]`, the
//! same voice chain as everything else — characters resolve via the model fallback). INTERIM
//! (0075's pre-§5 reading): the §5s that pinned `$CAH` as the victim-dispatch trigger
//! (decisions 0149/0279) traced no exertion play on its byte route — where the exertion
//! columns actually fire is unpinned, a wow-re sliver; the audible result is accepted.
//! same voice chain as everything else — characters resolve via the model fallback). **INTERIM,
//! and now pinned wrong** (decision 1555): the sliver 0075 left open — "where the exertion
//! columns actually fire is unpinned" — has been answered, and it is not here. Exertion is
//! **packet-driven at swing start**: `SMSG_ATTACKERSTATEUPDATE` → `0x6246a0` → `0x624786` →
//! `0x623b10`, class `([hitrec+0x10] >> 7) & 1` (the crit bit — so the column index benilla
//! already computes is right), gated on `victimState != 0` and the victim's health > 0. There
//! is no `call [reg+0x88]` anywhere in `0x6247d0`, so `$CAH` never reaches the exertion columns
//! at all; what `$CAH` actually drives is `0x6249bb call 0x624530` — the **victim's** injury
//! vocal, which benilla already fires off that crossing. Correcting the trigger moves *when*
//! a grunt is heard (swing start, not mid-clip) and *whether* a clean miss grunts at all, so it
//! is deliberately a separate change from 1555's bus routing and is not folded in here.
//!
//! The **contact family** consumes [`SwingImpact`] — the victim dispatch `0x624530` + the
//! `0x6247d0` weapon-sound block, fired at the swing clip's **`$AH0–3`/`$CAH`** crossing, or at
@@ -39,6 +46,13 @@
//! A `text_only` flush (supersede/attack-stop) drops its sounds — only the floating number
//! flushes (decision 0149's flush law, inherited from the shared dispatch).
//!
//! Two legs of the reference's melee family benilla does not voice at all, both now pinned
//! (decision 1555): the **connecting swing** — a `$CSS` on any victimState outside {0, 2, 6}
//! plays `WeaponSwingSounds2.dbc` (kits 233–238, Light/Medium/Heavy × Normal/Critical, index
//! `critical + swingType*2`) on the capped bus 6, where benilla plays nothing and voices only the
//! miss whoosh; and the **material foley** that rides the same `$FSD` as the terrain footstep
//! (`0x6233d9 call [vt+0x8c]` → `0x4584e0`, bus 0, uncapped).
//!
//! INTERIM readings (flagged for a wow-re pass): victims' armor lands on the flesh slot (the
//! chain/plate slots need the armor-material chain);
//! blocks assume a metal shield; a defended outcome suppresses block 1's generic weapon impact
@@ -57,12 +71,12 @@ use bevy::prelude::*;
use benilla_formats::{impact_slot, WeaponImpactCatalog};
use crate::creature_anim::{AnimSoundEvent, SwingImpact, SwingMessage, Wielded};
use crate::net::NetEntity;
use crate::net::{Embodied, NetEntity};
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::creature::CreatureVoices;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::kit::{play_kit_ext, Bus, KitRef, PlayExtras, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
// vmangos `HitInfo` bits (UnitDefines.h, 1.12 wire).
@@ -185,7 +199,7 @@ fn combat_sounds(
mut contacts: MessageReader<SwingImpact>,
mut events: MessageReader<AnimSoundEvent>,
mut last: Local<LastSwing>,
units: Query<(&Transform, Option<&Wielded>, &NetEntity)>,
units: Query<(&Transform, Option<&Wielded>, &NetEntity, Has<Embodied>)>,
impacts: Option<Res<WeaponImpacts>>,
voices: Option<Res<CreatureVoices>>,
kits: Option<ResMut<SoundKits>>,
@@ -211,23 +225,29 @@ fn combat_sounds(
return;
};
let listener = listener.pos;
let play = |kits: &mut SoundKits, out: &mut SoundOutput, kit: u32, pos: Vec3, what: &str| {
if kit == 0 {
return;
}
if let Err(e) = play_kit(
kits,
&assets,
out,
&config,
listener,
KitRef::Id(kit),
Some(pos),
SoundCategory::Sfx,
) {
warn!("combat {what} (kit {kit}): {e:#}");
}
};
// Every combat play carries its **voice bus** (decision 1555): the melee-contact family all
// contends for bus 10's four voices, the vocals for their own one or two, and the miss whoosh
// for nothing at all. A kit refused at the cap is not an error — it is the gate doing its job,
// and `play_kit_ext` reports it as an ordinary silent success.
let play =
|kits: &mut SoundKits, out: &mut SoundOutput, kit: u32, pos: Vec3, bus: Bus, what: &str| {
if kit == 0 {
return;
}
if let Err(e) = play_kit_ext(
kits,
&assets,
out,
&config,
listener,
KitRef::Id(kit),
Some(pos),
SoundCategory::Sfx,
PlayExtras { bus, ..default() },
) {
warn!("combat {what} (kit {kit}): {e:#}");
}
};
// The tag-driven pair: the whoosh and the exertion vocal.
for ev in events.read() {
@@ -238,7 +258,7 @@ fn combat_sounds(
let Some(swing) = last.0.get(&ev.entity) else {
continue; // an attack anim without a tracked swing (e.g. spawned mid-fight)
};
let Ok((attacker_tr, wielded, net)) = units.get(ev.entity) else {
let Ok((attacker_tr, wielded, net, _)) = units.get(ev.entity) else {
continue;
};
let pos = attacker_tr.translation;
@@ -251,7 +271,7 @@ fn combat_sounds(
} else {
COMBAT_MISS_1H
};
play(&mut kits, &mut out, kit, pos, "miss whoosh");
play(&mut kits, &mut out, kit, pos, Bus::DEFAULT, "miss whoosh");
}
} else {
let crit = swing.hit_info & HITINFO_CRITICAL != 0;
@@ -260,7 +280,7 @@ fn combat_sounds(
.and_then(|d| voices.0.for_display(d))
.map(|v| v.exertion[usize::from(crit)])
.unwrap_or(0);
play(&mut kits, &mut out, vocal, pos, "exertion");
play(&mut kits, &mut out, vocal, pos, Bus::EXERTION, "exertion");
}
}
@@ -285,7 +305,7 @@ fn combat_sounds(
// The attacker's weapon row (`0x625460(attacker, leftswing)`) — shared by both
// blocks below. `None` for a wand/thrown: no melee row, nothing to strike with.
let offhand = swing.hit_info & 0x4 != 0;
let (subclass, wooden) = swing_weapon(attacker.and_then(|(_, w, _)| w), offhand);
let (subclass, wooden) = swing_weapon(attacker.and_then(|(_, w, ..)| w), offhand);
let row = impacts.0.get(subclass, !wooden);
let defended = defended(swing.victim_state);
@@ -298,21 +318,28 @@ fn combat_sounds(
if let Some(n) = imp.natural {
// The attacker's own natural-weapon sound replaces the generic weapon impact.
let vocal = attacker
.and_then(|(_, _, net)| net.display_id)
.and_then(|(_, _, net, _)| net.display_id)
.and_then(|d| voices.0.for_display(d))
.and_then(|v| v.custom_attack.get(usize::from(n)).copied())
.unwrap_or(0);
play(&mut kits, &mut out, vocal, pos, "natural impact");
play(
&mut kits,
&mut out,
vocal,
pos,
Bus::MELEE_IMPACT,
"natural impact",
);
} else if !defended {
if let Some(row) = row {
// A landed hit: the victim's material slot (players/rowless → flesh).
let material = victim
.and_then(|(_, _, net)| net.display_id)
.and_then(|(_, _, net, _)| net.display_id)
.and_then(|d| voices.0.for_display(d))
.map(|v| v.impact_type)
.unwrap_or(0);
let kit = landed_impact(row, material, crit);
play(&mut kits, &mut out, kit, pos, "impact");
play(&mut kits, &mut out, kit, pos, Bus::MELEE_IMPACT, "impact");
}
}
@@ -325,11 +352,18 @@ fn combat_sounds(
if let (true, Some(row)) = (defended, row) {
// The parry family by the *victim's* weapon body (INTERIM heuristic).
let victim_wooden = victim
.map(|(_, w, _)| swing_weapon(w, false).1)
.map(|(_, w, ..)| swing_weapon(w, false).1)
.unwrap_or(false);
let kit = defense_clang(row, swing.victim_state, victim_wooden);
let at = victim.map(|(t, ..)| t.translation).unwrap_or(pos);
play(&mut kits, &mut out, kit, at, "defense clang");
play(
&mut kits,
&mut out,
kit,
at,
Bus::MELEE_IMPACT,
"defense clang",
);
}
}
@@ -340,7 +374,7 @@ fn combat_sounds(
&& swing.hit_info & 0x60 == 0
&& !matches!(swing.victim_state, VICTIM_PARRY | VICTIM_BLOCK)
{
if let Some((victim_tr, _, net)) = victim {
if let Some((victim_tr, _, net, victim_is_you)) = victim {
let crushing = swing.hit_info & HITINFO_CRUSHING != 0;
let vocal = net
.display_id
@@ -354,7 +388,21 @@ fn combat_sounds(
.unwrap_or(0)
})
.unwrap_or(0);
play(&mut kits, &mut out, vocal, victim_tr.translation, "injury");
// Your own wounds get the CGPlayer twin's private bus 8 (cap 1); everyone
// else's share the world's bus 7 (cap 2).
let bus = if victim_is_you {
Bus::SELF_INJURY
} else {
Bus::INJURY
};
play(
&mut kits,
&mut out,
vocal,
victim_tr.translation,
bus,
"injury",
);
}
}
}
+28 -11
View File
@@ -24,7 +24,7 @@ use benilla_world::schedule::WorldStage;
use super::kit::{
bark_chance_pass, play_kit, play_kit_ext, source_kit_playing, stop_source_kit,
unit_voice_playing, voice_category, KitRef, SoundCategory, SoundKits, STAND_CHANCE,
unit_voice_playing, voice_category, KitRef, PlayExtras, SoundCategory, SoundKits, STAND_CHANCE,
STAND_COOLDOWN,
};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -126,8 +126,23 @@ fn death_vocals(
/// threshold is **100** and the compare is inclusive, so `P = 1`. (wow-re's `smsg-ai-reaction.md`
/// calling ALERT "probabilistic" was too strong, and has been corrected at the table.) Its one
/// real gate is a mute while a **server-pushed** object sound is live on the unit — the
/// `SMSG_PLAY_OBJECT_SOUND` (opcode `0x278`) registry, an opcode benilla does not implement at
/// all — so nothing is owed here until that lands. ALERT stores no latch and stays off the slot.
/// `SMSG_PLAY_OBJECT_SOUND` (opcode `0x278`) registry. ALERT stores no latch and stays off the
/// slot.
///
/// **That gate IS owed, and the note that said otherwise was wrong when it was written.** 1401
/// recorded the opcode as one "benilla does not implement at all"; benilla has parsed, decoded
/// and played it since well before that — `SMSG_PLAY_OBJECT_SOUND` →
/// `net::apply::world::play_object_sound` → `ServerSoundKind::ObjectSound` →
/// `sound::zone::server_sounds`, which even resolves the source entity to position the kit at it.
/// What is missing is not the opcode but the **per-GUID registry** the gates consult (`0x4591f0`,
/// tested by `0x623a40`'s gate (i) and by `0x623490`'s gate 3 for classes 0,1,2,3 and 8): the
/// pushed channel plays untagged, so nothing can ask "is an object sound live on this unit".
///
/// Tagging it is one argument (`PlayExtras::source`) — but tagging it into the *shared* `source`
/// latch would deepen the conflation `kit::source_playing` already documents, where a unit's body
/// loop and water splash mask a greeting the reference would let through. The honest fix is the
/// per-latch marker decision 1399 raised, and this is a second caller waiting on it, not a reason
/// to slip a third meaning into one tag.
#[allow(clippy::too_many_arguments)]
fn ai_reaction_vocals(
mut reactions: MessageReader<crate::net::AiReactionMessage>,
@@ -199,10 +214,11 @@ fn ai_reaction_vocals(
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
None,
Some(r.unit),
false,
Some(voice_category::HOSTILE),
PlayExtras {
source: Some(r.unit),
voice: Some(voice_category::HOSTILE),
..default()
},
) {
warn!("aggro vocal (kit {kit}): {e:#}");
}
@@ -289,10 +305,11 @@ fn creature_body_loops(
KitRef::Id(desired),
Some(transform.translation),
SoundCategory::Sfx,
None,
Some(entity),
true,
None,
PlayExtras {
source: Some(entity),
force_loop: true,
..default()
},
) {
warn!("body loop (kit {desired}): {e:#}");
}
+6 -2
View File
@@ -49,7 +49,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::creature::CreatureVoices;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::kit::{play_kit_ext, Bus, KitRef, PlayExtras, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
/// The loaded terrain-chain catalog. `pub(crate)`: [`crate::footprints`] reads the same chain's
@@ -168,7 +168,7 @@ fn footstep_sounds(
.room_group(who)
.map_or_else(|| "adt".to_string(), |g| format!("wmo g{g}"))
);
if let Err(e) = play_kit(
if let Err(e) = play_kit_ext(
&mut kits,
&assets,
&mut out,
@@ -177,6 +177,10 @@ fn footstep_sounds(
KitRef::Id(kit),
Some(transform.translation()),
SoundCategory::Sfx,
PlayExtras {
bus: Bus::FOOTSTEP,
..default()
},
) {
warn!("footstep (kit {kit}): {e:#}");
}
+6 -5
View File
@@ -48,7 +48,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
use super::kit::{self, play_kit_ext, KitRef, SoundCategory, SoundKits};
use super::kit::{self, play_kit_ext, KitRef, PlayExtras, SoundCategory, SoundKits};
use super::{SoundConfig, SoundOutput};
/// The number of HELLO takes the select greeter plays before it steps into the pissed kit
@@ -206,10 +206,11 @@ fn play_line(
KitRef::Id(kit_id),
Some(pos),
SoundCategory::Sfx,
variant,
Some(npc),
false,
None,
PlayExtras {
variant,
source: Some(npc),
..default()
},
) {
warn!("npc vocal (kit {kit_id}): {e:#}");
}
+169 -5
View File
@@ -111,6 +111,8 @@ pub(crate) struct ActiveChannel {
/// `set_volume`). Written via [`set_source_kit_gain`].
gain: f32,
category: SoundCategory,
/// The **voice bus** this channel occupies, for the concurrency cap ([`Bus`]).
bus: Bus,
/// The **voice category latched on this channel**, when this play is a unit's one-shot
/// creature bark — the reference's `[unit+0xb24]` beside the `[unit+0xb20]` handle
/// ([`unit_voice_playing`]). `None` for every other play, which is what keeps a unit's body
@@ -163,6 +165,107 @@ pub(super) const STAND_CHANCE: u32 = 40;
/// so a silent or distance-culled `$FDX` burns the window for everyone too.
pub(super) const STAND_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(10);
/// A **voice bus** — the reference's per-bus concurrency domain, and the index into its
/// compile-time cap table (wow-re `sound/scratch/voice-cap-and-headroom.md`, and
/// `creature-vocal-gates.md` §3.1/§5.3).
///
/// This is **not** the volume category ([`SoundCategory`]) — VERIFIED orthogonal: the bus lives at
/// `[chan+0x84]` and takes 0..12, while the category is flag bits `0x2`/`0x8`/`0x10` in
/// `[chan+0x38]` selecting one of three master cells. A footstep and a spell impact are both SFX
/// and land on different buses; the music stream and a UI click are different categories on the
/// same bus 0.
///
/// A play whose bus is already at its cap is **refused outright** — byte-proven: the only exits
/// from the pre-play gate `0x7a66a0` are `mov al,1; ret` / `xor al,al; ret`, with no stop, steal,
/// queue or priority compare anywhere on the path, and the callers do not retry (they simply fail
/// to set their latch, so the next natural trigger tries again).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(super) struct Bus(pub(super) u8);
impl Bus {
/// Bus 0 — cap `0x7FFFFFFF`, i.e. **uncapped**. Where spell impacts, UI, music and ambience
/// all live in the reference, so it is also benilla's default and the honest answer for any
/// play whose bus we have not pinned. The `$CSS` **miss** whoosh is here too (bus 0, uncapped)
/// — only a *connecting* swing takes the capped bus 6, which benilla does not yet voice.
pub(super) const DEFAULT: Bus = Bus(0);
/// Bus 5 — **cap 1**. The attacker's exertion vocal (`CreatureSoundData.Exertion` = class 0,
/// `ExertionCritical` = class 1), routed through `0x624786` → `0x623b10`. One exertion voice
/// in the whole world at a time: a pack of mobs swinging together grunts *once*.
///
/// (`0x623b70`'s creature-classification flag routes some units to bus **11** instead. The cap
/// there is also 1, so it buys a private pool of the same size and never changes the answer —
/// which is why benilla can route by column without resolving that bit.)
pub(super) const EXERTION: Bus = Bus(5);
/// Bus 7 — **cap 2**. The victim's wound vocal (`Injury` = class 2, `InjuryCritical` = 3,
/// `InjuryCrushingBlow` = 9, which is dead in shipped data: 0 of 406 rows populate it).
/// Bus **12** is its classified twin, cap 2 as well.
pub(super) const INJURY: Bus = Bus(7);
/// Bus 8 — **cap 1**. The **local player's own** wound vocal: the CGPlayer twin substitutes
/// this bus for classes 2/3/9 when the unit is the active player (`0x62f8c5..0x62f8f3`), so
/// your own grunts get a private slot of one rather than sharing the world's two. It is the
/// injury half only — your exertion still competes on [`Self::EXERTION`].
pub(super) const SELF_INJURY: Bus = Bus(8);
/// Bus 9 — **cap 6**. The terrain footstep off `$FSD` (`0x62342a` → `0x458380`). Note the
/// same `$FSD` also fires a *material foley* on bus 0 in the reference (`[vt+0x8c]` →
/// `0x4584e0`), which benilla does not voice — benilla plays one sound per footstep, so the
/// one it plays is this one.
pub(super) const FOOTSTEP: Bus = Bus(9);
/// Bus 10 — **cap 4**. The whole melee-contact family, which all contends for the same four
/// voices: the generic `WeaponImpactSounds` hit (`0x624977` → `0x457ec0`), the natural-weapon
/// `CustomAttack[n]` column that replaces it (`0x6248ea`), and the parry/block clang
/// (`0x623640` → `0x457dc0`). **Not** the deflect clang, which is a fixed kit on bus 0.
pub(super) const MELEE_IMPACT: Bus = Bus(10);
}
/// The reference's cap table — `.data` `0x87ce60`, **13 dwords, no writer anywhere in the image**
/// (the `cmp` at `0x7a66b5` is its only reference), so these are compile-time constants and this
/// array is a transcription of the bytes:
///
/// ```text
/// 87ce60 ffffff7f 01000000 02000000 02000000
/// 87ce70 01000000 01000000 02000000 02000000
/// 87ce80 01000000 06000000 04000000 01000000
/// 87ce90 02000000
/// ```
const BUS_CAP: [u32; 13] = [0x7fff_ffff, 1, 2, 2, 1, 1, 2, 2, 1, 6, 4, 1, 2];
/// Is `bus` already carrying its cap's worth of live channels? Pure over the live buses, so the
/// whole gate is testable without a device (the caps are the reference's, and getting one wrong
/// silences a class of sound outright).
fn bus_at_cap(live: impl Iterator<Item = Bus>, bus: Bus) -> bool {
let cap = BUS_CAP[usize::from(bus.0)];
// Bus 0's cap is `0x7FFFFFFF` — reachable only in principle, so skip the walk entirely. It is
// also where every unpinned play lands, which makes this the common path.
cap != BUS_CAP[0] && live.filter(|b| *b == bus).count() as u32 >= cap
}
/// The optional half of the client's full play surface — the extras [`play_kit_ext`] carries
/// beyond "which kit, where, which category". Grouped rather than passed as a row of positional
/// booleans: there were already eight of them behind a `too_many_arguments` waiver, and the bus
/// would have been the ninth.
#[derive(Clone, Copy, Default)]
pub(super) struct PlayExtras {
/// An **explicit variation index** (`Some(i)` = play `files[i]`, bypassing the depleting
/// random pool — the client's `variant != -1`; the NPC-greeting sequence cycler drives this).
pub(super) variant: Option<usize>,
/// A **source entity tag** — the played channel is tagged so its liveness serves as that
/// entity's per-unit latch ([`source_playing`]).
pub(super) source: Option<Entity>,
/// Loop regardless of the kit's own 0x200 flag, for the drivers whose *column* is the loop
/// authority (the creature body-loop; see [`play_kit_ext`]'s own note).
pub(super) force_loop: bool,
/// The category latched on the unit's one-shot bark slot ([`ActiveChannel::voice`]); `Some`
/// only for the barks the reference stores in `[unit+0xb20]`.
pub(super) voice: Option<u8>,
/// The **voice bus** this play competes on ([`Bus`]). Defaults to the uncapped bus 0.
pub(super) bus: Bus,
}
/// A kit to play, by id or by `PlaySoundByName` name.
pub(crate) enum KitRef<'a> {
Id(u32),
@@ -185,7 +288,15 @@ pub(crate) fn play_kit(
category: SoundCategory,
) -> Result<()> {
play_kit_ext(
kits, assets, out, config, listener, kit_ref, pos, category, None, None, false, None,
kits,
assets,
out,
config,
listener,
kit_ref,
pos,
category,
PlayExtras::default(),
)
}
@@ -210,11 +321,15 @@ pub(super) fn play_kit_ext(
kit_ref: KitRef<'_>,
pos: Option<Vec3>,
category: SoundCategory,
variant: Option<usize>,
source: Option<Entity>,
force_loop: bool,
voice: Option<u8>,
extras: PlayExtras,
) -> Result<()> {
let PlayExtras {
variant,
source,
force_loop,
voice,
bus,
} = extras;
// The cover's audio hold ([`SoundConfig::world_hold`]): while the loading screen is up, no
// new sound starts — checked before anything allocates, so the per-frame retry drivers (the
// creature body-loop, the liquid loops) cost one bool while held and start clean at the
@@ -252,6 +367,16 @@ pub(super) fn play_kit_ext(
return Ok(()); // a kit with no files is playable-as-nothing, not an error
}
// The **per-bus concurrency cap** — the always-on arm of the pre-play gate `0x7a66a0`, checked
// *before* the duplicate walk exactly as the binary does (`0x7a66ae/b5` precede `0x7a66c4`).
// The count is of **allocated** channels, not audible ones, which is what `out.channels` holds:
// the reference's `[0xcf553c + 4*bus]` keeps a paused or muted channel's slot and releases a
// distance-culled one only because the cull hands the channel back — and benilla's pump reaps a
// culled channel out of this list on the same edge.
if bus_at_cap(out.channels.iter().map(|c| c.bus), bus) {
return Ok(());
}
// Duplicate suppression — byte-verified (wow-re uisound-tables.md, corrected §5): the FMOD
// pre-play gate 0x7a66a0 drops a play when kit flag 0x20 is set AND a same-kit instance is
// still audible (0x458f40 lifts SoundEntries +0x7c bit 0x20 into the FMOD flags word). The
@@ -341,6 +466,7 @@ pub(super) fn play_kit_ext(
v,
gain: 1.0,
category,
bus,
voice,
});
Ok(())
@@ -491,6 +617,7 @@ pub(crate) fn play_file(
v: 1.0,
gain: 1.0,
category,
bus: Bus::DEFAULT,
voice: None,
});
Ok(())
@@ -782,6 +909,43 @@ pub(super) fn plugin(app: &mut App) {
mod tests {
use super::*;
/// The cap table is a transcription of `.data` `0x87ce60`, so it is pinned as bytes, not as
/// intent: 13 dwords, bus 0 effectively unlimited, and the four buses benilla actually routes
/// onto carrying the numbers the binary carries.
#[test]
fn the_cap_table_is_the_bytes_at_0x87ce60() {
assert_eq!(BUS_CAP.len(), 13);
assert_eq!(BUS_CAP, [0x7fff_ffff, 1, 2, 2, 1, 1, 2, 2, 1, 6, 4, 1, 2]);
}
/// The gate refuses at the cap and not before it, per bus, independently — and bus 0 never
/// refuses however loaded it gets, which is what keeps spell impacts, UI, music and ambience
/// behaving exactly as they did.
#[test]
fn a_bus_refuses_at_its_cap_and_only_its_own() {
let live = |bus: u8, n: usize| std::iter::repeat_n(Bus(bus), n);
// Bus 0 is uncapped: a thousand live channels still admit the next one.
assert!(!bus_at_cap(live(0, 1000), Bus::DEFAULT));
// A cap-1 bus (5 = exertion) admits the first and refuses the second.
assert!(!bus_at_cap(std::iter::empty(), Bus(5)));
assert!(bus_at_cap(live(5, 1), Bus(5)));
// A cap-2 bus (7 = injury) admits two.
assert!(!bus_at_cap(live(7, 1), Bus(7)));
assert!(bus_at_cap(live(7, 2), Bus(7)));
// The capped melee/footstep pair, at their own numbers.
assert!(!bus_at_cap(live(10, 3), Bus(10)));
assert!(bus_at_cap(live(10, 4), Bus(10)));
assert!(!bus_at_cap(live(9, 5), Bus(9)));
assert!(bus_at_cap(live(9, 6), Bus(9)));
// Buses are independent domains: a saturated bus 5 does not gate bus 7.
assert!(!bus_at_cap(live(5, 9), Bus(7)));
}
/// The depleting pool (`0x45bb70`/`0x45bd40`): with the data's typical all-1 weights, every
/// variation plays exactly once per cycle (no repeats until the pool empties), then the pool
/// refills and the next cycle again covers all of them.
+6 -5
View File
@@ -34,7 +34,7 @@ use benilla_world::schedule::WorldStage;
use super::kit::{
self, play_kit_ext, set_source_kit_gain, source_kit_playing, stop_source_kit, KitRef,
SoundCategory, SoundKits,
PlayExtras, SoundCategory, SoundKits,
};
use super::{SoundConfig, SoundOutput};
@@ -305,10 +305,11 @@ fn start_loop(
KitRef::Id(kit_id),
Some(pos),
SoundCategory::Ambience,
None,
Some(emitter),
true,
None,
PlayExtras {
source: Some(emitter),
force_loop: true,
..default()
},
) {
warn!("liquid loop kit {kit_id}: {e:#}");
return;
+8 -5
View File
@@ -15,7 +15,7 @@ use crate::entities::MissileSound;
use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
use super::kit::{play_kit_ext, stop_source, KitRef, SoundCategory, SoundKits};
use super::kit::{play_kit_ext, stop_source, KitRef, PlayExtras, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
fn route_missile_sounds(
@@ -49,10 +49,13 @@ fn route_missile_sounds(
KitRef::Id(kit_sound),
Some(pos),
SoundCategory::Sfx,
None,
Some(entity), // tag the loop to the missile — the pump follows it in flight
true, // the missile loop is looping by construction, not by the flag
None,
PlayExtras {
// tag the loop to the missile — the pump follows it in flight, and the
// loop is looping by construction rather than by the kit's own flag
source: Some(entity),
force_loop: true,
..default()
},
) {
warn!("missile flight sound {kit_sound}: {e:#}");
}
+7 -5
View File
@@ -16,7 +16,9 @@ use crate::net::NetEntity;
use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
use super::kit::{kit_looping, play_kit, play_kit_ext, KitRef, SoundCategory, SoundKits};
use super::kit::{
kit_looping, play_kit, play_kit_ext, KitRef, PlayExtras, SoundCategory, SoundKits,
};
use super::{AudioListener, SoundConfig, SoundOutput};
#[allow(clippy::too_many_arguments)] // the standard sound-route param set + the hold ledger
@@ -71,10 +73,10 @@ fn route_spell_kit_sounds(
KitRef::Id(kit_sound),
pos,
SoundCategory::Sfx,
None,
Some(entity),
false,
None,
PlayExtras {
source: Some(entity),
..default()
},
)
} else {
play_kit(
+5 -5
View File
@@ -28,7 +28,7 @@ use crate::net::NetEntity;
use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
use super::kit::{play_kit_ext, source_kit_playing, KitRef, SoundCategory, SoundKits};
use super::kit::{play_kit_ext, source_kit_playing, KitRef, PlayExtras, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
/// SoundEntries 1096 `CharacterSplashSoundMedium` (byte-verified in the type-21 census).
@@ -96,10 +96,10 @@ fn water_splashes(
KitRef::Id(SPLASH_KIT),
Some(transform.translation),
SoundCategory::Sfx,
None,
Some(entity),
false,
None,
PlayExtras {
source: Some(entity),
..default()
},
) {
warn!("water splash: {e:#}");
}
+16 -2
View File
@@ -725,8 +725,8 @@ pub(super) fn clear_target_requests(
/// ([`scan::commit`]) — the app half of the reference's `TargetUnit` Lua shim. Callers: the player
/// frame's left-click (`TargetUnit("player")`) and the party frames' (`TargetUnit("partyN")`,
/// decision 0434 phase 5). Only tokens resolving to a STREAMED unit act: `"player"` → our avatar;
/// `"target"` → the current selection (a dedup no-op); `"partyN"` → that roster slot when its
/// entity is in range (an out-of-range member needs the guid-only selection the phase-4
/// `"target"` → the current selection (a dedup no-op); `"partyN"`/`"raidN"` → that roster slot
/// when its entity is in range (an out-of-range member needs the guid-only selection the phase-4
/// out-of-range slice owns — until then the click no-ops, like the real client on a nonexistent
/// unit); `"pet"` → the bar's cached pet guid (decision 0990, the pet frame's left click).
/// Everything else (mouseover/name) waits for its wire.
@@ -761,6 +761,20 @@ pub(super) fn target_unit_requests(
.then(|| index.0.get(&pet.spells.pet_guid))
.flatten()
.map(|&e| (e, pet.spells.pet_guid)),
// `raidN` before the `partyN` arm for the same reason the reference's own resolver
// orders `partypet` ahead of `party`: a prefix test that runs first wins. These are
// the RaidFrame rows' left-click (decision 1549); like a party slot, an out-of-range
// member no-ops until the guid-only selection lands.
t if t.starts_with("raid") => t
.strip_prefix("raid")
.and_then(|n| n.parse::<usize>().ok())
.filter(|n| (1..=40).contains(n))
.and_then(|n| {
crate::ui_party::raid_row_guids(&group, self_guid)
.get(n - 1)
.copied()
})
.and_then(|g| index.0.get(&g).map(|e| (*e, g))),
t => t
.strip_prefix("party")
.and_then(|n| n.parse::<usize>().ok())
+21 -1
View File
@@ -7,7 +7,8 @@
//! below is that function's order, gate for gate (re-pinned end to end by the 0948 §5,
//! `gcd-power-gate.md`) — profession intercept, auto-repeat toggle, targeting abort, in-flight,
//! reagents/totems, target binding + range, then the validator `0x6094f0`'s opening rungs
//! (not-ready/GCD, power), mounted, moving, form, the deferred targeting-cursor entry — followed
//! (not-ready/GCD, power), mounted, moving, form, the deferred cast-arm refusal and
//! targeting-cursor entry — followed
//! by the commit's own tail (ranged stance, auto-repeat arm, the send, the auto-attack start, the
//! GCD arm).
//!
@@ -313,6 +314,7 @@ fn send_spell_cast(
// chain calls `CGItem::Use` with the lock's guid, the bag click with zero (decision 0769).
let mut pending_word = None;
let mut item_target = None;
let mut deferred_refusal = None;
let explicit_object = match commit {
CastCommit::Item { on_object, .. } => on_object,
CastCommit::Spell => None,
@@ -362,6 +364,16 @@ fn send_spell_cast(
cast_errors.push_local(spell_id, reason);
return;
}
// TryCast's own tail after ArmCast returns FALSE (`6e5045`–`6e507b`) — the
// SIBLING of the targeting-cursor arm, reached from the same place, which is
// *below* the requirement validator. So it parks here and fires where the cursor
// would come up (decision 1554): a mounted or shapeshifted press with an empty
// weapon hand reads the ref's "You are mounted" / "Can't do that while
// shapeshifted" first, exactly as 0948 established for the cursor.
cast_target::CastWireTarget::RefusedAtArm(reason) => {
deferred_refusal = Some(reason);
None
}
}
}
};
@@ -528,6 +540,14 @@ fn send_spell_cast(
return;
}
}
// The deferred ArmCast-FALSE refusal (`6e5050`), the cursor arm's sibling: every validator
// rung above has passed and the bind still found nothing bindable, so the reference's own
// red line goes out here — no packet, no GCD, no pending arm (decision 1554).
if let Some(reason) = deferred_refusal {
debug!("ui_action: cast {spell_id} refused locally — the cast-arm tail ({reason:#x})");
cast_errors.push_local(spell_id, reason);
return;
}
// The deferred targeting-cursor entry (the ref's cast-arm position `6e50c8`): every
// validator rung above has passed — NOW the cursor comes up and waits for its click.
if let Some(word) = pending_word {
+44 -14
View File
@@ -16,7 +16,9 @@
//! **equipped main hand** (`6e5361`–`6e5391`): the client picks the weapon itself and binds it
//! as an ITEM target, so a shaman's Rockbiter / Flametongue / Frostbrand / Windfury Weapon —
//! 22 rows, four names, the whole 5875 family — casts straight onto the weapon with no cursor
//! and no click. Decision 1552.
//! and no click (decision 1552). With the hand **empty** there is still no cursor: TryCast
//! tests the same bit again on ArmCast's FALSE return (`6e504b`), clears the word and says
//! *"Your weapon hand is empty"* (decision 1554).
//! - a candidate that satisfies nothing falls back to the **active player** — gated on the
//! `autoSelfCast` CVar (name `0x870dc0`, gate `[0xceac34]+0x28` at `0x6e53d7`; registered with
//! engine default `"0"`). The classic "buffing with an enemy targeted casts on yourself".
@@ -70,6 +72,12 @@ const UNIT_BITS: u16 = TF_UNIT
pub(crate) const ERR_NO_TARGET: u8 = 0x09;
pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A;
/// `SPELL_FAILED_MAINHAND_EMPTY` — "Your weapon hand is empty". The reference's own refusal for
/// an imbue pressed with nothing in the main hand, raised at `6e5050` off TryCast's bit-9 test
/// (`6e504b`). Unlike the two stand-ins above this is a *real* reference refusal site, which is
/// why it travels as [`CastWireTarget::RefusedAtArm`]. Decision 1554.
pub(crate) const ERR_MAINHAND_EMPTY: u8 = 0x2d;
/// The dest-location bit — the ground-cast wire mask (`BindLocation 0x6e60f0`'s bit-6 arm; the
/// source bit 5 completes `TargetingWantsLocation 0x6e6320`'s `0x60`, still refused below).
const TF_DEST_LOCATION: u16 = 0x0040;
@@ -120,8 +128,17 @@ pub(crate) enum CastWireTarget {
/// the GO guid). Blizzard and Flamestrike arm the first; poisons, oils and enchants the second;
/// Opening and Pick Lock arm the second **and** the third at once.
Targeting(u16),
/// Nothing bindable — do NOT send; surface this client error instead.
/// Nothing bindable — do NOT send; surface this client error instead. These are the INTERIM
/// stand-ins for the unmodeled *unit* hand-cursor mode (module docs): the reference has no
/// refusal at all where these fire, it leaves the word standing and asks for a click. So they
/// stay where the resolver returns them.
Refused(u8),
/// A refusal the **reference actually raises**, from TryCast's tail after `ArmCast 0x6e5250`
/// returns FALSE (`6e5045`–`6e507b`). That tail sits *below* the requirement validator
/// `0x6094f0`, in the same place as the targeting-cursor arm it is the sibling of — so this
/// one is parked and fired at the cursor-entry point, not at the resolver's return
/// ([`super::send_spell_cast`]). Decision 1554.
RefusedAtArm(u8),
}
/// The three places `ArmCast 0x6e5250` can take its candidate guid from (`6e5361`–`6e539f`),
@@ -397,18 +414,28 @@ pub(crate) fn resolve_cast_target(
// candidates; an item candidate clears bit 4, which is precisely why Rockbiter and Windfury
// Weapon were raising a cursor over a weapon the client already knew how to pick.
if def.targets_main_hand_item() {
if let Some(item) = cand.main_hand_item {
match cand.main_hand_item {
// `BindTarget`'s item arm: `6e5f1e: testl $0x4010` admits it, `6e5f2e` ORs
// `TARGET_FLAG_ITEM` into the wire mask, `6e5f36` clears word bits 4 and 14. A word
// that clears to zero commits on the spot (`6e60c1` → `SendCast 0x6e54f0`).
if word & ITEM_ARM_BITS != 0 && word & !ITEM_ARM_BITS == 0 {
return CastWireTarget::Item(item);
// `TARGET_FLAG_ITEM` into the wire mask, `6e5f35` clears word bits 4 and 14 (`and
// $0xbfef`). A word that clears to zero commits on the spot (`6e60c1` → `SendCast
// 0x6e54f0`).
Some(item) if word & ITEM_ARM_BITS != 0 && word & !ITEM_ARM_BITS == 0 => {
return CastWireTarget::Item(item)
}
// **Nothing in the main hand — and this is NOT a cursor** (decision 1554, correcting
// 1552). The guid reads 0, `0x468460` resolves no object, `BindTarget` bails writing
// nothing, and ArmCast returns FALSE. TryCast's tail then tests the SAME attribute
// bit a second time (`6e504b: test ch,0x2`), **clears the flag_word outright**
// (`6e5057: mov WORD ds:0xcecac0,0`) and raises reason `0x2d` — jumping clean over
// the cursor arm at `6e50ab`. "Your weapon hand is empty", no cursor, no packet.
None => return CastWireTarget::RefusedAtArm(ERR_MAINHAND_EMPTY),
// The main hand is there but the word carries a residual the item arm cannot
// discharge. The ref binds the item, `BindTarget` returns TRUE, ArmCast returns TRUE
// — so TryCast's tail is never reached and the word waits for the rest at the cursor.
// Unreachable on shipped data: all 22 attribute-bearing rows are `Targets == 0x10`
// exactly (censused in `benilla-formats`), which is what the defer below assumes.
Some(_) => {}
}
// No main hand — or a residual the item arm can't discharge (no shipped row: all 22
// attribute-bearing rows are `Targets == 0x10` exactly, censused in
// `benilla-formats`). The ref's bind fails, the word stands, and the press ends in the
// item cursor: the defer below. The selection is never consulted for these.
}
// Bits outside the unit family (item/gameobject/location/string) have no candidate here.
// The DEST-location word (Blizzard's bare `Targets = 0x40`, default switch arm) is the
@@ -710,7 +737,10 @@ mod tests {
CastWireTarget::Item(WEAPON),
"neither the selection nor autoSelfCast has anything to do with it"
);
// Unarmed: the ref's esi:edi = 0 resolves no object, the bind fails, the word stands.
// Unarmed: esi:edi = 0 resolves no object and the bind writes nothing, so ArmCast
// returns FALSE — and TryCast's SECOND test of the same bit (`6e504b`) clears the word
// and refuses. Emphatically **not** a cursor: that arm (`6e50ab`) is jumped over. This
// assertion is decision 1554 correcting 1552, which had it as the item cursor.
assert_eq!(
resolve_cast_target(
Some(&rockbiter),
@@ -718,8 +748,8 @@ mod tests {
true,
&rel_none()
),
CastWireTarget::Targeting(TF_ITEM),
"no main hand — the item cursor, not a guessed target"
CastWireTarget::RefusedAtArm(ERR_MAINHAND_EMPTY),
"no main hand — \"Your weapon hand is empty\", never the item cursor"
);
// The attribute is the whole difference: a sharpening stone / poison / oil / enchant is
// the same `Targets 0x10` word and must still ask for its item.
@@ -153,6 +153,10 @@ pub(super) struct ChatProbes<'w, 's> {
/// The subject's [`benilla_protocol::EntityKind`] — the plate gate's own player test, so the
/// diagnostic reports the branch the gate actually took rather than a second guess at it.
kinds: Query<'w, 's, &'static crate::net::NetEntity>,
/// **`/partytest raid`** (decision 1549): the synthetic raid seats US as its leader, and
/// "leader" on this wire is a guid. Here rather than as a 17th drain parameter for the
/// reason this struct exists at all — the drain is at Bevy's 16-param ceiling.
self_guid: Res<'w, crate::net::SelfGuid>,
}
/// Everything the drain **queues into another subsystem's one setter** rather than applying itself,
@@ -210,6 +214,7 @@ pub(super) fn drain_chat_input(
reputations,
guids,
kinds,
self_guid,
} = &probes;
let Some(mut script) = script else {
return;
@@ -497,6 +502,16 @@ pub(super) fn drain_chat_input(
}
ParsedChat::PartyTest { arg } => match arg.as_str() {
"off" => group.clear_session(),
// The raid grid's instrument (decision 1549) — 25 synthetic rows, us leading.
"raid" => {
for line in crate::ui_party::synthetic_raid(&mut group, &mut names, self_guid.0)
{
chat_log.push_event(super::event::ChatEvent::text_only(
super::event::ChatEventKind::System,
line,
));
}
}
"invite" => group.pending_invite = Some("Partner".to_string()),
// Serverless mark eyeball: skull the current target on the LOCAL board (the
// real send round-trips through the server's echo, which /partytest lacks).
@@ -159,14 +159,17 @@ pub(in crate::ui_chat) enum ParsedChat {
/// opens the panel and fills its edit box, a bare `/ignore` opens the ignore list), and
/// keeping them in the FrameXML is what stops that behaviour being re-derived here.
Social { verb: SocialVerb, arg: String },
/// `/partytest [lead|invite|mark|off]` — the party-frame dev instrument (decision 0434, the
/// `/partytest [lead|raid|invite|mark|off]` — the party-frame dev instrument (decision 0434, the
/// `/chattest` pattern): a synthetic roster through the real apply path (`lead` = the same
/// roster with US leading, for the leader-only popup rows), a fake pending invite for the
/// popup, a local skull on the current target (the mark renders without a server echo), or
/// clear. While the roster is synthetic the drain runs in SANDBOX mode
/// ([`crate::ui_party::GroupState::test`]): the popup's group-mutating rows — promote,
/// kick, leave, loot settings, raid marks — apply to the local mirror, so the whole menu
/// surface is exercisable serverless.
/// surface is exercisable serverless. `raid` is the same idea one level out (decision 1549):
/// a synthetic 25-member RAID with us leading, which is the only way the Raid tab's 8×5 grid
/// is eyeballable without forty accounts — the drag, Ready Check and the kick all apply to
/// the local mirror there too.
PartyTest { arg: String },
/// `/target [name]` — select by name (`TargetByName`, decision 0886). Resolves creatures AND
/// players, case-insensitively, by whole name or longest common prefix, with no range, cone,
+709 -8
View File
@@ -7,7 +7,8 @@ use benilla_protocol::messages::{
member_status, GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo, GROUP_MEMBER_ASSISTANT,
};
use benilla_ui::script::{
PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, ScriptValue, UiScript, UnitState,
PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo, ScriptValue,
UiScript, UnitState,
};
use bevy::prelude::*;
@@ -36,10 +37,36 @@ pub(super) struct FedParty {
/// own raid row's zone — a plain value watched as a counter).
names_generation: gate::Watch,
area: gate::Watch,
/// The `raid1..raid40` snapshots, same per-token diff as [`Self::units`] (decision 1549).
/// A `Vec` rather than a `[_; 40]`: `UnitState` is not `Copy`, and forty of them in a `Local`
/// that a solo player never fills is worth the one allocation a raid pays.
raid_units: Vec<Option<UnitState>>,
/// The raid roster's IDENTITY, which is what `RAID_ROSTER_UPDATE` fires on — see the fire
/// site for why it is these four fields and not the whole row.
raid_key: Vec<(u64, u32, u32, bool)>,
/// The saved-instance list last pushed, and whether the server had answered — the pair
/// `UPDATE_INSTANCE_INFO` fires on.
saved: Vec<SavedInstanceInfo>,
saved_seen: bool,
/// The ready-check ticket last seen ([`GroupState::ready_check`]).
ready_check: u32,
}
const PARTY_TOKENS: [&str; 4] = ["party1", "party2", "party3", "party4"];
/// `raid1`..`raid40` — the unit tokens the RaidFrame's rows target, tooltip and re-read levels
/// through. Spelled out rather than `format!`ed per push: [`UiScript::set_unit`] wants a `&str`,
/// and a table of forty `&'static str` costs nothing where forty `String`s per roster change
/// would (decision 1549). `MAX_RAID_MEMBERS` is the reference's own 40.
#[rustfmt::skip]
const RAID_TOKENS: [&str; 40] = [
"raid1", "raid2", "raid3", "raid4", "raid5", "raid6", "raid7", "raid8", "raid9", "raid10",
"raid11", "raid12", "raid13", "raid14", "raid15", "raid16", "raid17", "raid18", "raid19",
"raid20", "raid21", "raid22", "raid23", "raid24", "raid25", "raid26", "raid27", "raid28",
"raid29", "raid30", "raid31", "raid32", "raid33", "raid34", "raid35", "raid36", "raid37",
"raid38", "raid39", "raid40",
];
/// `GROUPTYPE_RAID` — `SMSG_GROUP_LIST`'s first byte (`0` party, `1` raid; vmangos `Group.h:116`).
const GROUPTYPE_RAID: u8 = 1;
@@ -69,6 +96,10 @@ pub(super) fn feed_party(
// the instruments, and naming it from a game module would push it across the world-API wall
// (`tests/world_api_wall.rs`, decision 1164) for a value this already answers.
here: benilla_world::world_point::WorldPoint,
// `Map.dbc`'s display names — `SMSG_RAID_INSTANCE_INFO` carries a map id and the Raid Info
// panel shows a name (decision 1549). `Option` like every other catalog here: an engine-less
// harness has none, and a lockout then shows its map id, never a blank row.
map_catalog: Option<Res<benilla_assets::MapCatalogRes>>,
mut fed: Local<crate::ui_script::VmMemo<FedParty>>,
) {
let Some(mut script) = script else {
@@ -87,6 +118,7 @@ pub(super) fn feed_party(
let stores_removed = !removed_stores.is_empty();
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
let areas_changed = areas.as_ref().is_some_and(|r| r.is_changed());
let maps_changed = map_catalog.as_ref().is_some_and(|r| r.is_changed());
gate::trace(
"feed_party",
&[
@@ -99,6 +131,7 @@ pub(super) fn feed_party(
("removed", stores_removed),
("factions", factions_changed),
("areas", areas_changed),
("maps", maps_changed),
],
);
let gate = gate::Gate::new(
@@ -110,7 +143,8 @@ pub(super) fn feed_party(
|| stores_changed
|| stores_removed
|| factions_changed
|| areas_changed,
|| areas_changed
|| maps_changed,
);
removed_stores.clear();
if gate.skip() {
@@ -178,6 +212,7 @@ pub(super) fn feed_party(
level: store.0.unit_level().unwrap_or(0),
area: here.area(),
dead: store.0.unit_is_dead(),
class: store.0.unit_class(),
});
let zone_name = |area: u32| {
let areas = areas.as_ref()?;
@@ -190,6 +225,12 @@ pub(super) fn feed_party(
.map(str::to_string)
};
let raid = raid_roster(&group, me.as_ref(), &names, &zone_name);
// The `RAID_ROSTER_UPDATE` key, taken before the roster moves into the snapshot below (the
// fire site further down carries why it is these four fields).
let raid_key: Vec<(u64, u32, u32, bool)> = raid
.iter()
.map(|r| (r.guid, r.rank, r.subgroup, r.online))
.collect();
let fresh = PartyState {
members,
@@ -228,6 +269,60 @@ pub(super) fn feed_party(
}
}
// ── raid1..raid40 (decision 1549) ──────────────────────────────────────────────────────
//
// The RaidFrame's rows carry a `raid<N>` token and use it for everything a party row uses a
// `party<N>` token for: left-click targets it, the tooltip reads it, and the reference's own
// `UNIT_LEVEL`/`UNIT_HEALTH` handlers re-read the row it names. The token index is the
// GetRaidRosterInfo row index — `raid_row_guids`' order, the one place that is decided — so
// `raid7` and `GetRaidRosterInfo(7)` can never be two different people.
//
// Row 1 is US, and our snapshot comes off our own descriptor rather than the roster: the wire
// list never contains the recipient, so there is no `GroupMemberEntry` to build it from.
let raid_guids = raid_row_guids(&group, self_guid);
fed.raid_units.resize(RAID_TOKENS.len(), None);
for (i, token) in RAID_TOKENS.iter().enumerate() {
let snap = raid_guids.get(i).and_then(|guid| {
if Some(*guid) == self_guid {
let (_, store) = self_pair?;
let name = names.peek(*guid).map(str::to_string);
let mut s = crate::ui_unit::snapshot(store, name, 0);
s.is_player = true;
s.guid = *guid;
s.raid_target = group.raid_target_index(*guid);
s.faction_group = own_group.clone();
// A raid member is a same-faction friendly player, exactly as `member_unit_state`
// holds for the others — the popup's cooperate gates read it.
s.reaction = 5;
s.is_connected = true;
Some(s)
} else {
let m = group.members.iter().find(|m| m.guid == *guid)?;
Some(member_unit_state(
m,
group.stats.get(guid),
&group,
&index,
&stores,
own_group.clone(),
))
}
});
if fed.raid_units[i] != snap {
gate.audit("feed_party", "a raid-token snapshot");
script.set_unit(token, snap.clone());
if let Some(cur) = &snap {
crate::ui_unit::fire_transitions(
&mut script,
token,
fed.raid_units[i].as_ref(),
cur,
);
}
fed.raid_units[i] = snap;
}
}
// The party events, on edges.
let roster: Vec<u64> = group.members.iter().map(|m| m.guid).collect();
if roster != fed.roster {
@@ -245,6 +340,72 @@ pub(super) fn feed_party(
script.fire_event("PARTY_LOOT_METHOD_CHANGED", vec![]);
fed.loot = group.loot;
}
// ── RAID_ROSTER_UPDATE (decision 1549) ──────────────────────────────────────────────────
//
// Fired on the roster's IDENTITY moving — who is in it, in what order, at what rank, in which
// subgroup, online or not — and NOT on the whole row. That split is the reference's own, read
// off its consumers rather than guessed: `RaidGroupFrame_OnEvent` re-reads a member's LEVEL
// from `UNIT_LEVEL` and their dead colour from `UNIT_HEALTH`, so those two fields are
// expected to move *without* this event, and firing on them would make the whole raid pane
// repaint every time somebody took damage.
//
// The exact fire SITE is not pinned to bytes. wow-re has the event (FrameScript id `0x1f3`,
// its name slot `0xbe1964`) and the raid-roster TU that owns the neighbouring lines
// (`0x4ba220`/`0x4ba550`, `object-layer/scratch/party-group-wire.md`), but nobody has carved
// which of that TU's arms signal it. What IS constrained: `SMSG_GROUP_LIST` is the only
// packet that can move any of these four fields, and the reference's RaidFrame repaints on
// this event and on `PARTY_MEMBERS_CHANGED` alike — so a client that fires it on every
// identity change of the roster cannot show a stale pane, whatever the extra arms turn out to
// be. INFERRED, and named as such rather than left to be discovered from a bug.
if raid_key != fed.raid_key {
gate.audit("feed_party", "the raid-roster edge");
fed.raid_key = raid_key;
script.fire_event("RAID_ROSTER_UPDATE", vec![]);
}
// ── READY_CHECK (decision 1549) ─────────────────────────────────────────────────────────
//
// The reference's event `0x218`, fired by the `MSG_RAID_READY_CHECK` open handler
// (`0x4ba360` — wow-re `system/ui/ui.md`, "Raid target icons + ready check"), which is also
// where its 30 s deadline is armed (`0xb713f4 = clock + 0x7530`). UIParent registers it and
// calls `ShowReadyCheck()`; the countdown itself is the popup's own OnUpdate, so the deadline
// is Lua-side here rather than a second clock in Rust.
if group.ready_check != fed.ready_check {
gate.audit("feed_party", "the ready-check edge");
fed.ready_check = group.ready_check;
// Not on the first observation after a VM reset: `ready_check` is a session counter and a
// fresh VM starting at 0 against a live 3 would pop a popup for a check that ended.
if !vm_reset {
script.fire_event("READY_CHECK", vec![]);
}
}
// ── UPDATE_INSTANCE_INFO (decision 1549) ────────────────────────────────────────────────
//
// The saved-lockout list, pushed with its map names already resolved (the wire carries
// `Map.dbc` ids; a missing catalog degrades to the id rather than to a blank row). The event
// fires on the list OR on the "server has answered" flag moving, because the reference's
// Raid Info button is enabled off exactly that second fact.
let saved: Vec<SavedInstanceInfo> = group
.saved_instances
.iter()
.map(|e| SavedInstanceInfo {
name: map_catalog
.as_ref()
.and_then(|c| c.0.name(e.map))
.map_or_else(|| e.map.to_string(), str::to_string),
instance: e.instance,
reset: e.reset,
})
.collect();
if saved != fed.saved || group.saved_instances_seen != fed.saved_seen {
gate.audit("feed_party", "the saved-instance edge");
fed.saved = saved.clone();
fed.saved_seen = group.saved_instances_seen;
script.set_saved_instances(saved);
script.fire_event("UPDATE_INSTANCE_INFO", vec![]);
}
if group.pending_invite != fed.invite {
gate.audit("feed_party", "the invite edge");
match &group.pending_invite {
@@ -274,6 +435,87 @@ pub(super) struct RaidSelf {
/// deliberately `unit_is_dead` rather than `unit_reads_dead`, so a feigning hunter does not
/// take the arm a health test would not.
pub(super) dead: bool,
/// **Our own `UNIT_FIELD_BYTES_0` class byte**, and it has to come from the descriptor rather
/// than from the name cache the way everyone else's does (decision 1549).
///
/// `NameCache::player_traits` is filled by `SMSG_NAME_QUERY_RESPONSE`, and **we never query
/// ourselves**: the login seeds our own name with `traits: None` precisely so `"player"` needs
/// no round trip (`net::apply::session::connected`). So for our own row that lookup is `None`
/// *forever*, which painted the local player's raid row with no class column and the plain
/// font colour instead of their class's — caught in a live `/partytest raid` run, invisible to
/// every unit test because the fixtures seed the traits by hand.
///
/// The descriptor is the right source anyway: it is where `"player"`'s own snapshot reads
/// class from (`ui_unit::snapshot`), and it is correct the instant the object lands.
pub(super) class: Option<u8>,
}
/// The raid roster's ROW ORDER, as guids — **the one place the array's shape is decided**.
///
/// [`raid_roster`] fills the rows and the RaidFrame's drag/kick/menu paths address them by index,
/// and those two must never disagree about which player row 7 is: an off-by-one here kicks the
/// wrong person. So both read this, and the ordering law lives in one function — self first (the
/// wire's list excludes the recipient; [`raid_roster`]'s doc carries why that position is not
/// itself derived), then the wire's own member order.
///
/// Empty outside a raid, which is what makes every raid-index verb a no-op in a plain party.
pub(crate) fn raid_row_guids(group: &GroupState, self_guid: Option<u64>) -> Vec<u64> {
if group.group_type != GROUPTYPE_RAID {
return Vec::new();
}
self_guid
.into_iter()
.chain(group.members.iter().map(|m| m.guid))
.collect()
}
/// A raid row index (1-based, the Lua scale) → the guid it names, or `None` for a row that is not
/// there. Every raid-management verb goes through this, so "index 0", "index 500" and "not in a
/// raid" all collapse to the same quiet no-op the reference's own bindings produce.
fn raid_guid_at(group: &GroupState, self_guid: Option<u64>, index: u32) -> Option<u64> {
let rows = raid_row_guids(group, self_guid);
rows.get(usize::try_from(index).ok()?.checked_sub(1)?)
.copied()
}
/// A guid → the character name the wire wants for the by-name group opcodes. Ours comes from the
/// name cache (we are never in our own roster list); everyone else's is on the roster itself.
fn raid_name_of(
group: &GroupState,
self_guid: Option<u64>,
names: &NameCache,
guid: u64,
) -> Option<String> {
if Some(guid) == self_guid {
return names.peek(guid).map(str::to_string);
}
group
.members
.iter()
.find(|m| m.guid == guid)
.map(|m| m.name.clone())
}
/// A character name → the guid the guid-bodied opcodes want (`CMSG_GROUP_SET_LEADER`,
/// `CMSG_GROUP_ASSISTANT_LEADER`). UnitPopup addresses raid rows by NAME, and the wire wants a
/// guid for exactly two of the four rank verbs, so the walk back happens here rather than in Lua.
/// Case-insensitive, like every other name compare against the roster.
fn raid_guid_for_name(
group: &GroupState,
self_guid: Option<u64>,
names: &NameCache,
name: &str,
) -> Option<u64> {
if let Some(g) = self_guid {
if names.peek(g).is_some_and(|n| n.eq_ignore_ascii_case(name)) {
return Some(g);
}
}
group
.members
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
.map(|m| m.guid)
}
/// Build `GetRaidRosterInfo`'s array (decision 0434 §6's roster, wow-re
@@ -310,16 +552,18 @@ fn raid_roster(
0
}
};
// The class BYTE, resolved to its (display name, token) pair. Two sources, because the client
// has two: everyone else's rides `SMSG_NAME_QUERY_RESPONSE` into the name cache, and our own
// comes off our descriptor ([`RaidSelf::class`] carries why).
let row = |guid: u64,
name: String,
flags: u8,
level: u32,
zone: Option<String>,
online: bool,
ninth: bool| {
let class = names
.player_traits(guid)
.and_then(|(_, class, _)| crate::ui_unit::class_names(class));
ninth: bool,
class_byte: Option<u8>| {
let class = class_byte.and_then(crate::ui_unit::class_names);
RaidMemberInfo {
name,
guid,
@@ -346,6 +590,7 @@ fn raid_roster(
me.area.and_then(zone_name),
true,
me.dead,
me.class,
));
}
for m in &group.members {
@@ -365,6 +610,7 @@ fn raid_roster(
// is the corroboration recorded on `RaidMemberInfo::ninth` — an offline dead member
// answers nil there, and reproducing the conjunction is the point.
online && m.status & member_status::DEAD != 0,
names.player_traits(m.guid).map(|(_, class, _)| class),
));
}
roster
@@ -562,6 +808,79 @@ pub(super) fn drain_party(
threshold,
});
}
// ── The raid-management verbs (decision 1549) ────────────────────────────────────
//
// Three address forms meet one wire here (see `script::party`'s module doc): the
// RaidFrame hands us raid ROW INDICES, UnitPopup hands us NAMES, and the wire wants
// whichever of name/guid vmangos declared per opcode. Every resolution failure is a
// quiet no-op — the reference's bindings marshal and send, and a row that is not
// there produces no packet rather than an error line.
PartyRequest::ConvertToRaid => {
let _ = commands.0.send(ClientCommand::GroupRaidConvert);
}
PartyRequest::SetSubgroup { index, group: sub } => {
// The Lua scale is 1..8 and the wire's is 0..7 (`SMSG_GROUP_LIST`'s own flag bits
// 0-2, vmangos `Group.cpp:158`). Out-of-range subgroups are dropped rather than
// wrapped: an 8-value field with a 3-bit home is exactly where a silent modulo
// would move somebody into the wrong raid group.
let Some(sub) = (1..=8).contains(&sub).then(|| (sub - 1) as u8) else {
continue;
};
let Some(name) = raid_guid_at(&group, self_guid, index)
.and_then(|g| raid_name_of(&group, self_guid, &names, g))
else {
continue;
};
let _ = commands
.0
.send(ClientCommand::GroupChangeSubGroup { name, group: sub });
}
PartyRequest::SwapSubgroup { index, other } => {
let pair = raid_guid_at(&group, self_guid, index)
.zip(raid_guid_at(&group, self_guid, other))
.and_then(|(a, b)| {
Some((
raid_name_of(&group, self_guid, &names, a)?,
raid_name_of(&group, self_guid, &names, b)?,
))
});
if let Some((name, other)) = pair {
let _ = commands
.0
.send(ClientCommand::GroupSwapSubGroup { name, other });
}
}
PartyRequest::PromoteName(name) => {
if let Some(guid) = raid_guid_for_name(&group, self_guid, &names, &name) {
let _ = commands.0.send(ClientCommand::GroupSetLeader { guid });
}
}
PartyRequest::AssistantLeader { name, grant } => {
if let Some(guid) = raid_guid_for_name(&group, self_guid, &names, &name) {
let _ = commands
.0
.send(ClientCommand::GroupAssistantLeader { guid, grant });
}
}
PartyRequest::UninviteRaid(index) => {
// `CMSG_GROUP_UNINVITE` takes a NAME even though a `_GUID` twin exists; the ref's
// party path already uses the name form, and one form for both keeps the server's
// "not in your party" reply meaning the same thing on either.
if let Some(name) = raid_guid_at(&group, self_guid, index)
.and_then(|g| raid_name_of(&group, self_guid, &names, g))
{
let _ = commands.0.send(ClientCommand::GroupUninvite { name });
}
}
PartyRequest::ReadyCheckStart => {
let _ = commands.0.send(ClientCommand::ReadyCheckStart);
}
PartyRequest::ReadyCheckAnswer(ready) => {
let _ = commands.0.send(ClientCommand::ReadyCheckAnswer { ready });
}
PartyRequest::RequestRaidInfo => {
let _ = commands.0.send(ClientCommand::RequestRaidInfo);
}
}
}
}
@@ -658,10 +977,110 @@ fn test_apply_local(
}
true
}
// ── The raid verbs, sandboxed (decision 1549) ─────────────────────────────────────────
//
// These are the whole reason `/raidtest` can be a look-pass instrument rather than a
// still photograph: the drag really moves someone, Ready Check really opens the popup,
// and the kick really empties a slot — through the same Lua the live client runs, with
// this standing in for the server echo. A raid needs 40 accounts otherwise.
PartyRequest::ConvertToRaid => {
group.group_type = 1;
true
}
PartyRequest::SetSubgroup { index, group: sub } => {
if (1..=8).contains(sub) {
set_test_subgroup(group, self_guid, *index, (*sub - 1) as u8);
}
true
}
PartyRequest::SwapSubgroup { index, other } => {
let a = test_subgroup_of(group, self_guid, *index);
let b = test_subgroup_of(group, self_guid, *other);
if let (Some(a), Some(b)) = (a, b) {
set_test_subgroup(group, self_guid, *index, b);
set_test_subgroup(group, self_guid, *other, a);
}
true
}
PartyRequest::UninviteRaid(index) => {
// Row 1 is us; "kick yourself" is not a thing the server would do either.
if let Some(guid) =
raid_guid_at(group, self_guid, *index).filter(|g| Some(*g) != self_guid)
{
group.members.retain(|m| m.guid != guid);
group.stats.remove(&guid);
}
true
}
PartyRequest::PromoteName(name) => {
if let Some(m) = group
.members
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name))
{
group.leader = m.guid;
}
true
}
PartyRequest::AssistantLeader { name, grant } => {
if let Some(m) = group
.members
.iter_mut()
.find(|m| m.name.eq_ignore_ascii_case(name))
{
if *grant {
m.flags |= GROUP_MEMBER_ASSISTANT;
} else {
m.flags &= !GROUP_MEMBER_ASSISTANT;
}
}
true
}
PartyRequest::ReadyCheckStart => {
// The echo a real server sends back to the whole raid, us included — which is what
// makes the popup appear for the person who pressed the button.
group.apply_ready_check();
true
}
PartyRequest::RequestRaidInfo => {
// `/raidtest` seeds the lockouts up front; the ask just marks them answered.
group.saved_instances_seen = true;
true
}
_ => false,
}
}
/// `/raidtest`'s subgroup read: the 0-based subgroup bits of the raid row `index` names (`None`
/// for a row that is not there). Our own row lives in `own_flags`, everyone else's in their
/// roster entry — the same split the wire has.
fn test_subgroup_of(group: &GroupState, self_guid: Option<u64>, index: u32) -> Option<u8> {
let guid = raid_guid_at(group, self_guid, index)?;
if Some(guid) == self_guid {
return Some(group.own_flags & GROUP_MEMBER_SUBGROUP);
}
group
.members
.iter()
.find(|m| m.guid == guid)
.map(|m| m.flags & GROUP_MEMBER_SUBGROUP)
}
/// `/raidtest`'s subgroup write ([`test_subgroup_of`]'s twin) — bits 0-2 only, so the assistant
/// flag riding the same byte survives a move.
fn set_test_subgroup(group: &mut GroupState, self_guid: Option<u64>, index: u32, sub: u8) {
let Some(guid) = raid_guid_at(group, self_guid, index) else {
return;
};
let put =
|flags: &mut u8| *flags = (*flags & !GROUP_MEMBER_SUBGROUP) | (sub & GROUP_MEMBER_SUBGROUP);
if Some(guid) == self_guid {
put(&mut group.own_flags);
} else if let Some(m) = group.members.iter_mut().find(|m| m.guid == guid) {
put(&mut m.flags);
}
}
/// `SetLootMethod`'s string → the wire's `LootMethod` id.
fn loot_method_id(method: &str) -> Option<u32> {
Some(match method {
@@ -753,6 +1172,136 @@ pub(crate) fn synthetic_roster(
lines
}
/// The `/partytest raid` instrument (decision 1549) — [`synthetic_roster`]'s raid twin, and the
/// only way the Raid tab's grid is eyeballable without forty accounts.
///
/// 24 synthetic members across subgroups 1-5 plus us in group 1 = a 25-row raid: every colour the
/// pane can paint is on screen at once (eight class colours, one dead member in red, one offline
/// in grey), one assistant carries the `(A)` token and we carry `(L)`, and **we lead**, so Convert
/// To Raid is correctly hidden, Ready Check and Add Member are live, and a drag really moves
/// somebody (the sandbox drain applies the subgroup verbs locally — [`test_apply_local`]).
///
/// The names go into the NAME CACHE with race/class traits, because the raid roster resolves a
/// member's class from there rather than from the wire ([`raid_roster`]): without that seeding the
/// grid would paint 25 white rows with an empty class column, which is precisely the thing the
/// instrument exists to let someone look at.
///
/// Two fake lockouts are seeded too, so the Raid Info panel has rows.
pub(crate) fn synthetic_raid(
group: &mut GroupState,
names: &mut NameCache,
self_guid: Option<u64>,
) -> Vec<String> {
// (name, class id, race id) — `benilla-formats`' own `ChrClasses`/`ChrRaces` ids, the pair
// `NameCache::player_traits` hands `ui_unit::class_names`. Eight classes so every colour in
// `RAID_CLASS_COLORS` shows; the races are Alliance-side and cosmetic here.
const ROSTER: [(&str, u8, u8); 24] = [
("Alaric", 1, 1), // Warrior
("Brienne", 2, 1), // Paladin
("Cassian", 3, 3), // Hunter
("Dara", 4, 4), // Rogue
("Elowen", 5, 1), // Priest
("Fenwick", 7, 3), // Shaman — Horde-only in 1.12; the pane does not care and the
("Gwendal", 8, 7), // colour is the point
("Halvard", 9, 1), // Warlock
("Isolde", 11, 4), // Druid
("Jorund", 1, 3),
("Kestrel", 4, 4),
("Lysa", 5, 4),
("Mordred", 9, 1),
("Nessa", 8, 7),
("Oswin", 2, 1),
("Perrin", 3, 4),
("Quilla", 11, 4),
("Roderick", 1, 1),
("Sable", 4, 4),
("Tarrin", 5, 3),
("Ulric", 2, 1),
("Vesper", 8, 7),
("Wystan", 9, 1),
("Yorick", 3, 3),
];
let mut members = Vec::with_capacity(ROSTER.len());
for (i, (name, class, race)) in ROSTER.iter().enumerate() {
let guid = 0xF100 + i as u64;
// Subgroups 1-5, five to a group — but we occupy one seat of group 1, so the first four
// fill it and the rest lay out five apiece. `i / 5` on 24 members lands 4/5/5/5/5.
let subgroup = ((i + 1) / 5) as u8 & GROUP_MEMBER_SUBGROUP;
let mut status = member_status::ONLINE;
let mut flags = subgroup;
match i {
// One assistant (the `(A)` token), one dead (red), one offline (grey), one AFK.
0 => flags |= GROUP_MEMBER_ASSISTANT,
3 => status |= member_status::DEAD,
7 => status = member_status::OFFLINE,
12 => status |= member_status::AFK,
_ => {}
}
names.insert_player(guid, (*name).to_string(), Some((*race, *class, 0)));
members.push(GroupMemberEntry {
name: (*name).to_string(),
guid,
status,
flags,
});
}
// groupType 1 = raid, and the leader is US: `apply_list` takes the leader guid, and passing
// our own makes `IsRaidLeader()` true so the leader-only surface is live.
let leader = self_guid.unwrap_or(0);
let lines = group.apply_list(
1,
0, // our own flags: subgroup 1 (0-based 0), no assistant bit — we are the leader
members,
leader,
Some(GroupLootInfo {
method: 2,
master: leader,
threshold: 3,
}),
);
// Levels and health, so the level column is not 25 blanks and the merged view has something
// to show. Every member is out of streaming range by construction (fake guids), so this is
// the `PARTY_MEMBER_STATS` leg — the same one a real raid spread over an instance uses.
for (i, _) in ROSTER.iter().enumerate() {
let guid = 0xF100 + i as u64;
let dead = i == 3;
group.apply_stats(
guid,
true,
PartyMemberStatsInfo {
status: None,
cur_hp: Some(if dead {
0
} else {
2100 + (i as u16 * 37) % 900
}),
max_hp: Some(3000),
level: Some(58 + (i as u16 % 3)),
power_type: Some(0),
cur_power: Some(1200),
max_power: Some(2400),
..Default::default()
},
);
}
// Two lockouts for the Raid Info panel: Molten Core (map 409) and Onyxia's Lair (249).
group.apply_raid_instance_info(vec![
benilla_protocol::messages::RaidInstanceEntry {
map: 409,
reset: 3 * 86_400 + 7_200,
instance: 1234,
},
benilla_protocol::messages::RaidInstanceEntry {
map: 249,
reset: 14_400,
instance: 77,
},
]);
// Sandbox on, after `apply_list` cleared it (the wire-wins default) — see [`synthetic_roster`].
group.test = true;
lines
}
#[cfg(test)]
mod tests {
use super::*;
@@ -834,7 +1383,12 @@ mod tests {
#[test]
fn the_raid_roster_maps_the_wire_to_get_raid_roster_info() {
let mut names = NameCache::default();
names.insert_player(0x5E1F, "Me".into(), Some((4, 11, 0))); // Night Elf DRUID
// **Our own row has NO traits in the cache, and that is the live shape, not a gap in the
// fixture** (decision 1549): the login seeds our name with `traits: None` so `"player"`
// never needs a name query, so `player_traits(self)` is None forever. This used to be
// `Some((4, 11, 0))` here, which is why the local player's raid row shipped colourless —
// the fixture was seeding a packet the client never sends itself.
names.insert_player(0x5E1F, "Me".into(), None);
names.insert_player(0xA11CE, "Alice".into(), Some((1, 1, 1))); // Human WARRIOR
names.insert_player(0xB0B, "Bob".into(), None); // no traits yet
@@ -876,12 +1430,14 @@ mod tests {
level: 60,
area: Some(1537),
dead: false,
// Off our DESCRIPTOR, which is the only source that has it for us. 11 = Druid.
class: Some(11),
};
let roster = raid_roster(&group, Some(&me), &names, &zone);
assert_eq!(roster.len(), 3, "the player is spliced back in");
// Row 1 — us. Leader (rank 2), subgroup stored 0, class from our own name-query traits.
// Row 1 — us. Leader (rank 2), subgroup stored 0, and the class off the DESCRIPTOR.
assert_eq!(roster[0].name, "Me");
assert_eq!(roster[0].rank, 2);
assert_eq!(
@@ -911,4 +1467,149 @@ mod tests {
group.group_type = 0;
assert!(raid_roster(&group, Some(&me), &names, &zone).is_empty());
}
/// The row ORDER, and the two resolutions every raid verb goes through (decision 1549).
///
/// This is the off-by-one that kicks the wrong person, so it is asserted against the same
/// helper `raid_roster` fills its array from — the whole reason that helper exists rather than
/// each site re-deriving "self first, then the wire's order".
#[test]
fn a_raid_row_index_names_the_same_player_the_roster_array_does() {
let mut group = GroupState {
group_type: GROUPTYPE_RAID,
..Default::default()
};
let wire = |name: &str, guid: u64| GroupMemberEntry {
name: name.into(),
guid,
status: member_status::ONLINE,
flags: 0,
};
group.members = vec![wire("Alice", 0xA11CE), wire("Bob", 0xB0B)];
let me = Some(0x5E1Fu64);
assert_eq!(raid_row_guids(&group, me), vec![0x5E1F, 0xA11CE, 0xB0B]);
assert_eq!(raid_guid_at(&group, me, 1), Some(0x5E1F), "row 1 is us");
assert_eq!(raid_guid_at(&group, me, 3), Some(0xB0B));
for miss in [0, 4, 500] {
assert_eq!(
raid_guid_at(&group, me, miss),
None,
"index {miss} names nobody"
);
}
// Names: ours comes from the cache (we are never in our own roster list), everyone
// else's from the roster row.
let mut names = NameCache::default();
names.insert_player(0x5E1F, "Sam".into(), None);
assert_eq!(
raid_name_of(&group, me, &names, 0x5E1F).as_deref(),
Some("Sam")
);
assert_eq!(
raid_name_of(&group, me, &names, 0xA11CE).as_deref(),
Some("Alice")
);
assert_eq!(raid_name_of(&group, me, &names, 0xDEAD), None);
// And the walk back, which the two guid-bodied opcodes need. Case-insensitive.
assert_eq!(
raid_guid_for_name(&group, me, &names, "alice"),
Some(0xA11CE)
);
assert_eq!(raid_guid_for_name(&group, me, &names, "SAM"), Some(0x5E1F));
assert_eq!(raid_guid_for_name(&group, me, &names, "Nobody"), None);
// Outside a raid every one of them is empty — which is what makes a raid verb typed in a
// party a quiet no-op rather than a packet about the wrong player.
group.group_type = 0;
assert!(raid_row_guids(&group, me).is_empty());
assert_eq!(raid_guid_at(&group, me, 1), None);
}
/// `/partytest raid`'s sandbox half: the subgroup verbs land on the local mirror the way the
/// server echo would, and the assistant bit sharing the byte survives a move.
#[test]
fn the_sandbox_moves_and_swaps_subgroups_locally() {
let mut group = GroupState::default();
let me = Some(0x5E1Fu64);
let mut names = NameCache::default();
names.insert_player(0x5E1F, "Sam".into(), None);
synthetic_raid(&mut group, &mut names, me);
assert!(group.test, "the synthetic raid arms the sandbox");
assert_eq!(group.group_type, GROUPTYPE_RAID);
// Row 2 is the first wire member, and `synthetic_raid` gives it the assistant bit.
assert_eq!(test_subgroup_of(&group, me, 2), Some(0));
assert!(group.members[0].flags & GROUP_MEMBER_ASSISTANT != 0);
// Move it to subgroup 8 (Lua) = 7 (wire).
assert!(test_apply_local(
&mut group,
&PartyRequest::SetSubgroup { index: 2, group: 8 },
me,
None
));
assert_eq!(test_subgroup_of(&group, me, 2), Some(7));
assert!(
group.members[0].flags & GROUP_MEMBER_ASSISTANT != 0,
"the assistant bit rides the same byte and must survive the move"
);
// Swap it with row 1 — us, whose subgroup lives in `own_flags` rather than in the list.
let mine = test_subgroup_of(&group, me, 1).unwrap();
assert!(test_apply_local(
&mut group,
&PartyRequest::SwapSubgroup { index: 1, other: 2 },
me,
None
));
assert_eq!(test_subgroup_of(&group, me, 1), Some(7));
assert_eq!(test_subgroup_of(&group, me, 2), Some(mine));
// An out-of-range subgroup is dropped, never wrapped into a 3-bit field.
assert!(test_apply_local(
&mut group,
&PartyRequest::SetSubgroup { index: 2, group: 9 },
me,
None
));
assert_eq!(
test_subgroup_of(&group, me, 2),
Some(mine),
"9 is not a subgroup"
);
// Ready Check echoes back to us, which is what puts the popup on the asker's screen.
let before = group.ready_check;
assert!(test_apply_local(
&mut group,
&PartyRequest::ReadyCheckStart,
me,
None
));
assert_eq!(group.ready_check, before + 1);
// And the kick empties a seat — but never our own row.
let n = group.members.len();
assert!(test_apply_local(
&mut group,
&PartyRequest::UninviteRaid(1),
me,
None
));
assert_eq!(
group.members.len(),
n,
"row 1 is us; the server would refuse too"
);
assert!(test_apply_local(
&mut group,
&PartyRequest::UninviteRaid(3),
me,
None
));
assert_eq!(group.members.len(), n - 1);
}
}
+34 -1
View File
@@ -34,7 +34,7 @@ use bevy::prelude::*;
use crate::ui_script::UiInput;
mod feed;
pub(crate) use feed::synthetic_roster;
pub(crate) use feed::{raid_row_guids, synthetic_raid, synthetic_roster};
pub(crate) struct UiPartyPlugin;
@@ -83,6 +83,23 @@ pub struct GroupState {
/// instead of dispatching CMSGs into a void. Any real `SMSG_GROUP_LIST` switches it off —
/// the wire always wins.
pub test: bool,
/// Our saved raid lockouts (`SMSG_RAID_INSTANCE_INFO`) — the Raid tab's Raid Info panel
/// (decision 1549). The answer replaces the list wholesale, empty included.
///
/// A lockout is per-CHARACTER, not per-group, so this is the one field here that is not a
/// group fact. It lives here anyway for the reason that matters: it is per-SESSION state that
/// must die with the socket, and [`Self::clear_session`] is that guarantee — a second
/// resource would be a second thing to remember to clear.
pub saved_instances: Vec<benilla_protocol::messages::RaidInstanceEntry>,
/// **Answered by the server**: has any `SMSG_RAID_INSTANCE_INFO` arrived this session? The
/// reference gates its Raid Info button on the SECOND `UPDATE_INSTANCE_INFO`
/// (`RaidFrame.hasRaidInfo`), which is the same idea by a different route — an empty list and
/// "we have not asked yet" are indistinguishable without this.
pub saved_instances_seen: bool,
/// A ready-check TICKET, not a flag: every `MSG_RAID_READY_CHECK` open bumps it, so the feed
/// fires `READY_CHECK` on a counter edge and a second check while the first popup is still up
/// re-arms it. A boolean could not tell the two apart.
pub ready_check: u32,
}
// The GlobalStrings templates, quoted verbatim from the reference client's own patch chain
@@ -334,6 +351,22 @@ impl GroupState {
pub fn clear_session(&mut self) {
*self = GroupState::default();
}
/// `SMSG_RAID_INSTANCE_INFO` — our saved lockouts, replacing the list wholesale (decision
/// 1549). The empty answer is the ordinary one and still counts as an answer.
pub fn apply_raid_instance_info(
&mut self,
entries: Vec<benilla_protocol::messages::RaidInstanceEntry>,
) {
self.saved_instances = entries;
self.saved_instances_seen = true;
}
/// `MSG_RAID_READY_CHECK` (open form) — the leader started one. Bumps the ticket the feed
/// turns into a `READY_CHECK` event edge.
pub fn apply_ready_check(&mut self) {
self.ready_check = self.ready_check.wrapping_add(1);
}
}
#[cfg(test)]
@@ -115,6 +115,28 @@ pub(super) fn assert_geometry_matches(
reference: &Path,
expected: &[&str],
min_compared: usize,
) {
assert_geometry_matches_text(
ours,
&std::fs::read_to_string(reference).unwrap(),
expected,
min_compared,
);
}
/// [`assert_geometry_matches`] against reference TEXT rather than a file on disk.
///
/// The split exists because not every reference file is *on* disk: `_extracted_framexml/` holds
/// FrameXML only, and a window whose specification is partly a `Blizzard_*` **addon** — the raid
/// grid is the first, decision 1549 — has to read its half straight out of the patch chain
/// (`benilla_formats::open_chain`, the same door `mpqx` uses). Reading it rather than requiring
/// someone to have extracted it first is what keeps that half of the diff ARMED instead of
/// skipped, and it writes nothing into the install.
pub(super) fn assert_geometry_matches_text(
ours: &str,
reference_text: &str,
expected: &[&str],
min_compared: usize,
) {
let ours_text = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -122,10 +144,7 @@ pub(super) fn assert_geometry_matches(
.join(ours),
)
.unwrap();
let theirs: HashMap<String, Vec<(f32, f32)>> =
scrape(&std::fs::read_to_string(reference).unwrap())
.into_iter()
.collect();
let theirs: HashMap<String, Vec<(f32, f32)>> = scrape(reference_text).into_iter().collect();
let mut compared = 0;
let mut drifted = Vec::new();
@@ -61,6 +61,14 @@ fn setup() -> UiScript {
// otherwise let those frames load art-less and silent.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
// The social window's fourth tab lives in its own file, and it is part of THIS window's
// manifest slice now: `BENILLA_FRIENDS_SUBFRAMES` names "RaidFrame", and both
// `FriendsFrame_ShowSubFrame` and `FriendsFrame_OnHide` resolve every name in that list
// through `getglobal` and call `:Hide()` on it. The reference's list names it too and never
// guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in
// the harness's load order, not in shipped Lua defending against a state the client cannot be
// in (decision 1549).
load_xml(&s, "RaidFrame.xml");
s
}
@@ -245,6 +245,14 @@ fn setup() -> UiScript {
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
// The social window's fourth tab lives in its own file, and it is part of THIS window's
// manifest slice now: `BENILLA_FRIENDS_SUBFRAMES` names "RaidFrame", and both
// `FriendsFrame_ShowSubFrame` and `FriendsFrame_OnHide` resolve every name in that list
// through `getglobal` and call `:Hide()` on it. The reference's list names it too and never
// guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in
// the harness's load order, not in shipped Lua defending against a state the client cannot be
// in (decision 1549).
load_xml(&s, "RaidFrame.xml");
s
}
+6
View File
@@ -816,6 +816,12 @@ mod friends_tests;
#[cfg(test)]
mod guild_tests;
/// The social window's fourth tab — the raid pane and its grid (decision 1549). Its own module for
/// `guild_tests`' reason: every test in it pushes a RAID roster first, which a file about the
/// friends list must not be in the business of.
#[cfg(test)]
mod raid_tests;
#[cfg(test)]
mod quest_tests;
@@ -0,0 +1,847 @@
//! The social window's fourth tab — the raid pane (decision 1549, `RaidFrame.xml`): the tab, the
//! two states the pane has, the 8x5 grid's seating and colouring, the drag's three landings, the
//! row menu, the ready-check popup and the saved-instance panel.
//!
//! Its own module rather than more of `friends_tests` for `guild_tests`' reason: the pane is Lua
//! over a **raid** roster, so every test here has to push one first, and a file about the friends
//! list must not be in that business.
//!
//! What these guard that `ui_party`'s Rust tests structurally cannot: the pane is Lua over a
//! snapshot, so a row seated in the wrong slot, a colour rule inverted, a drag wired to the wrong
//! verb, or a menu row shown to the wrong person are all invisible there and green in the parse
//! sweep. Each test below fails on exactly one of those.
use benilla_ui::script::{
PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo, UiScript,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error **or unknown-template
/// warning** — `friends_tests::load_xml`'s reason verbatim: `inherits=` a template this house does
/// not ship is a warning, the frame still loads, and every behavioural test stays green while the
/// art is missing.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
let missing: Vec<&String> = report
.warnings
.iter()
.filter(|w| w.contains("unknown template"))
.collect();
assert!(
missing.is_empty(),
"{file}: inherits a template this house does not ship (the frame loads, its ART does \
not): {missing:?}"
);
}
/// The window's slice of the manifest, in `load_default_ui` order. `UIParent.xml` is in it for
/// two functions the pane really calls — `MouseIsOver` (the drag's hover sweep) and
/// `SecondsToTime` (the lockout rows) — and for the `READY_CHECK` arm that opens the popup.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
load_xml(&s, "RaidFrame.xml");
s
}
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap()
}
fn text_of(s: &UiScript, region: &str) -> String {
s.eval::<String>(&format!("return {region}:GetText() or \"\""))
.unwrap()
}
/// One raid row. `rank` is 2 leader / 1 assistant / 0 member; `subgroup` is the **1-based** number
/// the pane shows, which the record stores 0-based — the binding is what adds the one
/// (`RaidMemberInfo::subgroup`'s own doc, the reference's `0x4bb61a inc`). Converting here rather
/// than at every call site is the point: a fixture that hands the record a 1-based number seats
/// every row one group to the right, which looks exactly like a seating bug in the pane.
fn row(
name: &str,
rank: u32,
subgroup: u32,
class: &str,
online: bool,
dead: bool,
) -> RaidMemberInfo {
RaidMemberInfo {
name: name.to_string(),
guid: 0xF000 + u64::from(subgroup) * 16 + name.len() as u64,
rank,
subgroup: subgroup - 1,
level: 60,
class: Some(class.to_string()),
class_file: Some(class.to_uppercase()),
zone: Some("Molten Core".to_string()),
online,
ninth: dead,
}
}
/// Push a roster and fire the event that follows it, exactly as `ui_party::feed_party` does.
fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
// `members` is our own SUBGROUP's slice — non-empty is what makes `IsPartyLeader()`/
// `IsRaidLeader()` answer 1 for leader_index 0 (the shared `leads_the_group` predicate).
let members = raid
.iter()
.skip(1)
.take(4)
.map(|r| PartyMemberInfo {
name: r.name.clone(),
guid: r.guid,
})
.collect();
s.set_party(PartyState {
members,
leader_index: 0,
raid,
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
});
s.fire_event("RAID_ROSTER_UPDATE", Vec::new());
}
/// A 12-member raid across three subgroups, us leading — the shape most tests want.
fn twelve() -> Vec<RaidMemberInfo> {
let mut raid = vec![row("Me", 2, 1, "Warrior", true, false)];
for i in 1..12u32 {
let subgroup = i / 4 + 1;
raid.push(row(
&format!("Member{i}"),
if i == 1 { 1 } else { 0 },
subgroup,
"Priest",
true,
false,
));
}
raid
}
/// Open the tab **and drain what opening it queues**. `RaidFrame`'s OnShow calls
/// `RequestRaidInfo()` — the reference's own, and the reason the Raid Info button can ever be
/// right — so every `take_party_requests` after this would otherwise start with that ask and no
/// test about a button would be about that button.
fn open_raid_tab(s: &mut UiScript) {
s.run("ToggleFriendsFrame(4)").unwrap();
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::RequestRaidInfo],
"showing the pane asks the server for our lockouts"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The tab
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Tab 4 exists, opens the pane, titles the window RAID, and puts every other subframe away.
///
/// This is the test the whole decision exists for: `FriendsFrame.xml` shipped three tabs and said
/// so in its header, and the tab strip is the one place a missing pane is visible without a raid.
#[test]
fn the_fourth_tab_opens_the_raid_pane() {
let mut s = setup();
assert_eq!(
s.eval::<i64>("return FriendsFrame.numTabs").unwrap(),
4,
"PanelTemplates_SetNumTabs says four now — it said three while the pane did not exist"
);
open_raid_tab(&mut s);
assert!(visible(&s, "RaidFrame"), "the pane opens");
assert_eq!(text_of(&s, "FriendsFrameTitleText"), "Raid");
for other in [
"FriendsListFrame",
"IgnoreListFrame",
"WhoFrame",
"GuildFrame",
] {
assert!(!visible(&s, other), "{other} goes away");
}
// And the tab's own OnClick is wired — a tab that PanelTemplates enables but that does
// nothing when clicked is exactly the dead tab the guild arc had to fix.
s.run("FriendsFrameTab1:Click()").unwrap();
assert!(!visible(&s, "RaidFrame"));
s.run("FriendsFrameTab4:Click()").unwrap();
assert!(visible(&s, "RaidFrame"), "the tab button opens it too");
}
/// Closing the window closes the Raid Info flyout with it — the ref's fourth satellite.
#[test]
fn hiding_the_window_closes_the_raid_info_panel() {
let mut s = setup();
open_raid_tab(&mut s);
s.run("RaidInfoFrame:Show()").unwrap();
assert!(visible(&s, "RaidInfoFrame"));
s.run("HideUIPanel(FriendsFrame)").unwrap();
assert!(
!visible(&s, "RaidInfoFrame"),
"the flyout goes with the window"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The not-in-a-raid state
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Out of a raid the pane is the blurb plus Convert To Raid, and the button is live only for the
/// leader of an actual party — the three states, in order.
#[test]
fn convert_to_raid_is_live_only_for_a_party_leader() {
let mut s = setup();
open_raid_tab(&mut s);
assert!(visible(&s, "RaidFrameRaidDescription"), "solo: the blurb");
assert!(visible(&s, "RaidFrameConvertToRaidButton"));
assert_eq!(
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
.unwrap(),
0,
"solo there is no party to convert"
);
assert!(
!visible(&s, "RaidFrameReadyCheckButton"),
"and no raid verbs"
);
assert!(!visible(&s, "RaidFrameAddMemberButton"));
// A party we lead: `leader_index == 0` with members is the shared leads-the-group predicate.
s.set_party(PartyState {
members: vec![PartyMemberInfo {
name: "Alice".into(),
guid: 0xA,
}],
leader_index: 0,
..Default::default()
});
s.fire_event("PARTY_MEMBERS_CHANGED", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
.unwrap(),
1,
"a party leader can convert"
);
s.run("RaidFrameConvertToRaidButton:Click()").unwrap();
assert!(
s.take_party_requests()
.contains(&PartyRequest::ConvertToRaid),
"and the button really sends it"
);
// A party we do NOT lead.
s.set_party(PartyState {
members: vec![PartyMemberInfo {
name: "Alice".into(),
guid: 0xA,
}],
leader_index: 1,
..Default::default()
});
s.fire_event("PARTY_LEADER_CHANGED", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
.unwrap(),
0,
"a party member cannot"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The grid
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The roster paints: each row lands in ITS SUBGROUP's next free slot, carries its rank token, and
/// rows past the roster stay hidden.
///
/// The seating is the assertion with teeth. Row order and slot order are different orders — row 5
/// of a raid whose first four are in group 1 is group 2's *first* slot — and every drag, kick and
/// menu action downstream addresses a row by the index this seating implies.
#[test]
fn the_grid_seats_each_row_in_its_own_subgroups_next_free_slot() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
// Groups 1-3 hold 4/4/4; the roster is 12 with us at row 1 in group 1.
assert_eq!(
s.eval::<String>("return RaidGroupButton1.slot").unwrap(),
"RaidGroup1Slot1",
"row 1 (us) takes group 1's first seat"
);
assert_eq!(
s.eval::<String>("return RaidGroupButton5.slot").unwrap(),
"RaidGroup2Slot1",
"row 5 is the first of group 2 — NOT group 1's fifth seat"
);
assert_eq!(
s.eval::<String>("return RaidGroupButton12.slot").unwrap(),
"RaidGroup3Slot4"
);
// The slot knows its occupant too — the link the drop code reads to decide move-vs-swap.
assert_eq!(
s.eval::<String>("return RaidGroup2Slot1.button").unwrap(),
"RaidGroupButton5"
);
assert_eq!(text_of(&s, "RaidGroupButton1Name"), "Me");
assert_eq!(
text_of(&s, "RaidGroupButton1Rank"),
"(L)",
"the leader token"
);
assert_eq!(
text_of(&s, "RaidGroupButton2Rank"),
"(A)",
"the assistant token"
);
assert_eq!(
text_of(&s, "RaidGroupButton3Rank"),
"",
"and a plain member has none"
);
assert_eq!(text_of(&s, "RaidGroupButton1Level"), "60");
assert!(visible(&s, "RaidGroupButton12"), "the last real row shows");
assert!(
!visible(&s, "RaidGroupButton13"),
"and the first empty one does not"
);
assert!(
visible(&s, "RaidGroup8"),
"all eight groups show while in a raid"
);
// Leaving the raid puts the whole grid away and brings the blurb back.
push_raid(&mut s, Vec::new());
assert!(!visible(&s, "RaidGroup1"));
assert!(!visible(&s, "RaidGroupButton1"));
assert!(visible(&s, "RaidFrameRaidDescription"));
}
/// The colour ladder: offline grey beats everything, then dead red, then the class colour — and
/// all three columns take it together.
///
/// Inverting any two of these is invisible to every other test in this file: the rows still seat,
/// the names still read, the clicks still fire.
#[test]
fn a_rows_colour_is_offline_then_dead_then_class() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(
&mut s,
vec![
row("Me", 2, 1, "Warrior", true, false),
row("Corpse", 0, 1, "Mage", true, true),
row("Gone", 0, 1, "Mage", false, false),
// Offline AND dead: grey wins, because a disconnected member's health is not news.
row("GoneDead", 0, 1, "Mage", false, true),
],
);
let color = |button: &str| {
s.eval::<(f32, f32, f32)>(&format!(
"local r, g, b = {button}Name:GetTextColor() return r, g, b"
))
.unwrap()
};
let round = |(r, g, b): (f32, f32, f32)| {
(
(r * 100.0).round() / 100.0,
(g * 100.0).round() / 100.0,
(b * 100.0).round() / 100.0,
)
};
// WARRIOR = 0.78, 0.61, 0.43 (Fonts.xml's RAID_CLASS_COLORS, the reference's own values).
let warrior: (f32, f32, f32) = s
.eval("return RAID_CLASS_COLORS.WARRIOR.r, RAID_CLASS_COLORS.WARRIOR.g, RAID_CLASS_COLORS.WARRIOR.b")
.unwrap();
assert_eq!(
round(color("RaidGroupButton1")),
round(warrior),
"alive: class colour"
);
assert_eq!(
round(color("RaidGroupButton2")),
(1.0, 0.1, 0.1),
"dead: RED_FONT_COLOR"
);
assert_eq!(
round(color("RaidGroupButton3")),
(0.5, 0.5, 0.5),
"offline: GRAY_FONT_COLOR"
);
assert_eq!(
round(color("RaidGroupButton4")),
(0.5, 0.5, 0.5),
"offline AND dead is grey — offline is tested first"
);
// The level column takes the same colour as the name, never a different one. (The CLASS
// column is a Button rather than a FontString — the reference makes it one because it is the
// class pullout's drag handle — and a Button in this engine has `SetTextColor` but no getter,
// so it cannot be read back here. `RaidGroupButton_SetRowColor` writes all three from one
// colour in three adjacent lines; these two are the readable half of that.)
assert_eq!(
round(color("RaidGroupButton2")),
round(
s.eval::<(f32, f32, f32)>(
"local r, g, b = RaidGroupButton2Level:GetTextColor() return r, g, b"
)
.unwrap()
),
"name and level share the row's colour"
);
}
/// `UNIT_HEALTH`/`UNIT_LEVEL` repaint ONE row and do not go through the roster event.
///
/// This is the reason `RAID_ROSTER_UPDATE` is fired on the roster's identity rather than on every
/// field: a 40-row rebuild per point of damage taken is what the other choice costs.
#[test]
fn a_units_health_and_level_repaint_only_that_row() {
let mut s = setup();
open_raid_tab(&mut s);
let mut raid = twelve();
push_raid(&mut s, raid.clone());
assert_eq!(text_of(&s, "RaidGroupButton3Level"), "60");
// The row dies. Only the per-unit event is fired — no RAID_ROSTER_UPDATE.
raid[2].ninth = true;
s.set_party(PartyState {
members: vec![PartyMemberInfo {
name: "Member1".into(),
guid: raid[1].guid,
}],
leader_index: 0,
raid: raid.clone(),
..Default::default()
});
s.fire_event(
"UNIT_HEALTH",
vec![benilla_ui::script::ScriptValue::Str("raid3".into())],
);
let red = s
.eval::<f32>("local r, g = RaidGroupButton3Name:GetTextColor() return g")
.unwrap();
assert!(red < 0.2, "row 3 went red without a roster rebuild ({red})");
// A unit event for something that is not a raid token is ignored rather than mis-parsed.
s.fire_event(
"UNIT_HEALTH",
vec![benilla_ui::script::ScriptValue::Str("target".into())],
);
s.fire_event(
"UNIT_LEVEL",
vec![benilla_ui::script::ScriptValue::Str("player".into())],
);
assert_eq!(
text_of(&s, "RaidGroupButton3Level"),
"60",
"and nothing else moved"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The management buttons
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Ready Check is the leader's alone; Add Member shows for everyone in a raid. Both are hidden
/// outright out of one — they share their seat with Convert To Raid.
#[test]
fn ready_check_is_the_leaders_and_add_member_is_everyones() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
assert!(
visible(&s, "RaidFrameReadyCheckButton"),
"we lead, so we may ask"
);
assert!(visible(&s, "RaidFrameAddMemberButton"));
assert!(
!visible(&s, "RaidFrameConvertToRaidButton"),
"already a raid"
);
assert!(!visible(&s, "RaidFrameRaidDescription"));
s.run("RaidFrameReadyCheckButton:Click()").unwrap();
assert!(s
.take_party_requests()
.contains(&PartyRequest::ReadyCheckStart));
// A member, not the leader: no Ready Check button at all.
let raid = twelve();
s.set_party(PartyState {
members: vec![PartyMemberInfo {
name: "Member1".into(),
guid: raid[1].guid,
}],
leader_index: 1,
raid,
..Default::default()
});
s.fire_event("RAID_ROSTER_UPDATE", Vec::new());
assert!(!visible(&s, "RaidFrameReadyCheckButton"));
assert!(
visible(&s, "RaidFrameAddMemberButton"),
"but Add Member still shows"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The drag
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The drag's three landings: an empty slot in another group MOVES, an occupied one SWAPS, and
/// anything else — including this row's own group — sends nothing and springs the row home.
///
/// Driven through the real handlers with `TARGET_RAID_SLOT` standing in for the hover sweep, which
/// needs a live cursor this harness has no way to place.
#[test]
fn dragging_a_row_moves_swaps_or_springs_back() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
let drag = |s: &UiScript, button: &str, onto: &str| {
s.run(&format!(
"MOVING_RAID_MEMBER = {button}; TARGET_RAID_SLOT = {onto}; \
RaidGroupButton_OnDragStop({button})"
))
.unwrap();
};
// Row 12 (group 3, seat 4) onto group 3's fifth seat — its OWN group. Nothing goes out.
drag(&s, "RaidGroupButton12", "RaidGroup3Slot5");
assert!(
s.take_party_requests().is_empty(),
"a drop inside the row's own group is not a move"
);
// Row 12 onto group 5's empty first seat — a MOVE, and the subgroup is the Lua 1-based one.
drag(&s, "RaidGroupButton12", "RaidGroup5Slot1");
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::SetSubgroup {
index: 12,
group: 5
}]
);
// Row 12 onto group 1's second seat, which row 2 is in — a SWAP, by the two ROW indices.
drag(&s, "RaidGroupButton12", "RaidGroup1Slot2");
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::SwapSubgroup {
index: 12,
other: 2
}]
);
}
/// A non-leader cannot drag at all — the gate is on both ends, so a drag that never started also
/// never stops.
#[test]
fn only_the_leader_may_drag() {
let mut s = setup();
open_raid_tab(&mut s);
let raid = twelve();
s.set_party(PartyState {
members: vec![PartyMemberInfo {
name: "Member1".into(),
guid: raid[1].guid,
}],
leader_index: 1,
raid,
..Default::default()
});
s.fire_event("RAID_ROSTER_UPDATE", Vec::new());
s.run(
"MOVING_RAID_MEMBER = RaidGroupButton12; TARGET_RAID_SLOT = RaidGroup5Slot1; \
RaidGroupButton_OnDragStop(RaidGroupButton12)",
)
.unwrap();
assert!(
s.take_party_requests().is_empty(),
"a member's drag sends nothing"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The row's click and menu
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Left-clicking a row targets that row's `raidN` token — the same token the tooltip reads.
#[test]
fn left_clicking_a_row_targets_its_raid_token() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
s.run("RaidGroupButton7:Click(\"LeftButton\")").unwrap();
assert_eq!(s.take_target_requests(), vec!["raid7".to_string()]);
}
/// Right-clicking a row opens the RAID menu, and each of its four rows appears for exactly the
/// person the reference shows it to.
///
/// The menu is addressed by ROW INDEX, and every rank rule re-reads that row's rank — so a menu
/// opened on the wrong index would offer Promote on somebody who is already an assistant.
#[test]
fn the_row_menu_offers_the_rank_verbs_by_rank() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
// Row 3 is a plain member: as the leader we may promote it, not demote it, and may kick it.
s.run("RaidGroupButton3:Click(\"RightButton\")").unwrap();
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the menu opens"
);
let shown = |s: &UiScript, key: &str| {
s.eval::<bool>(&format!(
"for i = 1, UIDROPDOWNMENU_MAXBUTTONS do \
local b = getglobal(\"DropDownList1Button\"..i) \
if b and b:IsShown() and b.value == \"{key}\" then return true end \
end return false"
))
.unwrap()
};
assert!(
shown(&s, "RAID_PROMOTE"),
"a plain member can be made an assistant"
);
assert!(!shown(&s, "RAID_DEMOTE"), "and cannot be demoted from one");
assert!(shown(&s, "RAID_LEADER"), "and can be handed the lead");
assert!(shown(&s, "RAID_REMOVE"));
// Row 2 is the assistant: demote, not promote.
s.run("HideDropDownMenu(1) RaidGroupButton2:Click(\"RightButton\")")
.unwrap();
assert!(shown(&s, "RAID_DEMOTE"));
assert!(!shown(&s, "RAID_PROMOTE"));
// Row 1 is the leader — us. Nothing may be done to the leader.
s.run("HideDropDownMenu(1) RaidGroupButton1:Click(\"RightButton\")")
.unwrap();
assert!(!shown(&s, "RAID_LEADER"));
assert!(
!shown(&s, "RAID_REMOVE"),
"the leader cannot be kicked, not even by themself"
);
}
/// The menu's verbs reach the engine addressed the way the wire wants them — three by name, and
/// the kick by the row index.
#[test]
fn the_menu_verbs_queue_the_right_requests() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
let click = |s: &UiScript, key: &str| {
s.run(&format!(
"for i = 1, UIDROPDOWNMENU_MAXBUTTONS do \
local b = getglobal(\"DropDownList1Button\"..i) \
if b and b:IsShown() and b.value == \"{key}\" then b:Click() return end \
end error(\"{key} not shown\")"
))
.unwrap();
};
s.run("RaidGroupButton3:Click(\"RightButton\")").unwrap();
click(&s, "RAID_PROMOTE");
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::AssistantLeader {
name: "Member2".into(),
grant: true
}]
);
s.run("HideDropDownMenu(1) RaidGroupButton3:Click(\"RightButton\")")
.unwrap();
click(&s, "RAID_REMOVE");
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::UninviteRaid(3)],
"the kick goes by ROW INDEX, which is what the reference passes"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Raid Info
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The saved-instance panel fills from `GetSavedInstanceInfo`, and its button is live only once
/// the server has answered with something.
#[test]
fn the_raid_info_panel_lists_the_saved_lockouts() {
let mut s = setup();
open_raid_tab(&mut s);
// The first UPDATE_INSTANCE_INFO only arms the ref's latch (`RaidFrame.hasRaidInfo`).
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
s.set_saved_instances(vec![
SavedInstanceInfo {
name: "Molten Core".into(),
instance: 1234,
reset: 3 * 86_400,
},
SavedInstanceInfo {
name: "Onyxia's Lair".into(),
instance: 77,
reset: 3_600,
},
]);
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0")
.unwrap(),
1
);
// The rows live inside the flyout, so `IsVisible` is false until it is open — open it, which
// is what the button does, and then ask.
s.run("RaidInfoFrame:Show()").unwrap();
assert!(visible(&s, "RaidInfoInstance1"));
assert!(visible(&s, "RaidInfoInstance2"));
assert!(
!visible(&s, "RaidInfoInstance3"),
"rows past the list stay away"
);
assert_eq!(text_of(&s, "RaidInfoInstance1Name"), "Molten Core");
assert_eq!(text_of(&s, "RaidInfoInstance1ID"), "1234");
assert!(
text_of(&s, "RaidInfoInstance1Reset").starts_with("Resets in 3 Days"),
"the reset is a REMAINING duration through SecondsToTime, not a timestamp: {:?}",
text_of(&s, "RaidInfoInstance1Reset")
);
assert!(
!visible(&s, "RaidInfoScrollFrameScrollBar"),
"four rows fit, so two need no bar"
);
// Bound to nothing: the button dies, and the panel does not keep a stale bar standing.
s.set_saved_instances(Vec::new());
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0")
.unwrap(),
0
);
assert!(!visible(&s, "RaidInfoInstance1"));
// The button toggles the flyout.
assert!(visible(&s, "RaidInfoFrame"));
s.run("RaidFrameRaidInfoButton:GetScript(\"OnClick\")()")
.unwrap();
assert!(!visible(&s, "RaidInfoFrame"), "and closes it again");
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The ready check
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// `READY_CHECK` opens the popup naming the leader, and the two buttons answer opposite ways —
/// including the No button's argument-less call, which is the reference's own spelling.
#[test]
fn the_ready_check_popup_opens_and_answers() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
assert!(!visible(&s, "ReadyCheckFrame"), "nothing to answer yet");
s.fire_event("READY_CHECK", Vec::new());
assert!(visible(&s, "ReadyCheckFrame"), "UIParent's arm opens it");
assert!(
text_of(&s, "ReadyCheckFrameText").starts_with("Me has initiated a ready check."),
"named for the rank-2 row: {:?}",
text_of(&s, "ReadyCheckFrameText")
);
s.run("ReadyCheckFrameYesButton:Click()").unwrap();
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::ReadyCheckAnswer(true)]
);
assert!(!visible(&s, "ReadyCheckFrame"));
s.fire_event("READY_CHECK", Vec::new());
s.run("ReadyCheckFrameNoButton:Click()").unwrap();
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::ReadyCheckAnswer(false)],
"No passes no argument at all, and absent must mean not-ready"
);
// The raid ending ends the question.
s.fire_event("READY_CHECK", Vec::new());
assert!(visible(&s, "ReadyCheckFrame"));
push_raid(&mut s, Vec::new());
assert!(
!visible(&s, "ReadyCheckFrame"),
"the raid went, so the question went"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The reference-geometry diff (decision 0675's discipline)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Every element this file shares with the reference carries the reference's numbers.
///
/// The pane's specification is TWO reference files — `RaidFrame.xml` from FrameXML and
/// `Blizzard_RaidUI.xml` from the load-on-demand addon — so this runs twice. The addon half is
/// read straight out of the patch chain rather than from an extraction on disk: `_extracted_framexml/`
/// holds FrameXML only, and requiring someone to have extracted the addon first would leave half
/// the window's geometry guarded by nothing.
#[test]
fn the_geometry_matches_both_reference_files() {
// The FrameXML half: the pane, its two buttons, the info flyout and the lockout row template.
if let Some(reference) = super::framexml_diff::reference("RaidFrame.xml") {
super::framexml_diff::assert_geometry_matches(
"RaidFrame.xml",
&reference,
&[
// The reference's close button inherits `UIPanelCloseButton`, which carries the
// 32x32; no such template ships in this house (FriendsFrame's own close button
// says the same), so ours states the size the template would have given it. The
// ANCHOR offsets match; only the extra size pair differs.
"RaidInfoCloseButton",
],
10,
);
}
// The addon half: the row, the slot, the group frame and the ready-check popup.
let Some(data) = benilla_formats::wow_data() else {
return;
};
let chain = benilla_formats::open_chain(&data).expect("open chain");
let bytes = chain
.read("Interface\\AddOns\\Blizzard_RaidUI\\Blizzard_RaidUI.xml")
.expect("Blizzard_RaidUI.xml is in the patch chain");
super::framexml_diff::assert_geometry_matches_text(
"RaidFrame.xml",
&String::from_utf8_lossy(&bytes),
&[],
10,
);
}
+5
View File
@@ -1011,6 +1011,11 @@ pub enum SessionEvent {
/// One member's ready-check answer, forwarded to the leader only (`MSG_RAID_READY_CHECK`,
/// non-empty body).
ReadyCheckAnswer { guid: u64, ready: u8 },
/// Our saved raid lockouts (`SMSG_RAID_INSTANCE_INFO`) — the Raid tab's Raid Info panel
/// (decision 1549). Replaces the list wholesale; empty is the normal answer.
RaidInstanceInfo {
entries: Vec<crate::messages::RaidInstanceEntry>,
},
/// A duel challenge (`SMSG_DUEL_REQUESTED`, decision 0633) — delivered to challenger and
/// challenged alike. `arbiter` is the duel-flag GameObject that identifies the duel and is
/// echoed on accept/cancel; `challenger` equal to our own guid means we are the one asking.
@@ -463,6 +463,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::RaidTargetList { entries }]
}
ServerPacket::ReadyCheckRequest => vec![SessionEvent::ReadyCheckRequest],
ServerPacket::RaidInstanceInfo { entries } => {
vec![SessionEvent::RaidInstanceInfo { entries }]
}
ServerPacket::ReadyCheckAnswer { guid, ready } => {
vec![SessionEvent::ReadyCheckAnswer { guid, ready }]
}
@@ -510,3 +510,46 @@ pub fn ready_check_start() -> Vec<u8> {
pub fn ready_check_answer(ready: bool) -> Vec<u8> {
vec![u8::from(ready)]
}
/// One row of `SMSG_RAID_INSTANCE_INFO` — a raid lockout the character is bound to (VERIFIED
/// vmangos `Objects/Player.cpp::Player::SendRaidInfo`: the writer walks `m_boundInstances` and
/// emits a row for every **permanent** bind, so a heroic-style temporary bind never appears).
///
/// `reset` is a REMAINING duration in seconds (`resetTime - time(nullptr)` on the server), not an
/// absolute timestamp — the Lua binding hands it straight to `SecondsToTime`, and reading it as a
/// timestamp would print "Resets in 57 years".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RaidInstanceEntry {
/// `Map.dbc` id — the *name* the UI shows is the client's own DBC lookup, never on the wire.
pub map: u32,
/// Seconds until the lockout resets.
pub reset: u32,
/// The instance id, which is what the UI shows beside the name (`GetSavedInstanceInfo`'s
/// second return).
pub instance: u32,
}
/// Read an `SMSG_RAID_INSTANCE_INFO` body (see [`RaidInstanceEntry`]): `u32 count` then that many
/// 12-byte rows. The count is authoritative — the server writes it back over its placeholder — so
/// a short body is a malformed packet rather than a silent truncation.
pub(super) fn read_raid_instance_info(r: &mut &[u8]) -> io::Result<Vec<RaidInstanceEntry>> {
let count = read_u32_le(r)?;
// A cap before the allocation: `count` is attacker-controlled in the general case, and the
// real client's own list is `MAX_RAID_INFOS`-bounded at the UI. 1024 is far above anything a
// server can legitimately send and far below a memory problem.
let mut out = Vec::with_capacity((count as usize).min(1024));
for _ in 0..count {
out.push(RaidInstanceEntry {
map: read_u32_le(r)?,
reset: read_u32_le(r)?,
instance: read_u32_le(r)?,
});
}
Ok(out)
}
/// Body of `CMSG_REQUEST_RAID_INFO` (VERIFIED vmangos `Handlers/GroupHandler.cpp`'s
/// `HandleRequestRaidInfoOpcode` — the handler reads nothing and answers `SendRaidInfo()`): empty.
pub fn request_raid_info() -> Vec<u8> {
Vec::new()
}
+3 -2
View File
@@ -118,8 +118,9 @@ pub use group::{
group_invite, group_raid_convert, group_set_leader, group_swap_sub_group, group_uninvite,
group_uninvite_guid, loot_method, member_status, minimap_ping, party_member_mask,
party_operation, party_result, raid_target_request, raid_target_set, ready_check_answer,
ready_check_start, request_party_member_stats, GroupLootInfo, GroupMemberEntry,
PartyMemberStatsInfo, RaidTargetUpdate, ReadyCheck, GROUP_MEMBER_ASSISTANT,
ready_check_start, request_party_member_stats, request_raid_info, GroupLootInfo,
GroupMemberEntry, PartyMemberStatsInfo, RaidInstanceEntry, RaidTargetUpdate, ReadyCheck,
GROUP_MEMBER_ASSISTANT,
};
pub use guild::{
guild_accept, guild_add_rank, guild_command, guild_command_error, guild_create, guild_decline,
@@ -934,6 +934,14 @@ pub const MSG_RAID_TARGET_UPDATE: u16 = 0x0321; // 801
/// Same opcode both directions — an empty body starts/requests, a non-empty one answers (VERIFIED
/// vmangos `Server/Packets/Group.cpp:84-96`, `:126-130`; see [`super::group::ReadyCheck`]).
pub const MSG_RAID_READY_CHECK: u16 = 0x0322; // 802
/// Ask the server for our saved-instance (raid lockout) list — empty body (VERIFIED vmangos
/// `Server/Protocol/Opcodes.cpp` registers it against `HandleRequestRaidInfoOpcode`, which is a
/// bare `SendRaidInfo()`). The RaidFrame's `RequestRaidInfo()` on every OnShow (decision 1549).
pub const CMSG_REQUEST_RAID_INFO: u16 = 0x02CD; // 717
/// The answer: `u32 count` then `count` × `{u32 mapId, u32 secondsUntilReset, u32 instanceId}`
/// (VERIFIED vmangos `Objects/Player.cpp::Player::SendRaidInfo`, permanent binds only; see
/// [`super::group::read_raid_instance_info`]).
pub const SMSG_RAID_INSTANCE_INFO: u16 = 0x02CC; // 716
// The duel family (decision 0633) — the six inbound handlers WoW.exe registers in
// `Ui/DuelInfo.cpp` at `0x4d4710` plus the two it sends from `AcceptDuel 0x4d4830` /
@@ -958,6 +958,11 @@ pub enum ServerPacket {
guid: u64,
ready: u8,
},
/// `SMSG_RAID_INSTANCE_INFO` — our saved raid lockouts, answering `CMSG_REQUEST_RAID_INFO`
/// (decision 1549). Empty is the ordinary answer for a character bound to nothing.
RaidInstanceInfo {
entries: Vec<super::group::RaidInstanceEntry>,
},
/// `SMSG_DUEL_REQUESTED` — a duel challenge. Sent to challenger and challenged alike; which
/// one we are is `challenger == our guid` (decision 0633).
DuelRequested {
@@ -1404,6 +1409,7 @@ impl ServerPacket {
ServerPacket::ReadyCheckRequest | ServerPacket::ReadyCheckAnswer { .. } => {
"MSG_RAID_READY_CHECK".into()
}
ServerPacket::RaidInstanceInfo { .. } => "SMSG_RAID_INSTANCE_INFO".into(),
ServerPacket::DuelRequested { .. } => "SMSG_DUEL_REQUESTED".into(),
ServerPacket::DuelOutOfBounds => "SMSG_DUEL_OUTOFBOUNDS".into(),
ServerPacket::DuelInBounds => "SMSG_DUEL_INBOUNDS".into(),
@@ -933,6 +933,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
}
group::RaidTargetUpdate::List(entries) => ServerPacket::RaidTargetList { entries },
},
opcode::SMSG_RAID_INSTANCE_INFO => ServerPacket::RaidInstanceInfo {
entries: group::read_raid_instance_info(&mut r)?,
},
opcode::MSG_RAID_READY_CHECK => match group::read_ready_check(&mut r)? {
group::ReadyCheck::Started => ServerPacket::ReadyCheckRequest,
group::ReadyCheck::Answer { guid, ready } => {
@@ -154,4 +154,13 @@ impl WorldWriter {
&messages::ready_check_answer(ready),
)
}
/// Ask for our saved-instance list (`CMSG_REQUEST_RAID_INFO`, empty body — decision 1549's
/// Raid Info panel). Answered by `SMSG_RAID_INSTANCE_INFO`.
pub fn request_raid_info(&mut self) -> Result<()> {
self.send(
opcode::CMSG_REQUEST_RAID_INFO,
&messages::request_raid_info(),
)
}
}
+50
View File
@@ -675,3 +675,53 @@ fn ready_check_smsg_shapes() {
other => panic!("ready check answer decode: {other:?}"),
}
}
/// `CMSG_REQUEST_RAID_INFO` / `SMSG_RAID_INSTANCE_INFO` (decision 1549's Raid Info panel;
/// vmangos `Player::SendRaidInfo`): an empty request, and a `u32 count` + `count` × 12-byte rows
/// answer. The zero-count body is the ordinary answer for a character bound to nothing, and it
/// must decode to an EMPTY list rather than to nothing at all — the UI reads the arrival as "the
/// server has spoken", which is what disables the Raid Info button.
#[test]
fn raid_instance_info_wire() {
assert_eq!(
messages::request_raid_info(),
Vec::<u8>::new(),
"CMSG_REQUEST_RAID_INFO body"
);
let p = messages::parse_server(opcode::SMSG_RAID_INSTANCE_INFO, &hx("00000000")).unwrap();
match &p {
ServerPacket::RaidInstanceInfo { entries } => assert!(entries.is_empty()),
other => panic!("expected empty RaidInstanceInfo, got {}", other.name()),
}
match decode(p).as_slice() {
[SessionEvent::RaidInstanceInfo { entries }] => assert!(entries.is_empty()),
other => panic!("empty raid info decode: {other:?}"),
}
// Two rows: Molten Core (map 409) resetting in 0x00015180 = 86400 s, instance 1234; and
// Onyxia's Lair (map 249) in 3600 s, instance 77.
let body = hx(concat!(
"02000000", // count
"99010000", "80510100", "d2040000", // map 409, 86400 s, instance 1234
"f9000000", "100e0000", "4d000000", // map 249, 3600 s, instance 77
));
let p = messages::parse_server(opcode::SMSG_RAID_INSTANCE_INFO, &body).unwrap();
match &p {
ServerPacket::RaidInstanceInfo { entries } => {
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].map, 409);
assert_eq!(entries[0].reset, 86_400);
assert_eq!(entries[0].instance, 1234);
assert_eq!(entries[1].map, 249);
assert_eq!(entries[1].reset, 3_600);
assert_eq!(entries[1].instance, 77);
}
other => panic!("expected RaidInstanceInfo, got {}", other.name()),
}
// A count the body cannot back is a parse failure, not a short list.
assert!(
messages::parse_server(opcode::SMSG_RAID_INSTANCE_INFO, &hx("0100000099010000")).is_err()
);
}
+1 -1
View File
@@ -179,7 +179,7 @@ pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use model::Model;
pub use model::TextureProbe;
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
+8
View File
@@ -401,6 +401,12 @@ pub(crate) struct Model {
/// Party/loot intents (`AcceptGroup`/`InviteToParty`/`SetLootMethod`/…) queued since the
/// app's last [`super::UiScript::take_party_requests`] drain — the outbound seam ([`party`]).
pub(crate) party_requests: Vec<party::PartyRequest>,
/// The saved raid lockouts the Raid tab's info panel reads (decision 1549), pushed by the app
/// from `SMSG_RAID_INSTANCE_INFO`. Its own field rather than a [`party::PartyState`] member
/// because it arrives on its own packet and survives every roster change.
pub(crate) saved_instances: Vec<party::SavedInstanceInfo>,
/// `SetRaidRosterSelection`'s cursor — a client-side raid row index, never sent anywhere.
pub(crate) raid_selection: i64,
/// The social snapshot the app pushes (friends, ignores, the last `/who` — decision 0668):
/// `GetNumFriends`/`GetFriendInfo`/`GetWhoInfo`/… read it ([`social`]). Already
/// display-resolved (names, class/zone names) because the reference resolves them
@@ -1352,6 +1358,8 @@ impl Model {
joined_channels: Vec::new(),
party: party::PartyState::default(),
party_requests: Vec::new(),
saved_instances: Vec::new(),
raid_selection: 0,
social: social::SocialState::default(),
social_requests: Vec::new(),
guild: guild::GuildState::default(),
+355 -2
View File
@@ -21,7 +21,20 @@
//! 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.
//! 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};
@@ -38,6 +51,19 @@ pub struct PartyMemberInfo {
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.
@@ -163,6 +189,34 @@ pub enum PartyRequest {
/// 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 {
@@ -178,6 +232,13 @@ impl super::UiScript {
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> {
@@ -496,6 +557,169 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
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
@@ -909,8 +1133,15 @@ mod tests {
);
}
/// `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_nil_until_the_raid_arc() {
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());
}
@@ -1015,6 +1246,128 @@ mod tests {
.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();