diff --git a/crates/benilla-app/assets/ui/FriendsFrame.xml b/crates/benilla-app/assets/ui/FriendsFrame.xml
index 5ed4afb5..b31c31d3 100644
--- a/crates/benilla-app/assets/ui/FriendsFrame.xml
+++ b/crates/benilla-app/assets/ui/FriendsFrame.xml
@@ -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 @@
+
+
FriendsFrame_OnLoad()
diff --git a/crates/benilla-app/assets/ui/RaidFrame.xml b/crates/benilla-app/assets/ui/RaidFrame.xml
new file mode 100644
index 00000000..8d66c5d5
--- /dev/null
+++ b/crates/benilla-app/assets/ui/RaidFrame.xml
@@ -0,0 +1,1076 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ getglobal(this:GetName() .. "Label"):SetText(GROUP .. " " .. this:GetID())
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ConvertToRaid()
+
+
+
+
+
+
+
+
+
+
+
+
+ if UnitCanCooperate("player", "target") then
+ InviteByName(UnitName("target"))
+ else
+ StaticPopup_Show("ADD_RAIDMEMBER")
+ end
+
+
+
+
+
+
+
+
+
+
+ DoReadyCheck()
+
+
+
+
+
+
+
+
+
+
+ if RaidInfoFrame:IsShown() then
+ RaidInfoFrame:Hide()
+ else
+ RaidInfoFrame:Show()
+ end
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RaidInfoFrame:Hide()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RaidFrame_OnLoad()
+
+ RaidFrame_Update()
+ RequestRaidInfo()
+
+ RaidGroupFrame_OnHide()
+ RaidGroupFrame_OnEvent(event, arg1)
+ RaidGroupFrame_OnUpdate()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ConfirmReadyCheck(1)
+ ReadyCheckFrame.timer = nil
+ ReadyCheckFrame:Hide()
+
+
+
+
+
+
+
+
+
+ ConfirmReadyCheck()
+ ReadyCheckFrame.timer = nil
+ ReadyCheckFrame:Hide()
+
+
+
+
+
+ this:RegisterEvent("RAID_ROSTER_UPDATE")
+
+ -- The raid ending ends the question with it.
+ if event == "RAID_ROSTER_UPDATE" and GetNumRaidMembers() == 0 then
+ ReadyCheckFrame.timer = nil
+ ReadyCheckFrame:Hide()
+ end
+
+ ReadyCheck_OnUpdate(arg1)
+
+
+
diff --git a/crates/benilla-app/assets/ui/UIParent.xml b/crates/benilla-app/assets/ui/UIParent.xml
index aab1e323..a4402517 100644
--- a/crates/benilla-app/assets/ui/UIParent.xml
+++ b/crates/benilla-app/assets/ui/UIParent.xml
@@ -21,7 +21,10 @@
-->
- this:RegisterEvent("PLAYER_ENTERING_WORLD")
+
+ this:RegisterEvent("PLAYER_ENTERING_WORLD")
+ this:RegisterEvent("READY_CHECK")
+ UIParent_OnEvent(event)
@@ -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.
diff --git a/crates/benilla-app/assets/ui/UnitPopup.xml b/crates/benilla-app/assets/ui/UnitPopup.xml
index 4998a63d..de9090bf 100644
--- a/crates/benilla-app/assets/ui/UnitPopup.xml
+++ b/crates/benilla-app/assets/ui/UnitPopup.xml
@@ -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;
diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc
index a570ca57..9b0187b6 100644
--- a/crates/benilla-app/assets/ui/benilla.toc
+++ b/crates/benilla-app/assets/ui/benilla.toc
@@ -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
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index 1e7ce459..8bee1079 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -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
diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs
index 3dd26ad5..05e7d834 100644
--- a/crates/benilla-app/src/net/apply.rs
+++ b/crates/benilla-app/src/net/apply.rs
@@ -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 ──
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index c36cce65..1b1b3132 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -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(),
diff --git a/crates/benilla-app/src/sound/combat.rs b/crates/benilla-app/src/sound/combat.rs
index 7f51edd7..b97ab92b 100644
--- a/crates/benilla-app/src/sound/combat.rs
+++ b/crates/benilla-app/src/sound/combat.rs
@@ -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,
mut events: MessageReader,
mut last: Local,
- units: Query<(&Transform, Option<&Wielded>, &NetEntity)>,
+ units: Query<(&Transform, Option<&Wielded>, &NetEntity, Has)>,
impacts: Option>,
voices: Option>,
kits: Option>,
@@ -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",
+ );
}
}
}
diff --git a/crates/benilla-app/src/sound/creature.rs b/crates/benilla-app/src/sound/creature.rs
index 9bacd3e4..8daacfc8 100644
--- a/crates/benilla-app/src/sound/creature.rs
+++ b/crates/benilla-app/src/sound/creature.rs
@@ -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,
@@ -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:#}");
}
diff --git a/crates/benilla-app/src/sound/footsteps.rs b/crates/benilla-app/src/sound/footsteps.rs
index 31723014..ac0f7905 100644
--- a/crates/benilla-app/src/sound/footsteps.rs
+++ b/crates/benilla-app/src/sound/footsteps.rs
@@ -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:#}");
}
diff --git a/crates/benilla-app/src/sound/greeting.rs b/crates/benilla-app/src/sound/greeting.rs
index f623d997..c330caf5 100644
--- a/crates/benilla-app/src/sound/greeting.rs
+++ b/crates/benilla-app/src/sound/greeting.rs
@@ -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:#}");
}
diff --git a/crates/benilla-app/src/sound/kit.rs b/crates/benilla-app/src/sound/kit.rs
index ca230072..35b48af2 100644
--- a/crates/benilla-app/src/sound/kit.rs
+++ b/crates/benilla-app/src/sound/kit.rs
@@ -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, 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,
+ /// 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,
+ /// 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,
+ /// 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,
category: SoundCategory,
- variant: Option,
- source: Option,
- force_loop: bool,
- voice: Option,
+ 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.
diff --git a/crates/benilla-app/src/sound/liquid_loop.rs b/crates/benilla-app/src/sound/liquid_loop.rs
index a05eee86..0e85c573 100644
--- a/crates/benilla-app/src/sound/liquid_loop.rs
+++ b/crates/benilla-app/src/sound/liquid_loop.rs
@@ -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;
diff --git a/crates/benilla-app/src/sound/missile.rs b/crates/benilla-app/src/sound/missile.rs
index 3323ee03..96466212 100644
--- a/crates/benilla-app/src/sound/missile.rs
+++ b/crates/benilla-app/src/sound/missile.rs
@@ -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:#}");
}
diff --git a/crates/benilla-app/src/sound/spell.rs b/crates/benilla-app/src/sound/spell.rs
index dc064930..f98a57d9 100644
--- a/crates/benilla-app/src/sound/spell.rs
+++ b/crates/benilla-app/src/sound/spell.rs
@@ -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(
diff --git a/crates/benilla-app/src/sound/water.rs b/crates/benilla-app/src/sound/water.rs
index d460cd40..d83fe0e2 100644
--- a/crates/benilla-app/src/sound/water.rs
+++ b/crates/benilla-app/src/sound/water.rs
@@ -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:#}");
}
diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs
index 8b223bfe..212a6296 100644
--- a/crates/benilla-app/src/target/click.rs
+++ b/crates/benilla-app/src/target/click.rs
@@ -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::().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::().ok())
diff --git a/crates/benilla-app/src/ui_action/cast_send.rs b/crates/benilla-app/src/ui_action/cast_send.rs
index 95edf381..0bb7692b 100644
--- a/crates/benilla-app/src/ui_action/cast_send.rs
+++ b/crates/benilla-app/src/ui_action/cast_send.rs
@@ -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 {
diff --git a/crates/benilla-app/src/ui_action/cast_target.rs b/crates/benilla-app/src/ui_action/cast_target.rs
index cc482b33..f4c42f6c 100644
--- a/crates/benilla-app/src/ui_action/cast_target.rs
+++ b/crates/benilla-app/src/ui_action/cast_target.rs
@@ -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.
diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs
index 1c9ca2b5..8462538a 100644
--- a/crates/benilla-app/src/ui_chat/input/mod.rs
+++ b/crates/benilla-app/src/ui_chat/input/mod.rs
@@ -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).
diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs
index afd641c4..2d41768c 100644
--- a/crates/benilla-app/src/ui_chat/input/parse.rs
+++ b/crates/benilla-app/src/ui_chat/input/parse.rs
@@ -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,
diff --git a/crates/benilla-app/src/ui_party/feed.rs b/crates/benilla-app/src/ui_party/feed.rs
index 541b07a8..52658bf1 100644
--- a/crates/benilla-app/src/ui_party/feed.rs
+++ b/crates/benilla-app/src/ui_party/feed.rs
@@ -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