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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:#}");
|
||||
}
|
||||
|
||||
@@ -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:#}");
|
||||
}
|
||||
|
||||
@@ -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:#}");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:#}");
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:#}");
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user