the social window, the escape menu, and items that work from the bar

- The social window: friends, ignore and `/who`, in the reference's own two-level
  arrangement with its inner tabs and per-row right-click menus. The lists ride the
  real wire — guid-keyed with no names, resolved through the name cache, and capped
  at 50 friends / 25 ignores because those are the client's own caps — and the
  ignore list does its actual job: an ignored speaker's chat is dropped silently, a
  whisper additionally tells the sender, and a duel challenge is auto-declined.
  Guild ships as a disabled tab, which is what a guildless character sees.
- The escape menu: ESC's last rung finally opens something. The game menu lands at
  the reference's own size and ladder, and leaving works — logout and quit with the
  server owning the clock (instant at an inn, refused in combat, or the 20-second
  countdown), narrated by the real camp/quit dialogs. While the menu holds the
  center nothing else opens, and the bag bar greys out under it.
- Items on the action bar act like items. An equipped trinket was a dead button;
  the reference's use law is two stages — not equippable means use, equippable and
  already worn means use in place — over its exact search order (equipment first,
  then the bags, then the backpack, then the keyring; never the bank). What can go
  on the bar is now the reference's own two filters, byte-read: an item with an
  on-use spell or one you can equip, and no passive spells — and dropping a passive
  there speaks the real client's own line, "You can't put a passive ability in the
  action bar."
- A quest-starter item opens its quest instead of "The item was not found." The
  question goes to the item's own guid, never through item-use — which the server
  refuses for every one of the 215 quest-starter items — and refusing the quest
  says what the client says, then closes the window.
- For the toolbox: a pixel pick that reports everything under the cursor all the
  way back, a lens that says which render phase a draw landed in, and switches that
  separate "discarded" from "lost depth" from "never drawn" — built while cornering
  a flickering tent, whose fix ships in the next snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-26 19:19:06 +07:00
co-authored by Claude Fable 5
parent c1ba444119
commit c887170b37
95 changed files with 10437 additions and 1162 deletions
+21
View File
@@ -76,6 +76,27 @@ impl AreaTableCatalog {
None
}
/// The id of the zone **named** `name`, case-insensitively — the reverse of [`Self::name`],
/// for the one caller that has a name and needs the id: `/who`'s `z-"Elwynn Forest"` term,
/// which goes on the wire as a zone id (decision 0668).
///
/// Names are not unique across the table (a subzone can share its zone's name, instance rows
/// repeat), so a **top-level** row (`zone_id == 0`) wins over any other match — that is what
/// "zone" means to the caller. Linear, which is fine at one call per query.
pub fn id_for_name(&self, name: &str) -> Option<u32> {
let mut fallback = None;
for (id, row) in &self.by_id {
if !row.name.eq_ignore_ascii_case(name) {
continue;
}
if row.zone_id == 0 {
return Some(*id);
}
fallback = Some(*id);
}
fallback
}
pub fn len(&self) -> usize {
self.by_id.len()
}
@@ -23,7 +23,7 @@ use clap::Parser;
use probes::{
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, Loot, Probe, QueryNames, Quest,
QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor, WorldState,
QuestItem, QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor, WorldState,
};
use world::{DeathArc, Tracked, World};
@@ -137,6 +137,17 @@ struct Cli {
/// deploy's probes are gmlevel 6).
#[arg(long)]
questlog: bool,
/// Live-verify the quest-STARTER item wire (decision 0664): `.additem` the Northshire Gift
/// Voucher (entry 14646, starts quest 5805 "Welcome!"), find it in the backpack, and run the
/// fork a bag right-click makes for an item whose template carries a non-zero `StartQuest` —
/// `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the **item's own guid** (never `CMSG_USE_ITEM`,
/// which the server refuses with `EQUIP_ERR_ITEM_NOT_FOUND`, the red "The item was not found."
/// line) — requiring `SMSG_QUESTGIVER_QUEST_DETAILS`, then `CMSG_QUESTGIVER_ACCEPT_QUEST` on
/// the same guid, requiring BOTH the quest id landing in `PLAYER_QUEST_LOG` and the starter
/// item being destroyed. Cleans up after itself (`.quest remove`). Needs a GM account (the
/// deploy's probes are gmlevel 6).
#[arg(long)]
quest_item: bool,
/// Live-verify the force-speed-change wire: GM `.modify speed 1.5` (self-targeted), require
/// `SMSG_FORCE_RUN_SPEED_CHANGE` to arrive and parse (flat speed 10.5 = 1.5 × the 7.0 base),
/// ack it (`CMSG_FORCE_RUN_SPEED_CHANGE_ACK` echoing counter + exact speed with our live pose),
@@ -340,6 +351,9 @@ fn main() -> Result<()> {
if cli.giverstatus {
probes.push(Box::new(GiverStatus));
}
if cli.quest_item {
probes.push(Box::new(QuestItem));
}
if cli.questlog {
probes.push(Box::new(QuestLog));
}
@@ -68,6 +68,7 @@ mod giverstatus;
mod loot;
mod query_names;
mod quest;
mod quest_item;
mod questlog;
mod speed;
mod spells;
@@ -86,6 +87,7 @@ pub(crate) use giverstatus::GiverStatus;
pub(crate) use loot::Loot;
pub(crate) use query_names::QueryNames;
pub(crate) use quest::Quest;
pub(crate) use quest_item::QuestItem;
pub(crate) use questlog::QuestLog;
pub(crate) use speed::Speed;
pub(crate) use spells::Spells;
@@ -0,0 +1,218 @@
//! `--quest-item`: the quest-STARTER item wire (decision 0664) — the fork a bag right-click makes
//! when the clicked item's template carries a non-zero `StartQuest`. The client does NOT send
//! `CMSG_USE_ITEM` for such an item (the server refuses that with `EQUIP_ERR_ITEM_NOT_FOUND`, the
//! red "The item was not found." line): it sends `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the
//! **item's own guid**, and accepts against that same guid.
//!
//! This probe proves the whole item-as-questgiver round trip against the live server: add the item,
//! query its quest from the item guid, require `SMSG_QUESTGIVER_QUEST_DETAILS`, accept from the same
//! guid, and require the quest to land in `PLAYER_QUEST_LOG` with the starter kept-or-consumed as
//! the quest's own `ReqItemId`/`SrcItemId` dictate (vmangos `Player::AddQuest`).
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use benilla_protocol::{decode, SessionEvent, WorldSession};
use crate::probes::{Ctx, Probe, FIELD_PLAYER_QUEST_LOG_1_1};
/// A per-event handler for this probe's drain pump: it inspects each decoded [`SessionEvent`] and
/// returns `Some(msg)` to stop the drain early (the match landed) or `None` to keep pumping — the
/// `--quest` probe's own shape.
type QuestEventHandler = Box<dyn FnMut(&SessionEvent) -> Option<String>>;
/// The probe target: "Northshire Gift Voucher" (entry 14646), which starts quest 5805 "Welcome!" —
/// picked because it is takeable by the probe body as it stands (`MinLevel` 1, no prerequisite
/// quest, no race/class gate, item `RequiredLevel` 0 — VERIFIED live against
/// `mangos.item_template` ⋈ `quest_template`), so no rigging is needed. Non-equippable
/// (`inventory_type` 0) and spell-less, like all but five of the 215 quest-starters in 1.12.
const ITEM_ENTRY: u32 = 14646;
const QUEST_ID: u32 = 5805;
pub(crate) struct QuestItem;
impl Probe for QuestItem {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
// A clean slate so the accept is a real fresh accept across re-runs, then hand ourselves
// the starter item (`verify` subtracts it again at the end).
cx.session.send_chat(&format!(".quest remove {QUEST_ID}"))?;
cx.session.send_chat(&format!(".additem {ITEM_ENTRY}"))?;
println!("sent GM: .quest remove {QUEST_ID}; .additem {ITEM_ENTRY}");
Ok(())
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
let world = &mut *cx.world;
let session = &mut *cx.session;
let self_guid = world.self_guid;
// Pump packets up to `secs`, folding self-descriptor deltas into `self_fields` and handing
// each decoded event to `f`; stop early when `f` returns Some. (The `--quest` probe's own
// drain, which is a closure over its locals and so can't simply be shared.)
let drain = |session: &mut WorldSession,
sf: &mut Option<benilla_protocol::messages::ObjectFields>,
secs: u64,
mut f: QuestEventHandler|
-> Option<String> {
let until = Instant::now() + Duration::from_secs(secs);
while Instant::now() < until {
let Ok(msg) = session.recv() else { continue };
for ev in decode(msg) {
if let SessionEvent::ObjectValues { guid: g, fields } = &ev {
if *g == self_guid {
if let Some(sf) = sf.as_mut() {
sf.merge(fields.clone());
}
}
}
if let Some(done) = f(&ev) {
return Some(done);
}
}
}
None
};
// 1) Find the starter item in the backpack — the guid IS the questgiver on this wire.
let sf = world
.self_fields
.as_ref()
.context("no self descriptor — can't walk the backpack")?;
let item_guid = (0..16)
.filter_map(|i| sf.player_pack_slot(i).filter(|g| *g != 0))
.find(|g| world.item_entries.get(g).copied() == Some(ITEM_ENTRY))
.with_context(|| {
format!(
"--quest-item: item {ITEM_ENTRY} isn't in the backpack — did `.additem` land? \
(needs a GM account; try a longer --seconds)"
)
})?;
println!("\nquest-starter item {ITEM_ENTRY}: guid {item_guid:#x} (the giver on this wire)");
// 2) The fork's own packet: QUERY_QUEST addressed to the ITEM guid → DETAILS.
println!("CMSG_QUESTGIVER_QUERY_QUEST({QUEST_ID}) on the item guid");
session.questgiver_query_quest(item_guid, QUEST_ID)?;
let details = drain(
session,
&mut world.self_fields,
5,
Box::new(|ev| match ev {
SessionEvent::QuestDetail(d) if d.quest_id == QUEST_ID => Some(format!(
"SMSG_QUESTGIVER_QUEST_DETAILS: \"{}\" — giver {:#x}",
d.title, d.npc
)),
_ => None,
}),
)
.context(
"--quest-item: no SMSG_QUESTGIVER_QUEST_DETAILS within 5s — the item guid was not \
accepted as a questgiver",
)?;
println!("✅ details: {details}");
// 3) Accept against the same item guid: the quest must land in the log, and the starter
// must survive — vmangos `Player::AddQuest`'s "remove start item if not need" destroys a
// `TYPEID_ITEM` giver ONLY when the quest neither requires it (`ReqItemId`) nor names it
// `SrcItemId`, and this one is both (`quest_template` 5805: `SrcItemId = ReqItemId1 =
// 14646` — the voucher IS the turn-in). Asserting the retention pins the branch as firmly
// as a destroy would, and keeps the probe's item deterministic across re-runs.
println!("CMSG_QUESTGIVER_ACCEPT_QUEST({QUEST_ID}) on the item guid");
session.questgiver_accept_quest(item_guid, QUEST_ID)?;
let mut destroyed = false;
let mut in_log = false;
for _ in 0..6 {
if drain(
session,
&mut world.self_fields,
1,
Box::new(move |ev| match ev {
SessionEvent::ObjectDestroyed(g) if *g == item_guid => Some(String::new()),
_ => None,
}),
)
.is_some()
{
destroyed = true;
}
in_log = world.self_fields.as_ref().is_some_and(|sf| {
(0..20).any(|i| {
sf.raw_fields().any(|(idx, val)| {
idx == FIELD_PLAYER_QUEST_LOG_1_1 + 3 * i && val == QUEST_ID
})
})
});
if in_log {
break;
}
}
anyhow::ensure!(
in_log,
"--quest-item: quest {QUEST_ID} never landed in a PLAYER_QUEST_LOG field — the \
item-sourced accept did not take"
);
anyhow::ensure!(
!destroyed,
"--quest-item: the starter item {item_guid:#x} was destroyed, but quest {QUEST_ID} \
requires it (ReqItemId1) — vmangos' AddQuest keeps a required starter"
);
println!(
"✅ accept: quest {QUEST_ID} is in PLAYER_QUEST_LOG; the starter item survived (it is \
the quest's own required turn-in)"
);
// 4) The director's second click (decision 0669): the starter is still in the bag while
// the quest is in the log, so clicking it again re-sends the same QUERY. vmangos'
// `HandleQuestgiverQueryQuestOpcode` has NO status gate — it answers with the DETAILS
// again, which is why the panel legitimately re-opens — and the accept behind it is what
// fails: `CanTakeQuest` → `SatisfyQuestStatus` → `SendCanTakeQuestResponse`
// (`SMSG_QUESTGIVER_QUEST_INVALID`) with `INVALIDREASON_QUEST_ALREADY_ON` = 13 = 0x0d.
// That 13 is the code the client maps to `ERR_QUEST_ALREADY_ON` — pin BOTH halves here.
println!("\nsecond click while ON the quest — QUERY then ACCEPT again");
session.questgiver_query_quest(item_guid, QUEST_ID)?;
let reopened = drain(
session,
&mut world.self_fields,
5,
Box::new(|ev| match ev {
SessionEvent::QuestDetail(d) if d.quest_id == QUEST_ID => {
Some(format!("\"{}\"", d.title))
}
_ => None,
}),
)
.context(
"--quest-item: the re-query got no DETAILS — this server DOES gate the query by \
quest status, so the panel would not re-open (decision 0669 expects vmangos' \
ungated handler)",
)?;
println!("✅ the panel legitimately re-opens: DETAILS {reopened}");
session.questgiver_accept_quest(item_guid, QUEST_ID)?;
let refusal = drain(
session,
&mut world.self_fields,
5,
Box::new(|ev| match ev {
SessionEvent::QuestGiverInvalid { reason } => Some(reason.to_string()),
_ => None,
}),
)
.context(
"--quest-item: the second accept drew no SMSG_QUESTGIVER_QUEST_INVALID within 5s",
)?;
anyhow::ensure!(
refusal == "13",
"--quest-item: expected QUEST_INVALID reason 13 (ALREADY_ON, the director's 0x0d), \
got {refusal}"
);
println!("✅ refusal: QUEST_INVALID reason 13 → ERR_QUEST_ALREADY_ON (the ref's 0x5dbca0)");
// Leave the probe character as found: drop the quest and the copy staging added (this
// starter survives the accept, so without the subtract every run would litter one).
session.send_chat(&format!(".quest remove {QUEST_ID}"))?;
session.send_chat(&format!(".additem {ITEM_ENTRY} -1"))?;
drain(session, &mut world.self_fields, 2, Box::new(|_| None));
println!("cleanup: .quest remove {QUEST_ID}; .additem {ITEM_ENTRY} -1");
Ok(())
}
}
+38 -10
View File
@@ -11,12 +11,13 @@
use crate::messages::{
ActionButton, AttackerState, ChannelNoticeTail, Character, DamageShield,
EnvironmentalDamageLog, GossipOption, GroupLootInfo, GroupMemberEntry, ItemInfo,
ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon,
LootStartRoll, MailListEntry, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo,
PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss,
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, XpGain,
EnvironmentalDamageLog, FriendEntry, FriendStatusUpdate, GossipOption, GroupLootInfo,
GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MonsterMoveFacing, ObjectFields,
PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
QuestOfferReward, QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog,
SpellHealLog, SpellLogMiss, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell,
TransportPose, VendorItem, WhoResults, XpGain,
};
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
@@ -118,6 +119,14 @@ pub enum SessionEvent {
/// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — we are back at character select.
/// The IO thread cycles the connection immediately; a fresh [`Self::CharacterList`] follows.
LoggedOut,
/// The server answered `CMSG_LOGOUT_REQUEST` (`SMSG_LOGOUT_RESPONSE`) — and this is where the
/// camp countdown comes from (decision 0674). `reason` non-zero is a refusal (vmangos: 1 in
/// combat, 3 jumping/falling, 2 GM-frozen) and nothing further happens; `reason == 0` starts a
/// logout that is either `instant` — [`Self::LoggedOut`] follows at once (resting, on a taxi,
/// or a GM account) — or a 20-second server-side timer the client narrates.
LogoutResponse { reason: u32, instant: bool },
/// The server dropped a pending logout at our `CMSG_LOGOUT_CANCEL` (`SMSG_LOGOUT_CANCEL_ACK`).
LogoutCancelled,
/// The session ended (socket closed / handshake failure); carries a human-readable reason.
Disconnected { reason: String },
/// An object entered range / was created: a unit, player, or GameObject at a raw-WoW pose. Carries
@@ -608,10 +617,15 @@ pub enum SessionEvent {
QuestFailed { quest_id: u32, timed: bool },
/// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`).
QuestLogFull,
/// A questgiver interaction was rejected (`SMSG_QUESTGIVER_QUEST_INVALID` msg code /
/// `SMSG_QUESTGIVER_QUEST_FAILED` reason code — `quest_id` is `0` for INVALID, which
/// doesn't name the quest).
QuestGiverRefused { quest_id: u32, reason: u32 },
/// The giver won't OFFER the quest (`SMSG_QUESTGIVER_QUEST_INVALID`): one `QuestFailedReason`
/// msg code and no quest id — vmangos' `SendCanTakeQuestResponse`, the answer to a query or an
/// accept that fails `CanTakeQuest` ("already on that quest", "not high enough level").
QuestGiverInvalid { reason: u32 },
/// A quest the giver DID offer failed on accept (`SMSG_QUESTGIVER_QUEST_FAILED`): the
/// `{questId, reason}` pair, whose line names the quest. Kept apart from
/// [`Self::QuestGiverInvalid`] because the reference reads the two packets with two different
/// handlers and two different message tables (decision 0669).
QuestGiverFailed { quest_id: u32, reason: u32 },
/// The gossip window closes (`SMSG_GOSSIP_COMPLETE`) — no menu is open server-side any more.
GossipComplete,
/// The greeting record for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our
@@ -868,6 +882,20 @@ pub enum SessionEvent {
},
/// Start the duel countdown (`SMSG_DUEL_COUNTDOWN`) — already in whole seconds.
DuelCountdown { seconds: u32 },
/// The whole friend list (`SMSG_FRIEND_LIST`, decision 0668) — pushed unasked at login and
/// again for every `CMSG_FRIEND_LIST`. Always the complete list, never a delta, so it
/// replaces whatever is held. Names are NOT on the wire (see [`crate::messages::social`]);
/// the consumer resolves them through its name cache.
FriendList { friends: Vec<FriendEntry> },
/// The whole ignore list (`SMSG_IGNORE_LIST`) — the same replace-everything shape, guids only.
IgnoreList { guids: Vec<u64> },
/// One friend/ignore result (`SMSG_FRIEND_STATUS`): both the ack for an add/remove **and**
/// the broadcast a friend's login/logout sends to everyone listing them. The
/// [`friend_result`](crate::messages::friend_result) code says which, and carries the
/// presence tail for the two online flavours.
FriendStatus(FriendStatusUpdate),
/// A `/who` answer (`SMSG_WHO`) — at most 49 rows, plus the true match total.
WhoResults(WhoResults),
/// The taxi map (`SMSG_SHOWTAXINODES`, decision 0484): `flightmaster` is the NPC the menu
/// opened on, `nearest_node` the node it sits at, `known_mask` the full known-node bitmask
/// ([`TaxiMask::is_known`]). The wire's window-framing constant carries no state and is
+12 -5
View File
@@ -25,6 +25,10 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
realm: None,
}],
ServerPacket::LogoutComplete => vec![SessionEvent::LoggedOut],
ServerPacket::LogoutResponse { reason, instant } => {
vec![SessionEvent::LogoutResponse { reason, instant }]
}
ServerPacket::LogoutCancelAck => vec![SessionEvent::LogoutCancelled],
ServerPacket::PlaySound { sound_id } => vec![SessionEvent::PlaySound { sound_id }],
ServerPacket::PlayMusic { music_id } => vec![SessionEvent::PlayMusic { music_id }],
ServerPacket::PlayObjectSound { sound_id, guid } => {
@@ -247,12 +251,11 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
timed: true,
}],
ServerPacket::QuestLogFull => vec![SessionEvent::QuestLogFull],
ServerPacket::QuestGiverInvalid { msg } => vec![SessionEvent::QuestGiverRefused {
quest_id: 0,
reason: msg,
}],
ServerPacket::QuestGiverInvalid { msg } => {
vec![SessionEvent::QuestGiverInvalid { reason: msg }]
}
ServerPacket::QuestGiverFailed { quest_id, reason } => {
vec![SessionEvent::QuestGiverRefused { quest_id, reason }]
vec![SessionEvent::QuestGiverFailed { quest_id, reason }]
}
ServerPacket::GossipComplete => vec![SessionEvent::GossipComplete],
ServerPacket::NpcText { text_id, blocks } => {
@@ -438,6 +441,10 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
loser,
}],
ServerPacket::DuelCountdown { seconds } => vec![SessionEvent::DuelCountdown { seconds }],
ServerPacket::FriendList { friends } => vec![SessionEvent::FriendList { friends }],
ServerPacket::IgnoreList { guids } => vec![SessionEvent::IgnoreList { guids }],
ServerPacket::FriendStatus(status) => vec![SessionEvent::FriendStatus(status)],
ServerPacket::WhoResults(results) => vec![SessionEvent::WhoResults(results)],
ServerPacket::DestroyObject { guid } => vec![SessionEvent::ObjectDestroyed(guid)],
ServerPacket::TriggerCinematic { cinematic_id } => {
vec![SessionEvent::CinematicTriggered { cinematic_id }]
+49 -2
View File
@@ -109,8 +109,13 @@ pub struct ItemInfo {
/// feeds the range formula.
pub ranged_mod_range: f32,
/// The 5-slot `ItemSpell` block, **filtered to entries with `spell_id != 0`**, in wire order —
/// every "Use:"/"Equip:"/"Chance on hit:" trigger line the tooltip can render.
/// every "Use:"/"Equip:"/"Chance on hit:" trigger line the tooltip can render. Each entry
/// carries its own [`ItemSpellEntry::index`], since this vector's positions are not the
/// template's block ordinals.
pub spells: Vec<ItemSpellEntry>,
/// Spell **block 0**'s `SpellCharges` word, raw and unfiltered — the reference's
/// `template+0x144`, and the sole input to [`Self::has_finite_charges`].
pub spell_charges_0: i32,
/// The first ON_USE (`SpellTrigger == 0`) spell block — what a right-click/action-bar use
/// casts, and the key the item's cooldown tracks (the client's own 5-slot scan: spell id > 0,
/// trigger == 0 — wow-re `wave-cooldown.md` `GetItemCooldown 0x6e2ed0`). `None` for items with
@@ -157,6 +162,32 @@ pub struct ItemInfo {
pub bag_family: u32,
}
impl ItemInfo {
/// Does this item carry **finite charges**? The reference's
/// `template+0x144 != 0 && template+0x144 != -1` (wow-re `action-item-slot.md` §8.2) — the
/// gate on the use path's mode-`0x20` inventory search, which skips spent copies so a click
/// reaches one that still works. `-1` is the "unlimited" sentinel, `0` "no charges at all".
pub fn has_finite_charges(&self) -> bool {
self.spell_charges_0 != 0 && self.spell_charges_0 != -1
}
/// The **block ordinal** (0..4) of the first ON_USE spell — the third byte of `CMSG_USE_ITEM`
/// (wow-re `action-item-slot.md` §8.3). `None` for an item with no on-use spell. Almost
/// always 0, but an item whose block 0 is an ON_EQUIP proc and whose on-use sits in block 1
/// needs the real index or the server casts the wrong block.
pub fn use_spell_index(&self) -> Option<u8> {
self.spells.iter().find(|s| s.trigger == 0).map(|s| s.index)
}
/// Can this item be placed on an action-bar slot? `PlaceAction`'s only item filter, byte-read
/// (wow-re `action-item-slot.md` §5, `4e6571`–`4e6598`): **an on-use spell OR equippable**.
/// No quality, bind, class/subclass, container or level test exists anywhere on that path — a
/// bag (`InventoryType` 18) IS placeable; a grey trade good with neither is silently refused.
pub fn placeable_on_action_bar(&self) -> bool {
self.use_spell.is_some() || self.inventory_type != 0
}
}
/// One `Damage` block ([`ItemInfo::damages`] — block 0 is also mirrored into
/// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]).
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -174,6 +205,12 @@ pub struct ItemDamage {
/// as [`ItemUseSpell`]'s (VERIFIED vmangos `ItemHandler.cpp:354-391`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ItemSpellEntry {
/// Which of the template's **five** spell blocks this is (0..4). Not the index in
/// [`ItemInfo::spells`] — that vector drops empty blocks, and the wire's own
/// `CMSG_USE_ITEM` spell byte is this block ordinal (wow-re `action-item-slot.md` §8.3: the
/// reference scans `SpellId[5]`/`SpellTrigger[5]` for the block it is casting and sends its
/// position).
pub index: u8,
pub spell_id: u32,
/// `ItemSpelltriggerType`: 0 ON_USE, 1 ON_EQUIP, 2 CHANCE_ON_HIT.
pub trigger: u32,
@@ -297,15 +334,24 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
// client's own 5-slot scan.
let mut spells = Vec::new();
let mut use_spell = None;
for _ in 0..5 {
// Block 0's charges, kept RAW — whether the item has finite charges is the reference's
// `template+0x144 != 0 && != -1` test on exactly this word ([`ItemInfo::has_finite_charges`],
// wow-re `action-item-slot.md` §8.2), which reads block 0 even when block 0 carries no spell
// and so never reaches `spells` below.
let mut spell_charges_0 = 0;
for block in 0..5u8 {
let spell_id = read_u32_le(r)?;
let trigger = read_u32_le(r)?;
let charges = read_i32_le(r)?;
let cooldown_ms = read_i32_le(r)?;
let category = read_u32_le(r)?;
let category_cooldown_ms = read_i32_le(r)?;
if block == 0 {
spell_charges_0 = charges;
}
if spell_id != 0 {
spells.push(ItemSpellEntry {
index: block,
spell_id,
trigger,
charges,
@@ -378,6 +424,7 @@ pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option
ammo_type,
ranged_mod_range,
spells,
spell_charges_0,
use_spell,
bonding,
description,
@@ -41,6 +41,7 @@ mod progression;
mod quest;
mod roster;
mod skills;
mod social;
mod spellbook;
mod spells;
mod taxi;
@@ -134,6 +135,12 @@ pub use roster::{
RACE_HUMAN,
};
pub use skills::unlearn_skill;
pub use social::{
add_friend, add_ignore, del_friend, del_ignore, friend_list, friend_result, friend_status,
read_friend_list, read_friend_status, read_ignore_list, read_who, who, FriendEntry,
FriendOnline, FriendStatusUpdate, WhoEntry, WhoRequest, WhoResults, WHO_MAX_SEARCH_TERMS,
WHO_MAX_ZONES,
};
pub use spellbook::SpellCooldown;
pub use spells::{
cancel_aura, cast_spell, cast_spell_gameobject, cast_spell_item, CastOutcome, SpellCastTargets,
@@ -35,6 +35,8 @@ pub const SMSG_BINDPOINTUPDATE: u16 = 0x0155;
pub const SMSG_SET_PROFICIENCY: u16 = 0x0127;
pub const SMSG_LOGOUT_RESPONSE: u16 = 0x004C;
pub const SMSG_LOGOUT_COMPLETE: u16 = 0x004D;
/// 79 — the ack for [`CMSG_LOGOUT_CANCEL`] (VERIFIED vmangos `Opcodes_1_12_1.h`), empty body.
pub const SMSG_LOGOUT_CANCEL_ACK: u16 = 0x004F;
pub const SMSG_UPDATE_OBJECT: u16 = 0x00A9;
pub const SMSG_DESTROY_OBJECT: u16 = 0x00AA;
// The cinematic pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 250/252). The server sends the trigger
@@ -197,6 +199,9 @@ pub const CMSG_CHAR_CREATE: u16 = 0x0036;
pub const CMSG_CHAR_ENUM: u16 = 0x0037;
pub const CMSG_PLAYER_LOGIN: u16 = 0x003D;
pub const CMSG_LOGOUT_REQUEST: u16 = 0x004B;
/// 78 — call off a pending logout (VERIFIED vmangos `Opcodes_1_12_1.h`), empty body; answered by
/// [`SMSG_LOGOUT_CANCEL_ACK`]. The CAMP/QUIT dialog's Cancel (decision 0674).
pub const CMSG_LOGOUT_CANCEL: u16 = 0x004E;
pub const CMSG_NAME_QUERY: u16 = 0x0050; // 80
pub const CMSG_CREATURE_QUERY: u16 = 0x0060; // 96
/// VERIFIED vmangos `Opcodes_1_12_1.h:85`: 82. Body in [`super::client::pet_name_query`]. Answered
@@ -575,6 +580,21 @@ pub const CMSG_FORCE_MOVE_ROOT_ACK: u16 = 0x00E9; // 233
pub const SMSG_FORCE_MOVE_UNROOT: u16 = 0x00EA; // 234
pub const CMSG_FORCE_MOVE_UNROOT_ACK: u16 = 0x00EB; // 235
// The social family — the friend list, the ignore list, and `/who` (VERIFIED vmangos
// `Opcodes_1_12_1.h` + `Server/Packets/Social.{h,cpp}`, `Handlers/MiscHandler.cpp`,
// `SocialMgr.{h,cpp}`; client side wow-re's `FriendList.cpp` TU, `system/net/scratch/w2b.md`).
// Bodies in [`super::social`]; decision 0668.
pub const CMSG_WHO: u16 = 0x0062; // 98
pub const SMSG_WHO: u16 = 0x0063; // 99
pub const CMSG_FRIEND_LIST: u16 = 0x0066; // 102
pub const SMSG_FRIEND_LIST: u16 = 0x0067; // 103
pub const SMSG_FRIEND_STATUS: u16 = 0x0068; // 104
pub const CMSG_ADD_FRIEND: u16 = 0x0069; // 105
pub const CMSG_DEL_FRIEND: u16 = 0x006A; // 106
pub const SMSG_IGNORE_LIST: u16 = 0x006B; // 107
pub const CMSG_ADD_IGNORE: u16 = 0x006C; // 108
pub const CMSG_DEL_IGNORE: u16 = 0x006D; // 109
// The group/party family — invite/accept/decline/kick/leader/disband, the loot-method setting, the
// roster push (`SMSG_GROUP_LIST`), party command feedback, live member stats for the party/raid
// frame, minimap pings, raid subgroup management, raid-target icons, and ready checks (VERIFIED
+43 -9
View File
@@ -6,14 +6,14 @@ use crate::wire::Vector3d;
use super::{
ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage,
CorpseLocation, DamageShield, EnvironmentalDamageLog, GameObjectQueryInfo, GossipOption,
GroupLootInfo, GroupMemberEntry, InitWorldStates, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
Object, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody,
SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog,
SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell,
TransportPose, VendorItem, XpGain,
CorpseLocation, DamageShield, EnvironmentalDamageLog, FriendEntry, FriendStatusUpdate,
GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry, InitWorldStates, ItemInfo,
ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon,
LootStartRoll, MailListEntry, Object, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete,
QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate,
ResurrectRequestBody, SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo,
SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended,
TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -741,10 +741,25 @@ pub enum ServerPacket {
counter: u32,
on: bool,
},
/// `SMSG_LOGOUT_COMPLETE` — the world session is over; we are back at character select.
LogoutComplete,
/// `SMSG_LOGOUT_RESPONSE` — the server's answer to `CMSG_LOGOUT_REQUEST`, and the ONLY thing
/// that decides whether logging out is instant or a countdown. Body `{u32 reason, u8 instant}`
/// (vmangos `WorldPackets::Misc::LogoutResponse`; the real client reads the same pair — wow-re
/// `system/net/ledger.tsv` 0x5b4630 `Handle(0x4c) — {u32, u8}`).
///
/// `reason` non-zero is a REFUSAL and no logout starts (vmangos `HandleLogoutRequestOpcode`:
/// 1 in combat, 3 jumping/falling, 2 GM-frozen). `instant` is set when the server logs you out
/// on the spot — resting (an inn or a city), on a taxi, or a GM account — in which case
/// `LogoutComplete` follows immediately; otherwise a 20-second server-side timer runs, which is
/// what the client's CAMP/QUIT dialog counts down.
LogoutResponse {
result: u32,
reason: u32,
instant: bool,
},
/// `SMSG_LOGOUT_CANCEL_ACK` — the server dropped a pending logout at our `CMSG_LOGOUT_CANCEL`.
/// Empty body; it is what takes the countdown dialog back down (`LOGOUT_CANCEL`).
LogoutCancelAck,
/// `SMSG_PONG` — the echo of our `CMSG_PING`'s sequence number (the keepalive's return leg;
/// the io layer matches it against the ping clock to measure the round-trip).
Pong {
@@ -855,6 +870,20 @@ pub enum ServerPacket {
DuelCountdown {
seconds: u32,
},
/// `SMSG_FRIEND_LIST` — the whole friend list, guids + presence (decision 0668). Pushed
/// unasked at login and on every `CMSG_FRIEND_LIST`; always complete, never a delta.
FriendList {
friends: Vec<FriendEntry>,
},
/// `SMSG_IGNORE_LIST` — the whole ignore list: guids and nothing else.
IgnoreList {
guids: Vec<u64>,
},
/// `SMSG_FRIEND_STATUS` — one result about one player: the ack for an add/remove, or the
/// login/logout broadcast every friend-lister receives.
FriendStatus(FriendStatusUpdate),
/// `SMSG_WHO` — the `/who` answer: up to 49 rows plus the true match total.
WhoResults(WhoResults),
/// A `SMSG_SPLINE_SET_*_SPEED` — a speed change on a unit we don't control (a creature, or a
/// player mid-spline): `[packed guid][f32 speed]`, no counter, no ack (decision 0441 — how an
/// observed unit's mounted speed reaches us).
@@ -1107,6 +1136,7 @@ impl ServerPacket {
ServerPacket::WaterWalk { on: false, .. } => "SMSG_MOVE_LAND_WALK".into(),
ServerPacket::LogoutComplete => "SMSG_LOGOUT_COMPLETE".into(),
ServerPacket::LogoutResponse { .. } => "SMSG_LOGOUT_RESPONSE".into(),
ServerPacket::LogoutCancelAck => "SMSG_LOGOUT_CANCEL_ACK".into(),
ServerPacket::Pong { .. } => "SMSG_PONG".into(),
ServerPacket::ForceSpeedChange { kind, .. } => {
format!("SMSG_FORCE_{kind:?}_SPEED_CHANGE")
@@ -1135,6 +1165,10 @@ impl ServerPacket {
ServerPacket::DuelComplete { .. } => "SMSG_DUEL_COMPLETE".into(),
ServerPacket::DuelWinner { .. } => "SMSG_DUEL_WINNER".into(),
ServerPacket::DuelCountdown { .. } => "SMSG_DUEL_COUNTDOWN".into(),
ServerPacket::FriendList { .. } => "SMSG_FRIEND_LIST".into(),
ServerPacket::IgnoreList { .. } => "SMSG_IGNORE_LIST".into(),
ServerPacket::FriendStatus(..) => "SMSG_FRIEND_STATUS".into(),
ServerPacket::WhoResults(..) => "SMSG_WHO".into(),
ServerPacket::SplineSpeedChange { kind, .. } => {
format!("SMSG_SPLINE_SET_{kind:?}_SPEED")
}
+20 -4
View File
@@ -12,9 +12,9 @@ use crate::wire::{
use super::{
action_bar, attack, bank, channel, chat, combat_log, death, duel, gameobject, gossip, group,
items, loot, mail, monster_move, movement, opcode, progression, quest, spellbook, spells, taxi,
trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, ServerPacket,
SpeedKind,
items, loot, mail, monster_move, movement, opcode, progression, quest, social, spellbook,
spells, taxi, trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo,
ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -768,9 +768,13 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
ServerPacket::GameObjectQueryResponse { entry, info }
}
opcode::SMSG_LOGOUT_COMPLETE => ServerPacket::LogoutComplete,
// `{u32 reason, u8 instant}` — the `instant` byte is what the CAMP/QUIT countdown hangs
// on (see the variant's own doc); it used to be dropped on the floor here.
opcode::SMSG_LOGOUT_RESPONSE => ServerPacket::LogoutResponse {
result: read_u32_le(&mut r)?,
reason: read_u32_le(&mut r)?,
instant: read_u8(&mut r)? != 0,
},
opcode::SMSG_LOGOUT_CANCEL_ACK => ServerPacket::LogoutCancelAck,
// The keepalive echo: our CMSG_PING's sequence number back (vmangos `_HandlePing` — the
// body is the one u32 it read from us).
opcode::SMSG_PONG => ServerPacket::Pong {
@@ -863,6 +867,18 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_DUEL_COUNTDOWN => ServerPacket::DuelCountdown {
seconds: duel::read_duel_countdown(&mut r)?,
},
// The social family (decision 0668; bodies in `social`). Both list packets are
// replace-everything snapshots; the status packet is one result about one player.
opcode::SMSG_FRIEND_LIST => ServerPacket::FriendList {
friends: social::read_friend_list(&mut r)?,
},
opcode::SMSG_IGNORE_LIST => ServerPacket::IgnoreList {
guids: social::read_ignore_list(&mut r)?,
},
opcode::SMSG_FRIEND_STATUS => {
ServerPacket::FriendStatus(social::read_friend_status(&mut r)?)
}
opcode::SMSG_WHO => ServerPacket::WhoResults(social::read_who(&mut r)?),
// The observer speed legs (decision 0441): a unit we don't control changed speed — a
// creature or mid-spline player (SPLINE family), or a freely-moving player (MOVE_SET
// family, which carries a fresh pose too). No ack on either.
@@ -0,0 +1,601 @@
//! The social family — the friend list, the ignore list, and `/who` (opcodes 0x62–0x6d;
//! decision 0668).
//!
//! Like the duel family, **both ends are pinned at the bytes**. The server side is vmangos
//! `SocialMgr.{h,cpp}` (`PlayerSocial::SendFriendList`/`SendIgnoreList`,
//! `SocialMgr::SendFriendStatus`), `Server/Packets/Social.{h,cpp}` (every `AppendBodyTo`),
//! `Server/Packets/Misc.{h,cpp}` (the five client reads) and `Handlers/MiscHandler.cpp`
//! (`HandleWhoOpcode`'s `WhoListClientQueryTask` + the add/del handlers). The client side is
//! WoW.exe's own `FriendList.cpp` TU, recorded VERIFIED in wow-re `system/net/scratch/w2b.md`
//! (the 0x728-byte `FriendList` object `DAT_00c28168`, its six registered SMSG handlers, and the
//! Lua glue):
//!
//! | opcode | client fn | what it reads / writes |
//! |---|---|---|
//! | `SMSG_FRIEND_LIST` 0x67 | `FriendList::ParseFriendList 0x5ae350` | `u8` count, per slot `{guid u64, online u8, area/level/class u32}` |
//! | `SMSG_FRIEND_STATUS` 0x68 | `0x5add30` → `HandleResult 0x5acab0` | `u8` op + `u64` guid (+ the online tail) |
//! | `SMSG_IGNORE_LIST` 0x6b | the ignore-slot half of the same object | `u8` count, `u64` guids into the 25 slots @+0x650 |
//! | `SMSG_WHO` 0x63 | `0x5adde0` | count-led entry list → FrameScript |
//!
//! Two client-side facts that shape everything above this layer, both from that TU:
//!
//! - **The friend/ignore lists are guids, not names.** A slot holds `{status, note*, guid, area,
//! level, class}` — no name — and the client resolves the display name through the **ObjectMgr
//! name cache** (`0x55f080`, the `CMSG_NAME_QUERY` cache) at format time. benilla's
//! [`NameCache`](../../../benilla/src/names.rs) plays that role, which is why a friend row can
//! populate a frame late rather than never.
//! - **The client's own capacity is 50 friends / 25 ignores** (the slot arrays at `+0x008` and
//! `+0x650`), exactly vmangos's `SOCIALMGR_FRIEND_LIMIT` / `SOCIALMGR_IGNORE_LIMIT`. The
//! FrameXML's `MAX_IGNORE = 50` is dead data — the engine cap is 25.
use std::io::{self, Read};
use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8};
/// `SMSG_FRIEND_STATUS`'s result byte — vmangos's `FriendsResult` (`SocialMgr.h:81-110`), which is
/// the client's own enum: `FriendList::HandleResult 0x5acab0` switches on it and displays
/// `0x496720`'s catalog ids `0x104..0x114` (one per result, `ADDED_ONLINE`/`ADDED_OFFLINE`
/// sharing `ERR_FRIEND_ADDED_S`). Every one of those rows routes **kind 0 = the chat frame**, not
/// the red error line — see [`crate::messages`]'s consumer for that mapping.
pub mod friend_result {
/// `ERR_FRIEND_DB_ERROR` — the server's lookup failed.
pub const DB_ERROR: u8 = 0x00;
/// `ERR_FRIEND_LIST_FULL` — 50 friends already.
pub const LIST_FULL: u8 = 0x01;
/// `ERR_FRIEND_ONLINE_SS` — a friend just logged in (broadcast, not an ack).
pub const ONLINE: u8 = 0x02;
/// `ERR_FRIEND_OFFLINE_S` — a friend just logged out (broadcast).
pub const OFFLINE: u8 = 0x03;
/// `ERR_FRIEND_NOT_FOUND` — no character by that name.
pub const NOT_FOUND: u8 = 0x04;
/// `ERR_FRIEND_REMOVED_S` — the ack for `CMSG_DEL_FRIEND`.
pub const REMOVED: u8 = 0x05;
/// `ERR_FRIEND_ADDED_S`, and the added friend is online — carries the online tail.
pub const ADDED_ONLINE: u8 = 0x06;
/// `ERR_FRIEND_ADDED_S`, added while offline — no tail.
pub const ADDED_OFFLINE: u8 = 0x07;
/// `ERR_FRIEND_ALREADY_S`.
pub const ALREADY: u8 = 0x08;
/// `ERR_FRIEND_SELF`.
pub const SELF: u8 = 0x09;
/// `ERR_FRIEND_WRONG_FACTION` — cross-faction friending, refused by config.
pub const ENEMY: u8 = 0x0A;
/// `ERR_IGNORE_FULL` — 25 ignores already.
pub const IGNORE_FULL: u8 = 0x0B;
/// `ERR_IGNORE_SELF`.
pub const IGNORE_SELF: u8 = 0x0C;
/// `ERR_IGNORE_NOT_FOUND`.
pub const IGNORE_NOT_FOUND: u8 = 0x0D;
/// `ERR_IGNORE_ALREADY_S`.
pub const IGNORE_ALREADY: u8 = 0x0E;
/// `ERR_IGNORE_ADDED_S` — the ack for `CMSG_ADD_IGNORE`.
pub const IGNORE_ADDED: u8 = 0x0F;
/// `ERR_IGNORE_REMOVED_S` — the ack for `CMSG_DEL_IGNORE`.
pub const IGNORE_REMOVED: u8 = 0x10;
/// `ERR_IGNORE_AMBIGUOUS`.
pub const IGNORE_AMBIGUOUS: u8 = 0x11;
/// `ERR_FRIEND_ERROR` — "Unknown friend response from server."; vmangos's `FRIEND_UNKNOWN`.
pub const UNKNOWN: u8 = 0x1A;
}
/// A friend slot's presence byte — vmangos's `FriendStatus` (`SocialMgr.h:36-43`). `0` is the
/// only value the wire treats specially (it suppresses the area/level/class tail); the rest are
/// the AFK/DND flavours the friends list renders as its `<AFK>` / `<DND>` tag.
pub mod friend_status {
/// Offline — the entry carries no area/level/class.
pub const OFFLINE: u8 = 0;
/// Online, no away flag.
pub const ONLINE: u8 = 1;
/// Online and flagged AFK.
pub const AFK: u8 = 2;
/// Online and flagged DND (`3` is unused by 1.12 vmangos).
pub const DND: u8 = 4;
}
/// One row of the friend list — a guid plus, when the friend is online, where they are.
///
/// The name is deliberately absent: it is not on the wire (see the module doc's first client-side
/// fact) and is resolved from the name cache by the consumer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FriendEntry {
/// The friend's player guid. vmangos rebuilds it as `ObjectGuid(HIGHGUID_PLAYER, lowguid)`,
/// so it is always a player guid.
pub guid: u64,
/// [`friend_status`] — `OFFLINE` means the three fields below were **not** sent and read back
/// as zero.
pub status: u8,
/// The friend's zone id (`AreaTable.dbc`), `0` when offline.
pub area: u32,
/// The friend's level, `0` when offline.
pub level: u32,
/// The friend's class id, `0` when offline.
pub class: u32,
}
impl FriendEntry {
/// Is this friend online at all (any of ONLINE/AFK/DND)? The wire's own test — vmangos writes
/// the area/level/class tail under exactly `if (entry.status)`, and the client's parse reads
/// it under the same condition.
pub fn is_online(&self) -> bool {
self.status != friend_status::OFFLINE
}
}
/// Read `SMSG_FRIEND_LIST` (VERIFIED vmangos `Social.cpp`
/// `WorldPackets::Social::FriendList::AppendBodyTo`, and WoW.exe
/// `FriendList::ParseFriendList 0x5ae350`): a `u8` count, then per friend a `u64` guid and a `u8`
/// status — and the `u32` area/level/class triple **only when the status is non-zero**.
///
/// The conditional tail is why this cannot be a fixed-stride read: an offline friend is 9 bytes,
/// an online one 21.
pub fn read_friend_list(r: &mut impl Read) -> io::Result<Vec<FriendEntry>> {
let count = read_u8(r)?;
let mut friends = Vec::with_capacity(count as usize);
for _ in 0..count {
let mut entry = FriendEntry {
guid: read_u64_le(r)?,
status: read_u8(r)?,
..Default::default()
};
if entry.is_online() {
entry.area = read_u32_le(r)?;
entry.level = read_u32_le(r)?;
entry.class = read_u32_le(r)?;
}
friends.push(entry);
}
Ok(friends)
}
/// Read `SMSG_IGNORE_LIST` (VERIFIED vmangos `Social.cpp`
/// `WorldPackets::Social::IgnoreList::AppendBodyTo`): a `u8` count then that many full guids, and
/// nothing else — an ignore slot in the client is 8 bytes wide for the same reason (the `+0x650`
/// array).
pub fn read_ignore_list(r: &mut impl Read) -> io::Result<Vec<u64>> {
let count = read_u8(r)?;
(0..count).map(|_| read_u64_le(r)).collect()
}
/// `SMSG_FRIEND_STATUS` — one result code about one player, and (for the two "is online" results)
/// where they are.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FriendStatusUpdate {
/// A [`friend_result`] code. It is both an ack (we asked to add/remove) and a broadcast (a
/// friend logged in or out) — the code, not the opcode, says which.
pub result: u8,
/// The player the result is about. Note vmangos sends the *asked-for* guid even for
/// `NOT_FOUND`, where it is `0`.
pub guid: u64,
/// The online tail, present iff the result is [`friend_result::ONLINE`] or
/// [`friend_result::ADDED_ONLINE`] (VERIFIED `SocialMgr::SendFriendStatus`'s switch).
pub online: Option<FriendOnline>,
}
/// The presence tail `SMSG_FRIEND_STATUS` carries for an online friend — the same four fields a
/// [`FriendEntry`] holds, so a status update can refresh a list row in place.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FriendOnline {
/// [`friend_status`] — 1.12 sends this byte (the `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_8_4`
/// arm; the 1.8 wire had no status byte here).
pub status: u8,
/// Zone id.
pub area: u32,
/// Level.
pub level: u32,
/// Class id.
pub class: u32,
}
/// Read `SMSG_FRIEND_STATUS` (VERIFIED vmangos `Social.cpp`
/// `WorldPackets::Social::FriendStatus::AppendBodyTo` + `SocialMgr::SendFriendStatus`; client
/// handler `0x5add30`, which reads `u8` op + `u64` guid before handing off to
/// `HandleResult 0x5acab0`).
///
/// The tail is read on the *result code*, not on a length check: only `FRIEND_ADDED_ONLINE` and
/// `FRIEND_ONLINE` carry it. Reading it on any other code would desynchronise the parse, which is
/// exactly why the condition is spelled out rather than "read if bytes remain".
pub fn read_friend_status(r: &mut impl Read) -> io::Result<FriendStatusUpdate> {
let result = read_u8(r)?;
let guid = read_u64_le(r)?;
let online = matches!(result, friend_result::ONLINE | friend_result::ADDED_ONLINE)
.then(|| -> io::Result<FriendOnline> {
Ok(FriendOnline {
status: read_u8(r)?,
area: read_u32_le(r)?,
level: read_u32_le(r)?,
class: read_u32_le(r)?,
})
})
.transpose()?;
Ok(FriendStatusUpdate {
result,
guid,
online,
})
}
/// One `/who` hit. Unlike the friend list, every field is on the wire — a who result is a
/// snapshot the server composed, not a reference into any client cache.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WhoEntry {
/// Character name.
pub name: String,
/// Guild name, empty when unguilded (vmangos writes `GetGuildNameById(0)` = `""`).
pub guild: String,
/// Level.
pub level: u32,
/// Class id.
pub class: u32,
/// Race id.
pub race: u32,
/// Zone id (`AreaTable.dbc`) — the server sends the id, the client names it.
pub zone: u32,
}
/// `SMSG_WHO` — the results, plus the two counts the "N players total (M displayed)" line needs.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WhoResults {
/// How many entries follow (`clientCount`, ≤ 49 — vmangos breaks the walk at 49 despite the
/// "50 is maximum" comment).
pub displayed: u32,
/// How many matched in total: the full match count when it exceeds 49, otherwise the same as
/// [`Self::displayed`] (VERIFIED `data.put(4, count > 49 ? count : clientCount)`).
pub total: u32,
/// The entries themselves, server order.
pub entries: Vec<WhoEntry>,
}
/// Read `SMSG_WHO` (VERIFIED vmangos `MiscHandler.cpp`'s `WhoListClientQueryTask::operator()`):
/// two `u32` counts, then `displayed` entries of `{name, guild, level, class, race, zone}`.
///
/// 1.12 does **not** carry the trailing party-status `u32` — vmangos writes it only under
/// `SUPPORTED_CLIENT_BUILD <= CLIENT_BUILD_1_8_4`, and its own comment calls it "not actually
/// displayed anywhere".
pub fn read_who(r: &mut impl Read) -> io::Result<WhoResults> {
let displayed = read_u32_le(r)?;
let total = read_u32_le(r)?;
let mut entries = Vec::with_capacity(displayed.min(64) as usize);
for _ in 0..displayed {
entries.push(WhoEntry {
name: read_cstring(r)?,
guild: read_cstring(r)?,
level: read_u32_le(r)?,
class: read_u32_le(r)?,
race: read_u32_le(r)?,
zone: read_u32_le(r)?,
});
}
Ok(WhoResults {
displayed,
total,
entries,
})
}
/// A `/who` query, already parsed out of the filter string the player typed.
///
/// The *parsing* is engine work in the real client too (the FrameXML hands `SendWho` a raw string
/// like `z-"Elwynn Forest" 1-10`), and it needs `AreaTable.dbc` to turn a zone name into the id
/// the wire wants — so benilla parses it app-side, where the DBCs live, and this struct is the
/// result. [`Default`] is the query the client sends for a bare `/who` with no terms.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhoRequest {
/// Inclusive minimum level.
pub level_min: u32,
/// Inclusive maximum level. The real client's un-set value is **100** (vmangos's own comment:
/// "client send in case not set max level value 100"), not 255 — so that is the default here.
pub level_max: u32,
/// `n-` — a name substring, empty for "any".
pub player_name: String,
/// `g-` — a guild-name substring, empty for "any".
pub guild_name: String,
/// `r-` — a bitmask over race ids (`1 << race`), all-ones for "any".
pub race_mask: u32,
/// `c-` — a bitmask over class ids (`1 << class`), all-ones for "any".
pub class_mask: u32,
/// `z-` — zone ids, resolved from names client-side. The server refuses more than 10.
pub zones: Vec<u32>,
/// Untagged words: the server matches each against name, guild **and** zone name (any hit
/// keeps the row). Refused past 4.
pub search_terms: Vec<String>,
}
impl Default for WhoRequest {
fn default() -> Self {
Self {
level_min: 0,
level_max: 100,
player_name: String::new(),
guild_name: String::new(),
race_mask: u32::MAX,
class_mask: u32::MAX,
zones: Vec::new(),
search_terms: Vec::new(),
}
}
}
/// The server's own limits on a `/who` (`HandleWhoOpcode`: `> 10` zones or `> 4` terms and the
/// whole packet is dropped, silently). Trimming to them client-side turns a dropped query into a
/// narrower one.
pub const WHO_MAX_ZONES: usize = 10;
/// See [`WHO_MAX_ZONES`].
pub const WHO_MAX_SEARCH_TERMS: usize = 4;
/// Body of `CMSG_WHO` (VERIFIED vmangos `Misc.cpp` `WorldPackets::Misc::Who::ReadFromWorldPacket`):
/// `levelMin`, `levelMax`, name cstring, guild cstring, `raceMask`, `classMask`, then a counted
/// `u32` zone array and a counted cstring array — in that order.
///
/// Both arrays are trimmed to the server's caps ([`WHO_MAX_ZONES`], [`WHO_MAX_SEARCH_TERMS`]):
/// over them the handler `return`s before doing anything, so an untrimmed query would look
/// exactly like a `/who` that matched nobody.
pub fn who(request: &WhoRequest) -> Vec<u8> {
let zones = &request.zones[..request.zones.len().min(WHO_MAX_ZONES)];
let terms = &request.search_terms[..request.search_terms.len().min(WHO_MAX_SEARCH_TERMS)];
let mut body = Vec::with_capacity(32 + request.player_name.len() + request.guild_name.len());
body.extend_from_slice(&request.level_min.to_le_bytes());
body.extend_from_slice(&request.level_max.to_le_bytes());
push_cstring(&mut body, &request.player_name);
push_cstring(&mut body, &request.guild_name);
body.extend_from_slice(&request.race_mask.to_le_bytes());
body.extend_from_slice(&request.class_mask.to_le_bytes());
body.extend_from_slice(&(zones.len() as u32).to_le_bytes());
for zone in zones {
body.extend_from_slice(&zone.to_le_bytes());
}
body.extend_from_slice(&(terms.len() as u32).to_le_bytes());
for term in terms {
push_cstring(&mut body, term);
}
body
}
/// Body of `CMSG_FRIEND_LIST` (VERIFIED vmangos `Opcodes.cpp` — `NullClientPacket`): empty. The
/// list also arrives unasked at login (`CharacterHandler.cpp:527`), so this is a refresh, which is
/// exactly what the FrameXML's `ShowFriends()` is for.
pub fn friend_list() -> Vec<u8> {
Vec::new()
}
/// Body of `CMSG_ADD_FRIEND` (VERIFIED vmangos `Misc.cpp` `AddFriend::ReadFromWorldPacket`): one
/// cstring, the name to befriend. The server normalises the case itself
/// (`normalizePlayerName`), so the client need not.
pub fn add_friend(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_DEL_FRIEND` (VERIFIED vmangos `Misc.cpp` `DelFriend::ReadFromWorldPacket`): one
/// full guid. Removal is by **guid**, not name — which is why the client keeps the list keyed by
/// guid and resolves names for display only.
pub fn del_friend(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
/// Body of `CMSG_ADD_IGNORE` (VERIFIED vmangos `Misc.cpp` `AddIgnore::ReadFromWorldPacket`): one
/// cstring, the name to ignore.
pub fn add_ignore(name: &str) -> Vec<u8> {
cstring_body(name)
}
/// Body of `CMSG_DEL_IGNORE` (VERIFIED vmangos `Misc.cpp` `DelIgnore::ReadFromWorldPacket`): one
/// full guid, the [`del_friend`] shape.
pub fn del_ignore(guid: u64) -> Vec<u8> {
guid.to_le_bytes().to_vec()
}
/// A body that is exactly one NUL-terminated string.
fn cstring_body(s: &str) -> Vec<u8> {
let mut body = Vec::with_capacity(s.len() + 1);
push_cstring(&mut body, s);
body
}
/// Append a NUL-terminated string to `body`.
fn push_cstring(body: &mut Vec<u8>, s: &str) {
body.extend_from_slice(s.as_bytes());
body.push(0);
}
#[cfg(test)]
mod tests {
use super::*;
/// `SMSG_FRIEND_LIST`: the area/level/class tail rides on the status byte, so an offline and
/// an online entry have different widths in the same packet — the parse has to follow it or
/// every later entry is garbage.
#[test]
fn friend_list_tail_is_conditional_on_the_status_byte() {
let mut body = vec![2u8]; // count
body.extend_from_slice(&7u64.to_le_bytes()); // offline friend
body.push(friend_status::OFFLINE);
body.extend_from_slice(&9u64.to_le_bytes()); // online friend
body.push(friend_status::AFK);
body.extend_from_slice(&12u32.to_le_bytes()); // area
body.extend_from_slice(&60u32.to_le_bytes()); // level
body.extend_from_slice(&11u32.to_le_bytes()); // class
let friends = read_friend_list(&mut &body[..]).unwrap();
assert_eq!(
friends,
vec![
FriendEntry {
guid: 7,
status: friend_status::OFFLINE,
..Default::default()
},
FriendEntry {
guid: 9,
status: friend_status::AFK,
area: 12,
level: 60,
class: 11,
},
]
);
assert!(!friends[0].is_online() && friends[1].is_online());
}
/// `SMSG_IGNORE_LIST` is a `u8` count and bare guids.
#[test]
fn ignore_list_is_a_counted_guid_array() {
let mut body = vec![2u8];
body.extend_from_slice(&0x1122u64.to_le_bytes());
body.extend_from_slice(&0x3344u64.to_le_bytes());
assert_eq!(
read_ignore_list(&mut &body[..]).unwrap(),
vec![0x1122, 0x3344]
);
}
/// `SMSG_FRIEND_STATUS`: only ONLINE and ADDED_ONLINE carry the presence tail. A REMOVED with
/// trailing bytes must still read as tail-less — the code decides, not the length.
#[test]
fn friend_status_tail_is_keyed_on_the_result_code() {
let mut online = vec![friend_result::ONLINE];
online.extend_from_slice(&9u64.to_le_bytes());
online.push(friend_status::DND);
online.extend_from_slice(&1519u32.to_le_bytes());
online.extend_from_slice(&60u32.to_le_bytes());
online.extend_from_slice(&8u32.to_le_bytes());
assert_eq!(
read_friend_status(&mut &online[..]).unwrap(),
FriendStatusUpdate {
result: friend_result::ONLINE,
guid: 9,
online: Some(FriendOnline {
status: friend_status::DND,
area: 1519,
level: 60,
class: 8,
}),
}
);
let mut removed = vec![friend_result::REMOVED];
removed.extend_from_slice(&9u64.to_le_bytes());
removed.extend_from_slice(&[0xFF; 13]); // must be ignored, not parsed
let status = read_friend_status(&mut &removed[..]).unwrap();
assert_eq!(status.result, friend_result::REMOVED);
assert_eq!(status.online, None);
}
/// `SMSG_WHO`: two counts, then name/guild cstrings and four `u32`s per entry. `total` can
/// exceed `displayed` — that difference is what the "(N displayed)" suffix reports.
#[test]
fn who_results_carry_both_counts() {
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_le_bytes()); // displayed
body.extend_from_slice(&57u32.to_le_bytes()); // total
body.extend_from_slice(b"Tigole\0");
body.extend_from_slice(b"Legacy of Steel\0");
body.extend_from_slice(&40u32.to_le_bytes()); // level
body.extend_from_slice(&4u32.to_le_bytes()); // class
body.extend_from_slice(&1u32.to_le_bytes()); // race
body.extend_from_slice(&40u32.to_le_bytes()); // zone
assert_eq!(
read_who(&mut &body[..]).unwrap(),
WhoResults {
displayed: 1,
total: 57,
entries: vec![WhoEntry {
name: "Tigole".into(),
guild: "Legacy of Steel".into(),
level: 40,
class: 4,
race: 1,
zone: 40,
}],
}
);
}
/// An unguilded who row still carries its (empty) guild cstring — dropping it would eat the
/// next field.
#[test]
fn who_reads_the_empty_guild_string() {
let mut body = Vec::new();
body.extend_from_slice(&1u32.to_le_bytes());
body.extend_from_slice(&1u32.to_le_bytes());
body.extend_from_slice(b"Solo\0");
body.extend_from_slice(b"\0");
body.extend_from_slice(&5u32.to_le_bytes());
body.extend_from_slice(&1u32.to_le_bytes());
body.extend_from_slice(&3u32.to_le_bytes());
body.extend_from_slice(&1u32.to_le_bytes());
let who = read_who(&mut &body[..]).unwrap();
assert_eq!(who.entries[0].guild, "");
assert_eq!(who.entries[0].level, 5);
}
/// `CMSG_WHO`'s field order, byte for byte — the golden for the one body in this family the
/// server actually parses field-by-field.
#[test]
fn who_request_body_is_byte_exact() {
let request = WhoRequest {
level_min: 1,
level_max: 10,
player_name: "bob".into(),
guild_name: String::new(),
race_mask: 0xFFFF_FFFF,
class_mask: 0x0000_0002,
zones: vec![12],
search_terms: vec!["elw".into()],
};
let mut want = Vec::new();
want.extend_from_slice(&1u32.to_le_bytes());
want.extend_from_slice(&10u32.to_le_bytes());
want.extend_from_slice(b"bob\0");
want.extend_from_slice(b"\0");
want.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
want.extend_from_slice(&2u32.to_le_bytes());
want.extend_from_slice(&1u32.to_le_bytes());
want.extend_from_slice(&12u32.to_le_bytes());
want.extend_from_slice(&1u32.to_le_bytes());
want.extend_from_slice(b"elw\0");
assert_eq!(who(&request), want);
}
/// The default query is the bare `/who`: every level up to the client's own 100, every race
/// and class, no terms.
#[test]
fn the_default_who_query_is_the_clients_unset_shape() {
let body = who(&WhoRequest::default());
assert_eq!(&body[0..4], &0u32.to_le_bytes(), "levelMin");
assert_eq!(&body[4..8], &100u32.to_le_bytes(), "levelMax — not 255");
assert_eq!(&body[8..10], b"\0\0", "both name filters empty");
assert_eq!(&body[10..14], &u32::MAX.to_le_bytes(), "every race");
assert_eq!(&body[14..18], &u32::MAX.to_le_bytes(), "every class");
assert_eq!(&body[18..22], &0u32.to_le_bytes(), "no zones");
assert_eq!(&body[22..26], &0u32.to_le_bytes(), "no terms");
assert_eq!(body.len(), 26);
}
/// Both arrays are trimmed to the server's caps: past them `HandleWhoOpcode` drops the whole
/// packet, so an over-long query would silently return nothing at all.
#[test]
fn who_trims_to_the_servers_caps() {
let request = WhoRequest {
zones: (0..15).collect(),
search_terms: (0..7).map(|i| i.to_string()).collect(),
..Default::default()
};
let body = who(&request);
let zone_count = u32::from_le_bytes(body[18..22].try_into().unwrap());
assert_eq!(zone_count as usize, WHO_MAX_ZONES);
let after_zones = 22 + WHO_MAX_ZONES * 4;
let term_count = u32::from_le_bytes(body[after_zones..after_zones + 4].try_into().unwrap());
assert_eq!(term_count as usize, WHO_MAX_SEARCH_TERMS);
}
/// The four small client bodies: two cstrings, two bare guids.
#[test]
fn the_add_and_del_bodies() {
assert_eq!(add_friend("Bob"), b"Bob\0");
assert_eq!(add_ignore("Bob"), b"Bob\0");
assert_eq!(del_friend(0xDEAD_BEEF), 0xDEAD_BEEFu64.to_le_bytes());
assert_eq!(del_ignore(0xDEAD_BEEF), 0xDEAD_BEEFu64.to_le_bytes());
assert!(friend_list().is_empty());
}
}
@@ -14,6 +14,17 @@ impl ObjectFields {
pub fn item_stack_count(&self) -> Option<u32> {
self.get_u32(FIELD_ITEM_STACK_COUNT)
}
/// `ITEM_FIELD_SPELL_CHARGES + i` — this *instance*'s remaining charges for its `i`th spell
/// block (`i < 5`), signed: negative = a consumable-on-empty counter, `0` = spent. Distinct
/// from the TEMPLATE's `ItemSpellEntry::charges`, which says how many a fresh copy carries.
/// Field 16 = `FIELD_ITEM_STACK_COUNT`(14) + DURATION(15) + 1 — the chain between the tested
/// STACK_COUNT and FLAGS(21) anchors, and the client's own `[item+0x114]+0x28` (wow-re
/// `action-item-slot.md` §8.2, the mode-`0x20` search filter that reads exactly this).
pub fn item_spell_charges(&self, i: u8) -> Option<i32> {
(i < 5)
.then(|| self.get_u32(FIELD_ITEM_STACK_COUNT + 2 + u16::from(i)))?
.map(|v| v as i32)
}
/// `ITEM_FIELD_FLAGS` (field 21 — wire index; the client's item-relative index 15 + the 6
/// object fields, VERIFIED wow-re inventory-alert-law): bit `0x08` = wrapped (a gift — never
/// alerts, never reads broken), bit `0x10` = force-red (status 4 regardless of durability;
+3 -3
View File
@@ -718,9 +718,9 @@ impl WorldSession {
while std::time::Instant::now() < deadline {
match self.recv() {
Ok(ServerPacket::LogoutComplete) => return Ok(()),
Ok(ServerPacket::LogoutResponse { result }) => {
// A non-success result means the server refused (combat, etc.).
denied = result != messages::LOGOUT_SUCCESS;
Ok(ServerPacket::LogoutResponse { reason, .. }) => {
// A non-zero reason means the server refused (combat, falling, GM-frozen).
denied = reason != messages::LOGOUT_SUCCESS;
}
Ok(_) => {}
Err(_) => {} // read-timeout tick — keep polling until the deadline
@@ -31,6 +31,14 @@ impl WorldWriter {
self.send(opcode::CMSG_LOGOUT_REQUEST, &[])
}
/// Call off a pending logout (`CMSG_LOGOUT_CANCEL`, empty body) — the CAMP/QUIT dialog's Cancel
/// (decision 0674). Only meaningful while the server's 20-second timer is running (a non-instant
/// [`logout_request`](Self::logout_request)); the server drops the timer, unroots the character
/// and answers `SMSG_LOGOUT_CANCEL_ACK`.
pub fn logout_cancel(&mut self) -> Result<()> {
self.send(opcode::CMSG_LOGOUT_CANCEL, &[])
}
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`, empty body) — the
/// packet the real client sends when the cinematic ends or the player ESCs out. Must answer
/// every `SMSG_TRIGGER_CINEMATIC` ([`SessionEvent::CinematicTriggered`]
@@ -39,6 +39,7 @@
//! | [`duel`] | accept and cancel |
//! | [`mail`] | the mailbox |
//! | [`pvp`] | the one PvP-flag toggle |
//! | [`social`] | friends, ignores, and `/who` |
use std::net::TcpStream;
@@ -69,6 +70,7 @@ mod quest;
mod selection;
mod self_movement;
mod skills;
mod social;
mod spells;
mod taxi;
mod trade;
@@ -0,0 +1,50 @@
//! The social family's `WorldWriter` sends — friends, ignores, and `/who` (decision 0668).
//!
//! Note the asymmetry the wire imposes and the UI has to live with: you **add** by name and
//! **remove** by guid (`CMSG_ADD_FRIEND` takes a cstring, `CMSG_DEL_FRIEND` a guid). That is not
//! an inconsistency to paper over — it is why the client keeps both lists keyed by guid and
//! resolves display names through the name cache (see [`crate::messages::social`]'s module doc).
//! A "remove Bob" verb therefore has to find Bob's guid in the list *before* it can send anything.
use anyhow::Result;
use crate::messages::{self, opcode, WhoRequest};
use super::WorldWriter;
impl WorldWriter {
/// Ask for the friend list again (`CMSG_FRIEND_LIST`, empty body) — the FrameXML's
/// `ShowFriends()`. The server also pushes it unasked at login, so this is a refresh, never
/// the only way the list arrives.
pub fn friend_list(&mut self) -> Result<()> {
self.send(opcode::CMSG_FRIEND_LIST, &messages::friend_list())
}
/// Befriend a character by name (`CMSG_ADD_FRIEND`). The answer is an `SMSG_FRIEND_STATUS`
/// carrying one of the `FRIEND_ADDED_*` / refusal codes — never a silent success.
pub fn add_friend(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_ADD_FRIEND, &messages::add_friend(name))
}
/// Drop a friend by guid (`CMSG_DEL_FRIEND`); acked with `FRIEND_REMOVED`.
pub fn del_friend(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_DEL_FRIEND, &messages::del_friend(guid))
}
/// Ignore a character by name (`CMSG_ADD_IGNORE`); acked with `FRIEND_IGNORE_ADDED`.
pub fn add_ignore(&mut self, name: &str) -> Result<()> {
self.send(opcode::CMSG_ADD_IGNORE, &messages::add_ignore(name))
}
/// Stop ignoring, by guid (`CMSG_DEL_IGNORE`); acked with `FRIEND_IGNORE_REMOVED`.
pub fn del_ignore(&mut self, guid: u64) -> Result<()> {
self.send(opcode::CMSG_DEL_IGNORE, &messages::del_ignore(guid))
}
/// Run a `/who` (`CMSG_WHO`). The server answers at most one query per session at a time
/// (`ReceivedWhoRequest` gates it) and runs it as an async task, so the `SMSG_WHO` lands a
/// tick or two later — a second query fired while one is in flight is dropped, not queued.
pub fn who(&mut self, request: &WhoRequest) -> Result<()> {
self.send(opcode::CMSG_WHO, &messages::who(request))
}
}
+5
View File
@@ -238,8 +238,10 @@ fn item_query_response_full_weapon_golden() {
delay_ms: 2800,
ammo_type: 2,
ranged_mod_range: 1.5,
spell_charges_0: 0,
spells: vec![
ItemSpellEntry {
index: 0,
spell_id: 17_251,
trigger: 0,
charges: 0,
@@ -248,6 +250,7 @@ fn item_query_response_full_weapon_golden() {
category_cooldown_ms: 60_000,
},
ItemSpellEntry {
index: 1,
spell_id: 671,
trigger: 1,
charges: 0,
@@ -403,7 +406,9 @@ fn item_query_response_consumable_all_zero_slots_are_empty_not_garbage() {
delay_ms: 0,
ammo_type: 0,
ranged_mod_range: 0.0,
spell_charges_0: 0,
spells: vec![ItemSpellEntry {
index: 0,
spell_id: 2024,
trigger: 0,
charges: 0,
+9
View File
@@ -147,6 +147,15 @@ impl super::UiScript {
pub fn take_action_sets(&mut self) -> Vec<(u32, u32)> {
std::mem::take(&mut self.model_mut().action_sets)
}
/// Drain the GlobalStrings keys queued by engine-side client-local refusals since the last
/// call — the app resolves each against the VM's own GlobalStrings and fires
/// `UI_ERROR_MESSAGE`, standing in for the reference's inline `CGGameUI::DisplayError` (whose
/// engine can call it directly; ours is on the far side of the crate boundary). See
/// [`crate::script::model::Model::ui_errors`].
pub fn take_ui_errors(&mut self) -> Vec<&'static str> {
std::mem::take(&mut self.model_mut().ui_errors)
}
}
/// `checkCursor`'s truthiness (`UseAction`'s second argument): the reference's own numeric
@@ -162,6 +162,9 @@ impl Default for PlayerCombatStats {
pub struct InvSlotView {
/// The item's template entry (`GetInventoryItemID`).
pub item_id: u32,
/// The doll twin of [`super::container::ContainerSlot::bar_placeable`] — an equipped item
/// dragged straight to the bar goes through the same filter.
pub bar_placeable: bool,
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
pub icon: Option<String>,
/// Stack count (`GetInventoryItemCount` — 1 for equipment, the bag-summed count for ammo).
@@ -37,6 +37,11 @@ pub struct ContainerSlot {
/// template's `inventoryType` via `ui_items::find_equip_slot`; the engine holds no item
/// knowledge of its own.
pub equip_slots: Vec<u8>,
/// Whether this item may be placed on an ACTION-BAR slot — app-resolved from the template
/// exactly like [`Self::equip_slots`] (the engine holds no item knowledge of its own).
/// `PlaceAction`'s only item filter, byte-read: an on-use spell OR equippable
/// (`ItemInfo::placeable_on_action_bar`, wow-re `action-item-slot.md` §5 — decision 0666).
pub bar_placeable: bool,
/// The instance's live durability `(current, max)` — `ITEM_FIELD_DURABILITY`/`MAXDURABILITY`
/// off the streamed item object; `None` for indestructible items (max 0) or while the create
/// hasn't landed. The real-instance tooltip's "Durability X / Y" line reads it (a template
@@ -205,6 +210,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
link: s.link.clone(),
quality: s.quality,
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
});
match picked {
@@ -515,6 +521,7 @@ mod tests {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
@@ -689,6 +696,7 @@ mod tests {
state.slots.insert(
5,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
count: 1,
+13
View File
@@ -80,6 +80,9 @@ pub struct CursorItem {
/// [`doll::pickup_inventory_item`] reads it to decide a place-onto-doll-slot's fit;
/// [`doll::cursor_can_go_in_slot`] serves it straight to `CURSOR_UPDATE`'s highlight.
pub equip_slots: Vec<u8>,
/// Whether this item may be placed on an ACTION-BAR slot, captured at pickup from the
/// source's own `bar_placeable` — `PlaceAction`'s only item filter (decision 0666).
pub bar_placeable: bool,
}
/// A spell payload (`PickupSpell`, not yet built — anticipated by the enum for the spellbook
@@ -91,6 +94,9 @@ pub struct CursorSpell {
pub book_type: String,
pub spell_id: u32,
pub texture: Option<String>,
/// `Attributes & 0x40` (`SPELL_ATTR_PASSIVE`) — a passive cannot go on the action bar
/// (`PlaceAction`'s other filter, `0x4e63ad`; decision 0666).
pub passive: bool,
}
/// An action-slot payload (`PickupAction`, not yet built — anticipated by the enum for the
@@ -261,6 +267,7 @@ pub(crate) fn split_container_item(model: &mut Model, bag: i64, slot: u32, count
link: s.link.clone(),
quality: s.quality,
count: if whole { None } else { Some(n) },
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
};
model.cursor = Some(CursorPayload::Item(item));
@@ -434,6 +441,7 @@ mod tests {
slots.insert(
1,
crate::script::container::ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
@@ -468,6 +476,7 @@ mod tests {
// Item arm.
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
@@ -489,6 +498,7 @@ mod tests {
// Spell arm — the Era 4-tuple shape.
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 3,
book_type: "spell".into(),
spell_id: 133,
@@ -525,6 +535,7 @@ mod tests {
fn clear_cursor_widens_to_any_arm() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 1,
book_type: "spell".into(),
spell_id: 1,
@@ -543,6 +554,7 @@ mod tests {
use super::{world_drop_click, WorldPick};
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 3,
book_type: "spell".into(),
spell_id: 133,
@@ -567,6 +579,7 @@ mod tests {
// An item drops (the popup path) on BOTH empty-world legs, never over an object.
let item = CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
+132
View File
@@ -21,6 +21,12 @@ use crate::script::{ActionSlot, Model};
use super::{queue_cursor_update, CursorAction, CursorPayload};
/// The GlobalStrings key the reference's passive-spell refusal shows — errorId `0x9e`'s entry in
/// the errorId→key table (`0xb4b498 + 0x9e*0x14 = 0xb4c0f0` ← key ptr `0x84133c`), i.e.
/// `ERR_PASSIVE_ABILITY` = "You can't put a passive ability in the action bar.". Queued onto
/// `Model::ui_errors` for the app's action feed to resolve and fire.
const PASSIVE_ON_BAR_ERROR: &str = "ERR_PASSIVE_ABILITY";
/// Pack `(kind, action)` into the wire's `u32` slot word (`kind<<24 | action`, decision 0216 §1).
fn pack(kind: u8, action: u32) -> u32 {
(u32::from(kind) << 24) | (action & 0x00FF_FFFF)
@@ -78,6 +84,29 @@ pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
///
/// Returns whether the caller should repaint.
pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
// The two accept filters, byte-read (wow-re `action-item-slot.md` §5 — decision 0666). Both
// reject with a **bare return**: no store, no clear, no packet — mechanically identical to
// clicking with an empty cursor, and the refused payload STAYS on the cursor.
//
// - ITEM: placeable iff it has an on-use spell OR is equippable (`4e6571`–`4e6598`). There is
// no quality, bind, class/subclass, container or level test anywhere on that path — a BAG
// is placeable; a grey trade good with neither is silently refused.
// - SPELL: a passive (`Attributes & 0x40`) is refused (`4e63ad`), and — unlike the ITEM
// refusal, which is mute — it ALSO raises errorId `0x9e` through `CGGameUI::DisplayError`
// (`4e63ad: push 0x9e; call 0x496720`). That id resolves through the errorId→key table at
// base `0xb4b498`, stride `0x14`: entry `0xb4b498 + 0x9e*0x14 = 0xb4c0f0`, whose static-init
// `4861ff: mov [0xb4c0f0], 0x84133c` names [`PASSIVE_ON_BAR_ERROR`] — "You can't put a
// passive ability in the action bar." (The same arithmetic reproduces the independently
// recorded `ERR_ATTACK_MOUNTED` anchor: `0xa4` → `0xb4c168`.) Closes 0666's named
// divergence, which refused silently while the key was unpinned.
match &model.cursor {
Some(CursorPayload::Item(i)) if !i.bar_placeable => return false,
Some(CursorPayload::Spell(s)) if s.passive => {
model.ui_errors.push(PASSIVE_ON_BAR_ERROR);
return false;
}
_ => {}
}
let Some(held) = model.cursor.take() else {
return false;
};
@@ -234,6 +263,7 @@ mod tests {
fn place_action_item_payload_packs_the_item_kind_and_leaves_the_bag_untouched() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
@@ -258,6 +288,7 @@ mod tests {
fn place_action_spell_payload_packs_the_spell_kind() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 3,
book_type: "spell".into(),
spell_id: 133,
@@ -331,4 +362,105 @@ mod tests {
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
}
/// `PlaceAction`'s ITEM filter (decision 0666): an item with neither an on-use spell nor an
/// equip slot — a grey trade good — is refused, and the refusal is a **bare return**: nothing
/// stored, nothing sent, and the payload is still on the cursor afterwards.
#[test]
fn a_non_usable_non_equippable_item_is_refused_and_stays_held() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: false, // no on-use spell, InventoryType 0
bag: 0,
slot: 1,
item_id: 2589, // Linen Cloth
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: Vec::new(),
}));
assert!(!s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(!s.eval::<bool>("return HasAction(4)").unwrap(), "no store");
assert!(s.take_action_sets().is_empty(), "no packet");
assert!(
matches!(s.cursor_payload(), Some(CursorPayload::Item(_))),
"a refused payload stays on the cursor — the reference never clears it"
);
assert!(
s.take_ui_errors().is_empty(),
"the ITEM refusal is MUTE — only the SPELL arm carries a DisplayError (`4e63ad`); a \
toast here would be ours, not the reference's"
);
}
/// The SPELL twin: a passive cannot go on the bar (`Attributes & 0x40`) — and unlike the item
/// arm this one SPEAKS, raising errorId `0x9e` = `ERR_PASSIVE_ABILITY` (see
/// [`super::place_action`]). The refusal and the toast are separate halves: the refusal is
/// still a bare return, so nothing is stored, nothing is sent, and the spell stays held.
#[test]
fn a_passive_spell_is_refused_with_the_refs_error_and_stays_held() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 674, // Dual Wield, a passive
texture: Some("Interface\\Icons\\Ability_DualWield".into()),
passive: true,
}));
assert!(!s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(!s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_action_sets().is_empty());
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
assert_eq!(
s.take_ui_errors(),
vec![super::PASSIVE_ON_BAR_ERROR],
"the ref's errorId 0x9e toast — 'You can't put a passive ability in the action bar.'"
);
assert!(
s.take_ui_errors().is_empty(),
"…and the queue drains, so the toast fires once per refusal"
);
}
/// The other half of the same law: an ACTIVE spell places normally and raises nothing. Guards
/// the obvious over-correction — a toast on every spell drop.
#[test]
fn an_active_spell_places_without_an_error() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133, // Fireball
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert!(s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_ui_errors().is_empty());
}
/// The positive control on the other side of both filters — a BAG (`InventoryType` 18, no
/// on-use spell) IS placeable, which is the one answer people find surprising.
#[test]
fn a_bag_is_placeable() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true, // equippable (BAG), even with no on-use spell
bag: 0,
slot: 1,
item_id: 4496, // Small Brown Pouch
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: vec![20, 21, 22, 23],
}));
assert!(s.eval::<bool>("return PlaceAction(4)").unwrap());
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]);
}
}
@@ -53,6 +53,7 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
link: s.link.clone(),
quality: Some(u32::try_from(s.quality).unwrap_or(0)),
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
});
match picked {
@@ -239,6 +240,7 @@ mod tests {
fn doll_slots() -> crate::script::InventorySlots {
let mut slots: crate::script::InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 1234,
@@ -252,6 +254,7 @@ mod tests {
creator: None,
});
slots[11] = Some(InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 555,
@@ -265,6 +268,7 @@ mod tests {
creator: None,
});
slots[19] = Some(InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 999,
@@ -285,6 +289,7 @@ mod tests {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
count: 1,
@@ -426,6 +431,7 @@ mod tests {
});
// An equipped bag in Bag0Slot (id 20): its equip_slots is the four bag slots.
slots[20] = Some(InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 4496,
@@ -461,6 +467,7 @@ mod tests {
slots.insert(
3,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
@@ -517,6 +524,7 @@ mod tests {
// Spell/Action arms: always false (the fit-blind refusal).
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 1,
book_type: "spell".into(),
spell_id: 1,
@@ -234,6 +234,7 @@ mod tests {
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
@@ -283,6 +284,7 @@ mod tests {
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
@@ -321,6 +323,7 @@ mod tests {
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
@@ -361,6 +364,7 @@ mod tests {
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
@@ -397,6 +401,7 @@ mod tests {
.unwrap();
s.resolve();
s.set_cursor_for_test(CursorPayload::Spell(crate::script::cursor::CursorSpell {
passive: false,
book_slot: 1,
book_type: "spell".into(),
spell_id: 1,
+2
View File
@@ -727,6 +727,7 @@ mod tests {
// Put a bag item on the cursor, then click the send slot: it attaches.
s.model_mut().cursor = Some(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 5,
item_id: 2589,
@@ -760,6 +761,7 @@ mod tests {
use crate::script::cursor::{CursorItem, CursorPayload};
let mut s = UiScript::new().unwrap();
s.model_mut().cursor = Some(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 1,
+6
View File
@@ -74,9 +74,11 @@ mod quest;
mod quest_log;
mod region;
mod scrollframe;
mod session;
mod shapeshift;
mod skills;
mod slider;
mod social;
mod sound;
mod spellbook;
mod statusbar;
@@ -120,8 +122,10 @@ pub(crate) use model::Model;
pub use party::{PartyMemberInfo, PartyRequest, PartyState};
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
pub use session::SessionRequest;
pub use shapeshift::ShapeshiftFormView;
pub use skills::{SkillEntry, SkillsState};
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
pub use sound::SoundRequest;
pub use spellbook::{SpellBookState, SpellSlotView, SpellTabView};
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
@@ -249,7 +253,9 @@ impl UiScript {
object::install(&lua)?;
unit::install(&lua)?;
party::install(&lua)?;
social::install(&lua)?;
duel::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
death::install(&lua)?;
aura::install(&lua)?;
+28 -3
View File
@@ -5,9 +5,9 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, gossip, inspect, item_text,
loot, loot_roll, mail, merchant, party, quest, quest_log, skills, slider, spellbook, taxi,
trade, tradeskill, trainer, ActionSlot, AuraState, FontObject, ItemTemplateView,
PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
loot, loot_roll, mail, merchant, party, quest, quest_log, session, skills, slider, social,
spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject,
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -144,6 +144,14 @@ 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 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
/// engine-side too.
pub(crate) social: social::SocialState,
/// Social intents (`AddFriend`/`RemoveFriend`/`SendWho`/…) queued since the app's last
/// [`super::UiScript::take_social_requests`] drain — the outbound seam ([`social`]).
pub(crate) social_requests: Vec<social::SocialRequest>,
/// Whisper targets `ChatFrame_SendTell` queued since the app's last
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
@@ -152,6 +160,11 @@ pub(crate) struct Model {
/// [`super::UiScript::take_duel_requests`] drain — the outbound seam ([`duel`]). There is no
/// duel *snapshot* beside it: everything the UI reads arrives as event arguments.
pub(crate) duel_requests: Vec<duel::DuelRequest>,
/// Session-exit intents (`Logout`/`Quit`/`CancelLogout`/`ForceQuit`) queued since the app's
/// last [`super::UiScript::take_session_requests`] drain — the outbound seam ([`session`]).
/// Snapshot-less like the duel queue above: what the UI reads back (the camp/quit countdown)
/// arrives as the `PLAYER_CAMPING`/`PLAYER_QUITING`/`LOGOUT_CANCEL` events, not as state.
pub(crate) session_requests: Vec<session::SessionRequest>,
/// PvP-flag toggles (`TogglePVP`) queued since the app's last
/// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a
/// payload: `CMSG_TOGGLE_PVP` carries no body.
@@ -175,6 +188,14 @@ pub(crate) struct Model {
/// the app into `CMSG_SET_ACTION_BUTTON`, one send per entry (client-authoritative, per-change
/// — 0218 §4: a drag-swap is two sends, never atomic).
pub(crate) action_sets: Vec<(u32, u32)>,
/// GlobalStrings keys for **client-local refusals the ENGINE raises** — the engine-free half
/// of the app's `ui_action::UiErrorKeys`, which cannot reach in here. The reference does this
/// inline (`push <errorId>; call CGGameUI::DisplayError 0x496720`) because its engine owns
/// both the refusal and the message; ours splits at the crate boundary, so the refusal queues
/// its key and the app's action feed resolves it against the VM's own GlobalStrings and fires
/// `UI_ERROR_MESSAGE`. Always `&'static str`: every tenant is a literal read out of the ref's
/// errorId table (`0xb4b498`, stride `0x14`), never runtime-built.
pub(crate) ui_errors: Vec<&'static str>,
/// The player's known-spell book (decision 0216 §8, slice 5) — tabs + the flat slot list the
/// `GetSpellTabInfo`/`GetSpellName`/… bindings read ([`spellbook`]). Durable player state like
@@ -632,8 +653,11 @@ impl Model {
target_clear: false,
party: party::PartyState::default(),
party_requests: Vec::new(),
social: social::SocialState::default(),
social_requests: Vec::new(),
tell_requests: Vec::new(),
duel_requests: Vec::new(),
session_requests: Vec::new(),
pvp_toggles: 0,
sound_queue: Vec::new(),
actions: HashMap::new(),
@@ -641,6 +665,7 @@ impl Model {
bonus_bar_offset: 0,
action_uses: Vec::new(),
action_sets: Vec::new(),
ui_errors: Vec::new(),
spellbook: spellbook::SpellBookState::default(),
spell_casts: Vec::new(),
casting: false,
+78
View File
@@ -0,0 +1,78 @@
//! The **session-exit API** (decision 0674) — the four globals the game menu's Logout and Exit
//! Game buttons call, and the two dialogs' answers to them.
//!
//! The shape is [`super::duel`]'s: nothing to read, only the outbound half. Each call queues a
//! [`SessionRequest`] the app drains ([`super::UiScript::take_session_requests`]) and turns into a
//! packet or a process exit, so the engine keeps no reach into ECS/net (decision 0068 §3).
//!
//! What each one means, and where the reference puts it:
//!
//! - `Logout()` — ask to leave the world (`CMSG_LOGOUT_REQUEST`; the client's own
//! `ClientServices::SendLogout`, wow-re `system/net/ledger.tsv` 0x5ab000). The **server** decides
//! whether that is instant or a 20-second countdown, and says so in `SMSG_LOGOUT_RESPONSE`; the
//! client's whole job is to narrate the answer, which is the CAMP dialog.
//! - `Quit()` — the same request, plus a standing intent to end the *process* rather than return to
//! character select when the logout completes. That is why Exit Game shows the same countdown
//! under a different name (QUIT_TIMER "%d %s until exit").
//! - `CancelLogout()` — call off either one (`CMSG_LOGOUT_CANCEL`); the server acks with
//! `SMSG_LOGOUT_CANCEL_ACK`, which is what fires `LOGOUT_CANCEL` and takes the dialog down.
//! - `ForceQuit()` — the QUIT dialog's "Exit now": end the process immediately, without waiting out
//! the server's clock. (Its logout sibling `ForceLogout()` is deliberately absent: the reference's
//! own CAMP dialog has the force button commented out — "uncomment once forced logouts are
//! completely implemented (they currently have a failure case)" — so 1.12 ships no way to call it
//! from the UI, and neither do we.)
use mlua::Lua;
use super::Model;
/// Outbound session-exit intents queued by the Era API calls, drained by the app
/// ([`super::UiScript::take_session_requests`]). Plain data — [`super::DuelRequest`]'s twin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionRequest {
/// `Logout()` — leave the world back to character select.
Logout,
/// `Quit()` — leave the world and end the process once it completes.
Quit,
/// `CancelLogout()` — call off a pending logout or quit.
CancelLogout,
/// `ForceQuit()` — end the process now, no server round trip.
ForceQuit,
}
impl super::UiScript {
/// Drain the session-exit intents queued since the last call.
pub fn take_session_requests(&mut self) -> Vec<SessionRequest> {
std::mem::take(&mut self.model_mut().session_requests)
}
/// Queue an intent from the app side — the `/logout` and `/camp` slash commands, which benilla
/// parses in Rust rather than through `SlashCmdList` (the [`super::UiScript::queue_duel_request`]
/// posture), so they enter the same queue and get the same dialog.
pub fn queue_session_request(&mut self, request: SessionRequest) {
self.model_mut().session_requests.push(request);
}
}
/// Register the four session-exit globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
for (name, request) in [
("Logout", SessionRequest::Logout),
("Quit", SessionRequest::Quit),
("CancelLogout", SessionRequest::CancelLogout),
("ForceQuit", SessionRequest::ForceQuit),
] {
g.set(
name,
lua.create_function(move |lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.session_requests.push(request);
Ok(())
})?,
)?;
}
Ok(())
}
+425
View File
@@ -0,0 +1,425 @@
//! The social **Era API surface** — friends, ignores, and `/who` (decision 0668).
//!
//! The [`super::party`] shape exactly: the app pushes a [`SocialState`] snapshot built from its
//! own wire mirror ([`UiScript::set_social`]) and the getters here read that plain data; every
//! verb queues a [`SocialRequest`] the app drains ([`UiScript::take_social_requests`]) into the
//! matching `CMSG_*` send. No ECS or net reach from the engine (decision 0068 §3).
//!
//! **The snapshot is already display-ready** — names, class name, zone name, the `<AFK>` tag —
//! because every one of those is a *lookup the engine owns* in the real client too: the friend
//! slot on the wire holds a bare guid + class/area **ids**, and `FriendList`'s formatter
//! (`0x5ae160`) resolves them through the ObjectMgr name cache and the race/class/area GameTables
//! before Lua ever sees them. Pushing ids here and resolving in Lua would invent a client the
//! reference isn't.
//!
//! **Selection lives in the engine, not in Lua** — the reference keeps the selected friend and
//! ignore as *guids* on the FriendList object (`+0x648` / `+0x720`, read back by
//! `GetSelectedFriend` `0x5ad260` / `0x5ae510`), which is why `SetSelectedFriend(i)` here mutates
//! the snapshot in place *and* queues the intent: the same Lua tick reads the new value back
//! (`FriendsList_Update` does exactly that), and the app's own state follows so the next push
//! agrees.
use mlua::{Lua, Value};
use super::Model;
/// One friend row, already resolved for display — see the module doc on why the app resolves
/// rather than the VM. Mirrors `GetFriendInfo`'s six returns in field order.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FriendInfo {
/// The character's name, from the name cache. Empty while the `CMSG_NAME_QUERY` is still in
/// flight — the FrameXML's own `if ( not name ) then name = UNKNOWN` covers that frame, so
/// the row shows "Unknown" rather than vanishing.
pub name: String,
/// Level, `0` when offline (the wire sends no level for an offline friend).
pub level: u32,
/// Localized class name ("Warrior"), empty when offline.
pub class: String,
/// Zone name ("Elwynn Forest"), empty when offline or when the id has no `AreaTable` row.
pub area: String,
/// Online at all — `GetFriendInfo`'s fifth return, and what enables the Send Message /
/// Group Invite buttons.
pub connected: bool,
/// The away tag as the friends-list template wants it: `""`, `"<AFK>"`, or `"<DND>"`.
pub status: String,
}
/// One `/who` row — `GetWhoInfo`'s six returns in order (note the wire's class/race order is
/// swapped relative to this Lua-facing one: the API returns race *before* class).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WhoInfo {
pub name: String,
pub guild: String,
pub level: u32,
/// Localized race name ("Night Elf").
pub race: String,
/// Localized class name ("Druid").
pub class: String,
/// Zone name.
pub zone: String,
}
/// The social snapshot, pushed whole by the app whenever it changes ([`UiScript::set_social`]).
/// `default()` is the fresh-login shape: no friends, no ignores, no `/who` run yet.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SocialState {
/// The friend list, in the order the list frame shows it (the app sorts — the reference's
/// own `FriendList` comparator `0x5ada00` does the same engine-side).
pub friends: Vec<FriendInfo>,
/// The selected friend as a **1-based** index, `0` = none — `GetSelectedFriend`'s scale
/// (`0x5ad260` returns the stored slot + 1).
pub selected_friend: u32,
/// The ignore list: names only, which is all `GetIgnoreName` returns.
pub ignores: Vec<String>,
/// The selected ignore, same 1-based scale.
pub selected_ignore: u32,
/// The last `/who` answer's rows (≤ 49).
pub who: Vec<WhoInfo>,
/// The last `/who` answer's *total* match count — `GetNumWhoResults`'s second return, which
/// can exceed `who.len()` and is what drives the "(50 displayed)" suffix.
pub who_total: u32,
}
/// Outbound social intents queued by the Era API, drained by the app
/// ([`UiScript::take_social_requests`]). Plain data — [`super::party::PartyRequest`]'s twin.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SocialRequest {
/// `ShowFriends()` — refresh the list (`CMSG_FRIEND_LIST`).
RefreshFriends,
/// `AddFriend(name)`.
AddFriend(String),
/// `RemoveFriend(index)` — the friends frame's button, which addresses the *row*.
RemoveFriendIndex(u32),
/// `RemoveFriend(name)` — `/removefriend`, which addresses a name. One Era global takes
/// either, so the queue carries both shapes and the app resolves each to the guid the wire
/// wants.
RemoveFriendName(String),
/// `AddIgnore(name)`.
AddIgnore(String),
/// `DelIgnore(name)`.
DelIgnore(String),
/// `AddOrDelIgnore(name)` — `/ignore`'s toggle: ignore if not ignored, un-ignore if it is.
/// The app decides which, because only it holds the list.
ToggleIgnore(String),
/// `SetSelectedFriend(index)` — mirrored into the app so the next push agrees.
SelectFriend(u32),
/// `SetSelectedIgnore(index)`.
SelectIgnore(u32),
/// `SendWho(filter)` — the raw filter string as typed; parsing it into wire fields needs the
/// DBCs, so it happens app-side.
Who(String),
/// `SortWho(sortType)` — `"name"`/`"level"`/`"class"`/`"zone"`/`"guild"`/`"race"`. Sorting
/// is client-side; the app re-orders its own results and pushes them back.
SortWho(String),
/// `SetWhoToUI(flag)` — where the *next* `/who` answer goes: the Who frame (true) or the chat
/// frame (false). The WhoFrame's own OnShow/OnHide drive it.
SetWhoToUi(bool),
}
impl super::UiScript {
/// Push the social snapshot, replacing whatever was there. A bare setter — firing
/// `FRIENDLIST_UPDATE`/`IGNORELIST_UPDATE`/`WHO_LIST_UPDATE` on the edges is the app's job.
pub fn set_social(&mut self, state: SocialState) {
self.model_mut().social = state;
}
/// Drain the social intents queued since the last call.
pub fn take_social_requests(&mut self) -> Vec<SocialRequest> {
std::mem::take(&mut self.model_mut().social_requests)
}
/// Queue an intent from the app side — the slash commands. In the reference these ARE Lua
/// (`SlashCmdList["FRIENDS"]` calls `AddFriend`, …); benilla parses slash lines in Rust, so
/// the same intents enter the same queue here. [`super::duel`]'s `queue_duel_request` twin.
pub fn queue_social_request(&mut self, request: SocialRequest) {
self.model_mut().social_requests.push(request);
}
}
/// Register the social globals against the snapshot store.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// ── The friend list ──────────────────────────────────────────────────────────────────────
// GetNumFriends() → how many friends are listed (`0x5ad000` over CountFriends `0x5ae490`).
g.set(
"GetNumFriends",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.social.friends.len() as i64)
})?,
)?;
// GetFriendInfo(index) → name, level, class, area, connected, status (`0x5ad060`). An index
// past the end returns nothing at all — the FrameXML tests `if ( not name )`, so nil-per-
// return is the shape it expects, not an error.
g.set(
"GetFriendInfo",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(friend) = friend_at(&model.social.friends, index) else {
return Ok((
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
));
};
Ok((
Value::String(lua.create_string(&friend.name)?),
Value::Integer(i64::from(friend.level)),
Value::String(lua.create_string(&friend.class)?),
Value::String(lua.create_string(&friend.area)?),
// `connected` is the era 1/nil boolean the list frame branches on.
if friend.connected {
Value::Integer(1)
} else {
Value::Nil
},
Value::String(lua.create_string(&friend.status)?),
))
})?,
)?;
// GetSelectedFriend() → the 1-based selected row, 0 when nothing is selected.
g.set(
"GetSelectedFriend",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.social.selected_friend))
})?,
)?;
// SetSelectedFriend(index) — mutate now (the caller reads it back this tick) and mirror the
// intent to the app (module doc: selection is engine state in the reference too).
g.set(
"SetSelectedFriend",
lua.create_function(|lua, index: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let index = clamp_index(index, model.social.friends.len());
model.social.selected_friend = index;
model
.social_requests
.push(SocialRequest::SelectFriend(index));
Ok(())
})?,
)?;
// ShowFriends() — ask the server for the list again.
g.set(
"ShowFriends",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.social_requests.push(SocialRequest::RefreshFriends);
Ok(())
})?,
)?;
// AddFriend(name) — an empty name is dropped here rather than sent: the server answers a
// blank lookup with nothing at all, so it would look like a hang.
g.set(
"AddFriend",
lua.create_function(|lua, name: String| {
if !name.trim().is_empty() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.social_requests.push(SocialRequest::AddFriend(name));
}
Ok(())
})?,
)?;
// RemoveFriend(indexOrName) — the one global with two callers of different shapes (the
// frame's button passes a row index, /removefriend a name).
g.set(
"RemoveFriend",
lua.create_function(|lua, who: Value| {
let request = match who {
Value::Integer(i) if i >= 1 => Some(SocialRequest::RemoveFriendIndex(i as u32)),
Value::Number(n) if n >= 1.0 => Some(SocialRequest::RemoveFriendIndex(n as u32)),
Value::String(s) => {
let name = s.to_str()?.to_string();
(!name.trim().is_empty()).then_some(SocialRequest::RemoveFriendName(name))
}
_ => None,
};
if let Some(request) = request {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.social_requests.push(request);
}
Ok(())
})?,
)?;
// ── The ignore list ──────────────────────────────────────────────────────────────────────
// GetNumIgnores() → CountIgnores `0x5ae550` (the 25 slots at +0x650).
g.set(
"GetNumIgnores",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.social.ignores.len() as i64)
})?,
)?;
// GetIgnoreName(index) → the name, or nil past the end. The ignore list frame calls this for
// all 20 of its rows every update, most of them empty — nil is the ordinary case, not an
// error.
g.set(
"GetIgnoreName",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let name = usize::try_from(index - 1)
.ok()
.and_then(|i| model.social.ignores.get(i));
Ok(match name {
Some(name) => Value::String(lua.create_string(name)?),
None => Value::Nil,
})
})?,
)?;
// GetSelectedIgnore() / SetSelectedIgnore(index) — the friend pair's twin (`0x5ae630` /
// `0x5ae5f0`).
g.set(
"GetSelectedIgnore",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(model.social.selected_ignore))
})?,
)?;
g.set(
"SetSelectedIgnore",
lua.create_function(|lua, index: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let index = clamp_index(index, model.social.ignores.len());
model.social.selected_ignore = index;
model
.social_requests
.push(SocialRequest::SelectIgnore(index));
Ok(())
})?,
)?;
// AddIgnore / DelIgnore / AddOrDelIgnore(name).
for (global, make) in [
("AddIgnore", SocialRequest::AddIgnore as fn(String) -> _),
("DelIgnore", SocialRequest::DelIgnore as fn(String) -> _),
(
"AddOrDelIgnore",
SocialRequest::ToggleIgnore as fn(String) -> _,
),
] {
g.set(
global,
lua.create_function(move |lua, name: String| {
if !name.trim().is_empty() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.social_requests.push(make(name));
}
Ok(())
})?,
)?;
}
// ── /who ─────────────────────────────────────────────────────────────────────────────────
// GetNumWhoResults() → displayed, total. The second return is the server's true match count,
// which is why the frame can say "132 players total (50 displayed)".
g.set(
"GetNumWhoResults",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok((
model.social.who.len() as i64,
i64::from(model.social.who_total),
))
})?,
)?;
// GetWhoInfo(index) → name, guild, level, race, class, zone.
g.set(
"GetWhoInfo",
lua.create_function(|lua, index: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let row = usize::try_from(index - 1)
.ok()
.and_then(|i| model.social.who.get(i));
let Some(row) = row else {
return Ok((
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
));
};
Ok((
Value::String(lua.create_string(&row.name)?),
Value::String(lua.create_string(&row.guild)?),
Value::Integer(i64::from(row.level)),
Value::String(lua.create_string(&row.race)?),
Value::String(lua.create_string(&row.class)?),
Value::String(lua.create_string(&row.zone)?),
))
})?,
)?;
// SendWho(filter) — the filter string as typed, parsed app-side.
g.set(
"SendWho",
lua.create_function(|lua, filter: Option<String>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.social_requests
.push(SocialRequest::Who(filter.unwrap_or_default()));
Ok(())
})?,
)?;
// SortWho(sortType) — the column-header and dropdown sorts.
g.set(
"SortWho",
lua.create_function(|lua, sort_type: String| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.social_requests
.push(SocialRequest::SortWho(sort_type));
Ok(())
})?,
)?;
// SetWhoToUI(flag) — the WhoFrame's OnShow/OnHide. Era passes 0/1, so anything but a falsey
// 0/nil is "the frame wants them".
g.set(
"SetWhoToUI",
lua.create_function(|lua, flag: Value| {
let on = match flag {
Value::Nil | Value::Boolean(false) => false,
Value::Integer(0) => false,
Value::Number(n) => n != 0.0,
_ => true,
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.social_requests.push(SocialRequest::SetWhoToUi(on));
Ok(())
})?,
)?;
Ok(())
}
/// The 1-based friend lookup `GetFriendInfo` does, `None` past either end.
fn friend_at(friends: &[FriendInfo], index: i64) -> Option<&FriendInfo> {
usize::try_from(index - 1).ok().and_then(|i| friends.get(i))
}
/// Clamp a selection index to `0..=len` — `0` means "nothing selected", and a row past the end
/// selects nothing rather than a phantom.
fn clamp_index(index: i64, len: usize) -> u32 {
if index >= 1 && index <= len as i64 {
index as u32
} else {
0
}
}
@@ -143,6 +143,7 @@ fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
book_type: book_type.to_string(),
spell_id: slot.spell_id,
texture: slot.texture.clone(),
passive: slot.passive,
};
model.cursor = Some(CursorPayload::Spell(payload));
queue_cursor_update(model);
@@ -64,6 +64,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: None,
count: 1,
+2
View File
@@ -524,6 +524,7 @@ mod tests {
let item = |bag: i64, slot: u32| {
CursorPayload::Item(CursorItem {
bar_placeable: true,
bag,
slot,
item_id: 2589,
@@ -579,6 +580,7 @@ mod tests {
// A spell payload is refused — put back untouched, nothing queued.
s.model_mut().cursor = Some(CursorPayload::Spell(crate::script::cursor::CursorSpell {
passive: false,
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
+2
View File
@@ -11,6 +11,8 @@
//! of over versions: a burst of adjacent frames from a parked camera (`WOW_LIVE_SHOT_COUNT`), collapsed
//! to where the picture would not hold still.
pub mod relight;
use image::RgbImage;
/// A pixel counts as "changed" for [`Metrics::pct_over`] if any channel differs by more than this many
+128 -3
View File
@@ -60,6 +60,7 @@ struct Opts {
amplify: u32,
toggle_delta: u8,
pad: u32,
at: Option<String>,
}
fn main() -> Result<()> {
@@ -71,6 +72,7 @@ fn main() -> Result<()> {
amplify: DEFAULT_AMPLIFY,
toggle_delta: TOGGLE_MIN_DELTA,
pad: DEFAULT_PAD,
at: None,
};
let mut it = args.iter();
while let Some(a) = it.next() {
@@ -98,6 +100,7 @@ fn main() -> Result<()> {
.parse()
.context("--pad not an integer")?
}
"--at" => opts.at = Some(next(&mut it, "--at")?),
"-h" | "--help" => {
print_usage();
return Ok(());
@@ -167,6 +170,14 @@ fn main() -> Result<()> {
.context("hotspot needs --out <strip.png>")?;
hotspot(Path::new(dir), out, opts.toggle_delta, opts.pad)?;
}
"series" => {
let [dir] = one(rest, "series")?;
let at = opts
.at
.as_deref()
.context("series needs --at \"<x>,<y>[;<x>,<y>…]\"")?;
series(Path::new(dir), at)?;
}
"compose-dir" => {
let [da, db] = two(rest, "compose-dir")?;
let out = opts
@@ -183,6 +194,68 @@ fn main() -> Result<()> {
Ok(())
}
/// What one named pixel *did*, frame by frame — the colour counterpart to the client's `WOW_DEPTH`.
///
/// The aggregate readings (`flicker`, `hotspot`) answer "which pixels would not hold still" over a
/// whole region, which is the right question when you are looking for a defect and the wrong one once
/// you have found it. Correlating a renderer-side probe against the pixels needs the opposite: *this*
/// pixel, *these* frames, no averaging. Run `WOW_DEPTH` and `--at` on the same coordinates and the two
/// logs line up frame for frame — "the colour changed here but the depth did not" is a fact you cannot
/// get from a region mean, because a run mean over 19 000 pixels hides which of them moved.
///
/// It also guards the trap this exists inside: a burst that happens to contain **no** flip reads as a
/// clean negative. The per-frame Δ makes an absent phenomenon obvious instead of invisible.
fn series(dir: &Path, at: &str) -> Result<()> {
let pixels = parse_at(at)?;
let names = pngs(dir)?;
if names.is_empty() {
bail!("no PNGs in {}", dir.display());
}
let frames: Vec<image::RgbImage> = names
.iter()
.map(|n| load(&dir.join(n)))
.collect::<Result<_>>()?;
for (x, y) in pixels {
let (w, h) = (frames[0].width(), frames[0].height());
if x >= w || y >= h {
println!("({x}, {y}): outside the {w}x{h} frame");
continue;
}
println!("({x}, {y}):");
let mut prev: Option<f64> = None;
for (i, f) in frames.iter().enumerate() {
let p = f.get_pixel(x, y).0;
let luma =
0.2126 * f64::from(p[0]) + 0.7152 * f64::from(p[1]) + 0.0722 * f64::from(p[2]);
let delta = prev.map_or(String::new(), |q| format!(" Δ{:+.2}", luma - q));
println!(
" {i:3} rgb({:3}, {:3}, {:3}) luma {luma:6.2}{delta}",
p[0], p[1], p[2]
);
prev = Some(luma);
}
}
Ok(())
}
/// `"x,y;x,y"` → pixels. Strict: a typo here would silently report the wrong pixel's history.
fn parse_at(spec: &str) -> Result<Vec<(u32, u32)>> {
spec.split(';')
.filter(|s| !s.trim().is_empty())
.map(|pair| {
let (x, y) = pair.split_once(',').context("--at wants \"<x>,<y>;…\"")?;
Ok((
x.trim()
.parse()
.with_context(|| format!("bad x in {pair:?}"))?,
y.trim()
.parse()
.with_context(|| format!("bad y in {pair:?}"))?,
))
})
.collect()
}
/// Stitch every `*.png` present in both dirs into `left | right` side-by-side images under `out`.
fn compose_dir(da: &Path, db: &Path, out: &Path) -> Result<()> {
let names: BTreeSet<String> = pngs(da)?.intersection(&pngs(db)?).cloned().collect();
@@ -347,9 +420,11 @@ fn hotspot(dir: &Path, out: &Path, toggle_delta: u8, pad: u32) -> Result<()> {
.collect();
println!(" {:>2} -> {:<2} {}", i, i + 1, cols.join(" "));
}
// The two extremes of run #0, per channel. If a two-state flip is one light term switching, the
// channel ratios come out equal; if the surface swapped to a different (differently-coloured)
// lighting lane, they do not — and that is the whole difference between the two diagnoses.
// The two extremes of run #0, per channel — the *levels*, which is all this line is. Read the
// ratios as description, never as a diagnosis: equal ratios rule out a scalar multiply and
// nothing else, since an ADDED light moves the three channels by different factors too. The
// reading that actually separates "one surface, re-lit" from "two surfaces" is the per-pixel
// affine fit printed below it (`benilla_visual::relight`).
let s0 = &series[0];
let lo = s0
.iter()
@@ -368,6 +443,53 @@ fn hotspot(dir: &Path, out: &Path, toggle_delta: u8, pad: u32) -> Result<()> {
hi.mean_rgb_from[0], hi.mean_rgb_from[1], hi.mean_rgb_from[2],
ratio.join(" / "),
);
// Same surface re-lit, or a different surface? Fit each run's pixels across its own biggest
// frame-to-frame step. High R² = the pattern survived the flip and only its gain/offset moved,
// so it is one surface being shaded differently; low R² = the two frames show different
// surfaces, whatever the means do.
println!(
" across each run's biggest step — is it one surface re-lit? (R² near 1 = yes), \
against its quietest step as the control:"
);
for (i, r) in s.regions.iter().take(SERIES_RUNS).enumerate() {
let (Some(flip), Some(quiet)) = (
benilla_visual::relight::biggest_step(r, &frames),
benilla_visual::relight::quietest_step(r, &frames),
) else {
continue;
};
let fit = benilla_visual::relight::relight(r, &frames[flip], &frames[flip + 1]);
let ctl = benilla_visual::relight::relight(r, &frames[quiet], &frames[quiet + 1]);
let per: Vec<String> = (0..3)
.map(|c| {
format!(
"{:.2}x{:+.0} r²{:.3}",
fit.gain[c], fit.offset[c], fit.r2[c]
)
})
.collect();
// The control is what licenses the verdict: a low R² on the flip only means "different
// surfaces" if the same pixels under the same pan fit tightly when they did NOT flip.
let verdict = if ctl.worst_r2() < 0.9 {
"no control — the pan alone decorrelates these pixels; fit says nothing"
} else if fit.worst_r2() > 0.9 {
"ONE SURFACE, re-lit"
} else if fit.worst_r2() < 0.5 {
"DIFFERENT SURFACES"
} else {
"inconclusive"
};
println!(
" #{i} flip {:>2}->{:<2} {} worst r² {:.3} | quiet {:>2}->{:<2} r² {:.3} => {verdict}",
flip,
flip + 1,
per.join(" "),
fit.worst_r2(),
quiet,
quiet + 1,
ctl.worst_r2(),
);
}
let rect = biggest.bounds.padded(pad, w, h);
// The toggle map goes in as the first tile, so the sheet carries its own legend: this is the
// region, and here is what the frames did inside it.
@@ -514,12 +636,15 @@ fn print_usage() {
benilla-visual diff-dir <dir_a> <dir_b> [--out <diff_dir>] [--fail <mae>] [--amplify <n>]\n \
benilla-visual flicker <burst_dir> [--out <envelope.png>] [--fail <mae>] [--amplify <n>] [--toggle-delta <n>]\n \
benilla-visual hotspot <burst_dir> --out <strip.png> [--toggle-delta <n>] [--pad <n>]\n \
benilla-visual series <burst_dir> --at \"<x>,<y>[;<x>,<y>…]\"\n \
benilla-visual compose-dir <dir_a> <dir_b> --out <dir> (side-by-side `a | b` per image)\n\
\n\
flicker reads a WOW_LIVE_SHOT_COUNT burst (adjacent frames) in shot order and reports both\n\
the parked reading (envelope) and the moving-camera one (toggles).\n\
hotspot follows the toggle map: it measures the toggling pixels' shape — one solid run is a\n\
surface blinking, many thin ones are z-fighting — and crops the largest into a contact sheet.\n\
series drops the averaging: one named pixel's rgb/luma per frame, to line up against the\n\
client's WOW_DEPTH log at the same coordinates.\n\
Exits non-zero if any image's MAE exceeds --fail."
);
}
+248
View File
@@ -0,0 +1,248 @@
//! **Is it the same surface lit differently, or a different surface?** — the reading that separates
//! the two explanations a two-state flip always admits.
//!
//! [`Region::steps`] measures that a run alternates between two levels and that its pixels move
//! together. That is where the evidence used to stop, and the gap mattered: B38's tent was read off
//! the per-channel *ratios* as "two materials trading places", on the argument that one light term
//! switching would scale all three channels equally. **That argument is wrong.** Equal ratios rule
//! out a scalar multiply and nothing else — an *additive* term (a coloured light arriving or
//! leaving) changes the ratios freely. Measured there: dim `(77, 42, 32)` → bright `(192, 79, 35)`
//! is "×2.50 / ×1.89 / ×1.12", which reads as two materials, and is *also* exactly the dim state
//! plus a warm `(115, 37, 3)`. The ratios cannot tell those apart, so they were never evidence.
//!
//! What does tell them apart is the **spatial pattern**, which the means throw away. Re-lighting a
//! surface is an affine map on its pixels — every pixel keeps its place in the pattern, so
//! `bright ≈ gain·dim + offset` holds *per pixel* and the fit is tight. Two different surfaces have
//! unrelated patterns (canvas weave vs plank grain), and no affine map relates them: the fit is
//! loose however well the means happen to line up. So: least-squares fit one frame's pixels onto the
//! other's, per channel, and report R². That is a number, not an inference from a number.
//!
//! Fit across the run's **largest single frame-to-frame step**, never across the whole burst: the
//! camera pans during a capture, and a pixel only names the same bit of world for as long as the
//! image holds still under it. Adjacent frames at the sub-pixel-per-frame pan the toggle map needs
//! (decision 0656) are the same view; frames twenty apart are not.
use image::RgbImage;
use crate::Region;
/// A per-channel affine fit of one frame's pixels onto another's, over a [`Region`]'s own pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Relight {
/// Per channel, the `a` in `to ≈ a·from + b`. Near 1 with a non-zero [`Relight::offset`] is a
/// light being *added*; well above 1 with an offset near 0 is one being *scaled*.
pub gain: [f64; 3],
/// Per channel, the `b` in `to ≈ a·from + b`, in 0..255 units.
pub offset: [f64; 3],
/// Per channel coefficient of determination, 0..1 — how much of the target frame's variation
/// across the run the fit explains. **This is the reading.** Near 1: one surface, re-lit (the
/// pattern survived, only its scale and offset moved). Low: the two frames show *different*
/// surfaces, and no amount of re-lighting maps one to the other.
pub r2: [f64; 3],
/// Did this channel vary across the run at all? A flat channel (uniform, or clipped to 0/255)
/// has nothing for a fit to explain, so its R² is 0 for a reason that is **not** evidence of
/// different surfaces — and without this flag that 0 would outvote the channels that do carry
/// evidence, turning a plain re-light into a false "different surfaces".
pub determinate: [bool; 3],
}
impl Relight {
/// The weakest **determinate** channel's R² — the honest summary, since "one surface, re-lit"
/// has to hold in every channel that carries evidence. 0 when no channel does.
pub fn worst_r2(&self) -> f64 {
(0..3)
.filter(|&c| self.determinate[c])
.map(|c| self.r2[c])
.reduce(f64::min)
.unwrap_or(0.0)
}
}
/// Least-squares fit of `to`'s pixels onto `from`'s, per channel, over `region`'s own pixels.
///
/// A channel with no variation across the run (a flat, saturated, or clipped channel) has nothing
/// for a fit to explain; its R² is reported as 0 rather than dividing by zero, which reads as
/// "this channel is not evidence" — the conservative direction.
pub fn relight(region: &Region, from: &RgbImage, to: &RgbImage) -> Relight {
let mut out = Relight {
gain: [0.0; 3],
offset: [0.0; 3],
r2: [0.0; 3],
determinate: [false; 3],
};
let n = region.members.len() as f64;
if n < 2.0 {
return out;
}
for c in 0..3 {
let (mut sx, mut sy) = (0.0f64, 0.0f64);
for &(x, y) in &region.members {
sx += f64::from(from.get_pixel(x, y)[c]);
sy += f64::from(to.get_pixel(x, y)[c]);
}
let (mx, my) = (sx / n, sy / n);
let (mut sxx, mut sxy, mut syy) = (0.0f64, 0.0f64, 0.0f64);
for &(x, y) in &region.members {
let dx = f64::from(from.get_pixel(x, y)[c]) - mx;
let dy = f64::from(to.get_pixel(x, y)[c]) - my;
sxx += dx * dx;
sxy += dx * dy;
syy += dy * dy;
}
// A flat source or a flat target leaves the fit undetermined; report it as no evidence.
if sxx <= f64::EPSILON || syy <= f64::EPSILON {
out.gain[c] = 0.0;
out.offset[c] = my;
out.r2[c] = 0.0;
continue;
}
let a = sxy / sxx;
out.gain[c] = a;
out.offset[c] = my - a * mx;
// r² for a simple linear fit is the squared Pearson correlation.
out.r2[c] = (sxy * sxy) / (sxx * syy);
out.determinate[c] = true;
}
out
}
/// The index of the frame-to-frame step where the run's mean luma moved most — the flip itself,
/// and the pair [`relight`] should be fitted across. `None` for a burst with fewer than two frames.
pub fn biggest_step(region: &Region, frames: &[RgbImage]) -> Option<usize> {
extreme_step(region, frames, true)
}
/// The step where the run's mean luma moved **least** — the **control**, and the reason a low R² on
/// the biggest step can be believed at all.
///
/// The capture pans, so consecutive frames are never quite the same image, and a textured surface
/// sliding a fraction of a pixel decorrelates on its own. That means a low R² is only evidence of
/// *different surfaces* if a step where the run did **not** flip scores high across the same pixels
/// under the same motion. Quiet step near 1 and flip step near 0 is a real reading; both low means
/// the fit is measuring the pan and nothing else.
pub fn quietest_step(region: &Region, frames: &[RgbImage]) -> Option<usize> {
extreme_step(region, frames, false)
}
fn extreme_step(region: &Region, frames: &[RgbImage], want_max: bool) -> Option<usize> {
region
.steps(frames)
.iter()
.enumerate()
.map(|(i, s)| (i, s.mean_delta.abs()))
.reduce(|a, b| {
let take_b = if want_max { b.1 > a.1 } else { b.1 < a.1 };
if take_b {
b
} else {
a
}
})
.map(|(i, _)| i)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Rect;
use image::Rgb;
/// A run covering a whole small frame, so the fits are over every pixel.
fn whole(w: u32, h: u32) -> Region {
Region {
bounds: Rect {
x0: 0,
y0: 0,
x1: w - 1,
y1: h - 1,
},
pixels: u64::from(w) * u64::from(h),
members: (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).collect(),
}
}
/// A textured surface: a pattern with real variation for a fit to have to explain.
fn textured(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> RgbImage {
RgbImage::from_fn(w, h, |x, y| Rgb(f(x, y)))
}
#[test]
fn the_same_surface_plus_a_warm_light_fits_as_gain_one_and_an_offset() {
let dim = textured(16, 16, |x, y| [(x * 8) as u8, (y * 6) as u8, (x + y) as u8]);
// Exactly the B38 shape: add a warm constant, leave the pattern alone.
let bright = textured(16, 16, |x, y| {
[(x * 8 + 115) as u8, (y * 6 + 37) as u8, (x + y + 3) as u8]
});
let r = relight(&whole(16, 16), &dim, &bright);
assert!(
r.worst_r2() > 0.999,
"an added light is an exact fit: {r:?}"
);
for c in 0..2 {
assert!(
(r.gain[c] - 1.0).abs() < 1e-6,
"gain {c} should be 1: {r:?}"
);
}
assert!((r.offset[0] - 115.0).abs() < 1e-6, "{r:?}");
assert!((r.offset[1] - 37.0).abs() < 1e-6, "{r:?}");
}
#[test]
fn the_same_surface_scaled_fits_as_a_gain_with_no_offset() {
let dim = textured(16, 16, |x, y| [(x * 4) as u8, (y * 4) as u8, (x + y) as u8]);
let bright = textured(16, 16, |x, y| {
[(x * 8) as u8, (y * 8) as u8, ((x + y) * 2) as u8]
});
let r = relight(&whole(16, 16), &dim, &bright);
assert!(r.worst_r2() > 0.999, "a scale is an exact fit: {r:?}");
assert!((r.gain[0] - 2.0).abs() < 1e-6, "{r:?}");
assert!(r.offset[0].abs() < 1e-6, "{r:?}");
}
/// The discrimination the module exists for: two surfaces whose *means* differ exactly as a
/// re-light would, but whose patterns are unrelated. The means cannot tell them apart; R² can.
#[test]
fn two_different_surfaces_do_not_fit_however_well_their_means_line_up() {
// Vertical stripes vs horizontal stripes: same mean, same spread, no affine relation.
let plank = textured(16, 16, |x, _| [(x % 2) as u8 * 60 + 40, 40, 30]);
let canvas = textured(16, 16, |_, y| [(y % 2) as u8 * 60 + 40, 40, 30]);
let r = relight(&whole(16, 16), &plank, &canvas);
assert!(r.r2[0] < 0.1, "unrelated patterns must not fit, got {r:?}");
}
#[test]
fn a_flat_channel_is_reported_as_no_evidence_rather_than_a_divide_by_zero() {
let a = textured(8, 8, |_, _| [50, 50, 50]);
let b = textured(8, 8, |_, _| [90, 90, 90]);
let r = relight(&whole(8, 8), &a, &b);
assert_eq!(r.r2, [0.0; 3], "a flat run explains nothing: {r:?}");
assert_eq!(r.determinate, [false; 3], "and says so: {r:?}");
assert!((r.offset[0] - 90.0).abs() < 1e-9, "{r:?}");
}
/// The flat-channel rule has to be a *skip*, not a zero vote: a surface whose blue is clipped
/// flat but whose red and green track perfectly is one surface being re-lit, and reporting the
/// dead channel's 0 as the verdict would call it two.
#[test]
fn a_flat_channel_does_not_outvote_the_ones_carrying_evidence() {
let dim = textured(16, 16, |x, y| [(x * 8) as u8, (y * 6) as u8, 255]);
let bright = textured(16, 16, |x, y| [(x * 8 + 60) as u8, (y * 6 + 20) as u8, 255]);
let r = relight(&whole(16, 16), &dim, &bright);
assert_eq!(r.determinate, [true, true, false], "{r:?}");
assert!(
r.worst_r2() > 0.999,
"the clipped blue must not decide it: {r:?}"
);
}
#[test]
fn the_biggest_step_is_the_flip_and_the_quietest_is_the_control() {
let flat = textured(8, 8, |x, _| [(x * 10) as u8, 30, 30]);
let nudged = textured(8, 8, |x, _| [(x * 10 + 2) as u8, 30, 30]);
let jumped = textured(8, 8, |x, _| [(x * 10 + 90) as u8, 30, 30]);
let frames = vec![flat.clone(), nudged, jumped];
assert_eq!(biggest_step(&whole(8, 8), &frames), Some(1));
assert_eq!(quietest_step(&whole(8, 8), &frames), Some(0));
}
}
+41 -1
View File
@@ -687,8 +687,48 @@
-- drag feedback, IsInventoryItemLocked — the bag-slot twin of BenillaBagSlot_Update).
if IsInventoryItemLocked(invSlot) then
button.icon:SetVertexColor(0.4, 0.4, 0.4)
else
elseif button:IsEnabled() then
button.icon:SetVertexColor(1, 1, 1)
else
-- Disabled by Disable_BagButtons below: keep the grey. The reference needs no such
-- arm — its desaturation is a shader flag on the texture, untouched by an Update
-- that only re-sets the image — but ours IS a vertex colour (UiPanels.xml
-- SetDesaturation), so a bag update behind the open game menu would otherwise
-- repaint the icon bright under a button that stayed dead.
button.icon:SetVertexColor(0.5, 0.5, 0.5)
end
end
-- Disable_BagButtons / Enable_BagButtons — ref MainMenuBarBagButtons.lua l.102-128, called
-- by GameMenuFrame's OnShow/OnHide (decision 0674) and by nothing else: while the game menu
-- holds the screen, the whole bag row greys out. That is the *visible* half of a rule the
-- panel manager already enforces (ShowUIPanel's native-center gate refuses every window
-- while the menu is up — UiPanels.xml `CanOpenPanels`); without it the bags would look
-- clickable and simply do nothing.
--
-- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is BenillaBagToggle —
-- whose icon IS its NormalTexture (it carries no named $parentIcon region, so the greying
-- goes through GetNormalTexture) — and CharacterBag0..3Slot are BenillaBagBarSlot1..4, each
-- with the $parentIcon the Update path above also writes.
function Disable_BagButtons()
BenillaBagToggle:Disable()
SetDesaturation(BenillaBagToggle:GetNormalTexture(), 1)
for i = 1, 4 do
local slot = getglobal("BenillaBagBarSlot" .. i)
slot:Disable()
SetDesaturation(slot.icon, 1)
end
end
function Enable_BagButtons()
BenillaBagToggle:Enable()
SetDesaturation(BenillaBagToggle:GetNormalTexture(), nil)
for i = 1, 4 do
local slot = getglobal("BenillaBagBarSlot" .. i)
slot:Enable()
-- The slot's own paint decides the restored colour: an item locked on the cursor
-- wants 0.4 grey, not the flat white a bare SetDesaturation(nil) would leave.
BenillaBagBarSlot_Update(slot)
end
end
]]></Script>
File diff suppressed because it is too large Load Diff
+306
View File
@@ -0,0 +1,306 @@
<Ui>
<!--
The GAME MENU — the frame ESC opens (decision 0674). The last hole in the game shell's
outermost layer: until now ESC's chain ran out of things to close and did nothing, and the
Main Menu micro button was drawn but dead (MicroMenu.xml's SCOPE note).
Ground truth, all quoted rather than re-derived:
· **The frame** — ref `GameMenuFrame.xml` (patch.MPQ, extracted): 195x246, `frameStrata`
DIALOG, the UI-DialogBox backdrop with the 11/12/12/11 insets and 32px tile/edge, the
256x64 UI-DialogBox-Header at TOP +12 with MAIN_MENU centered 14px down it, and the
eight-button ladder — first button CENTER on the frame's TOP at (0,-37), each next one
TOP-to-BOTTOM at (0,-1), and Return to Game hung (0,-16) below Exit Game (the one gap in
the stack, which is what makes the last entry read as separate).
· **The button** — ref `GameMenuButtonTemplate` (UIPanelTemplates.xml l.403-410): a 144x21
`UIPanelButtonTemplate` (the UI-Panel-Button Up/Down/Disabled/Highlight four-state art,
TexCoords 0..0.625 / 0..0.6875) whose label trio is overridden to
GameFontHighlight/GameFontHighlight/GameFontDisable — i.e. the menu's labels are WHITE,
not the gold a normal panel button wears, and grey when disabled.
· **The labels** — GlobalStrings.lua (patch-2.MPQ), verbatim: VIDEOOPTIONS_MENU "Video
Options", SOUNDOPTIONS_MENU "Sound Options", UIOPTIONS_MENU "Interface Options",
KEY_BINDINGS "Key Bindings", MACROS "Macros", LOGOUT "Logout", EXIT_GAME "Exit Game",
RETURN_TO_GAME "Return to Game", MAIN_MENU "Main Menu". Declared as the real globals
(they are addon-visible API, decision 0068) and read back into each button's label in its
OnLoad — the ref's `text="LOGOUT"` attribute IS a global lookup, which this engine's
literal `text=` is not.
· **The sounds** — the ref's own OnClick lines: igMainMenuOption (the five option buttons),
igMainMenuLogout, igMainMenuQuit, igMainMenuContinue. All four kits verified present in
SoundEntries.dbc (patch-2.MPQ) alongside the igMainMenuOpen/Close pair already in use.
· **The two logout dialogs** — ref `StaticPopup.lua` l.564-600 (CAMP + QUIT), transcribed
onto the shared popup engine (UiPanels.xml), whose countdown-text branch already lists
both `which` names. Their driver events are the reference's own (`re/events/event-catalog
.tsv`: 276 PLAYER_CAMPING, 277 PLAYER_QUITING, 278 LOGOUT_CANCEL), fired by
`crate::ui_logout` off `SMSG_LOGOUT_RESPONSE`.
NAMES are the reference's, unprefixed (`GameMenuFrame`, `GameMenuButton*`) — the same call
MicroMenu.xml made for its straight transcriptions, and required here besides: `ToggleGameMenu`
and `GameMenuFrame` are Era API surface an addon addresses by name.
SCOPE / divergences (noted, not bugs):
- **Five buttons ship DISABLED**: Video/Sound/Interface Options, Key Bindings, Macros have no
panel behind them and won't for a long time. The director's call (this session) is that they
stay in the ladder — the frame keeps the ref's exact 195x246 and its exact silhouette — wearing
the template's own `-Disabled` art and grey label rather than being cut or left as clickable
no-ops. Each goes live by deleting one `self:Disable()` and writing the ref OnClick that is
already quoted beside it. (MicroMenu.xml made the opposite call for its dead icons, and says
why: three grey icons mid-row read as a broken bar. A menu of labels with some greyed is the
ordinary idiom — it reads as "not yet", which is the truth.)
- The ref frame is `toplevel="true" movable="true" parent="UIParent"`. Dropped: this engine has
no cross-file `parent=` attachment (SkillFrame.xml's header comment), no frame-level raise,
and `movable` is inert in the ref too (GameMenuFrame defines no drag scripts, so it never
actually moves). `frameStrata="DIALOG"` is what puts it over the world UI, and that is kept.
- No `OptionsFrameCancel` rung in ToggleGameMenu (UiPanels.xml) — no OptionsFrame exists.
-->
<Script><![CDATA[
-- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted
-- verbatim. `X = X or "..."` so a later real strings table wins, the MicroMenu.xml idiom.
MAIN_MENU = MAIN_MENU or "Main Menu"
VIDEOOPTIONS_MENU = VIDEOOPTIONS_MENU or "Video Options"
SOUNDOPTIONS_MENU = SOUNDOPTIONS_MENU or "Sound Options"
UIOPTIONS_MENU = UIOPTIONS_MENU or "Interface Options"
KEY_BINDINGS = KEY_BINDINGS or "Key Bindings"
MACROS = MACROS or "Macros"
LOGOUT = LOGOUT or "Logout"
EXIT_GAME = EXIT_GAME or "Exit Game"
RETURN_TO_GAME = RETURN_TO_GAME or "Return to Game"
CAMP_TIMER = CAMP_TIMER or "%d %s until logout"
QUIT_TIMER = QUIT_TIMER or "%d %s until exit"
QUIT_NOW = QUIT_NOW or "Exit now"
CANCEL = CANCEL or "Cancel"
-- One label setter for the five that have nothing behind them yet: set the ref's text, then
-- disable (grey label + the template's -Disabled art). The whole divergence lives here, so
-- making one live is deleting its name from the call and writing its ref OnClick.
function GameMenuButton_Pending(button, text)
button:SetText(text)
button:Disable()
end
-- ── The two logout dialogs (ref StaticPopup.lua l.564-600) ──────────────────────────────
-- Both are countdown dialogs: their `text` is a format string the popup engine's per-tick
-- branch fills with the remaining time (StaticPopup_OnUpdate's which-list already names CAMP
-- and QUIT), so they open blank and count down. `timeout = 20` matches the server's own
-- logout timer (vmangos `WorldSession::LogoutRequest` → 20 s), which is what the countdown
-- is displaying — the client is not the authority here, it is narrating the server's clock.
--
-- The asymmetry between them is the reference's: CAMP offers only Cancel (the commented-out
-- CAMP_NOW/ForceLogout button is dead in 1.12 — "uncomment once forced logouts are completely
-- implemented"), while QUIT offers Exit now + Cancel, because a force-quit needs no server
-- round trip. Closing either one EARLY (button, ESC, or an override) cancels the logout;
-- letting it run out does not — the engine zeroes `timeleft` before hiding, which is exactly
-- what the ref's `if this.timeleft > 0` guard reads.
StaticPopupDialogs["CAMP"] = {
text = CAMP_TIMER,
button1 = CANCEL,
OnAccept = function()
CancelLogout()
end,
OnHide = function()
if this.timeleft > 0 then
CancelLogout()
end
end,
timeout = 20,
whileDead = 1,
hideOnEscape = 1,
}
StaticPopupDialogs["QUIT"] = {
text = QUIT_TIMER,
button1 = QUIT_NOW,
button2 = CANCEL,
OnAccept = function()
ForceQuit()
this.timeleft = 0
end,
OnHide = function()
if this.timeleft > 0 then
CancelLogout()
end
end,
timeout = 20,
whileDead = 1,
hideOnEscape = 1,
}
-- The camp/quit event driver. The ref handles these three in UIParent's own OnEvent
-- (UIParent.lua l.304-315); benilla has no UIParent frame, so this is a dedicated hidden
-- listener — the BenillaDeleteItemConfirmDriver shape.
function BenillaLogoutDriver_OnEvent()
if event == "PLAYER_CAMPING" then
StaticPopup_Show("CAMP")
elseif event == "PLAYER_QUITING" then
StaticPopup_Show("QUIT")
elseif event == "LOGOUT_CANCEL" then
StaticPopup_Hide("CAMP")
StaticPopup_Hide("QUIT")
end
end
]]></Script>
<!-- ref GameMenuButtonTemplate (UIPanelTemplates.xml l.403-410) = UIPanelButtonTemplate at
144x21 with a white/white/grey label trio. UIPanelButtonTemplate itself is not transcribed
as a shared virtual (no other benilla file inherits it — each open-codes the same four-state
art, e.g. QuestFrame.xml l.753); its textures are inlined here with the ref TexCoords. -->
<Button name="GameMenuButtonTemplate" virtual="true">
<Size><AbsDimension x="144" y="21"/></Size>
<ButtonText name="$parentText"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
<NormalFont inherits="GameFontHighlight"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
</Button>
<Frame name="GameMenuFrame" frameStrata="DIALOG" enableMouse="true" hidden="true">
<Size><AbsDimension x="195" y="246"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<Texture name="GameMenuFrameHeader" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="12"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="GameMenuFrameTitle" inherits="GameFontNormal">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuFrameHeader"><Offset><AbsDimension x="0" y="-14"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The five with no panel behind them (header SCOPE note). Each keeps the ref's own
OnClick quoted above it, so going live is deleting the _Pending call and pasting it:
PlaySound("igMainMenuOption"); ShowUIPanel(OptionsFrame) -->
<Button name="GameMenuButtonOptions" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="CENTER" relativePoint="TOP"><Offset><AbsDimension x="0" y="-37"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, VIDEOOPTIONS_MENU)</OnLoad></Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); ShowUIPanel(SoundOptionsFrame) -->
<Button name="GameMenuButtonSoundOptions" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonOptions" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, SOUNDOPTIONS_MENU)</OnLoad></Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); ShowUIPanel(UIOptionsFrame) -->
<Button name="GameMenuButtonUIOptions" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonSoundOptions" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, UIOPTIONS_MENU)</OnLoad></Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); KeyBindingFrame_LoadUI(); ShowUIPanel(KeyBindingFrame) -->
<Button name="GameMenuButtonKeybindings" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonUIOptions" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, KEY_BINDINGS)</OnLoad></Scripts>
</Button>
<!-- ref: PlaySound("igMainMenuOption"); HideUIPanel(GameMenuFrame); ShowMacroFrame() -->
<Button name="GameMenuButtonMacros" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonKeybindings" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>GameMenuButton_Pending(self, MACROS)</OnLoad></Scripts>
</Button>
<!-- The three that are live. The ref's OnShow on both Logout and Quit disables the
button while either logout dialog is already up (you cannot re-arm a running
countdown); it runs on the menu's own show, which is when a re-opened menu has to
re-read that state. -->
<Button name="GameMenuButtonLogout" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonMacros" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(LOGOUT)</OnLoad>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
else
self:Disable()
end
</OnShow>
<OnClick>
PlaySound("igMainMenuLogout")
Logout()
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonQuit" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonLogout" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(EXIT_GAME)</OnLoad>
<OnShow>
if not StaticPopup_Visible("CAMP") and not StaticPopup_Visible("QUIT") then
self:Enable()
else
self:Disable()
end
</OnShow>
<OnClick>
PlaySound("igMainMenuQuit")
Quit()
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
<Button name="GameMenuButtonContinue" inherits="GameMenuButtonTemplate">
<Anchors>
<Anchor point="TOP" relativeTo="GameMenuButtonQuit" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-16"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>self:SetText(RETURN_TO_GAME)</OnLoad>
<OnClick>
PlaySound("igMainMenuContinue")
HideUIPanel(GameMenuFrame)
</OnClick>
</Scripts>
</Button>
</Frames>
<!-- ref GameMenuFrame OnShow/OnHide, verbatim: the micro row's Main Menu button pushes in
while the menu is up, and the bag bar greys out under it (you cannot open bags from
behind the menu — ShowUIPanel's center gate would refuse the window anyway, so the
greying is the visible half of a rule the panel manager already enforces). Guarded, the
UiPanels.xml `CloseMenus and CloseMenus()` posture: a headless test loads this file
without the action bar behind it. -->
<Scripts>
<OnLoad>GameMenuFrameTitle:SetText(MAIN_MENU)</OnLoad>
<OnShow>
if UpdateMicroButtons then UpdateMicroButtons() end
if Disable_BagButtons then Disable_BagButtons() end
</OnShow>
<OnHide>
if UpdateMicroButtons then UpdateMicroButtons() end
if Enable_BagButtons then Enable_BagButtons() end
</OnHide>
</Scripts>
</Frame>
<!-- The camp/quit event listener (the ref's UIParent OnEvent arms — script block above). -->
<Frame name="BenillaLogoutDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("PLAYER_CAMPING")
self:RegisterEvent("PLAYER_QUITING")
self:RegisterEvent("LOGOUT_CANCEL")
</OnLoad>
<OnEvent>BenillaLogoutDriver_OnEvent()</OnEvent>
</Scripts>
</Frame>
</Ui>
+21 -16
View File
@@ -29,17 +29,17 @@
bar plate doesn't paint over them. Same shape the bag bar next door already uses.
SCOPE / real-client divergences (noted, not bugs):
- THREE buttons have no panel to open yet: Socials (no FriendsFrame), Main Menu (no
GameMenuFrame — ESC runs BenillaOnEscape's close chain instead, UiPanels.xml) and Help (no
HelpFrame). They are drawn, hover, and highlight like the rest, but their click is a no-op
— the same "art only, wired the day the panel lands" call the page up/down arrows on this
same bar already make. Deliberately NOT greyed with the ref's `-Disabled` art: three dead
grey buttons mid-row read as a broken bar, and the point of this slice is that the bar
reads whole. Each is one OnClick line away from live.
- ONE button has no panel to open yet: Help (no HelpFrame). It is drawn, hovers, and
highlights like the rest, but its click is a no-op — the same "art only, wired the day the
panel lands" call the page up/down arrows on this same bar already make. Deliberately NOT
greyed with the ref's `-Disabled` art: a dead grey button mid-row reads as a broken bar,
and the point of this slice is that the bar reads whole. It is one OnClick line from live.
(Socials went live with decision 0668, Main Menu with 0674.)
- No GetBindingKey in this engine, so MicroButtonTooltipText's "(key)" suffix comes from a
static table of the bindings we actually ship (ui_script/input.rs `bare_key_binding`:
C/P/N/L/M) rather than from a live binding lookup — the same call ActionBar.xml makes for
its HotKey corners. The three unbound buttons show a bare label.
C/P/N/L/M/O) rather than from a live binding lookup — the same call ActionBar.xml makes
for its HotKey corners. The two unbound buttons (Main Menu, Help) show a bare label —
ESCAPE is a real binding, but not one that table carries.
- The hover is the ref's real two-line GameTooltip_AddNewbieTip (GameTooltip.xml): the label,
then the button's NEWBIE_TOOLTIP_* explanation, at the default screen corner. An earlier
note here claimed detailed tips were OFF in 1.12 and open-coded the else branch — the ref
@@ -144,16 +144,18 @@
end
end
-- ref UpdateMicroButtons (l.20-84): each button reflects whether its panel is up. The ref's
-- OptionsFrame/SoundOptionsFrame/UIOptionsFrame/KeyBindingFrame/MacroFrame arms of the
-- MainMenu test, and its keyring arm, have no frames here; the three unwired buttons
-- (Socials/MainMenu/Help) simply never push.
-- ref UpdateMicroButtons (l.20-84): each button reflects whether its panel is up. The
-- MainMenu arm is the ref's own six-way test (l.47-58) minus the five frames that don't
-- exist here — GameMenuFrame is the one that does (decision 0674), and it is the one the
-- reference lists first. Its keyring arm has no frame here; the still-unwired Help button
-- simply never pushes.
function UpdateMicroButtons()
BenillaMicroButton_SetPushed(CharacterMicroButton, BenillaMicro_PanelShown("BenillaCharacterFrame"))
BenillaMicroButton_SetPushed(SpellbookMicroButton, BenillaMicro_PanelShown("BenillaSpellBookFrame"))
BenillaMicroButton_SetPushed(TalentMicroButton, BenillaMicro_PanelShown("BenillaTalentFrame"))
BenillaMicroButton_SetPushed(QuestLogMicroButton, BenillaMicro_PanelShown("BenillaQuestLogFrame"))
BenillaMicroButton_SetPushed(WorldMapMicroButton, BenillaMicro_PanelShown("WorldMapFrame"))
BenillaMicroButton_SetPushed(MainMenuMicroButton, BenillaMicro_PanelShown("GameMenuFrame"))
end
-- ref CharacterMicroButton_SetPushed/_SetNormal (l.109-117): the portrait shifts its window
@@ -297,13 +299,13 @@
</Scripts>
</Button>
<!-- No FriendsFrame in this client yet — drawn and hovering, click inert (SCOPE note). -->
<Button name="SocialsMicroButton" inherits="BenillaMicroButtonTemplate">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="QuestLogMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, nil, NEWBIE_TOOLTIP_SOCIAL)</OnLoad>
<OnLoad>BenillaMicroButton_OnLoad(self, "Socials", SOCIAL_BUTTON, "O", NEWBIE_TOOLTIP_SOCIAL)</OnLoad>
<OnClick>ToggleFriendsFrame(1)</OnClick>
</Scripts>
</Button>
@@ -317,13 +319,16 @@
</Scripts>
</Button>
<!-- No GameMenuFrame yet — ESC runs BenillaOnEscape's close chain instead (SCOPE note). -->
<!-- The game menu (decision 0674). `ToggleGameMenu(1)` is the ref's own OnClick — the argument
is what makes a CLICK a plain toggle (close it, or close everything and open it) instead of
the ESC key's one-eater-per-press ladder; UiPanels.xml has both halves. -->
<Button name="MainMenuMicroButton" inherits="BenillaMicroButtonTemplate">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="WorldMapMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "MainMenu", MAINMENU_BUTTON, nil, NEWBIE_TOOLTIP_MAINMENU)</OnLoad>
<OnClick>ToggleGameMenu(1)</OnClick>
</Scripts>
</Button>
+1 -1
View File
@@ -126,7 +126,7 @@
BenillaStackSplitFrame:Hide()
end
-- OnHide (ref l.158-162): ANY hide — Okay, Cancel, or BenillaOnEscape's chain hook below —
-- OnHide (ref l.158-162): ANY hide — Okay, Cancel, or ToggleGameMenu's chain hook below —
-- clears the owner's `hasStackSplit` flag (ref ItemButtonTemplate's OnHide, ContainerFrame.xml
-- l.35-39, checks `this.hasStackSplit` to auto-close the spinner if the SLOT itself goes away
-- mid-split — no consumer needs that half here, but the flag's own clear is this function's
+371 -54
View File
@@ -24,7 +24,7 @@
UIPanelWindows["BenillaMerchantFrame"] = { area = "left", pushable = 0 }
-- The mail window (decision 0544): the reference UIParent.lua l.37 registers MailFrame
-- area="left", pushable=0. The OpenMailFrame is NOT a panel — it is a special-frame toplevel
-- (UIParent.lua l.46 UISpecialFrames), closed by ESC in BenillaOnEscape below (this v1 drops
-- (UIParent.lua l.46 UISpecialFrames), closed by ESC in ToggleGameMenu below (this v1 drops
-- the UISpecialFrames table, so escapable toplevels are hard-listed there, like WorldMapFrame).
UIPanelWindows["BenillaMailFrame"] = { area = "left", pushable = 0 }
-- The trade window (decision 0592 P1, TradeFrame.xml): the ref's own UIParent.lua l.27
@@ -66,6 +66,10 @@
-- guard already reads `info.whileDead` — ShowUIPanel's own comment: `UnitIsDead` doesn't
-- exist yet, so the guard is inert either way today).
UIPanelWindows["BenillaTalentFrame"] = { area = "left", pushable = 6 }
-- The social window (decision 0668) — the ref's own registration line verbatim
-- (`UIPanelWindows["FriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }`,
-- UIParent.lua l.29). whileDead: a corpse run is exactly when you want to whisper someone.
UIPanelWindows["BenillaFriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }
-- The crafting window (decision 0437 phase 2, TradeSkillFrame.xml): NOT an NPC session (it
-- opens off your own cast, TradeSkillFrame.xml's own header comment) — the ref Lua's own
-- registration line (Blizzard_TradeSkillUI.lua:13) is `{ area = "left", pushable = 3 }`,
@@ -76,6 +80,15 @@
-- (grepped both ref files directly to confirm: `3` is TradeSkillFrame's own line-13 value,
-- a different window/depth — CraftFrame.xml's own header comment has the same note).
UIPanelWindows["BenillaCraftFrame"] = { area = "left", pushable = 4 }
-- The game menu (decision 0674, GameMenuFrame.xml) — the ref's own registration line
-- verbatim (`UIPanelWindows["GameMenuFrame"] = { area = "center", pushable = 0,
-- whileDead = 1 }`, UIParent.lua l.15), and the FIRST **center**-area row benilla ships.
-- "center" is not a position here, it is a MODE: a native-center frame takes the center
-- slot keeping its own anchor (the menu is screen-CENTER by its own XML), closes every
-- other window on the way in, and — while it holds the slot — makes ShowUIPanel refuse
-- every non-center panel (`CanOpenPanels` below). That is the rule behind "you can't open
-- anything with the ESC menu up". whileDead: dying is exactly when you want to log out.
UIPanelWindows["GameMenuFrame"] = { area = "center", pushable = 0, whileDead = 1 }
-- The real client stores `.left`/`.center`/`.doublewide`/`.fullscreen` as fields directly on
-- the `UIParent` frame table (UIParent.lua l.789-885). benilla has no `UIParent` frame object
@@ -154,16 +167,38 @@
return slots.center
end
-- ShowUIPanel(frame) — UIParent.lua l.649-751, v1 left+center slice only (pin §3 steps 1,3-5).
-- Skipped by scope: `CanOpenPanels()` (l.655 — no gate exists to open panels on yet),
-- full-screen (l.668-675) and native-center (l.677-689) precedence (no `full`/`center`-area
-- panel ships), and the `full`/`doublewide` branches (l.692-710 — no such row is registered).
-- CanOpenPanels() — UIParent.lua l.887-910. Its dead-player arms are commented out in the
-- reference itself (both of them, with the note that the fear/out-of-control gate was
-- "previously"); what survives is one rule: **a native-center frame open ⇒ nothing else
-- opens**. The game menu is the only such frame (decision 0674), so this reads: with the
-- ESC menu up, no window opens — not from a keybind, not from an NPC's OPEN_WINDOW.
function CanOpenPanels()
local centerFrame = GetCenterFrame()
if not centerFrame then
return 1
end
local info = UIPanelWindows[centerFrame:GetName()]
if info and info.area == "center" then
return nil
end
return 1
end
-- ShowUIPanel(frame) — UIParent.lua l.649-751, the left+center slice (pin §3 steps 1,3-5)
-- plus the native-center mode decision 0674 needed. Still skipped by scope: full-screen
-- (l.668-675, l.692-696 — no `full` row is registered; benilla's world map is a plain
-- toplevel, not a panel) and `doublewide` (l.707-710 — likewise none).
function ShowUIPanel(frame, force)
if not frame or frame:IsVisible() then
return
end
local info = UIPanelWindows[frame:GetName()]
-- l.655-657. Note the reference's own precedence: an UNREGISTERED frame is not gated
-- (the `info and` term), so a bare toplevel still shows with the menu up.
if not CanOpenPanels() and info and info.area ~= "center" then
return
end
if not info then
frame:Show()
return
@@ -174,8 +209,35 @@
return
end
-- l.677-689: with a native-center frame open, only another center request gets through
-- — `force` evicts it instead of refusing. Redundant with the CanOpenPanels gate above
-- for every caller that doesn't force (the reference double-checks it too); kept because
-- it is the ONLY path that can displace the menu, and dropping it would silently turn
-- `force` into a no-op. No benilla caller passes `force` yet.
local centerFrame = GetCenterFrame()
if centerFrame then
local centerInfo = UIPanelWindows[centerFrame:GetName()]
if centerInfo and centerInfo.area == "center" and info.area ~= "center" then
if force then
SetCenterFrame(nil)
else
return
end
end
end
-- l.697-702: native-center frames just replace each other and take the screen —
-- everything else closes first. `skipSetPoint` is the load-bearing argument: the menu
-- keeps its own CENTER anchor instead of being dropped at the left-area seat.
if info.area == "center" then
CloseWindows()
CloseAllBags()
SetCenterFrame(frame, 1)
return
end
if info.area ~= "left" then
-- No center/full/doublewide row is registered in v1 — defensive fallback only.
-- No full/doublewide row is registered — defensive fallback only.
frame:Show()
return
end
@@ -269,15 +331,21 @@
-- UIPanel (no slot registration) — it must be closed here explicitly, exactly as the real
-- client closes its ContainerFrames outside the slot system. Naming BenillaBagFrame here
-- mirrors UIParent.lua naming ContainerFrame in the very same function.
-- The five bag windows (the real fn's ContainerFrame1..NUM_CONTAINER_FRAMES): the backpack
-- plus the four equipped-bag windows. Chunk-LOCAL rather than BagFrame.xml's own
-- BENILLA_BAG_WINDOWS global, so every user below stays nil-safe on a surface without the
-- bag file (a headless panel test) — each still resolves per window through `getglobal`.
-- (It was three identical inline copies until the game menu needed a fourth.)
local BAG_WINDOWS = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2",
"BenillaBagFrame3", "BenillaBagFrame4" }
function CloseAllWindows(ignoreCenter)
local bagsVisible = nil
-- Hide every open bag window — the backpack plus the four equipped-bag windows (the real
-- fn's ContainerFrame1..NUM_CONTAINER_FRAMES loop). Now that 'B' opens all bags at once
-- (BagFrame.xml BenillaBagToggle_OnClick), ESC must close all of them, not just the
-- backpack. The list is inlined (not BagFrame.xml's BENILLA_BAG_WINDOWS) so this stays
-- nil-safe on a surface without the bag file — a headless panel test — per window.
local bags = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2", "BenillaBagFrame3", "BenillaBagFrame4" }
for _, name in ipairs(bags) do
-- Hide every open bag window. Now that 'B' opens all bags at once (BagFrame.xml
-- BenillaBagToggle_OnClick), ESC must close all of them, not just the backpack. The bag
-- windows are NOT UIPanels (no slot registration) — they must be closed here explicitly,
-- exactly as the real client closes its ContainerFrames outside the slot system.
for _, name in ipairs(BAG_WINDOWS) do
local bag = getglobal(name)
if bag and bag:IsShown() then
bag:Hide()
@@ -288,6 +356,20 @@
return bagsVisible or windowsVisible
end
-- CloseAllBags() — the ref ContainerFrame.lua l.702-707 (`CloseBackpack()` + `CloseBag(1..N)`),
-- called by ShowUIPanel's native-center branch above. Distinct from CloseBackpack below on
-- purpose: this is the UNCONDITIONAL close (the ref's CloseBag ignores the was-open memory),
-- because a center frame takes the whole screen — nothing is being "restored afterwards"
-- the way a vendor window restores the bags it opened.
function CloseAllBags()
for _, name in ipairs(BAG_WINDOWS) do
local bag = getglobal(name)
if bag and bag:IsShown() then
bag:Hide()
end
end
end
-- OpenBackpack / CloseBackpack — the real ContainerFrame.lua globals (l.187-217) the
-- vendor/bank/mail/trade windows call from their OnShow/OnHide so your bags open with them
-- (playing igBackPackOpen/Close alongside the window's own panel sound — the two "go
@@ -298,18 +380,16 @@
-- benilla stores that on each frame (`wasOpen`). benilla DIVERGES from the ref here on
-- purpose: the real OpenBackpack touches only the backpack, but ours opens EVERY equipped
-- bag with the window (director's call — decision 0561 — matching the 'B' all-bags toggle),
-- so the names keep the ref's caller contract while the body loops the five windows. The
-- list is inlined (like CloseAllWindows above) and every step is guarded, so a surface
-- without the bag file — a headless panel test — no-ops rather than erroring.
-- so the names keep the ref's caller contract while the body loops the five windows. Every
-- step is guarded, so a surface without the bag file — a headless panel test — no-ops
-- rather than erroring.
function OpenBackpack()
local bags = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2",
"BenillaBagFrame3", "BenillaBagFrame4" }
for bagId = 0, 4 do
-- The backpack (bag 0) always exists; an equipped-bag window only when a bag is in
-- the slot (the same existence test as BagFrame.xml's BenillaOpenAllBags).
local exists = bagId == 0
or (C_Container and C_Container.GetContainerNumSlots(bagId) > 0)
local bag = exists and getglobal(bags[bagId + 1])
local bag = exists and getglobal(BAG_WINDOWS[bagId + 1])
if bag then
if bag:IsShown() then
bag.wasOpen = 1 -- already open: leave it, and don't close it on window hide
@@ -323,9 +403,7 @@
end
function CloseBackpack()
local bags = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2",
"BenillaBagFrame3", "BenillaBagFrame4" }
for _, name in ipairs(bags) do
for _, name in ipairs(BAG_WINDOWS) do
local bag = getglobal(name)
if bag and bag:IsShown() and not bag.wasOpen then
bag:Hide() -- OnHide → igBackPackClose
@@ -333,30 +411,59 @@
end
end
-- BenillaOnEscape() — the ESC keybinding action, run by the host (crates/benilla ui_input)
-- when no EditBox consumed the ESCAPE key. The real client binds ESCAPE → TOGGLEGAMEMENU →
-- ToggleGameMenu() (UIParent.lua l.1465-1497); its no-menu branch is an if/elseif chain
-- (l.1482-1496) that tries, in order:
-- if ( StaticPopup_EscapePressed() ) then
-- elseif ( OptionsFrame:IsVisible() ) then OptionsFrameCancel:Click()
-- elseif ( GameMenuFrame:IsVisible() ) then PlaySound("igMainMenuQuit"); HideUIPanel(GameMenuFrame)
-- elseif ( CloseMenus() ) then
-- elseif ( SpellStopCasting() ) then
-- elseif ( SpellStopTargeting() ) then
-- elseif ( CloseAllWindows() ) then -- l.1491
-- elseif ( ClearTarget() ) then
-- else PlaySound("igMainMenuOpen"); ShowUIPanel(GameMenuFrame) -- l.1494-1495
-- benilla has no options / game menu / spell targeting yet, so those branches are
-- absent and the chain collapses to StaticPopup → CloseMenus → SpellStopCasting →
-- CloseAllWindows → ClearTarget, with no menu to open when nothing ate the press. The
-- ORDER is the law
-- (the director's two-press report, decision 0449): ESC mid-cast cancels the cast and
-- ONLY the cast; the target drops on a later press once nothing else claims it.
-- ONE benilla addition, ahead of the close: a held cursor item is dropped (ClearCursor). The
-- cursor-item model (decision 0068 T2) has no real-client ESC handling to mirror — vanilla
-- leaves the item on the cursor — so this is a deliberate benilla convenience (ESC as the
-- universal "get me out" key). Guarded so it stays inert were the cursor slice ever absent.
function BenillaOnEscape()
-- ToggleGameMenu(clicked) — UIParent.lua l.1465-1497, the ESCAPE binding's action
-- (TOGGLEGAMEMENU), run by the host (crates/benilla ui_script/input.rs) when no EditBox
-- consumed the key, and by the Main Menu micro button with `clicked = 1`. It was
-- benilla's own `BenillaOnEscape` while there was no menu to open (decision 0674 retires
-- that name — this IS the reference function now, and an addon-visible one).
--
-- TWO behaviours in one function, which is the reference's own shape:
-- · `clicked` (the micro button, l.1466-1480) — a TOGGLE. Menu up ⇒ close it; menu down ⇒
-- CloseMenus() + CloseAllWindows() first, then open. It never cancels a cast or drops a
-- target: clicking a button is not "get me out of this".
-- · no argument (the ESC key, l.1482-1496) — the LADDER, one eater per press, in order:
-- if ( StaticPopup_EscapePressed() ) then
-- elseif ( OptionsFrame:IsVisible() ) then OptionsFrameCancel:Click()
-- elseif ( GameMenuFrame:IsVisible() ) then PlaySound("igMainMenuQuit"); HideUIPanel(GameMenuFrame)
-- elseif ( CloseMenus() ) then
-- elseif ( SpellStopCasting() ) then
-- elseif ( SpellStopTargeting() ) then
-- elseif ( CloseAllWindows() ) then -- l.1491
-- elseif ( ClearTarget() ) then
-- else PlaySound("igMainMenuOpen"); ShowUIPanel(GameMenuFrame) -- l.1494-1495
-- The ORDER is the law (the director's two-press report, decision 0449): ESC mid-cast
-- cancels the cast and ONLY the cast; the target drops on a later press once nothing
-- else claims it; and only a press with NOTHING left to eat opens the menu.
--
-- Absent branches, and why: no OptionsFrame (nothing to cancel — GameMenuFrame.xml's five
-- disabled buttons are exactly that gap), and no SpellStopTargeting (no ground-target
-- cursor mode yet). ONE benilla ADDITION, ahead of everything: a held cursor item is
-- dropped (ClearCursor). The cursor-item model (decision 0068 T2) has no real-client ESC
-- handling to mirror — vanilla leaves the item on the cursor — so this is a deliberate
-- benilla convenience (ESC as the universal "get me out" key). Guarded so it stays inert
-- were the cursor slice ever absent. Three further benilla rungs sit between the popup and
-- CloseMenus (stack split, world map, open mail); each is commented at its own line with
-- the reference mechanism it stands in for.
function ToggleGameMenu(clicked)
-- Looked up rather than indexed off the global: a headless panel test loads this file
-- without GameMenuFrame.xml behind it, and the reference's bare `GameMenuFrame:` would
-- abort the whole chain there (the `CloseMenus and` guard two rungs down, same reason).
local menu = getglobal("GameMenuFrame")
-- The micro button's toggle (l.1466-1480).
if clicked then
if menu and menu:IsVisible() then
PlaySound("igMainMenuQuit")
HideUIPanel(menu)
elseif menu then
if CloseMenus then CloseMenus() end
CloseAllWindows()
PlaySound("igMainMenuOpen")
ShowUIPanel(menu)
end
return
end
if CursorHasItem and CursorHasItem() then
ClearCursor()
end
@@ -366,6 +473,16 @@
if StaticPopup_EscapePressed() then
return
end
-- The menu itself closes next (l.1485-1487) — before every close/cancel rung below, so
-- ESC with the menu up only puts the menu away. (The OptionsFrame rung the reference
-- checks between these two has no benilla frame; GameMenuFrame.xml's disabled buttons
-- ARE that gap.) Note the sound: closing by ESC plays igMainMenuQuit, the same kit the
-- Exit Game button uses — the reference's choice, not a copy/paste slip here.
if menu and menu:IsVisible() then
PlaySound("igMainMenuQuit")
HideUIPanel(menu)
return
end
-- The stack-split spinner next (decision 0216 §6/slice 2, StackSplit.xml): the real
-- client's own StackSplitFrame hooks ESC directly (its OnKeyDown, `enableKeyboard="true"`)
-- — this engine has no plain-frame keyboard capture (StackSplit.xml's own header comment),
@@ -416,7 +533,37 @@
if CloseAllWindows() then
return
end
ClearTarget()
if ClearTarget() then
return
end
-- Nothing left to eat (l.1493-1495): the press opens the menu. ClearTarget answering
-- nil is what gets us here — which is why its `1`/nil return is load-bearing, and why
-- the ladder above must never return an unconditional truthy.
if menu then
PlaySound("igMainMenuOpen")
ShowUIPanel(menu)
end
end
-- SetDesaturation(texture, desaturation) — ref UIParent.lua l.1499-1510, verbatim. The
-- reference itself has a no-shader fallback, and OURS IS ALWAYS THAT PATH: this engine has
-- no SetDesaturated, so `shaderSupported` is false exactly as on a 1.12 card without the
-- desaturation shader, and the grey comes from the ref's own vertex-colour branch. Written
-- as the wrapper rather than open-coded at each call site so the day a real desaturation
-- lands, one function changes. First caller here is the game menu's bag-bar greying
-- (decision 0674); PartyFrame.xml's disconnected-member branch has been CALLING this
-- global since decision 0434 with nothing defining it — an error thrown every time a party
-- member went offline, which is why it went unnoticed.
function SetDesaturation(texture, desaturation)
if not texture then return end
local shaderSupported = texture.SetDesaturated and texture:SetDesaturated(desaturation)
if not shaderSupported then
if desaturation then
texture:SetVertexColor(0.5, 0.5, 0.5)
else
texture:SetVertexColor(1.0, 1.0, 1.0)
end
end
end
-- ── The shared dialog IS the ref's StaticPopup engine (decision 0308 §3) ─────────────────
@@ -432,9 +579,13 @@
-- waits for the server, XP_LOSS's first Accept re-asks), OnCancel(data, reason)
-- (reason ∈ "clicked"|"timeout"|"override"), OnUpdate(elapsed, dialog), timeout,
-- StartDelay, delayText, sound, whileDead, hideOnEscape, cancels.
-- · hasEditBox + maxLetters + EditBoxOnEnterPressed are live (decision 0668's
-- ADD_FRIEND/ADD_IGNORE are the first customers): the box shows, caps its length,
-- opens empty, and re-anchors the buttons under itself.
-- · Accepted but v1-inert: exclusive, showAlert (no alert icon/420-width yet),
-- interruptCinematic (cinematics are acked instantly — 0196), notClosableByLogout,
-- preferredIndex, and the editbox/money variants.
-- preferredIndex, hasWideEditBox (guild MOTD/notes — the guild arc), and the money
-- variant.
-- · text_arg1/2 stash on the DIALOG frame (the ref stashes them on the Text region;
-- our regions don't carry Lua fields).
-- · GetText("SECONDS"/"MINUTES") localization collapses to English singular/plural.
@@ -487,7 +638,13 @@
function StaticPopup_Resize(dialog, which)
local text = getglobal(dialog:GetName() .. "Text")
local button1 = getglobal(dialog:GetName() .. "Button1")
dialog:SetHeight(16 + text:GetHeight() + 8 + button1:GetHeight() + 16)
local editBox = getglobal(dialog:GetName() .. "EditBox")
if StaticPopupDialogs[which] and StaticPopupDialogs[which].hasEditBox then
dialog:SetHeight(16 + text:GetHeight() + 8 + editBox:GetHeight() + 8
+ button1:GetHeight() + 16)
else
dialog:SetHeight(16 + text:GetHeight() + 8 + button1:GetHeight() + 16)
end
end
-- Ref l.1441-1700. Returns the dialog, or nil when refused (whileDead gate / no free
@@ -499,7 +656,7 @@
end
-- The whileDead gate (ref l.1447-1452). UnitIsDeadOrGhost lands with the death arc's
-- ghost predicate — guarded like CursorHasItem in BenillaOnEscape until then.
-- ghost predicate — guarded like CursorHasItem in ToggleGameMenu until then.
if UnitIsDeadOrGhost and UnitIsDeadOrGhost("player") and not info.whileDead then
if info.OnCancel then
info.OnCancel()
@@ -573,21 +730,37 @@
text:SetText(format(info.text, text_arg1, text_arg2))
end
-- The edit box (ref l.1599-1624): shown only for a hasEditBox entry, capped at the
-- entry's maxLetters, and always empty on open — a name left over from the last time
-- the dialog ran would be sent by a reflexive Enter. The wide variant is guild-only
-- (MOTD/notes), so it waits for the guild arc.
local editBox = getglobal(dialog:GetName() .. "EditBox")
if info.hasEditBox then
editBox:SetMaxLetters(info.maxLetters or 0)
editBox:SetText("")
editBox:Show()
else
editBox:Hide()
end
-- The buttons (ref l.1634-1676; fixed 120 widths — header note). Button2 shows only
-- when the entry names it AND DisplayButton2 (when present) allows it; a solo button1
-- re-seats centered under the text.
-- re-seats centered under the text. With an edit box the pair hangs off the BOX's
-- bottom instead of the text's (ref l.1641-1643), which is what leaves room for it.
local buttonAnchor = info.hasEditBox and (dialog:GetName() .. "EditBox")
or (dialog:GetName() .. "Text")
local button1 = getglobal(dialog:GetName() .. "Button1")
local button2 = getglobal(dialog:GetName() .. "Button2")
if info.button2 and (not info.DisplayButton2 or info.DisplayButton2()) then
button1:ClearAllPoints()
button2:ClearAllPoints()
button1:SetPoint("TOPRIGHT", dialog:GetName() .. "Text", "BOTTOM", -6, -8)
button1:SetPoint("TOPRIGHT", buttonAnchor, "BOTTOM", -6, -8)
button2:SetPoint("LEFT", dialog:GetName() .. "Button1", "RIGHT", 13, 0)
button2:SetText(info.button2)
button2:Show()
else
button1:ClearAllPoints()
button1:SetPoint("TOP", dialog:GetName() .. "Text", "BOTTOM", 0, -8)
button1:SetPoint("TOP", buttonAnchor, "BOTTOM", 0, -8)
button2:Hide()
end
if info.button1 then
@@ -642,6 +815,24 @@
end
end
-- The edit box's two keys (ref StaticPopup.xml's template scripts). Enter runs the
-- entry's EditBoxOnEnterPressed hook, which by convention does the accept work and hides
-- the dialog itself; Escape dismisses the dialog through the ordinary cancel path so a
-- half-typed name never acts.
function StaticPopup_EditBoxOnEnterPressed(editBox)
local dialog = editBox:GetParent()
local info = StaticPopupDialogs[dialog.which]
if info and info.EditBoxOnEnterPressed then
info.EditBoxOnEnterPressed(dialog.data)
end
end
function StaticPopup_EditBoxOnEscapePressed(editBox)
local dialog = editBox:GetParent()
editBox:ClearFocus()
dialog:Hide()
end
-- Ref l.1832-1848: the open/close pair sounds on EVERY path, then the entry's own hook.
function StaticPopup_OnShow()
PlaySound("igMainMenuOpen")
@@ -921,7 +1112,9 @@
if not frame.selectedTab then return end
for i = 1, frame.numTabs do
local tab = getglobal(frame:GetName() .. "Tab" .. i)
if i == frame.selectedTab then
if tab.isDisabled then
PanelTemplates_SetDisabledTabState(tab)
elseif i == frame.selectedTab then
PanelTemplates_SelectTab(tab)
else
PanelTemplates_DeselectTab(tab)
@@ -929,6 +1122,37 @@
end
end
-- The disabled-tab arm (ref UIPanelTemplates.lua l.94-105 + l.135-148) — the piece this
-- kit deliberately omitted until a window needed it. First customer: the social window's
-- GUILD tab, which `InGuildCheck` disables for a character in no guild — i.e. permanently
-- until the guild arc exists (decision 0668). A disabled tab wears the INACTIVE
-- three-slice art (like an unselected tab) but is un-clickable with a greyed label, so it
-- reads as "there, not for you" rather than as the selected one.
function PanelTemplates_DisableTab(frame, index)
getglobal(frame:GetName() .. "Tab" .. index).isDisabled = 1
PanelTemplates_UpdateTabs(frame)
end
function PanelTemplates_EnableTab(frame, index)
local tab = getglobal(frame:GetName() .. "Tab" .. index)
tab.isDisabled = nil
tab:SetDisabledTextColor(HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
PanelTemplates_UpdateTabs(frame)
end
function PanelTemplates_SetDisabledTabState(tab)
local name = tab:GetName()
getglobal(name .. "Left"):Show()
getglobal(name .. "Middle"):Show()
getglobal(name .. "Right"):Show()
tab:Disable()
tab.text = tab:GetText()
tab:SetDisabledTextColor(GRAY_FONT_COLOR.r, GRAY_FONT_COLOR.g, GRAY_FONT_COLOR.b)
getglobal(name .. "LeftDisabled"):Hide()
getglobal(name .. "MiddleDisabled"):Hide()
getglobal(name .. "RightDisabled"):Hide()
end
function PanelTemplates_DeselectTab(tab)
local name = tab:GetName()
getglobal(name .. "Left"):Show()
@@ -1058,6 +1282,71 @@
</Scripts>
</Button>
<!--
The SMALL tab button (ref `TabButtonTemplate`, UIPanelTemplates.xml l.303-402, transcribed
VERBATIM: the HelpFrameTab Active/Inactive three-slice pair on 16/32/16 slices, text at
BOTTOM +5, the ADD highlight at BOTTOM +2,-8). Not the same thing as
`CharacterFrameTabButtonTemplate` above — that one is the big *window* tab along a panel's
bottom edge; this is the compact tab a panel uses to switch a list INSIDE itself. The
social window's Friends/Ignore pair is the first customer (decision 0668); the reference
also uses it for the help frame and the raid pane.
Slice naming matches the big tab's, so the shared PanelTemplates_SelectTab/DeselectTab kit
drives both unchanged — "Disabled" is "selected" here too. Same OnUpdate text-fit for the
same reason (our GetStringWidth lands a frame late).
-->
<Button name="TabButtonTemplate" virtual="true">
<Size><AbsDimension x="115" y="32"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeftDisabled" file="Interface\HelpFrame\HelpFrameTab-Active">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-3"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentMiddleDisabled" file="Interface\HelpFrame\HelpFrameTab-Active">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentLeftDisabled" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.75" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentRightDisabled" file="Interface\HelpFrame\HelpFrameTab-Active">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentMiddleDisabled" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentLeft" file="Interface\HelpFrame\HelpFrameTab-Inactive">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<TexCoords left="0" right="0.25" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentMiddle" file="Interface\HelpFrame\HelpFrameTab-Inactive">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.75" top="0" bottom="1.0"/>
</Texture>
<Texture name="$parentRight" file="Interface\HelpFrame\HelpFrameTab-Inactive">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentMiddle" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<ButtonText name="$parentText">
<Size><AbsDimension x="0" y="13"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="5"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<HighlightTexture name="$parentHighlightTexture" file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD">
<Size><AbsDimension x="5" y="32"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="2" y="-8"/></Offset></Anchor></Anchors>
</HighlightTexture>
<Scripts>
<OnUpdate>BenillaTabButton_OnUpdate(self)</OnUpdate>
</Scripts>
</Button>
<!--
The shared dialog instance — the ref's StaticPopup1 (decision 0308 §3; the engine lives in
the script block above). Geometry/art transcribed VERBATIM from the real StaticPopup.xml
@@ -1107,6 +1396,34 @@
<Anchors><Anchor point="LEFT" relativeTo="$parentButton1" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>StaticPopup_OnClick(self:GetParent(), 2)</OnClick></Scripts>
</Button>
<!-- The name-entry box (ref StaticPopup.xml l.84-140): 130x32 at BOTTOM +45, the
chat-input three-slice border, hidden unless the entry sets hasEditBox. First
customer: the social window's ADD_FRIEND / ADD_IGNORE dialogs (decision 0668).
Enter runs the entry's own EditBoxOnEnterPressed (the ref's hook name) and Escape
dismisses the whole dialog, which is the ref's EditBoxOnEscapePressed default. -->
<EditBox name="$parentEditBox" historyLines="1" hidden="true" autoFocus="false">
<Size><AbsDimension x="130" y="32"/></Size>
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="45"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\ChatFrame\UI-ChatInputBorder-Left">
<Size><AbsDimension x="75" y="32"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-10" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.29296875" top="0" bottom="1.0"/>
</Texture>
<Texture file="Interface\ChatFrame\UI-ChatInputBorder-Right">
<Size><AbsDimension x="75" y="32"/></Size>
<Anchors><Anchor point="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0.70703125" right="1.0" top="0" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<OnEnterPressed>StaticPopup_EditBoxOnEnterPressed(self)</OnEnterPressed>
<OnEscapePressed>StaticPopup_EditBoxOnEscapePressed(self)</OnEscapePressed>
</Scripts>
<FontString inherits="ChatFontNormal"/>
</EditBox>
</Frames>
<!-- The engine handlers (ref StaticPopup.xml template Scripts): open/close sounds ride
inside StaticPopup_OnShow/OnHide so every path sounds; OnUpdate drives the countdown/
+510
View File
@@ -0,0 +1,510 @@
//! `WOW_DEPTH` — **what depth actually won this pixel, and how far away is it?**
//!
//! The last unread link in B38. There, two surfaces of the Far Watch Post tower trade places: the
//! awning is nearer, writes depth, compares `GreaterEqual`, is not discarded, is not culled, keeps
//! the same mesh/material/texture every frame, and is submitted into `AlphaMask3d` at a stable draw
//! position on every single frame (decisions 0662, 0665, 0667) — and on some frames the plank behind
//! it wins the pixel anyway. Every one of those facts was established on the **CPU** side. None of
//! them says what value the GPU wrote into the depth buffer, which is the one thing that decides the
//! pixel and the one thing we could not read.
//!
//! So: `WOW_DEPTH="<x>,<y>[;<x>,<y>…]"` copies the view's depth texture back after the main pass and
//! logs, per frame, the raw reverse-Z value at each pixel and **where that puts the surface** — both
//! as a distance along the pixel's ray and as the perpendicular distance to the camera plane, because
//! those are different numbers and only the first is comparable with a ray cast.
//! `WOW_DEPTH_AT=<secs>` (default 20) / `WOW_DEPTH_COUNT=<n>` (default 1) shape the sampling like the
//! screenshot burst and the ray pick, so the three line up frame for frame.
//!
//! **Run it together with `WOW_PICK` at the same pixels.** This probe answers *what depth won*; the
//! ray pick answers *what is standing there* — every hit along the ray with its distance. Reading the
//! won distance against the hit distances is what turns a number into "**whose** depth won": if a
//! frame's depth matches hit 1's distance rather than hit 0's, the nearer surface never wrote depth
//! there, and no argument about draw order or culling survives that. Splitting the two keeps each
//! probe single-purpose, and because the geometry is static the pick's distances hold across the
//! whole burst.
//!
//! Coordinates are **screenshot pixels** — the same space `benilla-visual` reports boxes in and
//! `WOW_PICK` takes — and here they are used directly, because the depth texture is allocated in
//! physical pixels. (`WOW_PICK` has to divide by the window scale factor; its ray cast works in
//! logical units. Same input space, different reason.)
//!
//! **MSAA must be off** (`WOW_MSAA=off`). A multisampled depth texture cannot be copied to a buffer
//! at all, and there is no single "the" depth at a pixel to report if it could — there are four. The
//! probe refuses rather than reporting one of them, because a number that looks like the measurement
//! you wanted, taken slightly wrong, is how this bug has already produced four confident wrong
//! answers (0667).
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::ecs::query::QueryItem;
use bevy::prelude::*;
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
};
use bevy::render::render_resource::{
Buffer, BufferDescriptor, BufferUsages, Extent3d, MapMode, Origin3d, TexelCopyBufferInfo,
TexelCopyBufferLayout, TexelCopyTextureInfo, TextureAspect, TextureUsages,
};
use bevy::render::renderer::{RenderContext, RenderDevice};
use bevy::render::view::{ExtractedView, ViewDepthTexture};
use bevy::render::{Render, RenderApp, RenderSystems};
use crate::player::WorldCamera;
pub(crate) struct DepthProbePlugin;
impl Plugin for DepthProbePlugin {
fn build(&self, app: &mut App) {
let pixels = std::env::var("WOW_DEPTH")
.ok()
.map(|s| parse_pixels(&s))
.unwrap_or_default();
if pixels.is_empty() {
warn!("depth: WOW_DEPTH wants \"<x>,<y>[;<x>,<y>…]\" screenshot pixels — inert");
return;
}
let at = std::env::var("WOW_DEPTH_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20.0);
let count = std::env::var("WOW_DEPTH_COUNT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1u32)
.max(1);
app.insert_resource(DepthWatch {
pixels,
at,
count,
armed: false,
})
.add_systems(Update, arm)
.add_plugins((
ExtractResourcePlugin::<DepthWatch>::default(),
ExtractComponentPlugin::<DepthProbeView>::default(),
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
warn!("depth: no render app — inert");
return;
};
render_app
.init_resource::<DepthFramesRead>()
.add_systems(
Render,
(
prepare_staging.in_set(RenderSystems::PrepareResources),
// The node encodes the copy inside the graph; by the time the graph has run and
// submitted, the staging buffer holds it and can be mapped.
read_depth.after(RenderSystems::Render),
),
)
.add_render_graph_node::<ViewNodeRunner<DepthReadbackNode>>(Core3d, DepthReadbackLabel)
// Between the opaque pass (which draws `Opaque3d` *and* `AlphaMask3d`) and the
// transmissive/transparent ones. That is the depth as it stood when the opaque fight was
// decided, which is the question — see [`DepthReadbackNode`] for what reading it later
// gets you instead.
.add_render_graph_edges(
Core3d,
(
Node3d::MainOpaquePass,
DepthReadbackLabel,
Node3d::MainTransmissivePass,
),
);
}
}
/// The pixels to read and the sampling window. Extracted to the render world as a plain clone (a
/// handful of coordinates once per sampled frame).
#[derive(Resource, Clone)]
struct DepthWatch {
pixels: Vec<(u32, u32)>,
at: f32,
count: u32,
armed: bool,
}
impl ExtractResource for DepthWatch {
type Source = DepthWatch;
fn extract_resource(source: &Self::Source) -> Self {
source.clone()
}
}
/// Marks the one view whose depth to read. The depth texture is cached per *render target*, so the
/// world camera and the UI camera on the same window share it — but picking a view by viewport size
/// would be picking by coincidence, and this bug is made of numbers taken slightly wrong.
#[derive(Component, Clone, Copy, ExtractComponent)]
struct DepthProbeView;
/// The staging buffer, kept across frames so a 24-frame burst allocates once.
#[derive(Resource)]
struct DepthStaging {
buffer: Buffer,
bytes_per_row: u32,
height: u32,
}
/// Wait for the sampling window, then mark the world camera: opt its depth texture into `COPY_SRC`
/// (the default is `RENDER_ATTACHMENT` alone, which cannot be copied), and refuse the whole probe if
/// MSAA is on. Checking the live `Msaa` component rather than `$WOW_MSAA` means the refusal tracks
/// what the renderer is actually doing, not what the environment asked for.
fn arm(
mut watch: ResMut<DepthWatch>,
time: Res<Time>,
mut cam: Query<(Entity, &mut Camera3d, &Msaa), With<WorldCamera>>,
mut commands: Commands,
) {
if watch.armed || time.elapsed_secs() < watch.at {
return;
}
let Ok((entity, mut camera, msaa)) = cam.single_mut() else {
return;
};
if *msaa != Msaa::Off {
error!(
"depth: MSAA is {msaa:?} — a multisampled depth texture cannot be copied, and there is \
no single depth per pixel to report. Re-run with WOW_MSAA=off. Probe disabled."
);
watch.armed = true;
watch.count = 0;
return;
}
camera.depth_texture_usages =
(TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC).into();
commands.entity(entity).insert(DepthProbeView);
info!(
"depth: reading {} pixels for {} frames",
watch.pixels.len(),
watch.count
);
watch.armed = true;
}
/// Allocate the staging buffer to match the view's depth texture, before the graph runs.
fn prepare_staging(
watch: Option<Res<DepthWatch>>,
view: Query<&ViewDepthTexture, With<DepthProbeView>>,
staging: Option<Res<DepthStaging>>,
device: Res<RenderDevice>,
mut commands: Commands,
) {
let Some(watch) = watch else { return };
if !watch.armed {
return;
}
let Ok(depth) = view.single() else { return };
let size = depth.texture.size();
// `Depth32Float` is 4 bytes a texel, and a buffer copy's row stride must be 256-aligned — so the
// stride is the aligned *byte* count, which is not the aligned pixel count times four.
let bytes_per_row = RenderDevice::align_copy_bytes_per_row(size.width as usize * 4) as u32;
if staging.is_some_and(|s| s.bytes_per_row == bytes_per_row && s.height == size.height) {
return;
}
commands.insert_resource(DepthStaging {
buffer: device.create_buffer(&BufferDescriptor {
label: Some("wow_depth_readback"),
size: u64::from(bytes_per_row) * u64::from(size.height),
usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ,
mapped_at_creation: false,
}),
bytes_per_row,
height: size.height,
});
}
#[derive(RenderLabel, Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct DepthReadbackLabel;
/// Copies the depth texture aside **right after the opaque pass** — the moment the question is about.
///
/// Placement is the whole correctness of this probe, and getting it wrong is quiet. Read at the end of
/// the main pass instead and the numbers come back 5–8 yd at pixels where the ray cast finds the tower
/// at 32–43 yd, with a distance even at pixels that have **no** geometry along the ray: by then the
/// *transparent* pass has written depth of its own, from something that tracks the camera. Those were
/// stable, plausible, reproducible numbers about the wrong thing — which is worse than no probe,
/// because it looks like a measurement.
///
/// `MainOpaquePass` draws `Opaque3d` and then `AlphaMask3d`, which is exactly the pair the B38 fight is
/// between, so this is the depth that decided it.
#[derive(Default)]
struct DepthReadbackNode;
impl ViewNode for DepthReadbackNode {
type ViewQuery = (&'static ViewDepthTexture, &'static DepthProbeView);
fn run(
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext,
(depth, _): QueryItem<Self::ViewQuery>,
world: &World,
) -> Result<(), NodeRunError> {
let (Some(watch), Some(staging)) = (
world.get_resource::<DepthWatch>(),
world.get_resource::<DepthStaging>(),
) else {
return Ok(());
};
// `read_depth` counts the frames; the copy is cheap but not free, so stop with it.
if !watch.armed || world.resource::<DepthFramesRead>().0 >= watch.count {
return Ok(());
}
let size = depth.texture.size();
// `COPY_SRC` only lands on the texture allocated *after* `arm` patched the camera, so the
// first armed frame still has the old one. Skip it rather than trip wgpu validation.
if !depth.texture.usage().contains(TextureUsages::COPY_SRC) {
return Ok(());
}
// Depth-stencil formats reject partial copies (wgpu-core `validate_texture_copy_range`), so
// the whole texture goes across even though we want a handful of pixels. At one debug frame
// each, the simple thing and the correct thing are the same.
render_context.command_encoder().copy_texture_to_buffer(
TexelCopyTextureInfo {
texture: &depth.texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::DepthOnly,
},
TexelCopyBufferInfo {
buffer: &staging.buffer,
layout: TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(staging.bytes_per_row),
rows_per_image: Some(size.height),
},
},
Extent3d {
width: size.width,
height: size.height,
depth_or_array_layers: 1,
},
);
Ok(())
}
}
/// How many frames [`read_depth`] has reported — read by the graph node, which cannot hold a `Local`.
#[derive(Resource, Default)]
struct DepthFramesRead(u32);
/// Map back what the node copied and log the named pixels.
fn read_depth(
watch: Option<Res<DepthWatch>>,
view: Query<&ExtractedView, With<DepthProbeView>>,
device: Res<RenderDevice>,
staging: Option<Res<DepthStaging>>,
depth: Query<&ViewDepthTexture, With<DepthProbeView>>,
mut read: ResMut<DepthFramesRead>,
) {
let (Some(watch), Some(staging)) = (watch, staging) else {
return;
};
if !watch.armed || read.0 >= watch.count {
return;
}
let (Ok(view), Ok(depth)) = (view.single(), depth.single()) else {
return;
};
// Mirrors the node's own skip: no copy was encoded this frame, so there is nothing to map.
if !depth.texture.usage().contains(TextureUsages::COPY_SRC) {
return;
}
let size = depth.texture.size();
let slice = staging.buffer.slice(..);
slice.map_async(MapMode::Read, |_| {});
// Block. A probe run is not gameplay, and a frame's numbers are worth nothing if they arrive
// attached to a later frame's index.
if let Err(e) = device.poll(bevy::render::render_resource::PollType::wait_indefinitely()) {
error!("depth: poll failed: {e}");
return;
}
let frame = read.0;
read.0 += 1;
// The projection the frame was actually drawn with, once per burst. Stated rather than assumed:
// the reported distances are only as good as this matrix, and an instrument whose calibration is
// invisible is one whose numbers cannot be audited later.
if frame == 0 {
info!(
"depth: {}x{} view, clip_from_view P₂₂ {} P₃₂ {} P₀₀ {} P₁₁ {}",
size.width,
size.height,
view.clip_from_view.z_axis.z,
view.clip_from_view.w_axis.z,
view.clip_from_view.x_axis.x,
view.clip_from_view.y_axis.y,
);
}
let view_from_clip = view.clip_from_view.inverse();
{
let data = slice.get_mapped_range();
for &(x, y) in &watch.pixels {
if x >= size.width || y >= size.height {
warn!(
"depth#{frame} ({x}, {y}): outside the {}x{} view",
size.width, size.height
);
continue;
}
let at = (y * staging.bytes_per_row + x * 4) as usize;
let d = f32::from_le_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]);
match view_point(&view_from_clip, ndc_of(x, y, size.width, size.height), d) {
// Both distances, because they are not the same number and the difference is 15% at
// the edge of the frame: `along` is measured along the ray (what `WOW_PICK` reports,
// so this is the one to compare), `z` is the perpendicular distance to the camera
// plane (what the depth buffer natively encodes).
Some(p) => info!(
"depth#{frame} ({x}, {y}): {d:.9} = {:.4} yd along the ray ({:.4} yd view z)",
p.length(),
-p.z
),
// Reverse-Z clears to 0 = infinitely far: nothing drew here at all.
None => info!("depth#{frame} ({x}, {y}): {d:.9} = nothing drew (cleared)"),
}
}
}
staging.buffer.unmap();
}
/// A physical pixel's centre in NDC. Framebuffer rows run down, NDC y runs up.
fn ndc_of(x: u32, y: u32, width: u32, height: u32) -> Vec2 {
Vec2::new(
(x as f32 + 0.5) / width as f32 * 2.0 - 1.0,
1.0 - (y as f32 + 0.5) / height as f32 * 2.0,
)
}
/// Unproject a pixel's depth back to where it is in **view space**, in yards.
///
/// It has to be the whole point, not just the depth: a depth value alone linearises to the distance
/// to the camera *plane*, while a ray cast measures along the *ray*, and off-axis those differ by
/// `1/cos θ` — 15% at the edge of a 45° frame, six yards at this bug's range. Comparing the two
/// without converting is the same axis mix-up that mis-measured B38's surface gap twice (0665), and
/// it is 100× the gap the readback exists to resolve. So unproject the actual pixel and hand back the
/// point; the caller reports both lengths.
///
/// Going through the inverse of the live matrix, rather than a reverse-Z formula, means the reading
/// cannot silently disagree with the projection the frame was drawn with.
fn view_point(view_from_clip: &Mat4, ndc: Vec2, d: f32) -> Option<Vec3> {
let p = *view_from_clip * Vec4::new(ndc.x, ndc.y, d, 1.0);
// Behind the camera or at infinity (a reverse-Z clear reads 0 ⇒ w 0) means nothing drew here.
(p.w.abs() > f32::MIN_POSITIVE)
.then(|| p.truncate() / p.w)
.filter(|v| v.is_finite() && v.z < 0.0)
}
/// `"x,y;x,y"` → pixels. Malformed pairs are dropped with a warning rather than failing the run:
/// a typo in one coordinate of a list of eight should not cost a capture.
fn parse_pixels(spec: &str) -> Vec<(u32, u32)> {
spec.split(';')
.filter(|s| !s.trim().is_empty())
.filter_map(|pair| {
let (x, y) = pair.split_once(',')?;
match (x.trim().parse().ok(), y.trim().parse().ok()) {
(Some(x), Some(y)) => Some((x, y)),
_ => {
warn!("depth: skipping malformed pixel {pair:?}");
None
}
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_pixel_list_and_skips_junk() {
assert_eq!(
parse_pixels("412,396; 565,445 ;;nope;1,"),
vec![(412, 396), (565, 445)]
);
}
/// The projection the world camera actually draws with: Bevy's reverse-Z, infinite far.
fn proj() -> Mat4 {
Mat4::perspective_infinite_reverse_rh(
std::f32::consts::FRAC_PI_4,
3200.0 / 1800.0,
crate::player::CAM_NEAR,
)
}
/// What the rasteriser writes for a point `dist` yards straight ahead down the view axis.
fn depth_of(dist: f32) -> f32 {
let clip = proj() * Vec4::new(0.0, 0.0, -dist, 1.0);
clip.z / clip.w
}
#[test]
fn a_depth_unprojects_to_the_distance_it_came_from() {
let inv = proj().inverse();
for dist in [2.0f32, 22.0, 46.0253, 46.0897, 3000.0] {
let p =
view_point(&inv, Vec2::ZERO, depth_of(dist)).expect("a drawn pixel has a point");
assert!(
(p.length() - dist).abs() < dist * 1e-3,
"{dist} yd -> depth {} -> {} yd",
depth_of(dist),
p.length()
);
}
}
#[test]
fn off_axis_the_ray_is_longer_than_the_perpendicular_distance() {
// The bug this test exists for: reporting view-space z as if it were the ray-cast distance.
// Straight ahead the two agree; at the frame edge they must not.
let inv = proj().inverse();
let d = depth_of(46.0);
let centre = view_point(&inv, Vec2::ZERO, d).unwrap();
assert!(
(centre.length() - (-centre.z)).abs() < 1e-3,
"on-axis they agree"
);
let edge = view_point(&inv, Vec2::new(-0.78, 0.42), d).unwrap();
assert!(
(-edge.z - 46.0).abs() < 0.05,
"the perpendicular distance is what depth encodes: {} yd",
-edge.z
);
assert!(
edge.length() > 46.0 * 1.1,
"off-axis the ray must be materially longer, got {} yd",
edge.length()
);
}
#[test]
fn a_cleared_pixel_has_no_position() {
// Reverse-Z clears to 0.0: infinitely far, i.e. nothing drew.
assert_eq!(view_point(&proj().inverse(), Vec2::ZERO, 0.0), None);
}
#[test]
fn the_awning_and_the_plank_are_thousands_of_ulps_apart() {
// The whole instrument rests on this: at the B38 pin the two surfaces are 1.4 cm apart
// perpendicular, and a readback can only name the winner if that gap survives `f32`.
// If this ever fails, the probe cannot answer the question it exists for.
let (awning, plank) = (46.0253f32, 46.0897f32);
let (da, dp) = (depth_of(awning), depth_of(plank));
let ulps = ((da.to_bits() as i64) - (dp.to_bits() as i64)).abs();
assert!(
ulps > 1000,
"only {ulps} ULPs apart — a readback could not tell them apart"
);
// And they must not round to the same reported distance either.
let inv = proj().inverse();
let (ba, bp) = (
view_point(&inv, Vec2::ZERO, da).unwrap().length(),
view_point(&inv, Vec2::ZERO, dp).unwrap().length(),
);
assert!(
(ba - bp).abs() > 0.01,
"{ba} yd vs {bp} yd is not a distinguishable pair"
);
}
}
+3
View File
@@ -98,6 +98,7 @@ pub(super) fn seed_ui_fixture(
ammo_type: 0,
ranged_mod_range: 0.0,
spells: Vec::new(),
spell_charges_0: 0,
use_spell: None,
bonding: 0,
description: String::new(),
@@ -1049,6 +1050,7 @@ fn seed_bag_window(
let slot = |disp: u32, count: u32, id: u32, name: &str, quality: u32| {
benilla_ui::script::ContainerSlot {
durability: None,
bar_placeable: true,
texture: icon(disp),
count,
quality: Some(quality),
@@ -1122,6 +1124,7 @@ fn seed_equipped_bags(
let slot =
|disp: u32, count: u32, name: &str, quality: u32| benilla_ui::script::ContainerSlot {
durability: None,
bar_placeable: true,
texture: icon(disp),
count,
quality: Some(quality),
+4
View File
@@ -43,7 +43,9 @@ use crate::schedule::WorldStage;
// The UI fixture seeding (the synthetic window states), the scenario table, and the live-run
// probe instruments (`probes`) each live in their own file — the server-less harness (settle,
// screenshot, perf probe) is this one's concern.
mod depth_probe;
mod fixtures;
mod phase_probe;
mod pick_probe;
mod probe_bank;
mod probe_castcancel;
@@ -56,7 +58,9 @@ mod probe_rig;
mod probe_taxi;
mod probes;
mod scenarios;
pub(crate) use depth_probe::DepthProbePlugin;
use fixtures::seed_ui_fixture;
pub(crate) use phase_probe::PhaseProbePlugin;
pub(crate) use pick_probe::PickProbePlugin;
pub(crate) use probe_bank::ProbeBankPlugin;
pub(crate) use probe_castcancel::ProbeCastCancelPlugin;
+257
View File
@@ -0,0 +1,257 @@
//! `WOW_PHASE` — **what did the renderer actually do with this batch, frame by frame?**
//!
//! Every other flicker instrument we have reads the *scene*: `benilla-visual` says which pixels would
//! not hold still, `WOW_PICK` says what geometry stands at them and whether it is visible. B38
//! exhausted that layer. There, the awning is nearer than the plank behind it, writes depth, compares
//! `GreaterEqual`, is not discarded and is not culled, and keeps the same mesh, material and texture
//! on every single frame — and on half the frames the plank behind it wins the pixel anyway
//! (decisions 0662, 0665). Under those facts that is impossible, so one of them stops being true
//! somewhere *after* the scene and before the draw, and nothing we had could see into that gap.
//!
//! So: `WOW_PHASE=<uniqueId>` watches every model batch of one placed object and reports, per frame,
//! which render phase each batch landed in and **where in the draw order** it sits. Absent from every
//! phase means it was never submitted, which no amount of looking at pixels can distinguish from
//! losing a depth test. Present, but at a different position than last frame, means the draw order
//! moved — and for two surfaces that tie, draw order *is* the result.
//!
//! `WOW_PHASE_AT=<secs>` (default 20) / `WOW_PHASE_COUNT=<n>` (default 1) shape the sampling the same
//! way the screenshot burst and the ray pick do, so a phase log and a frame stack line up frame for
//! frame and "was it drawn?" can be read against "was this frame dim or bright?".
//!
//! Only the **main** 3D view's phases are read. Shadow and prepass views bin the same entities and
//! would triple every line for nothing; a defect in *those* would show up as the wrong shadow, which
//! is a different report.
use bevy::core_pipeline::core_3d::{AlphaMask3d, Opaque3d, Transparent3d};
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::render_phase::{ViewBinnedRenderPhases, ViewSortedRenderPhases};
use bevy::render::sync_world::MainEntity;
use bevy::render::{Render, RenderApp, RenderSystems};
use crate::interact::WorldObject;
use crate::terrain::WowModelMaterial;
pub(crate) struct PhaseProbePlugin;
impl Plugin for PhaseProbePlugin {
fn build(&self, app: &mut App) {
let Some(object) = std::env::var("WOW_PHASE")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
else {
warn!("phase: WOW_PHASE wants a placement uniqueId (e.g. 235256) — inert");
return;
};
let at = std::env::var("WOW_PHASE_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20.0);
let count = std::env::var("WOW_PHASE_COUNT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1u32)
.max(1);
app.insert_resource(PhaseWatch {
object,
at,
count,
batches: Vec::new(),
armed: false,
})
.add_systems(Update, collect_batches)
.add_plugins(ExtractResourcePlugin::<PhaseWatch>::default());
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
warn!("phase: no render app — inert");
return;
};
// After `PhaseSort`: binning and sorting are both settled, so membership and order are final.
render_app.add_systems(Render, report_phases.after(RenderSystems::PhaseSort));
}
}
/// The watch list, and the sampling window. Lives in the main world; the render half reads it through
/// the extract boundary as a plain clone (it is a handful of entities once per sampled frame, so the
/// cheap thing and the correct thing are the same).
#[derive(Resource, Clone)]
struct PhaseWatch {
/// The placement `uniqueId` whose batches to follow.
object: u32,
at: f32,
count: u32,
/// The watched batches: main-world entity, its WMO batch order, and how it draws.
batches: Vec<(Entity, i32, String)>,
/// Have we found the object and started sampling?
armed: bool,
}
impl ExtractResource for PhaseWatch {
type Source = PhaseWatch;
fn extract_resource(source: &Self::Source) -> Self {
source.clone()
}
}
/// Find the object's batch entities once it has streamed in. Re-run until it is found, then hold:
/// the set is per-placement and does not change, and re-collecting every frame would make the
/// watch list itself a moving part of the measurement.
fn collect_batches(
mut watch: ResMut<PhaseWatch>,
time: Res<Time>,
// `WorldObject` rides on **each submesh entity**, not on a parent that holds them as children —
// the same shape `WOW_PICK` relies on, where a single ray hit carries both its object identity
// and its batch. So the watch list is a filter over those entities, not a child walk.
parts: Query<(
Entity,
&WorldObject,
&crate::debug_panel::ModelPart,
&MeshMaterial3d<WowModelMaterial>,
)>,
materials: Res<Assets<WowModelMaterial>>,
) {
if watch.armed || time.elapsed_secs() < watch.at {
return;
}
let want = watch.object;
let mut found: Vec<(Entity, i32, String)> = parts
.iter()
.filter(|(_, obj, _, _)| obj.id == want)
.map(|(e, _, part, mat)| {
// The WMO authored batch order rides in the material's `sun_scale.y` (`model_render`),
// which is also how `WOW_PICK` names a batch — so a phase line and a pick line describe
// the same surface by the same number.
let order = materials
.get(&mat.0)
.map_or(-1, |m| m.extension.sun_scale.y as i32);
(e, order, format!("{:?}", part.blend))
})
.collect();
if found.is_empty() {
return;
}
// Sort by batch order so the report reads in the WMO's authored order, not spawn order.
found.sort_by_key(|&(_, order, _)| order);
info!(
"phase: watching {} batches of #{want} for {} frames",
found.len(),
watch.count
);
watch.batches = found;
watch.armed = true;
}
/// Where each watched batch sits in each phase, this frame.
fn report_phases(
watch: Option<Res<PhaseWatch>>,
opaque: Res<ViewBinnedRenderPhases<Opaque3d>>,
alpha_mask: Res<ViewBinnedRenderPhases<AlphaMask3d>>,
transparent: Res<ViewSortedRenderPhases<Transparent3d>>,
mut seen: Local<u32>,
) {
let Some(watch) = watch else { return };
if !watch.armed || *seen >= watch.count {
return;
}
let frame = *seen;
*seen += 1;
// The whole-frame census, before the per-batch lines. Every instrument we have is scoped to the
// thing we already suspect — this object's batches, `WorldObject`-tagged ray hits, the depth
// between the opaque and transmissive passes — so a draw that is *none of those* is invisible to
// all of them at once. An alpha-blended sprite over the awning (a particle, a glow card) would
// halve the pixel while leaving depth, material, tag, draw order and the light buffer exactly as
// measured, which is precisely the contradiction B38 now sits in. A transparent count that moves
// between a bright and a dim frame names that draw; one that does not, rules out the whole family.
let transparent_total: usize = transparent.values().map(|p| p.items.len()).sum();
info!(
"phase#{frame} CENSUS opaque {} alphamask {} transparent {}",
binned_total(&opaque),
binned_total(&alpha_mask),
transparent_total,
);
for &(entity, submesh, ref blend) in &watch.batches {
let main = MainEntity::from(entity);
let mut found = Vec::new();
// The main view is whichever phase map holds the most entities: shadow and prepass views bin
// subsets, so the largest is the one that draws the frame. Picking by view entity would tie
// this to the camera's identity in the render world, which is not stable to read from here.
if let Some(pos) = binned_position::<Opaque3d>(&opaque, main) {
found.push(format!("Opaque3d @{pos}"));
}
if let Some(pos) = binned_position::<AlphaMask3d>(&alpha_mask, main) {
found.push(format!("AlphaMask3d @{pos}"));
}
for phase in transparent.values() {
if let Some(pos) = phase.items.iter().position(|item| item.entity.1 == main) {
found.push(format!("Transparent3d @{pos}"));
}
}
// "NOT SUBMITTED" is the whole point of the instrument: a batch that reached no phase was
// never drawn, and that is indistinguishable in the pixels from one that drew and lost.
// The entity, because **batch order does not identify a surface**: a WMO placement is many
// groups and the order is per-group, so one placement has several batches sharing an order
// (79 entities over 19 orders on the Far Watch Post tower). Keying a report by order alone
// silently merges them and can hide the one batch that moved.
info!(
"phase#{frame} {entity} batch order {submesh:3} {blend:10} -> {}",
if found.is_empty() {
"NOT SUBMITTED".to_string()
} else {
found.join(", ")
}
);
}
}
/// The entity's ordinal within the largest binned phase for `BPI`, walking bins in iteration order —
/// which is the order the pass draws them, so the ordinal is a draw position and not just a yes/no.
fn binned_position<BPI>(phases: &ViewBinnedRenderPhases<BPI>, main: MainEntity) -> Option<usize>
where
BPI: bevy::render::render_phase::BinnedPhaseItem,
{
let phase = phases.values().max_by_key(|p| {
p.multidrawable_meshes
.values()
.map(|bins| bins.values().map(|b| b.entities().len()).sum::<usize>())
.sum::<usize>()
+ p.batchable_meshes
.values()
.map(|b| b.entities().len())
.sum::<usize>()
})?;
let mut n = 0usize;
for bins in phase.multidrawable_meshes.values() {
for bin in bins.values() {
if let Some(i) = bin.entities().get_index_of(&main) {
return Some(n + i);
}
n += bin.entities().len();
}
}
for bin in phase.batchable_meshes.values() {
if let Some(i) = bin.entities().get_index_of(&main) {
return Some(n + i);
}
n += bin.entities().len();
}
None
}
/// How many entities the main binned phase for `BPI` holds this frame — the census counterpart of
/// [`binned_position`], picking the same "largest phase map is the main view" way so the two numbers
/// describe the same view.
fn binned_total<BPI>(phases: &ViewBinnedRenderPhases<BPI>) -> usize
where
BPI: bevy::render::render_phase::BinnedPhaseItem,
{
let count = |p: &bevy::render::render_phase::BinnedRenderPhase<BPI>| {
p.multidrawable_meshes
.values()
.map(|bins| bins.values().map(|b| b.entities().len()).sum::<usize>())
.sum::<usize>()
+ p.batchable_meshes
.values()
.map(|b| b.entities().len())
.sum::<usize>()
};
phases.values().map(count).max().unwrap_or(0)
}
+124 -16
View File
@@ -15,15 +15,28 @@
//! invisible to a single cast and to the pixels alike: both look like the surface went away.
//!
//! Reporting the whole ray is the point. A surface that swaps with another between frames has a
//! rival *behind* it at nearly the same depth, and the gap between hit 0 and hit 1 is the number
//! that decides the diagnosis: an exact tie is a coplanar authoring tie (no depth precision can
//! break it — only a deterministic order or a bias), while a few millimetres is a precision
//! question. The nearest hit alone cannot tell those apart, and which one it is decides the fix.
//! rival *behind* it, and the gap between hit 0 and hit 1 is the number that decides the diagnosis:
//! an exact tie is a coplanar authoring tie (no depth precision can break it — only a deterministic
//! order or a bias), while a few millimetres is a precision question. The nearest hit alone cannot
//! tell those apart, and which one it is decides the fix.
//!
//! **The gap that decides it is the PERPENDICULAR one, not the distance along the ray** — reading
//! the along-ray gap as the separation is how B38 was mis-diagnosed twice. Where the ray meets a
//! surface obliquely it travels a long way between two planes that are barely apart; at the awning
//! there, hit 0 and hit 1 are 1–2 yd apart *along the ray* and 3–45 cm apart perpendicular. Both
//! are reported, along with each hit's **incidence angle**, because a surface nearly edge-on to the
//! ray is lost or won by sub-pixel coverage rather than by depth, and that is a different defect
//! wearing the same appearance.
//!
//! Each hit also names its **material and bound texture**, so "the same batch drew something else
//! this frame" is visible at all — it is indistinguishable from a depth fight in the pixels, and
//! ruling it out is what leaves the depth story standing.
//!
//! Coordinates are **screenshot pixels** — the same space `benilla-visual` reports boxes in — so a
//! hotspot box can be pasted straight in. They are divided by the window's scale factor here, since
//! `viewport_to_world` works in logical units and a Retina capture is 2× the logical window.
use bevy::mesh::MeshTag;
use bevy::picking::mesh_picking::ray_cast::{MeshRayCast, MeshRayCastSettings, RayCastVisibility};
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
@@ -90,14 +103,61 @@ fn parse_pixels(spec: &str) -> Vec<Vec2> {
.collect()
}
/// What a hit entity is asked for: its identity, the batch class it draws as, and the material
/// carrying the WMO batch order (`Option` because a hit need not be a model batch at all).
/// What a hit entity is asked for: its identity, the batch class it draws as, the material
/// carrying the WMO batch order, and its per-instance `MeshTag` (`Option` because a hit need not be a
/// model batch at all).
type HitIdentity = (
&'static WorldObject,
Option<&'static crate::debug_panel::ModelPart>,
Option<&'static MeshMaterial3d<WowModelMaterial>>,
Option<&'static MeshTag>,
);
/// Every shading input the batch actually has, as text — the five packed uniform rows and the base
/// colour, printed per cast so a value that MOVES inside one material is visible at all.
///
/// This exists because comparing material **handles** across frames — the check that "eliminated" a
/// distance-fade material swap for B38 (0665) — is blind to exactly this: the handle is stable while
/// the contents move, and three of these are re-sampled per frame *by design* (`sun_scale.zw`'s UV
/// animation, the tint lane's per-instance clone, the fade twin's alpha). `tint.w` is the WMO
/// interior batch-class lane (0 exterior / 1 interior-unlit / 2 the MOCV lerp) — a term that
/// re-lights every surface of one building together, which is B38's whole footprint.
fn shading_of(mat: Option<&WowModelMaterial>) -> String {
let Some(m) = mat else {
return "<no WowModelMaterial>".to_string();
};
let e = &m.extension;
let c = m.base.base_color.to_srgba();
format!(
"wmo {:.0} fade {:.0} class {:.1} tint {:.3},{:.3},{:.3} \
sidn {:.3},{:.3},{:.3} win {:.0} sunsel {:.2} bias {:.0} uv {:.4},{:.4} \
clutter {:.1},{:.1},{:.1},{:.1} base {:.3},{:.3},{:.3},{:.3} {:?}",
e.model_flags.x,
e.model_flags.y,
e.tint.w,
e.tint.x,
e.tint.y,
e.tint.z,
e.sidn.x,
e.sidn.y,
e.sidn.z,
e.sidn.w,
e.sun_scale.x,
e.sun_scale.y,
e.sun_scale.z,
e.sun_scale.w,
e.clutter_fade.x,
e.clutter_fade.y,
e.clutter_fade.z,
e.clutter_fade.w,
c.red,
c.green,
c.blue,
c.alpha,
m.base.alpha_mode,
)
}
/// Everything needed to turn a hit entity into a line of text — bundled so the system keeps one
/// parameter for "describe this hit" rather than two that must always travel together.
#[derive(bevy::ecs::system::SystemParam)]
@@ -141,38 +201,86 @@ fn fire_pick(
.with_filter(&filter)
.never_early_exit();
let hits = ray_cast.cast_ray(ray, &settings);
// The camera's GLOBAL transform, bit-exact, on the line that carries the cast index — so
// "did the camera move on this frame?" is answerable against "was this frame dim?" without
// aligning two logs by hand. It is deliberately *this* transform: it is the one
// `viewport_to_world` just used to build the ray, so a converging hit distance can be
// attributed to the camera or to the geometry rather than left ambiguous. A dump inside
// `seat_camera` cannot do that job — it prints the local `Transform` before propagation, and
// it has no frame index, which is how it got read against the wrong 75 frames once already.
let eye = cam_tf.translation();
info!(
"pick#{cast} ({}, {}) [logical {:.1}, {:.1}]: {} hits",
"pick#{cast} ({}, {}) [logical {:.1}, {:.1}]: {} hits eye [{:08x},{:08x},{:08x}] {:.6?}",
pixel.x,
pixel.y,
logical.x,
logical.y,
hits.len()
hits.len(),
eye.x.to_bits(),
eye.y.to_bits(),
eye.z.to_bits(),
eye,
);
let mut previous: Option<f32> = None;
// Distance ALONG THE RAY is not the gap that decides a depth fight, and reading it as if it
// were is how B38 got called "not a depth fight" (0662). At a grazing angle the ray travels
// nearly parallel to both surfaces, so a yard of ray can separate two planes that are a hair
// apart — and near a line where two planes INTERSECT the perpendicular gap goes to zero
// while the along-ray gap stays large. So report the perpendicular distance from this hit's
// point to the previous hit's plane as well: that is the number a depth buffer sees.
let mut previous: Option<(f32, Vec3, Vec3)> = None;
for (i, (entity, hit)) in hits.iter().enumerate() {
let gap = previous.map_or(String::new(), |p: f32| {
format!(" (+{:.5} yd behind the last)", hit.distance - p)
let gap = previous.map_or(String::new(), |(d, point, normal): (f32, Vec3, Vec3)| {
let perp = (hit.point - point).dot(normal).abs();
format!(
" (+{:.5} yd along the ray, but {:.5} yd PERPENDICULAR to the last)",
hit.distance - d,
perp,
)
});
previous = Some(hit.distance);
let Ok((obj, part, mat)) = names.identity.get(*entity) else {
previous = Some((hit.distance, hit.point, hit.normal));
let Ok((obj, part, mat, tag)) = names.identity.get(*entity) else {
info!(" {i:2} {:9.4} yd <untagged>{gap}", hit.distance);
continue;
};
// The WMO authored batch order rides in the material's `sun_scale.y` (see
// `model_render`): 0 means "no bias applied", which for a WMO batch is itself a finding.
let batch = mat
.and_then(|m| names.materials.get(&m.0))
let resolved = mat.and_then(|m| names.materials.get(&m.0));
let batch = resolved
.map(|m| m.extension.sun_scale.y as i32)
.unwrap_or(-1);
// The material + texture the hit actually draws with, per cast. A surface that swaps
// appearance while its GEOMETRY stays put (identical hits, identical order) is either
// being re-lit or bound to something else; only naming the bound texture each frame
// tells those apart, and "the same batch drew a different texture this frame" is
// otherwise completely invisible from the pixels.
let tex = resolved
.and_then(|m| m.base.base_color_texture.as_ref())
.map_or("-".to_string(), |h| format!("{:?}", h.id()));
// How edge-on is this surface to the ray? 90° is face-on; near 0° the triangle is nearly
// parallel to the view and covers a pixel by a hair, so which of it and whatever lies
// behind wins a pixel is decided by sub-pixel coverage rather than by depth — a
// completely different defect from a depth fight, and indistinguishable in the pixels.
let incidence = ray.direction.dot(hit.normal).abs().clamp(0.0, 1.0).asin();
info!(
" {i:2} {:9.4} yd {:?} #{:<10} bias {batch:3} {:?} {}{gap}",
" {i:2} {:9.4} yd {:?} #{:<10} bias {batch:3} {:?} {:5.1}° to the ray mat {:?} tex {tex} {}{gap}",
hit.distance,
obj.kind,
obj.id,
part.map(|p| p.blend),
incidence.to_degrees(),
mat.map(|m| m.0.id()),
obj.label,
);
// The contents, not the identity — the whole point (see [`shading_of`]). Printed for
// every hit on every cast: with `WOW_PICK_EVERY=0` that is a per-frame record of every
// shading input this batch has, which is the only way a term moving *inside* one
// material is visible. The per-instance tag rides the same line because bits 16..=29
// mean different things under the two material laws, so the pair must be read together.
info!(
" {} tag {}",
shading_of(resolved),
tag.map_or_else(|| "<none>".to_string(), |t| crate::mesh_tag::describe(t.0)),
);
}
}
}
+37 -1
View File
@@ -9,6 +9,7 @@ use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
use super::PROBE_WARMUP_FRAMES;
use crate::net::{ObjectStore, SelfPlayer};
/// The LIVE probe shot (`WOW_LIVE_SHOT=<png>`, delay via `WOW_LIVE_SHOT_AT` seconds, default 12):
/// one screenshot of the primary window on a NORMAL connected run — the "what does the live scene
@@ -83,10 +84,45 @@ fn burst_path(out: &str, index: u32, count: u32) -> String {
/// Fire the live screenshot(s) once the startup delay has elapsed — one per frame by default, so a
/// burst samples *adjacent* frames and a per-frame instability has nowhere to hide.
fn fire_live_shot(mut shot: ResMut<LiveShot>, time: Res<Time>, mut commands: Commands) {
///
/// **Refuses to fire while the avatar is dead or a ghost.** `preflight` already reports it, but a
/// single WARN in a four-hundred-line log is not a gate: a ghost renders the whole world through the
/// death filter, so every image in the burst is a desaturated wash and *every* measurement correlated
/// against it — pixel series, hotspot runs, depth readback, the phase census — silently describes the
/// filter instead of the scene. That cost a whole B38 session: eight bursts and three retracted
/// mechanisms measured on a corpse, because the images still looked like a plausible frame. A loud
/// no-op is worth far more than a burst that has to be recognised as garbage after the fact.
fn fire_live_shot(
mut shot: ResMut<LiveShot>,
time: Res<Time>,
mut commands: Commands,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut refused: Local<bool>,
) {
if shot.taken >= shot.count || time.elapsed_secs() < shot.next_at {
return;
}
if let Ok(store) = self_q.single() {
let what = if store.0.unit_is_dead() {
Some("DEAD")
} else if store.0.player_is_ghost() {
Some("A GHOST")
} else {
None
};
if let Some(what) = what {
if !*refused {
*refused = true;
error!(
"live-shot: REFUSING to capture — the character is {what}, so the world renders \
through the death filter and every pixel of this burst would describe that \
filter, not the scene. Send WOW_PROBE_CHAT=\".revive\" (probe accounts are \
gmlevel 6) and run again."
);
}
return;
}
}
let path = burst_path(&shot.out, shot.taken, shot.count);
commands
.spawn(Screenshot::primary_window())
+1
View File
@@ -217,6 +217,7 @@ pub(crate) fn test_template(name: &str) -> ItemInfo {
ammo_type: 0,
ranged_mod_range: 0.0,
spells: Vec::new(),
spell_charges_0: 0,
use_spell: None,
bonding: 0,
description: String::new(),
+47 -2
View File
@@ -240,6 +240,7 @@ fn build_light_data(
mut data: ResMut<WowLightData>,
time: Res<Time>,
mut last_dump: Local<f64>,
mut last_rows_dump: Local<f64>,
) {
let l = &*light;
let fog_enable = if debug.lighting.disable_fog { 0.0 } else { 1.0 };
@@ -308,9 +309,16 @@ fn build_light_data(
// measurable causes (a duplicate light stacking, a light at the wrong height, an over-driven
// colour, a count that shouldn't be there), and every one of them is a number here. Throttled,
// and capped at the nearest 8 so a torch-lit town doesn't flood the log.
if std::env::var_os("WOW_POINTS_DUMP").is_some() {
//
// `WOW_POINTS_DUMP=frame` drops the throttle. A once-a-second dump can only answer "is the pool
// right?", never "is it the *same* pool it was last frame?" — and B38's flicker turned out to
// alternate frame to frame, which a 1 Hz sample cannot see at all. Reading a per-second dump as
// evidence of per-frame stability is how that light was cleared once already (0665's parked
// culling test made the same mistake with a different instrument).
if let Some(mode) = std::env::var_os("WOW_POINTS_DUMP") {
let every = if mode == *"frame" { 0.0 } else { 1.0 };
let now = time.elapsed_secs_f64();
if now - *last_dump >= 1.0 {
if now - *last_dump >= every {
*last_dump = now;
// How contested the three slots are for the chunk under the camera — the number that
// decides whether ground pops as emitters move. Candidacy is the faithful Chebyshev
@@ -344,6 +352,43 @@ fn build_light_data(
}
}
}
// `WOW_LIGHT_DUMP=frame` (or `=1` for 1 Hz): the WHOLE packed header, bit-exact, per frame.
//
// The point of dumping every row rather than the interesting ones is that B38 has now eliminated
// every *per-material* and *per-instance* shading input by measurement — they are bit-identical
// on bright and dim frames alike — which leaves this buffer and the view as the only things that
// can still be moving. A dump of selected rows would answer "did ambient move?"; only the full
// set answers "did ANY shading input move?", and that is the question worth a run. Rows are
// printed as raw f32 bits, so a change far below a printed decimal cannot hide.
if let Some(mode) = std::env::var_os("WOW_LIGHT_DUMP") {
let every = if mode == *"frame" { 0.0 } else { 1.0 };
let now = time.elapsed_secs_f64();
if now - *last_rows_dump >= every {
*last_rows_dump = now;
let hash = data
.0
.rows
.iter()
.flatten()
.fold(0xcbf2_9ce4_8422_2325u64, |h, v| {
(h ^ u64::from(v.to_bits())).wrapping_mul(0x1000_0000_01b3)
});
eprintln!("[light] rows {hash:#018x}");
for (i, r) in data.0.rows.iter().enumerate() {
eprintln!(
" {i:2} {:08x} {:08x} {:08x} {:08x} {:9.5} {:9.5} {:9.5} {:9.5}",
r[0].to_bits(),
r[1].to_bits(),
r[2].to_bits(),
r[3].to_bits(),
r[0],
r[1],
r[2],
r[3],
);
}
}
}
}
/// Render-world: write the packed light into the shared buffer in place, before any draw reads it
+11 -4
View File
@@ -391,10 +391,17 @@ pub(super) fn update_time_lighting(
}
// `WOW_FOG_DUMP=1`: the interior-fog crossfade live probe, 1 Hz — the committed pair + the
// ramp t (the 4 s in/out is too fast for the per-game-minute light dump to catch).
if std::env::var_os("WOW_FOG_DUMP").is_some() {
let sec = time.elapsed_secs() as u32;
if *last_fog_dump != Some(sec) {
*last_fog_dump = Some(sec);
//
// `WOW_FOG_DUMP=frame` drops the throttle. 1 Hz is fast enough to watch a 4 s ramp travel and far
// too slow to see the **target** flip: `blend` switches hard between the scene triple and the
// staged interior one on `staged.is_some()`, so a target that alternates frame to frame alternates
// the committed fog frame to frame, and a once-a-second sample simply lands on whichever side it
// lands on. Rows 18/19 are read by WMO materials *alone*, which is exactly the footprint B38 shows
// (decision 0670: one WMO re-lit, terrain and sky untouched).
if let Some(mode) = std::env::var_os("WOW_FOG_DUMP") {
let sec = (mode != *"frame").then(|| time.elapsed_secs() as u32);
if sec.is_none() || *last_fog_dump != sec {
*last_fog_dump = sec;
let b = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as i32);
eprintln!(
"[fog] t {:.2} target {:?} scene {:?} {:.0}/{:.0} interior {:?} {:.0}/{:.0}",
+32
View File
@@ -96,6 +96,7 @@ mod ui_gossip;
mod ui_inspect;
mod ui_item_text;
mod ui_items;
mod ui_logout;
mod ui_loot;
mod ui_loot_roll;
mod ui_mail;
@@ -108,6 +109,7 @@ mod ui_quest_log;
mod ui_script;
mod ui_session;
mod ui_shapeshift;
mod ui_social;
mod ui_spellbook;
mod ui_talent;
mod ui_taxi;
@@ -172,6 +174,7 @@ use ui_duel::UiDuelPlugin;
use ui_gossip::UiGossipPlugin;
use ui_item_text::UiItemTextPlugin;
use ui_items::UiItemsPlugin;
use ui_logout::UiLogoutPlugin;
use ui_loot::UiLootPlugin;
use ui_loot_roll::UiLootRollPlugin;
use ui_mail::UiMailPlugin;
@@ -183,6 +186,7 @@ use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
use ui_script::UiScriptPlugin;
use ui_shapeshift::UiShapeshiftPlugin;
use ui_social::UiSocialPlugin;
use ui_spellbook::UiSpellbookPlugin;
use ui_talent::UiTalentPlugin;
use ui_taxi::UiTaxiPlugin;
@@ -326,6 +330,16 @@ fn main() {
thread_qos::QosClass::UserInteractive,
)
})),
// `WOW_THREADS=1` serialises the systems that run this frame. Not a
// performance dial — a **diagnostic**: a defect that alternates frame to
// frame with no camera, geometry or draw-order change behind it is what an
// unordered write between two systems looks like, and that is separable from
// every other cause only by taking the concurrency away. Anything that
// survives `WOW_THREADS=1` is not a race.
max_threads: match std::env::var("WOW_THREADS").ok().as_deref() {
Some("1") => 1,
_ => TaskPoolOptions::default().compute.max_threads,
},
..TaskPoolOptions::default().compute
},
..default()
@@ -481,6 +495,10 @@ fn main() {
// Duels (decision 0633): the wire session, the client-side countdown tick, the four Era
// events, and the accept/cancel/challenge intents.
.add_plugins(UiDuelPlugin)
// Leaving (decision 0674): the game menu's Logout/Exit Game — the request, the server's
// 20-second answer narrated as the CAMP/QUIT countdown, and the process exit.
.add_plugins(UiLogoutPlugin)
.add_plugins(UiSocialPlugin)
.add_plugins(UiTooltipPlugin)
// The character-window feed (decision 0208): the combat-stats/inventory snapshots + events
// the paper doll reads, and the paper-doll booth's yaw mirror.
@@ -620,6 +638,20 @@ fn main() {
if std::env::var("WOW_PICK").is_ok() {
app.add_plugins(capture::PickProbePlugin);
}
// The render-phase census: `WOW_PHASE=<uniqueId>` reports, per frame, which phase each of one
// placement's batches landed in and where in the draw order — the one thing every scene-side
// instrument is blind to, namely whether a surface was submitted at all (see
// `capture::PhaseProbePlugin`).
if std::env::var("WOW_PHASE").is_ok() {
app.add_plugins(capture::PhaseProbePlugin);
}
// The depth readback: `WOW_DEPTH="<x>,<y>"` reports what depth actually won each named pixel,
// per frame, as a distance in yards — the link past submission that decides the pixel. Pair it
// with `WOW_PICK` at the same pixels to turn "what won" into "whose it was" (see
// `capture::DepthProbePlugin`).
if std::env::var("WOW_DEPTH").is_ok() {
app.add_plugins(capture::DepthProbePlugin);
}
// The bevy_ui node census — "who owns this rectangle" for UI outside the FrameXML quad pass
// (see `capture::NodeProbePlugin`).
if std::env::var("WOW_NODE_PROBE").is_ok() {
+46
View File
@@ -136,10 +136,56 @@ pub(crate) fn shade_of(tag: u32) -> u8 {
((tag & SHADE_MASK) >> SHADE_SHIFT) as u8
}
/// Human-readable decode of a `MeshTag`, for the probes (`WOW_PICK`'s per-frame shading dump).
///
/// It lives **here** because this module owns the bit conventions: a probe that re-derived the masks
/// would silently drift the day a field moves — which is precisely how 0355 broke (the slot moved and
/// one of its two writers kept the old bits). The masks stay private; this is the read-out.
///
/// The payload's meaning switches on **material state**, which a tag alone cannot know, so BOTH
/// readings of bits 16..=29 are printed side by side: `shade` is the exterior law's byte, `slot` the
/// interior law's probe index. A writer using the wrong law for its material is invisible in either
/// reading alone and obvious in the pair — a shade byte of 255 and a slot of 255 are the same bits.
pub(crate) fn describe(tag: u32) -> String {
if tag == 0 {
return "0 (untagged ⇒ opaque)".to_string();
}
let flags = match (tag & HIGHLIGHT_BIT != 0, tag & INTERIOR_FOG_BIT != 0) {
(true, true) => " hi+fog",
(true, false) => " hi",
(false, true) => " fog",
(false, false) => "",
};
format!(
"{tag:#010x}{flags} α {:.4} shade {} / slot {}",
(tag & ALPHA_MASK) as f32 / 65535.0,
shade_of(tag),
(tag >> SHADE_SHIFT) & 0x3fff,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describe_prints_both_readings_of_the_shared_bits() {
// The 0-sentinel is called out by name rather than decoded as "α 0.0000" (invisible), which
// is the one thing it never means.
assert!(describe(0).contains("untagged"));
// Exterior law: the shade byte. Interior law: the same bits as a slot. Both, always.
let t = with_shade(alpha_bits(1.0), 255);
assert!(describe(t).contains("shade 255"), "{}", describe(t));
assert!(describe(t).contains("slot 255"), "{}", describe(t));
// A probe payload reads back its slot, and carries the fog flag it bakes in.
let t = probe_bits(6660);
assert!(describe(t).contains("slot 6660"), "{}", describe(t));
assert!(describe(t).contains("fog"), "{}", describe(t));
// Both standalone flags are named, and neither is mistaken for payload.
assert!(describe(HIGHLIGHT_BIT | alpha_bits(0.5)).contains("hi"));
assert!(describe(HIGHLIGHT_BIT | INTERIOR_FOG_BIT | 1).contains("hi+fog"));
}
#[test]
fn highlight_bit_is_orthogonal_to_both_payloads() {
// Neither payload mode can ever set bit 31, so OR-ing/masking the flag is lossless.
+5
View File
@@ -176,6 +176,11 @@ pub(crate) fn model_material(
} else {
match blend {
ModelBlend::Opaque => AlphaMode::Opaque,
// `WOW_NO_ALPHATEST=1` draws every cutout batch opaque — the A/B for "is the alpha test
// itself discarding this surface?", which is otherwise indistinguishable in the pixels
// from the surface losing a depth test or never being submitted. (B38: the flip
// survives it unchanged, so the cutout is not what removes the awning.)
ModelBlend::AlphaTest if std::env::var("WOW_NO_ALPHATEST").is_ok() => AlphaMode::Opaque,
ModelBlend::AlphaTest => AlphaMode::Mask(VANILLA_ALPHA_KEY_REF),
// Mod/Mod2x ride the transparent pass (they multiply what's already drawn, so the
// scene under them must exist); `specialize` swaps the actual blend state to the
+38 -5
View File
@@ -18,7 +18,8 @@ use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use benilla_protocol::{
EntityKind, JumpInfo, MoveSpeeds, ObjectFields, SessionEvent, SpeedKind, TransportPose,
messages::WhoRequest, EntityKind, JumpInfo, MoveSpeeds, ObjectFields, SessionEvent, SpeedKind,
TransportPose,
};
use bevy::prelude::*;
use crossbeam_channel::{Receiver, Sender};
@@ -564,10 +565,17 @@ pub(crate) enum ClientCommand {
/// about one, `0` for template-only); answered by an `ItemTemplate` event into the
/// [`crate::items::Items`] cache. Sent by the cache's ask-once resolve, never directly.
ItemQuery { entry: u32, guid: u64 },
/// Use an item by wire bag position (`CMSG_USE_ITEM` — bag 255 + absolute slot for the
/// backpack, a bag's own player-array slot 19–22 + inner slot otherwise). The container
/// drain maps the Lua `(bagID, slot)` space before sending.
UseItem { bag_index: u8, slot: u8 },
/// Use an item by wire bag position (`CMSG_USE_ITEM` — bag 255 + absolute slot for anything
/// in the player's own field array, i.e. an equipment slot, the backpack or the keyring; a
/// bag's own player-array slot 19–22 + inner slot otherwise). The container drain maps the
/// Lua `(bagID, slot)` space before sending. `spell_index` is the template block ordinal the
/// server should cast (`ItemInfo::use_spell_index`) — 0 for every item whose on-use spell is
/// its first block, which is nearly all of them.
UseItem {
bag_index: u8,
slot: u8,
spell_index: u8,
},
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, same bag addressing) — the drain's fork for an
/// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back
/// as `InventoryFailure` events onto the UI error line.
@@ -890,6 +898,10 @@ pub(crate) enum ClientCommand {
/// character), which the IO thread turns into a [`LoggedOutMessage`] + an immediate
/// reconnect whose fresh roster is the select screen.
Logout,
/// Call off a pending logout (`CMSG_LOGOUT_CANCEL`) — the CAMP/QUIT dialog's Cancel, decision
/// 0674. Only the non-instant logout has anything to cancel; the server acks with
/// `SMSG_LOGOUT_CANCEL_ACK`, which becomes the UI's `LOGOUT_CANCEL` event.
LogoutCancel,
/// Join a chat channel (`CMSG_JOIN_CHANNEL` — `/join`, and the 0288 P6 zone auto-join).
JoinChannel { name: String, password: String },
/// Leave a chat channel (`CMSG_LEAVE_CHANNEL` — `/leave`).
@@ -975,6 +987,27 @@ pub(crate) enum ClientCommand {
/// Decline / cancel / forfeit a duel (`CMSG_DUEL_CANCELLED`) — one opcode for all three; the
/// server reads the intent from the duel's state.
DuelCancelled { arbiter: u64 },
// ── The social family (decision 0668; writer bodies in benilla-protocol
// `world/writer/social.rs`). Note the wire's own asymmetry: add by NAME, remove by GUID.
/// Refresh the friend list (`CMSG_FRIEND_LIST`) — the FrameXML's `ShowFriends()`. The list
/// also arrives unasked at login, so this is never the only path.
FriendListRequest,
/// Befriend a character by name (`CMSG_ADD_FRIEND`).
AddFriend { name: String },
/// Drop a friend by guid (`CMSG_DEL_FRIEND`) — the caller resolves the name first.
DelFriend { guid: u64 },
/// Ignore a character by name (`CMSG_ADD_IGNORE`).
AddIgnore { name: String },
/// Stop ignoring, by guid (`CMSG_DEL_IGNORE`).
DelIgnore { guid: u64 },
/// Tell the server we dropped an ignored player's whisper (`CMSG_CHAT_IGNORED`) so it can
/// answer them "X is ignoring you". The drop itself is entirely client-side — the server
/// keeps delivering an ignored player's chat, which is why the client has to filter *and*
/// report.
ChatIgnored { guid: u64 },
/// Run a `/who` (`CMSG_WHO`) — the filter string is already parsed into wire fields
/// (`ui_social::who_query`, which needs the DBCs the parse resolves names against).
Who { request: Box<WhoRequest> },
/// Ask to flip our own PvP flag (`CMSG_TOGGLE_PVP`, empty body — decision 0646): `/pvp` and
/// the unit popup's PvP row, both through the VM's intent queue. Nothing local changes; the
/// answer is the descriptor's PvP bit (and flagging *off* waits out the server's 300 s timer).
+48 -5
View File
@@ -37,8 +37,8 @@ use loot::{
loot_roll_won, loot_start_roll,
};
use quests::{
quest_complete, quest_detail, quest_failed, quest_giver_refused, quest_giver_status,
quest_greeting, quest_log_full, quest_objective_item, quest_objective_kill,
quest_complete, quest_detail, quest_failed, quest_giver_failed, quest_giver_invalid,
quest_giver_status, quest_greeting, quest_log_full, quest_objective_item, quest_objective_kill,
quest_objectives_complete, quest_offer, quest_progress, quest_template,
};
use spells::{
@@ -127,6 +127,10 @@ pub(super) fn apply_net_updates(
ResMut<crate::ui_bank::BankErrors>,
ResMut<crate::world_state::WorldStates>,
ResMut<crate::ui_duel::DuelState>,
ResMut<crate::ui_social::SocialState>,
// The pending logout/quit (decision 0674): the server's response and cancel-ack land
// here, and `crate::ui_logout` turns them into the countdown dialog.
ResMut<crate::ui_logout::LogoutState>,
),
),
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
@@ -251,6 +255,8 @@ pub(super) fn apply_net_updates(
mut bank_errors,
mut world_states,
mut duel,
mut social,
mut logout,
),
) = caches;
let (
@@ -311,6 +317,12 @@ pub(super) fn apply_net_updates(
SessionEvent::LoggedOut => {
session::logged_out(&mut commands, &mut index, &mut self_guid, &mut logged_out)
}
// The logout arc's two narration packets (decision 0674) — `crate::ui_logout` owns the
// decision table; this is only the hand-off.
SessionEvent::LogoutResponse { reason, instant } => {
logout.apply_response(reason, instant)
}
SessionEvent::LogoutCancelled => logout.apply_cancelled(),
SessionEvent::Disconnected { reason } => {
disconnects.write(super::DisconnectedMessage {
reason: reason.clone(),
@@ -340,6 +352,7 @@ pub(super) fn apply_net_updates(
&mut trade_session,
&mut bank_open,
&mut duel,
&mut social,
&mut aura.6,
);
}
@@ -699,6 +712,20 @@ pub(super) fn apply_net_updates(
&format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")),
);
}
// The ignore gate (decision 0668): an ignored speaker is dropped SILENTLY —
// no line at all — which is the client's own `FriendList::IsIgnored 0x5ae5a0`
// check, VERIFIED for the sibling text-emote path (wow-re
// `system/ui/scratch/text-emote-composition.md`). A dropped WHISPER additionally
// tells the server, so the sender gets the "is ignoring you" answer: that is what
// `CMSG_CHAT_IGNORED` is for, and only the client can send it.
if social.is_ignored(m.sender_guid) {
if m.chat_type == benilla_protocol::messages::CHAT_MSG_WHISPER {
let _ = net_commands.0.send(crate::net::ClientCommand::ChatIgnored {
guid: m.sender_guid,
});
}
continue;
}
chat_log.push_wire(m);
}
SessionEvent::ChannelNotify {
@@ -798,6 +825,7 @@ pub(super) fn apply_net_updates(
arbiter,
challenger,
self_guid.0,
social.is_ignored(challenger),
),
SessionEvent::DuelOutOfBounds => crate::ui_duel::apply::bounds(&mut duel, true),
SessionEvent::DuelInBounds => crate::ui_duel::apply::bounds(&mut duel, false),
@@ -812,6 +840,20 @@ pub(super) fn apply_net_updates(
SessionEvent::DuelCountdown { seconds } => {
crate::ui_duel::apply::countdown(&mut duel, seconds);
}
// ── The social family (decision 0668): the friend/ignore lists, the `/who`
// answer, and the result codes that print their own chat lines. The lines and the
// Era events fire off the mirror in `ui_social::feed_social` — every one of them
// needs a NAME the drain has no cache handle for.
SessionEvent::FriendList { friends } => {
crate::ui_social::apply::friend_list(&mut social, friends)
}
SessionEvent::IgnoreList { guids } => {
crate::ui_social::apply::ignore_list(&mut social, guids)
}
SessionEvent::FriendStatus(update) => {
crate::ui_social::apply::friend_status(&mut social, update)
}
SessionEvent::WhoResults(results) => crate::ui_social::apply::who(&mut social, results),
SessionEvent::LootResponse {
guid,
loot_type,
@@ -1230,9 +1272,10 @@ pub(super) fn apply_net_updates(
);
quest.bump_reask();
}
SessionEvent::QuestLogFull => quest_log_full(&mut chat_log),
SessionEvent::QuestGiverRefused { quest_id, reason } => {
quest_giver_refused(quest_id, reason, &mut chat_log)
SessionEvent::QuestLogFull => quest_log_full(&mut quest),
SessionEvent::QuestGiverInvalid { reason } => quest_giver_invalid(reason, &mut quest),
SessionEvent::QuestGiverFailed { quest_id, reason } => {
quest_giver_failed(quest_id, reason, &mut quest, &mut quest_log, &net_commands)
}
SessionEvent::VendorInventory { vendor, items } => {
npc::vendor_inventory(vendor, items, &mut merchant)
+156 -17
View File
@@ -8,8 +8,9 @@ use benilla_protocol::messages::{
};
use bevy::prelude::*;
use crate::ui_action::UiError;
use crate::ui_chat::ChatLog;
use crate::ui_quest::QuestGiver;
use crate::ui_quest::{MsgSurface, QuestGiver};
use crate::ui_quest_log::QuestLog;
use super::super::NetCommands;
@@ -128,22 +129,160 @@ pub(super) fn quest_failed(
));
}
/// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`).
pub(super) fn quest_log_full(chat_log: &mut ChatLog) {
chat_log.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
"Your quest log is full.".into(),
));
/// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`). The ref's `0x195` arm is a
/// bare `DisplayError(153)` and nothing else (no panel close): `ERR_QUEST_LOG_FULL` is a kind-2
/// record, so it is the RED line, not a chat line (decision 0669 — it used to be a hardcoded
/// English chat push here).
pub(super) fn quest_log_full(quest: &mut QuestGiver) {
debug!("net: quest log full");
quest.push_message(MsgSurface::Error, UiError::key("ERR_QUEST_LOG_FULL"));
}
/// A questgiver interaction was rejected (`SMSG_QUESTGIVER_QUEST_INVALID` msg code /
/// `SMSG_QUESTGIVER_QUEST_FAILED` reason code).
pub(super) fn quest_giver_refused(quest_id: u32, reason: u32, chat_log: &mut ChatLog) {
// The per-reason GlobalStrings table (QuestFailedReasons / the INVALID msg codes)
// is unpinned — a bare fallback, same convention as ui_items.rs's equip_error_text.
debug!("net: quest giver refused (quest {quest_id}, reason {reason})");
chat_log.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
format!("Quest failed ({reason:#04x})."),
));
/// The giver won't offer the quest (`SMSG_QUESTGIVER_QUEST_INVALID`, ref handler `0x5dbca0`): one
/// `QuestFailedReason` code, no quest id. Its chat line comes from [`questgiver_invalid_key`]'s
/// byte-verified table — reason 13 is "You are already on that quest", the line the director's
/// bare `Quest failed (0x0d).` used to be.
///
/// Then the ref **closes the window**: both refusal handlers end in `0x501130(0,0)`, which zeroes
/// the current questgiver guid (`0xbe0810`) and signals Lua event `0x130` = `QUEST_FINISHED` —
/// our [`QuestGiver::clear`] plus the feed's own `QUEST_FINISHED` on the cleared view. Without
/// this the panel sat open on a refused accept (decision 0669).
pub(super) fn quest_giver_invalid(reason: u32, quest: &mut QuestGiver) {
debug!("net: questgiver refused to offer the quest (reason {reason})");
quest.push_message(
MsgSurface::Chat,
UiError::key(crate::ui_quest::questgiver_invalid_key(reason)),
);
quest.clear();
}
/// The accept failed on a quest the giver DID offer (`SMSG_QUESTGIVER_QUEST_FAILED`, ref handler
/// `0x5dc840`): `{questId, reason}`, and the line names the quest — the ref pushes the quest
/// record's title (`+0x9c`) as the format's `%s`, so we fill it from the open view (the panel that
/// is refusing IS this quest) and fall back to the template cache. A full-bag refusal shows a
/// SECOND line, the ref's bare `DisplayError(0)` = `ERR_INV_FULL` on the red surface. Closes the
/// window like [`quest_giver_invalid`].
pub(super) fn quest_giver_failed(
quest_id: u32,
reason: u32,
quest: &mut QuestGiver,
quest_log: &mut QuestLog,
net_commands: &NetCommands,
) {
debug!("net: quest {quest_id} accept failed (reason {reason})");
let title = quest
.view_title(quest_id)
.or_else(|| {
quest_log
.template(quest_id, net_commands)
.map(|t| t.title.clone())
})
.unwrap_or_default();
quest.push_message(
MsgSurface::Chat,
UiError {
key: crate::ui_quest::questgiver_failed_key(reason),
fill_s: Some(title),
fill_d: None,
},
);
if matches!(reason, 4 | 50) {
quest.push_message(MsgSurface::Error, UiError::key("ERR_INV_FULL"));
}
quest.clear();
}
#[cfg(test)]
mod tests {
use super::*;
fn open_detail(quest_id: u32) -> QuestGiver {
let mut giver = QuestGiver::default();
giver.open(
0x4000_0000_0000_0BAD,
crate::ui_quest::QuestView::Detail(QuestDetails {
npc: 0x4000_0000_0000_0BAD,
quest_id,
title: "A Threat Within".into(),
details: String::new(),
objectives: String::new(),
auto_finish: 0,
choices: vec![],
rewards: vec![],
money: 0,
reward_spell: 0,
}),
);
giver
}
/// The director's repro: the quest is already in the log, so the accept comes back
/// `QUEST_INVALID` reason 13. The line must be the ref's string key — never the bare
/// `Quest failed (0x0d).` — and the panel must CLOSE (`0x501130(0,0)` → `QUEST_FINISHED`),
/// which is what left it sitting open before decision 0669.
#[test]
fn an_already_on_refusal_speaks_and_closes_the_panel() {
let mut giver = open_detail(373);
quest_giver_invalid(13, &mut giver);
let msgs = giver.take_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].0, MsgSurface::Chat);
assert_eq!(msgs[0].1, UiError::key("ERR_QUEST_ALREADY_ON"));
assert!(!giver.is_open(), "the ref closes the window on a refusal");
}
/// The named half: the `%s` comes off the open panel, and a full bag adds the ref's second
/// line (`DisplayError(0)` = `ERR_INV_FULL`) on the RED surface.
#[test]
fn a_full_bag_refusal_names_the_quest_and_adds_the_inventory_line() {
let mut giver = open_detail(373);
let mut log = QuestLog::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
quest_giver_failed(373, 4, &mut giver, &mut log, &commands);
let msgs = giver.take_messages();
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].0, MsgSurface::Chat);
assert_eq!(
msgs[0].1,
UiError {
key: "ERR_QUEST_FAILED_BAG_FULL_S",
fill_s: Some("A Threat Within".into()),
fill_d: None,
}
);
assert_eq!(msgs[1].0, MsgSurface::Error);
assert_eq!(msgs[1].1, UiError::key("ERR_INV_FULL"));
assert!(!giver.is_open());
}
/// `SMSG_QUESTLOG_FULL` is the odd one out: the ref's arm is a bare `DisplayError(153)` — the
/// RED line, and NO close.
#[test]
fn a_full_log_takes_the_red_line_and_leaves_the_panel_alone() {
let mut giver = open_detail(373);
quest_log_full(&mut giver);
let msgs = giver.take_messages();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].0, MsgSurface::Error);
assert_eq!(msgs[0].1, UiError::key("ERR_QUEST_LOG_FULL"));
assert!(
giver.is_open(),
"the ref's 0x195 arm does not close the panel"
);
}
/// A refusal for a quest the open panel is NOT showing leaves the `%s` empty rather than
/// naming the wrong quest.
#[test]
fn a_refusal_for_another_quest_does_not_borrow_the_open_title() {
let mut giver = open_detail(373);
let mut log = QuestLog::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
quest_giver_failed(999, 17, &mut giver, &mut log, &commands);
let msgs = giver.take_messages();
assert_eq!(msgs[0].1.key, "ERR_QUEST_FAILED_MAX_COUNT_S");
assert_eq!(msgs[0].1.fill_s.as_deref(), Some(""));
}
}
+5
View File
@@ -124,6 +124,7 @@ pub(super) fn disconnected(
trade: &mut crate::ui_trade::TradeSession,
bank: &mut crate::ui_bank::BankOpen,
duel: &mut crate::ui_duel::DuelState,
social: &mut crate::ui_social::SocialState,
pending_transfer: &mut PendingTransfer,
) {
warn!("net: {reason} — tearing down the streamed world");
@@ -175,6 +176,10 @@ pub(super) fn disconnected(
// (decision 0633) — the server drops the duel too (`Player::DuelComplete(DUEL_FLED)` on
// logout), and a stale arbiter guid would make the next AcceptDuel echo a dead object.
*duel = crate::ui_duel::DuelState::default();
// The friend/ignore lists and the last `/who` are session state too (decision 0668): the
// server re-pushes both lists at the next login, and a stale ignore list would silence the
// wrong guids after a reconnect renumbers nothing but re-streams everything.
*social = crate::ui_social::SocialState::default();
// The death stores are session-scoped too: a reclaim expiry, resurrect offer, or corpse
// marker must not survive the socket (the reconnect re-sends the reclaim delay when dead).
*death_net = crate::death::DeathNet::default();
+13 -1
View File
@@ -657,7 +657,11 @@ fn writer_loop(
w.pet_name_query(pet_number, guid)
}
ClientCommand::ItemQuery { entry, guid } => w.item_query(entry, guid),
ClientCommand::UseItem { bag_index, slot } => w.use_item(bag_index, slot, 0),
ClientCommand::UseItem {
bag_index,
slot,
spell_index,
} => w.use_item(bag_index, slot, spell_index),
ClientCommand::AutoEquipItem { bag_index, slot } => {
w.auto_equip_item(bag_index, slot)
}
@@ -819,6 +823,7 @@ fn writer_loop(
} => w.set_trade_item(trade_slot, bag, slot),
ClientCommand::ClearTradeItem { trade_slot } => w.clear_trade_item(trade_slot),
ClientCommand::Logout => w.logout_request(),
ClientCommand::LogoutCancel => w.logout_cancel(),
ClientCommand::CompleteCinematic => w.complete_cinematic(),
ClientCommand::MoveRootAck {
guid,
@@ -858,6 +863,13 @@ fn writer_loop(
ClientCommand::DuelAccepted { arbiter } => w.duel_accepted(arbiter),
ClientCommand::DuelCancelled { arbiter } => w.duel_cancelled(arbiter),
ClientCommand::TogglePvp => w.toggle_pvp(),
ClientCommand::FriendListRequest => w.friend_list(),
ClientCommand::AddFriend { name } => w.add_friend(&name),
ClientCommand::DelFriend { guid } => w.del_friend(guid),
ClientCommand::AddIgnore { name } => w.add_ignore(&name),
ClientCommand::DelIgnore { guid } => w.del_ignore(guid),
ClientCommand::Who { request } => w.who(&request),
ClientCommand::ChatIgnored { guid } => w.chat_ignored(guid),
ClientCommand::TaxiNodeStatusQuery { guid } => w.taxi_node_status_query(guid),
ClientCommand::TaxiQueryNodes { guid } => w.taxi_query_available_nodes(guid),
ClientCommand::ActivateTaxi {
+28
View File
@@ -420,6 +420,34 @@ pub(super) fn seat_camera(
};
let frac = (rig.collision_distance / boom_len).clamp(0.0, 1.0);
cam_t.translation = head + boom * frac;
// `WOW_CAM_DUMP=frame`: the REALIZED pose, per frame, bit-exact — not the pose that was asked for.
//
// Every scripted probe sets `yaw`/`pitch`/`distance` and we then reason as though the camera is
// therefore where we put it. It is not: `collision_distance` is an exponentially-eased chase of a
// per-frame collision CAST, so a grazing hit that alternates gives an arm that snaps in and eases
// back out, and the camera keeps moving for as long as that lasts — with the scripted pose
// perfectly constant the whole time. B38's "the camera is static by construction, so nothing
// camera-derived can be the cause" (0671) rests entirely on that being untrue, and it was never
// measured. `open` is printed beside the eased arm so a hit/miss alternation in the CAST is
// visible even on a frame where the ease has not yet moved the camera far enough to see.
if std::env::var_os("WOW_CAM_DUMP").is_some() {
eprintln!(
"[cam] yaw {:.6} pitch {:.6} dist {:.6} open {:.6} coll {:.6} frac {:.6} \
pos [{:.6},{:.6},{:.6}] bits [{:08x},{:08x},{:08x}]",
cam.yaw,
cam.pitch,
rig.distance,
open,
rig.collision_distance,
frac,
cam_t.translation.x,
cam_t.translation.y,
cam_t.translation.z,
cam_t.translation.x.to_bits(),
cam_t.translation.y.to_bits(),
cam_t.translation.z.to_bits(),
);
}
// Fade the avatar as the camera nears its pivot (zoom-in / a wall pulling the boom in): opaque
// in third-person, ramping to invisible in first-person. Keyed off the *realized* camera→pivot
+2 -2
View File
@@ -626,13 +626,13 @@ fn interact_command(
}
}
/// Drain the ESC chain's `ClearTarget()` (the last leg of `BenillaOnEscape` — the ref's
/// Drain the ESC chain's `ClearTarget()` (the ref's ESC ladder's last close/cancel leg (`ToggleGameMenu` — the ref's
/// `ToggleGameMenu` order, `UIParent.lua:1492`) and commit the deselect. The target drops ONLY
/// when nothing earlier in the chain ate the press: a mid-cast ESC cancels the cast instead, an
/// open window closes instead — the two-press behavior the raw-key clear this replaces couldn't
/// express (it ran beside the chain, so the first ESC both canceled the cast AND dropped the
/// target — the director's 0449 report). EditBox precedence rides the chain too: a focused box
/// consumes ESCAPE before `BenillaOnEscape` ever runs.
/// consumes ESCAPE before `ToggleGameMenu` ever runs.
pub(super) fn clear_target_requests(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut selection: ResMut<Selection>,
+16 -1
View File
@@ -188,7 +188,22 @@ impl MaterialExtension for WowModelExt {
// depth step — decisive for coplanar ties, negligible (sub-mm) against real separation.
let order = key.bind_group_data.wmo_batch_order;
if order != 0 {
ds.bias.constant = i32::from(order);
// DIAGNOSTIC (B38): `WOW_WMO_BIAS=0` drops the bias entirely, so the "both batches
// saturate to depth 1.0" theory can be tested. The log proves the branch was taken.
// `WOW_WMO_BIAS=0` drops the per-batch bias, the A/B that says whether a given
// flicker is this bias resolving (or failing to resolve) a tie. It answered B38:
// the flip is bit-identical with the bias gone, which also settles what the Metal
// backend does with the constant — `wgpu-hal` hands `bias.constant` to
// `setDepthBias` raw, and were that raw value ADDED to a reverse-Z depth (~0.02
// here) every WMO batch would clamp to 1.0 and tie, so removing it could not
// possibly leave the picture unchanged. It does, so the constant is being scaled by
// the depth format's ULP and the comment above means what it says.
let on = !matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0"));
ds.bias.constant = if on { i32::from(order) } else { 0 };
debug!(
"wmo batch order {order}: bias {} compare {:?} depth_write {}",
ds.bias.constant, ds.depth_compare, ds.depth_write_enabled,
);
}
}
if key.bind_group_data.fade {
+281
View File
@@ -0,0 +1,281 @@
//! **The one cast-send path** — every spell benilla casts leaves through [`send_spell_cast`].
//!
//! The action button is only one of its callers; the spellbook (decision 0216 §8), the stance bar,
//! the trade-skill window and the craft window all funnel here too. That is the point: the client's
//! `TryCast 0x6e4b60` → commit `0x6e54f0` is *one* function with a long ladder of local gates and a
//! post-send tail, and duplicating any part of it per caller is how the two paths drift. The ladder
//! below is that function's order, gate for gate — profession intercept, auto-repeat toggle,
//! cooldown, GCD, in-flight guard, mounted, reagents/totems, target binding, range — followed by
//! the commit's own tail (ranged stance, auto-repeat arm, the send, the auto-attack start, the GCD
//! arm).
//!
//! A refusal here is **local and pre-commit**, exactly like the reference's: no packet, no GCD, no
//! pending arm, no autorepeat key — just the red error line's reason code.
use std::time::Instant;
use bevy::prelude::*;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use super::{cast_target, reagent_totem_refusal, state, AutoRepeatActive, CastErrors, Spells};
/// Send one spell cast at the current selection — the client's local cast-send follow-through
/// (the client's `0x6e54f0` tail): a ranged-attribute spell arms the **ranged stance** now
/// (`0x6e5930`'s `SetSheatheState(2,1,1)` — the echo START re-requests it, idempotent), an
/// auto-repeat spell sets the sticky armed state (`0x6e593b`'s `|= 0x200`, the standing Load/Hold
/// idle's gate — decision 0099 phase 5), and the resolved `CMSG_CAST_SPELL` goes out. Shared by
/// [`super::drain::drain_action_uses`] (a SPELL-kind action button) and
/// `ui_spellbook::drain_spell_casts` (a spellbook cast, decision 0216 §8) — ONE cast-send path, so
/// the follow-through can't drift between the two spell sources (the root-cause rule: never
/// duplicate a send path).
///
/// The `pending` guard is the client's optimistic in-flight refusal (wow-re `wave-cast.md`
/// `TryCast` IsCasting gate; see [`crate::ui_cast::PendingCast`]): a normal cast is dropped at the
/// source while one is already in flight, so mashing a key can no longer fire a duplicate
/// `CMSG_CAST_SPELL` the server bounces back as a spurious cast-bar cancel. Ranged/auto-repeat
/// shots keep their own lifecycle — they never arm the guard and are never blocked by it.
#[allow(clippy::too_many_arguments)] // every input the follow-through + the send itself need
pub(crate) fn send_spell_cast(
spell_id: u32,
ctx: &cast_target::CastContext,
commands: &NetCommands,
self_player: &Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
spells: Option<&Spells>,
items: &Items,
sheath: &mut MessageWriter<crate::creature_anim::SheathRequest>,
ecs: &mut Commands,
pending: &mut crate::ui_cast::PendingCast,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
cooldowns: &mut crate::cooldowns::Cooldowns,
cast_errors: &mut CastErrors,
auto_repeat: &mut AutoRepeatActive,
trade_skill_opens: &mut crate::ui_tradeskill::TradeSkillOpens,
) {
let now = Instant::now();
let def = spells.and_then(|s| s.catalog.get(spell_id));
// The profession-window intercept (decision 0437): `Spell_C::TryCast 0x6e4b60`'s own first
// special branch (wow-re `wave-cast.md`, VERIFIED) — an `Effect[0] == SPELL_EFFECT_TRADE_SKILL`
// cast NEVER reaches the wire; the crafting book opens client-side instead. Before the
// cooldown ladder, exactly where the client dispatches it (`6e4bce`, ahead of every gate).
if def.is_some_and(|d| d.effect_1 == benilla_formats::SPELL_EFFECT_TRADE_SKILL) {
debug!("ui_action: cast {spell_id} is a profession opener — the crafting book opens, no packet");
trade_skill_opens.0.push(spell_id);
return;
}
// The button re-press toggle (`0x4e60da`, wow-re `nocked-ammo-cancel.md` §Q-B-2):
// re-invoking the spell that IS the running auto-repeat cancels it instead of re-casting —
// the classic press-again-to-stop. Checked before the cooldown ladder, like the client's
// action-button handler (which never reaches TryCast for the toggle-off).
if def.is_some_and(|d| d.auto_repeat()) && auto_repeat.0 == Some(spell_id) {
debug!("ui_action: cast {spell_id} re-pressed — auto-repeat toggles off");
let self_e = self_player.single().ok().map(|(e, _)| e);
crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, ecs, commands);
return;
}
// The local not-ready refusal (the client's `IsSpellOnCooldown 0x6e1690` gate in the cast
// path): a spell/category cooldown refuses at the source with the client's own reason 0x3c
// ("Spell is not ready yet.") — never sent, exactly like the real client. The GCD is
// faithfully NOT part of this test (0x6e1690 skips the GCD fields).
if cooldowns.is_on_cooldown(spell_id, def, now) {
debug!("ui_action: cast {spell_id} refused locally — on cooldown");
cast_errors.0.push((spell_id, 0x3c));
return;
}
// The GCD leg of the local not-ready ladder ([`Cooldowns::gcd_locked`], decision 0379): a
// GCD-carrying spell pressed while its startRecoveryCategory still runs refuses at the
// source — same reason 0x3c, NEVER sent. Sending would draw the server's NOT_READY fail
// (vmangos enforces the GCD), whose faithful revert clears the RUNNING GCD — the spam-press
// vanished-pie bug. GCD-free presses (Heroic Strike's queue, Attack, Shoot) pass.
if def.is_some_and(|d| cooldowns.gcd_locked(d, now)) {
debug!("ui_action: cast {spell_id} refused locally — the GCD is running");
cast_errors.0.push((spell_id, 0x3c));
return;
}
// The cast classes at this seam. A ranged/auto-repeat shot (Auto Shot, wand Shoot, Throw) is
// not a cast-bar cast — it runs the ranged-stance / `AutoRepeatArmed` path, outside the
// in-flight guard. An on-next-swing spell (`Attributes & 0x404` — Heroic Strike, Cleave)
// queues on the server's melee slot: it arms [`crate::ui_cast::QueuedMeleeSpell`], never the
// in-flight guard, so a queued strike cannot block the next cast (the ref's `6e4d97`
// exemption on the inflight rec's 0x404 bits — wow-re `wave-cast.md`).
let on_next_swing = def.is_some_and(|d| d.on_next_swing());
let normal_cast = !def.is_some_and(|d| d.ranged_attack()) && !on_next_swing;
// Re-pressing the queued strike is the ref's silent same-spell bail (`6e4d43`) — no cancel,
// no error: 1.12 has no re-press-to-unqueue.
if on_next_swing && queued_melee.current() == Some(spell_id) {
debug!("ui_action: cast {spell_id} suppressed — already queued on next swing");
return;
}
if (normal_cast || on_next_swing) && pending.in_flight(now) {
// The ref's already-casting refusal: the same spell bails silently (`6e4d43`); a
// different one errors reason 0x61 "Another action is in progress" (`6e4d97` →
// `HandleCastFailed`) — the inflight rec here is always an ordinary cast, so even an
// on-next-swing press is refused while it holds.
if pending.current(now) != Some(spell_id) {
cast_errors.0.push((spell_id, 0x61));
}
debug!("ui_action: cast {spell_id} suppressed — a cast is already in flight");
return;
}
// The client-side mounted gate (decision 0481; wow-re `mounted-action-gate.md` §5:
// TryCast's requirement validator `0x6094f0`, mounted block `0x609c6c` — a live
// `UNIT_FIELD_MOUNTDISPLAYID` refuses a non-exempt cast with reason 0x39 "You are
// mounted" BEFORE the cast-arm's target binding, which is why a targetless mounted click
// never reads "You have no target"). Exemption: Attributes bit 24 (`0x01000000`,
// castable-while-mounted). The gate must be LOCAL: vmangos silently dismounts a mounted
// caster instead of erroring, so without this check the message can never appear. Named
// micro-divergence: the ref range-tests a RESOLVED target (its step 6) before this gate;
// ours range-tests after binding, so the mounted∧out-of-range double fault shows "You are
// mounted" where the ref shows "Out of range." — unobservable outside that corner.
if state::cast_mounted_refusal(
ctx.rel
.self_store
.is_some_and(|s| s.0.unit_mount_display_id() > 0),
def,
) {
debug!("ui_action: cast {spell_id} refused locally — mounted (0x39)");
cast_errors.0.push((spell_id, 0x39));
return;
}
// The pre-send totem/reagent possession check (`CheckReagentsAndTotems 0x6e4000`, TryCast's
// `0x6e4ded` — decision 0552): a missing tool (Mining Pick) or a short reagent refuses HERE
// with the client's own 0x78/0x5c red line and NEVER sends. The gate must be local: vmangos
// answers a sent pickless cast with the wrong code (`ITEM_GONE` "Item is gone"), so without
// it the real message can't appear. Placement after the mounted gate mirrors the ref only
// approximately (the `0x6094f0`-vs-`0x6e4ded` call order isn't pinned — double-fault
// corners only).
if reagent_totem_refusal(spell_id, def, ctx.rel.self_store, items, cast_errors) {
return;
}
// ArmCast (`0x6e5250`): resolve the wire target from the spell's targeting constraints —
// never the raw selection ([`cast_target`] module docs). A refusal is local and pre-commit,
// like the ref's residual flag_word: no send, no GCD, no pending arm, no autorepeat key.
let target = match cast_target::resolve_cast_target(
def,
ctx.selection_guid,
ctx.self_guid,
ctx.auto_self_cast,
&ctx.rel,
) {
cast_target::CastWireTarget::SelfImplicit => None,
cast_target::CastWireTarget::Unit(guid) => Some(guid),
cast_target::CastWireTarget::Refused(reason) => {
debug!("ui_action: cast {spell_id} refused locally — unbindable target ({reason:#x})");
cast_errors.0.push((spell_id, reason));
return;
}
};
// The local range gate (the client's TryCast runs `CanTargetUnit 0x6e4440` →
// `IsTargetInRange 0x6e47b0` BEFORE the commit `0x6e54f0`): an out-of-range / too-close
// press on a bound unit target refuses here — before the ranged-stance arm below, so a
// too-close Throw/Auto Shot never draws the bow and never stows the melee weapons (the
// sheath snap `0x6e5930` lives in the commit tail this refusal never reaches). The bound
// target is only ever the selection or ourselves; a self-bind (autoSelfCast) is distance 0
// with a min-0 range in practice, so only the selection leg is tested.
if let Some(d) = def {
if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid {
let row = spells.and_then(|s| s.ranges.get(d.range_index));
let dist_sq = ctx
.range
.self_pos
.zip(ctx.range.target_pos)
.map(|(a, b)| a.distance_squared(b));
if let Some(reason) = state::cast_range_refusal(
d,
row,
ctx.range.self_reach,
ctx.range.target_reach,
dist_sq,
) {
debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})");
cast_errors.0.push((spell_id, reason));
return;
}
}
}
// The wand-only auto-repeat handoff (the client's `0x60959e` inside TryCast's `0x6094f0`
// step, wow-re `nocked-ammo-cancel.md` §Q-B-5): a NEW cast cancels the running auto-repeat
// iff the CACHED spell carries `AttributesEx3 & 0x400000` — wand Shoot 5019 alone in the
// 1.12 data. Auto Shot survives by construction: hunter shot-weaving. The client first
// sends `CMSG_CANCEL_CAST` naming the cached wand spell (`0x6095b8`), then runs the local
// cancel (whose own `CMSG_CANCEL_AUTO_REPEAT` ack follows). The same-spell re-press never
// reaches here — the toggle above returned.
if let Some(cached) = auto_repeat.0 {
if spells
.and_then(|s| s.catalog.get(cached))
.is_some_and(|d| d.casting_cancels_autorepeat())
{
debug!("ui_action: cast {spell_id} cancels the running wand repeat {cached}");
let _ = commands
.0
.send(ClientCommand::CancelCast { spell_id: cached });
let self_e = self_player.single().ok().map(|(e, _)| e);
crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, ecs, commands);
}
}
if let Some(d) = def {
if let Ok((e, _)) = self_player.single() {
if d.ranged_attack() {
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 2,
ceremony: false,
});
}
if d.auto_repeat() {
ecs.entity(e).insert(crate::creature_anim::AutoRepeatArmed);
// The live autorepeat key (`0xceac30 = SpellRec+0x00` at `0x6e5947`) — what the
// button's flash/checked state reads until CANCEL_AUTO_REPEAT clears it.
auto_repeat.0 = Some(spell_id);
}
}
}
let _ = commands
.0
.send(ClientCommand::CastSpell { spell_id, target });
if normal_cast {
pending.arm(spell_id, now);
} else if on_next_swing {
queued_melee.arm(spell_id);
}
// TryCast's post-send tail (`6e51b5`) — byte-verified whole by the 2026-07-14 wow-re §5
// (`combat-feel-law.md` @ c445713b): a committed send whose rec passes
// [`SpellDisplay::initiates_auto_attack`] (on-next-swing `0x404` or `AttributesEx & 0x200`,
// and not the GO-deferred Ex2-bit20; Charge carries none) starts the melee auto-attack at
// the cast's bound unit target, unless one is already running (`0x60ecb0` over
// `[player+0xc48]`; our mirror is the wire-echoed `Engaged`). The start is the attack path's
// own pair (`0x6131a0` → `0x5ecb70`): melee-sheath SNAP + `CMSG_ATTACKSWING` — the same two
// edges as the Attack button's arm in [`super::drain::drain_action_uses`]. Path-independent
// in the ref (button/spellbook/CastSpellByName share the one tail) — matching our one send
// seam.
if let (Some(d), Some(guid)) = (def, target) {
if d.initiates_auto_attack() {
if let Ok((e, engaged)) = self_player.single() {
if !engaged {
debug!("ui_action: cast {spell_id} initiates auto-attack at {guid:#x}");
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
// Melee attack-start cancels a running auto-repeat UNCONDITIONALLY — the
// client's `0x5ecd8c` tail right after its melee snap (wow-re
// `nocked-ammo-cancel.md` §Q-B-5): you can't melee and auto-shoot at once.
crate::creature_anim::cancel_auto_repeat_local(
Some(e),
auto_repeat,
ecs,
commands,
);
let _ = commands.0.send(ClientCommand::AttackSwing { guid });
}
}
}
}
// Arm the GCD at send (`StartGlobalCooldown 0x6e2de0` ← the cast-send arm `0x6e58fb`,
// byte-verified) — a later `SMSG_CAST_RESULT` failure clears it again (`0x6e1630`).
if let Some(d) = def {
cooldowns.start_gcd(spell_id, d, now);
}
}
+311
View File
@@ -0,0 +1,311 @@
//! **Outward** — the two action-bar drains, and the law that decides what a click *does*.
//!
//! - **Use** ([`drain_action_uses`]): a queued `UseAction(n)` becomes wire. A SPELL action goes
//! through the one cast-send path ([`super::cast_send::send_spell_cast`]); the auto-attack action
//! (6603) sends `CMSG_ATTACKSWING` at the selection, or acquires the nearest enemy when there is
//! none; an ITEM action names an *entry*, not a position, so it must first find a copy and then
//! decide equip-vs-use — [`item_action_route`], the byte-verified two-stage law of decision 0666.
//! Macro actions are a stated gap (no macro window yet).
//! - **Set** ([`drain_action_sets`]): a queued `PickupAction`/`PlaceAction` mutation becomes one
//! `CMSG_SET_ACTION_BUTTON` per entry (0218 §4: the bar is client-authoritative, there is no
//! answer packet to lock against, and a drag-swap is two independent sends — never atomic).
//!
//! Both run `.after(UiInput)` so a click's intent goes out the same frame it was made. The two
//! queues are disjoint per gesture, so their relative order does not matter.
use std::time::Instant;
use bevy::prelude::*;
use benilla_protocol::messages::{ActionButton, ACTION_KIND_ITEM, ACTION_KIND_SPELL};
use benilla_ui::script::UiScript;
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, SelfPlayer};
use super::cast_send::send_spell_cast;
use super::{
attack_mounted_refusal, cast_target, AutoRepeatActive, CastErrors, PlayerActions, Spells,
UiErrorKeys, SPELL_ATTACK,
};
/// What clicking an ITEM action does, and to which copy — [`item_action_route`]'s verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ItemRoute {
/// Use this copy — the wire `(bag_index, slot)` plus the instance guid the shared use fork
/// needs (`ui_items::item_use_command`, decision 0664).
Use((u8, u8, u64)),
/// Equip this copy — the same triple.
Equip((u8, u8, u64)),
/// No copy anywhere the walk reaches — the click does nothing.
Nowhere,
}
/// The reference's **two-stage** equip-vs-use decision for an ITEM action, byte-verified (wow-re
/// `action-item-slot.md` §8.1, `0x4e5fdd`–`0x4e5ff7`; decision 0666, which supersedes 0216 §7's
/// guessed one):
///
/// ```text
/// InventoryType == 0 → USE (a consumable is never equipped)
/// InventoryType != 0, worn → USE IN PLACE (a copy is in equipment slots 0..18)
/// InventoryType != 0, not worn → EQUIP (the full walk finds the copy to equip)
/// ```
///
/// The second stage is the whole point. A ONE-stage `equippable → equip` fork can never *use* an
/// equipped trinket — it re-equips it forever — and before 0666 the walk did not look at the
/// equipment slots at all, so an equipped item's button was simply inert (reproduced live
/// 2026-07-26: `item action 1 (entry 25) not in any bag — skipped`).
///
/// `find` is the inventory walk ([`crate::ui_items::find_item`]) with the entry already bound, so
/// this stays a pure function of the template and the walk's answers — the law is testable
/// without a world.
pub(super) fn item_action_route(
template: &benilla_protocol::ItemInfo,
find: impl Fn(crate::ui_items::ItemSearch) -> Option<(u8, u8, u64)>,
) -> ItemRoute {
let anywhere = |live_charges_only| crate::ui_items::ItemSearch {
equipment_only: false,
live_charges_only,
};
if template.inventory_type == 0 {
// The use leg's mode-`0x20` charge filter (`0x4e603a`): only when the TEMPLATE says this
// item carries finite charges does the search skip spent copies.
return match find(anywhere(template.has_finite_charges())) {
Some(pos) => ItemRoute::Use(pos),
None => ItemRoute::Nowhere,
};
}
if let Some(pos) = find(crate::ui_items::ItemSearch {
equipment_only: true,
live_charges_only: false,
}) {
return ItemRoute::Use(pos);
}
match find(anywhere(false)) {
Some(pos) => ItemRoute::Equip(pos),
None => ItemRoute::Nowhere,
}
}
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
pub(super) fn drain_action_uses(
script: Option<NonSendMut<UiScript>>,
actions: Res<PlayerActions>,
targeting: cast_target::CastTargeting,
commands: Res<NetCommands>,
self_player: Query<(Entity, Has<crate::creature_anim::Engaged>), With<SelfPlayer>>,
mut items: ResMut<Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
mut acquire: MessageWriter<crate::target::AttackNearestRequest>,
spells: Option<Res<Spells>>,
mut pending: ResMut<crate::ui_cast::PendingCast>,
mut queued_melee: ResMut<crate::ui_cast::QueuedMeleeSpell>,
mut cooldowns: ResMut<crate::cooldowns::Cooldowns>,
// The error sinks, one tuple param (Bevy's 16-param ceiling): the reason-coded cast line
// + the by-key local line.
mut errors: (ResMut<CastErrors>, ResMut<UiErrorKeys>),
mut auto_repeat: ResMut<AutoRepeatActive>,
mut trade_skill_opens: ResMut<crate::ui_tradeskill::TradeSkillOpens>,
mut ecs: Commands,
) {
let selection = &targeting.selection;
let Some(mut script) = script else {
return;
};
for action in script.take_action_uses() {
let slot = match u8::try_from(action.saturating_sub(1)) {
Ok(s) => s,
Err(_) => continue,
};
match actions.buttons.get(&slot) {
Some(b) if b.kind == ACTION_KIND_SPELL && b.action == SPELL_ATTACK => {
// The mounted attack block ([`attack_mounted_refusal`]): the ref's validator
// refuses BEFORE the with-target swing and before the nearest-enemy scan
// (`0x613039` precedes `0x6130b5`), so both arms gate here.
if attack_mounted_refusal(targeting.self_store.iter().next(), &mut errors.1) {
continue;
}
match selection.guid {
Some(guid) => {
debug!("ui_action: attack swing at {guid:#x}");
// Auto-draw: initiating melee requests melee sheath state through the
// anim layer's ONE setter (decision 0080) — a SNAP, no ceremony, no
// sound: the attack path passes `(newState=1, bInstant=1, bFireEvent=1)`
// at `0x5ecd80` (wow-re `sheath-policy.md`). The setter's idempotency
// is the client's own "no-op if already melee".
if let Ok((e, _)) = self_player.single() {
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
}
// Melee attack-start cancels a running auto-repeat UNCONDITIONALLY —
// the client's `0x5ecd8c` (wow-re `nocked-ammo-cancel.md` §Q-B-5).
let self_e = self_player.single().ok().map(|(e, _)| e);
crate::creature_anim::cancel_auto_repeat_local(
self_e,
&mut auto_repeat,
&mut ecs,
&commands,
);
let _ = commands.0.send(ClientCommand::AttackSwing { guid });
}
// No target: the client's attack resolver runs the nearest-enemy core and
// swings at the winner (`0x612df0` @ `6130b5`) — `target::scan` answers.
None => {
debug!("ui_action: attack with no target — acquiring nearest");
acquire.write(crate::target::AttackNearestRequest);
}
}
}
Some(b) if b.kind == ACTION_KIND_SPELL => {
debug!("ui_action: cast {} (target {:?})", b.action, selection.guid);
send_spell_cast(
b.action,
&targeting.context(),
&commands,
&self_player,
spells.as_deref(),
&items,
&mut sheath,
&mut ecs,
&mut pending,
&mut queued_melee,
&mut cooldowns,
&mut errors.0,
&mut auto_repeat,
&mut trade_skill_opens,
);
}
// An item action names an item ENTRY, not a position, so the click has to find a copy
// — [`item_action_route`] is that law. A miss (the copy left the bags between the
// click and this drain, or a stale action from a previous session) is a
// debug-log-and-skip, NOT the red error line: nothing was attempted against the
// server, so "Item is not ready" would be a lie.
Some(b) if b.kind == ACTION_KIND_ITEM => {
let Some(store) = targeting.self_store.iter().next() else {
continue;
};
let template = items.template(b.action, 0, &commands).cloned();
// The reference reads the template first and bails on a null record; ours is all
// but always cached by click time (the icon resolve needed it).
let Some(template) = template else {
debug!(
"ui_action: item action {action} (entry {}) has no template yet — skipped",
b.action
);
continue;
};
let route = item_action_route(&template, |s| {
crate::ui_items::find_item(&store.0, &items, b.action, s)
});
let ((bag_index, slot0, guid), equip) = match route {
ItemRoute::Use(pos) => (pos, false),
ItemRoute::Equip(pos) => (pos, true),
ItemRoute::Nowhere => {
debug!(
"ui_action: item action {action} (entry {}) is nowhere in the inventory — skipped",
b.action
);
continue;
}
};
if equip {
// Deliberately WITHOUT the bag click's quest guard: the bar's own engine tests
// only `[rec+0x2c]` (inventoryType) before the equip route (`0x4e5fdd`), where
// `Script::UseContainerItem` also tests `StartQuest` (`0x4fa3c4`) — so an
// equippable quest-starter on the bar equips, exactly as the reference does
// (decision 0664).
debug!("ui_action: item action {action} auto-equip (wire {bag_index}/{slot0})");
let _ = commands.0.send(ClientCommand::AutoEquipItem {
bag_index,
slot: slot0,
});
} else {
// The item twin of the cast path's local not-ready refusal
// (`IsItemOnCooldown 0x6e2fc0`: the on-use spell against the cooldown list)
// — reason 0x28 is the client's "Item is not ready yet.".
let on_cooldown = template.use_spell.filter(|u| {
cooldowns.is_on_cooldown(
u.spell_id,
spells.as_ref().and_then(|s| s.catalog.get(u.spell_id)),
Instant::now(),
)
});
if let Some(u) = on_cooldown {
debug!("ui_action: item action {action} refused locally — on cooldown");
errors.0 .0.push((u.spell_id, 0x28));
continue;
}
// …then the shared use fork (`CGItem::Use` — the bar's engine calls the very
// same function at `0x4e607b`), so a quest-starter on the bar offers its quest
// instead of a `CMSG_USE_ITEM` the server can only refuse (decision 0664). The
// wire's third byte is the spell BLOCK ordinal, not a flag (decision 0666).
let spell_index = template.use_spell_index().unwrap_or(0);
debug!(
"ui_action: item action {action} use (wire {bag_index}/{slot0}, spell #{spell_index})"
);
let _ = commands.0.send(crate::ui_items::item_use_command(
Some(guid),
template.start_quest,
bag_index,
slot0,
spell_index,
));
}
}
Some(b) => {
debug!(
"ui_action: action {action} kind {:#04x} not castable yet (macro)",
b.kind
);
}
None => debug!("ui_action: UseAction({action}) on an empty slot"),
}
}
}
/// Drain the `(lua action id, packed)` pairs the cursor seam's `PickupAction`/`PlaceAction`
/// queued (decision 0216 §7) — the engine's own local mutation already agrees with what lands
/// here (it wrote the same value into its optimistic `model.actions` mirror before queuing this).
/// Each entry: write `PlayerActions.buttons` (`packed == 0` removes the slot, else inserts),
/// mark `dirty` so [`super::feed::feed_actions`] re-resolves + re-pushes + fires
/// `ACTIONBAR_SLOT_CHANGED` (the existing diff machinery — no bespoke event here), and send ONE
/// `CMSG_SET_ACTION_BUTTON` (0218 §4: client-authoritative, no answer packet, a drag-swap is two
/// independent sends).
pub(super) fn drain_action_sets(
script: Option<NonSendMut<UiScript>>,
mut actions: ResMut<PlayerActions>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
for (lua_id, packed) in script.take_action_sets() {
let Ok(slot) = u8::try_from(lua_id.saturating_sub(1)) else {
debug!("ui_action: set_action_button lua id {lua_id} out of range — ignored");
continue;
};
if packed == 0 {
actions.buttons.remove(&slot);
} else {
actions.buttons.insert(
slot,
ActionButton {
slot,
action: packed & 0x00FF_FFFF,
kind: (packed >> 24) as u8,
},
);
}
actions.dirty = true;
debug!(
"ui_action: set_action_button lua {lua_id} (wire slot {slot}) packed {packed:#010x}"
);
let _ = commands.0.send(ClientCommand::SetActionButton {
button: slot,
packed,
});
}
}
@@ -0,0 +1,75 @@
//! [`super::drain::item_action_route`] — the reference's two-stage equip-vs-use law for an ITEM
//! action (decision 0666). The inventory walk is stubbed, so these pin the *decision*, not the
//! search (`ui_items::find_item` has its own tests for the walk order).
use super::drain::{item_action_route, ItemRoute};
use crate::items::test_template;
use crate::ui_items::ItemSearch;
// `(bag_index, slot, instance guid)` — the triple the walk returns (the guid feeds the
// shared use fork, decision 0664).
const WORN_AT: (u8, u8, u64) = (255, 13, 0xE1); // the trinket-1 doll slot
const IN_BAG: (u8, u8, u64) = (255, 23, 0xB1); // the first backpack slot
/// A consumable (`InventoryType == 0`) is never equipped, wherever it is.
#[test]
fn a_consumable_always_uses() {
let food = test_template("Tough Jerky"); // inventory_type 0
assert_eq!(
item_action_route(&food, |_| Some(IN_BAG)),
ItemRoute::Use(IN_BAG)
);
assert_eq!(item_action_route(&food, |_| None), ItemRoute::Nowhere);
}
/// **The bug this closes.** An equippable item that IS worn uses in place — it does not
/// re-equip. A one-stage `equippable → equip` fork answers `Equip` here forever, and the old
/// bags-only walk answered `Nowhere`, which is what made the button inert.
#[test]
fn an_equipped_trinket_uses_in_place() {
let mut trinket = test_template("Trinket");
trinket.inventory_type = 12; // INVTYPE_TRINKET
let route = item_action_route(&trinket, |s: ItemSearch| {
// The doll stage finds it; so would the full walk, at the same position.
s.equipment_only.then_some(WORN_AT).or(Some(WORN_AT))
});
assert_eq!(route, ItemRoute::Use(WORN_AT));
}
/// The same trinket sitting in a bag equips instead — and the *same button* then uses it,
/// which is exactly the two-stage behaviour the reference has.
#[test]
fn an_unworn_trinket_equips() {
let mut trinket = test_template("Trinket");
trinket.inventory_type = 12;
let route = item_action_route(&trinket, |s: ItemSearch| {
(!s.equipment_only).then_some(IN_BAG)
});
assert_eq!(route, ItemRoute::Equip(IN_BAG));
}
/// The charge filter is asked for **only** when the template says the item has finite
/// charges, and only on the use leg (`template+0x144 != 0 && != -1`).
#[test]
fn the_charge_filter_rides_only_a_charged_use() {
let plain = test_template("Potion");
item_action_route(&plain, |s: ItemSearch| {
assert!(!s.live_charges_only, "an uncharged item never filters");
Some(IN_BAG)
});
let mut charged = test_template("Wand of Five Uses");
charged.spell_charges_0 = 5;
item_action_route(&charged, |s: ItemSearch| {
assert!(s.live_charges_only, "a charged item skips spent copies");
Some(IN_BAG)
});
// -1 is the "unlimited" sentinel, not a finite count.
let mut unlimited = test_template("Hearthstone");
unlimited.spell_charges_0 = -1;
item_action_route(&unlimited, |s: ItemSearch| {
assert!(!s.live_charges_only, "-1 means unlimited, not finite");
Some(IN_BAG)
});
}
+16 -6
View File
@@ -33,10 +33,12 @@ pub(crate) struct CastErrors(pub Vec<(u32, u8)>);
#[derive(Resource, Default)]
pub(crate) struct MountErrors(pub Vec<(bool, u32)>);
/// One client-LOCAL red-line message: a GlobalStrings key plus the `DisplayError` argText
/// fills. The 1.12 error formats use at most one `%s` and one `%d` ("Requires %s",
/// "Requires %s %d" — wow-re cursor-system.md §8.8's lock-refusal toasts, decision 0545);
/// a key whose string carries no token ignores its fills.
/// One `DisplayError` message: a GlobalStrings key plus the argText fills. The 1.12 error
/// formats use at most one `%s` and one `%d` ("Requires %s", "Requires %s %d" — wow-re
/// cursor-system.md §8.8's lock-refusal toasts, decision 0545); a key whose string carries no
/// token ignores its fills. Not red-line-only: this is the payload of the reference's ONE
/// `CGGameUI::DisplayError` (`0x496720`) whatever surface the message's record names — the
/// quest refusals carry their chat lines in it too (decision 0669).
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct UiError {
pub key: &'static str,
@@ -65,8 +67,9 @@ pub(crate) struct UiErrorKeys(pub Vec<UiError>);
/// Resolve one [`UiError`] to its displayed text — `GetText(key)` + the `%s`/`%d` argText
/// substitution ("Requires %s" + "Herbalism" → "Requires Herbalism", cursor-system.md §8.8).
/// `None` (absent or empty key) = show nothing: GlobalStrings data-suppression, faithfully.
pub(super) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
/// `None` (absent or empty key) = show nothing: GlobalStrings data-suppression, faithfully
/// (the ref's own `[record+0x00]` null/empty guard at `0x4967bd`/`0x4967c5`).
pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
let mut text = get(e.key)?;
if let Some(s) = &e.fill_s {
text = text.replace("%s", s);
@@ -309,6 +312,13 @@ mod ui_error_tests {
"That has already been used."
);
assert_eq!(g("ERR_USE_CANT_OPEN").unwrap(), "You can't open that.");
// The ENGINE's own by-key refusal (`benilla_ui`'s `place_action`, errorId `0x9e`). The
// key is the load-bearing half: an unresolvable one makes `ui_error_text` answer None and
// the refusal quietly goes back to being the divergence 0666 named.
assert_eq!(
g("ERR_PASSIVE_ABILITY").unwrap(),
"You can't put a passive ability in the action bar."
);
// The wire-side totem fill's template (feed_actions' 0x78 arm): "Requires Mining Pick".
assert_eq!(g("SPELL_FAILED_TOTEMS").unwrap(), "Requires %s");
+367
View File
@@ -0,0 +1,367 @@
//! **Inward** — the action bar's *identity* feed: what each of the 120 slots shows.
//!
//! [`feed_actions`] resolves each occupied slot's icon (spell: Spell.dbc × SpellIcon.dbc; item: the
//! item template chain, the same ask-once store the bags use) and count (item: the bag walk,
//! `ui_items::count_of`), diffs against what the VM already holds, pushes the changed slots and
//! fires `ACTIONBAR_SLOT_CHANGED` per transition.
//!
//! **What is gated and what is not** is the whole design here. The identity resolve runs on its two
//! inputs having changed — `PlayerActions::dirty` (a real, if occasional, event: login or a local
//! pickup/place) OR a landed item template ([`Items::template_epoch`], decision 0660: an ITEM
//! icon's template is fetched ask-once, so the *first* resolve of a cold entry is the one that
//! ISSUES the query and reads back nothing). Two things drift independently of BOTH and so refresh
//! every frame instead: an ITEM slot's bag **count** (eating a stack down never touches
//! `SMSG_ACTION_BUTTONS`) and a weapon-substituting **icon** ([`super::weapon_icon`], decisions
//! 0230/0231 — a swap changes it without touching the action table). Gating those on the same flag
//! is what leaves a stale Count fontstring or a stale Attack face.
//!
//! The feed also pumps the four red-error-line queues (cast fails, mount refusals, the app's
//! by-key local refusals, and the ENGINE's own — `benilla_ui` is engine-free and cannot reach
//! [`UiErrorKeys`], so a refusal raised inside the script crate queues its GlobalStrings key here
//! instead) into `UI_ERROR_MESSAGE`, and the stance page (`GetBonusBarOffset`) — our descriptor's
//! shapeshift-form byte indexed into `SpellShapeshiftForm.dbc`'s BonusActionBar column, wow-re
//! byte-verified, firing `UPDATE_BONUS_ACTIONBAR` on change.
use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use super::errors::{first_missing_totem, first_short_reagent, mount_result_key};
use super::weapon_icon::{auto_attack_icon, substitutes_weapon_icon};
use super::{
cast_fail, ui_error_text, CastErrors, MountErrors, PlayerActions, Spells, UiError, UiErrorKeys,
};
/// What an ITEM action shows when its icon cannot be resolved — the reference's own hardcoded
/// literal at `0x847fe4`, returned by the item-icon resolver's failure block `0x5d8927` (wow-re
/// `action-item-slot.md` §1/§4: exactly two engine sites binary-wide, zero Lua). Reached two ways:
/// the template hasn't answered yet (displayId 0 — the login window decision 0660 closes), or the
/// displayId has no `ItemDisplayInfo` row at all.
pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark";
/// The feed's memory of what it last pushed, for per-slot change events.
#[derive(Default)]
pub(super) struct FeedMemory {
pushed: HashMap<u32, ActionSlot>,
bonus_offset: u8,
/// The [`Items::template_epoch`] the last identity resolve ran at — the feed's half of the
/// landed-template redisplay (decision 0660). An advance re-resolves, exactly like a bar edit.
template_epoch: u64,
}
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
pub(super) fn feed_actions(
script: Option<NonSendMut<UiScript>>,
mut actions: ResMut<PlayerActions>,
mut cast_errors: ResMut<CastErrors>,
mut mount_errors: ResMut<MountErrors>,
mut ui_error_keys: ResMut<UiErrorKeys>,
spells: Option<Res<Spells>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
commands: Res<NetCommands>,
mut memory: Local<FeedMemory>,
) {
let Some(mut script) = script else {
return;
};
// Rejected casts surface as the client's red error line (UI_ERROR_MESSAGE → the errors
// frame), resolved through the byte-verified two-layer display ([`cast_fail`]) against the
// VM's own GlobalStrings — resolve first (immutable script), then fire (mutable).
// 0x78 TOTEMS / 0x5c REAGENTS are the argument-formatted reasons whose `%s` fill benilla
// models (decisions 0545 + 0552, the ref's shared fill arm `0x6e1e7f`): "Requires %s" /
// "Missing reagent: %s" + the FAILING slot's item name — re-derived here exactly as the
// check derived it (first missing totem / first short reagent against our bags). On an
// item-cache miss the ref queries and shows nothing that frame, then its DBCACHECALLBACK
// `0x6e29b0` REDISPLAYS when the answer lands — modeled by keeping the entry queued: the
// ask-once query is away, and the frame the template answers, the fill succeeds and fires.
let self_store = self_q.iter().next();
let mut await_template: Vec<(u32, u8)> = Vec::new();
let texts: Vec<String> = cast_errors
.0
.drain(..)
.filter_map(|(spell_id, reason)| {
let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
// 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* — the other argument-formatted family whose
// `%s` benilla models (`0x6e1db7`, the arm that resolves an item class/subclass name
// through `0x6e2380`): "Must have a **Wand** equipped", the SINGULAR DisplayName,
// where the spell tooltip's own requirement line takes the verbose plural. Purely a
// DBC read, so no query/redisplay round trip. A multi-bit mask resolves too — through
// ItemSubClassMask.dbc's group name, else the FIRST matching subclass (law
// §3-EQUIPITEM; the tooltip's twin joins instead).
if let (0x19..=0x1b, Some(d), Some(subs)) = (reason, d, sub_classes.as_deref()) {
if let Some(name) = (d.equipped_item_class >= 0)
.then(|| {
subs.0.requirement_display_name(
d.equipped_item_class as u32,
d.equipped_item_subclass_mask,
)
})
.flatten()
{
let key = cast_fail::CAST_FAIL_KEYS[reason as usize];
return get(key)
.filter(|s| !s.is_empty())
.map(|t| t.replace("%s", &name));
}
}
if reason == 0x78 || reason == 0x5c {
let d = d?;
let failing = if reason == 0x78 {
self_store
.and_then(|s| first_missing_totem(d, s, &items))
// No store to test against (a race): name the first tool at all.
.or_else(|| d.totems.iter().copied().find(|&t| t != 0))
} else {
self_store
.and_then(|s| first_short_reagent(d, s, &items))
.or_else(|| d.reagents.iter().map(|&(id, _)| id).find(|&id| id != 0))
}?;
let cached = items
.template(failing, 0, &commands)
.map(|i| i.name.clone());
let name = match cached {
Some(name) => name,
// Answered-unknown → the ref's callback fallback literal (`0x838044`);
// still pending → keep the entry queued for the redisplay.
None if items.template_answered_unknown(failing) => "UNKNOWN".to_string(),
None => {
await_template.push((spell_id, reason));
return None;
}
};
let key = if reason == 0x78 {
"SPELL_FAILED_TOTEMS"
} else {
"SPELL_FAILED_REAGENTS"
};
return get(key)
.filter(|s| !s.is_empty())
.map(|t| t.replace("%s", &name));
}
cast_fail::cast_fail_text(reason, d, &get)
})
.collect();
cast_errors.0.extend(await_template);
for text in texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
// (Dis)mount refusals ride the same red line, keyed straight into GlobalStrings
// ([`mount_result_key`] — no format arguments in any of these strings).
let mount_texts: Vec<String> = mount_errors
.0
.drain(..)
.filter_map(|(mount, code)| {
let key = mount_result_key(mount, code)?;
script.lua().globals().get::<String>(key).ok()
})
.collect();
for text in mount_texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
// Client-local by-key refusals (the `DisplayError` route — [`UiErrorKeys`]) ride the same
// red line; the key IS the GlobalStrings lookup, no code table between.
let key_texts: Vec<String> = ui_error_keys
.0
.drain(..)
.filter_map(|e| ui_error_text(&e, &|key| script.lua().globals().get::<String>(key).ok()))
.collect();
for text in key_texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
// The ENGINE's own by-key refusals ride the very same line. `benilla_ui` is engine-free and
// cannot reach [`UiErrorKeys`], so a refusal raised inside the script crate (today: dropping a
// passive spell on the bar, `ERR_PASSIVE_ABILITY`) queues its key and we resolve it here —
// standing in for the reference's inline `push <errorId>; call CGGameUI::DisplayError`. One
// frame late by construction (the refusal happens during the input pass this feed precedes),
// which is invisible on a toast.
let engine_keys = script.take_ui_errors();
let engine_texts: Vec<String> = engine_keys
.into_iter()
.filter_map(|key| {
ui_error_text(&UiError::key(key), &|k| {
script.lua().globals().get::<String>(k).ok()
})
})
.collect();
for text in engine_texts {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
let store = self_q.iter().next();
// Stance page: our own descriptor's form byte, pushed on change (UPDATE_BONUS_ACTIONBAR is
// the client's event for exactly this transition — the bar re-picks its page on it).
let form = store.map(|s| s.0.unit_shapeshift_form()).unwrap_or(0);
let offset = spells
.as_ref()
.and_then(|s| s.forms.get(&u32::from(form)))
.map(|f| f.bonus_bar)
.unwrap_or(0) as u8;
if offset != memory.bonus_offset {
debug!("ui_action: bonus bar offset {} (form {form})", offset);
memory.bonus_offset = offset;
script.set_bonus_bar_offset(offset);
script.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
}
// The identity resolve has TWO inputs, not one. `dirty` covers the action table; the item
// TEMPLATE cache is the other, and it fills asynchronously — an ITEM slot's icon needs a
// template that `Items::template` fetches **ask-once**, so the very first resolve of a cold
// entry is the call that ISSUES the query and it necessarily reads back `None`. Gating on
// `dirty` alone left that slot on the fallback question mark until some unrelated bar edit
// happened to re-dirty it — the login race that put a question mark on every fresh
// character's food/water button (decision 0660; verified live 2026-07-26: the Tough Jerky
// ask and the one and only feed landed 0.5 ms apart, in that order, and nothing re-fed).
// The epoch is the second input, so a landed answer redisplays like the ref's DBCACHECALLBACK.
let template_epoch = items.template_epoch();
if actions.dirty || template_epoch != memory.template_epoch {
actions.dirty = false;
memory.template_epoch = template_epoch;
// Resolve every occupied wire slot to its display, diff against what the VM holds, push +
// fire ACTIONBAR_SLOT_CHANGED (arg1 = the Lua action id) per transition. Item icons/counts
// resolve via the same ask-once template chain + bag walk the bags use
// (`ui_items::count_of`) — an in-flight template shows the fallback (no texture), and the
// epoch gate above re-runs this whole resolve the frame the answer lands.
let mut fresh: HashMap<u32, ActionSlot> = HashMap::new();
for (slot, button) in &actions.buttons {
let (texture, count) = match button.kind {
ACTION_KIND_SPELL => {
// The melee auto-attack shows the equipped weapon icon, not spell 6603's `Temp`
// placeholder (decision 0230); every other spell uses its own icon.
let d = spells.as_ref().and_then(|s| s.catalog.get(button.action));
let icon = d
.and_then(|d| {
auto_attack_icon(d, store, &mut items, icons.as_deref(), &commands)
})
.or_else(|| d.and_then(|d| d.icon.clone()));
(icon, 0)
}
ACTION_KIND_ITEM => {
// The question mark belongs HERE, not in the Lua (decision 0666, correcting
// 0660's modeling note): the reference's resolver never returns nil for a
// populated ITEM slot — an un-cached template (displayId 0) or a displayId
// with no row both fall into `0x5d88b0`'s failure block `0x5d8927`, which
// returns the hardcoded `INV_Misc_QuestionMark` at `0x847fe4`. Since ref
// FrameXML *hides* the icon on a nil texture, feeding nil here and letting a
// Lua `or` paint the fallback would show a BLANK button on faithful
// FrameXML — the placeholder is the engine's, at two sites binary-wide.
let texture = items
.template(button.action, 0, &commands)
.cloned()
.and_then(|t| icons.as_ref()?.catalog.get(t.display_info_id)?.icon.clone())
.unwrap_or_else(|| MISSING_ITEM_ICON.to_string());
let count = store
.map(|s| crate::ui_items::count_of(&s.0, &items, button.action))
.unwrap_or(0);
(Some(texture), count)
}
// Macro actions: no macro catalog yet (no macro window ships) — the engine-side
// fallback icon shows, matching the pre-slice-4 gap.
_ => (None, 0),
};
fresh.insert(
u32::from(*slot) + 1,
ActionSlot {
texture,
kind: button.kind,
action: button.action,
count,
},
);
}
let changed: Vec<u32> = fresh
.keys()
.chain(memory.pushed.keys())
.copied()
.collect::<HashSet<_>>()
.into_iter()
.filter(|a| fresh.get(a) != memory.pushed.get(a))
.collect();
for &action in &changed {
script.set_action(action, fresh.get(&action).cloned());
}
memory.pushed = fresh;
debug!(
"ui_action: fed {} changed slot(s) ({} occupied)",
changed.len(),
memory.pushed.len()
);
for action in changed {
script.fire_event(
"ACTIONBAR_SLOT_CHANGED",
vec![ScriptValue::Int(i64::from(action))],
);
}
}
// Two things drift independently of `dirty` (decision 0216 §7's module-doc note) and so must
// refresh every frame, not just on an action-table edit: an ITEM slot's COUNT (eating down a
// stack never touches SMSG_ACTION_BUTTONS) and the auto-attack's ICON (it tracks the equipped
// main-hand weapon, which a weapon swap changes without touching the action table — decision
// 0230). Gating either on `dirty` leaves it stale until the next unrelated action-bar edit.
// Bounded to the already-pushed slots (normally a handful) — the same per-frame bag walk /
// template lookup `count_of` already pays for the quest log's item objectives.
if let Some(store) = store {
for (&action, slot) in memory.pushed.iter_mut() {
let changed = match slot.kind {
ACTION_KIND_ITEM => {
let fresh = crate::ui_items::count_of(&store.0, &items, slot.action);
let changed = fresh != slot.count;
if changed {
slot.count = fresh;
}
changed
}
// A weapon-substituting icon tracks the equipped weapon — main-hand for Attack
// (decision 0230), the ranged slot for Auto Shot / wand Shoot (decision 0231's
// ranged case) — which a swap changes without touching the action table, so
// refresh it too. A normal spell's icon is stable, so it's skipped.
ACTION_KIND_SPELL => {
let d = spells.as_ref().and_then(|s| s.catalog.get(slot.action));
match d.filter(|d| substitutes_weapon_icon(d)) {
Some(d) => {
let fresh = auto_attack_icon(
d,
Some(store),
&mut items,
icons.as_deref(),
&commands,
)
.or_else(|| d.icon.clone());
let changed = fresh != slot.texture;
if changed {
slot.texture = fresh;
}
changed
}
None => false,
}
}
_ => false,
};
if changed {
script.set_action(action, Some(slot.clone()));
script.fire_event(
"ACTIONBAR_SLOT_CHANGED",
vec![ScriptValue::Int(i64::from(action))],
);
}
}
}
}
+14 -12
View File
@@ -1,4 +1,4 @@
//! [`super::feed_actions`] as a real Bevy system through a real Lua VM — the seam where the
//! [`super::feed::feed_actions`] as a real Bevy system through a real Lua VM — the seam where the
//! action bar's slot identity is resolved and pushed.
//!
//! What these pin is the **landed-template redisplay** (decision 0660): an ITEM slot's icon needs
@@ -16,7 +16,8 @@ use benilla_protocol::messages::{ActionButton, ACTION_KIND_ITEM};
use benilla_ui::script::UiScript;
use bevy::prelude::*;
use super::{feed_actions, CastErrors, MountErrors, PlayerActions, UiErrorKeys};
use super::feed::{feed_actions, MISSING_ITEM_ICON};
use super::{CastErrors, MountErrors, PlayerActions, UiErrorKeys};
use crate::entities::ItemDisplays;
use crate::items::{test_template, Items};
use crate::net::{ClientCommand, NetCommands};
@@ -78,19 +79,20 @@ fn fed_texture(app: &mut App) -> Option<String> {
.unwrap()
}
/// The whole bug in one test: the first resolve issues the query and can only feed nothing; the
/// frame the answer lands, the slot re-resolves and the real icon arrives. Before decision 0660 the
/// second half never happened — the button kept the fallback question mark for the whole session,
/// until some unrelated bar edit re-dirtied the feed.
/// The whole bug in one test: the first resolve issues the query and can only show the
/// placeholder; the frame the answer lands, the slot re-resolves and the real icon arrives.
/// Before decision 0660 the second half never happened — the button kept the question mark for the
/// whole session, until some unrelated bar edit re-dirtied the feed.
#[test]
fn a_landed_item_template_redisplays_the_action_slot() {
let (mut app, rx) = app_with_food_on_the_bar();
app.update();
assert_eq!(
fed_texture(&mut app),
None,
"the first resolve of a cold entry IS the ask — it cannot have an icon yet"
fed_texture(&mut app).as_deref(),
Some(MISSING_ITEM_ICON),
"the first resolve of a cold entry IS the ask, so it can only show the reference's own \
placeholder — and never nil, since ref FrameXML HIDES the icon on a nil texture (0666)"
);
assert!(
rx.try_iter()
@@ -154,9 +156,9 @@ fn an_unknown_entry_answers_once_and_settles() {
app.update();
assert_eq!(
fed_texture(&mut app),
None,
"an unknown entry has no display and so no icon"
fed_texture(&mut app).as_deref(),
Some(MISSING_ITEM_ICON),
"an unknown entry has no display, which is the resolver's OTHER route to the placeholder"
);
assert!(
app.world()
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
//! **Weapon-icon substitution** — the handful of spells that show an *equipped weapon's* icon
//! instead of their own (decisions 0230 + 0231; wow-re `attack-icon-substitution.md`).
//!
//! Two spells' worth of law, but it is character-level rather than spell-level: the melee
//! auto-attack borrows the main hand's icon (or `Spell-Reset` when unarmed), a ranged auto-repeat
//! shot borrows the ranged slot's (unless it is thrown, which keeps the spell's own face). Both
//! track the *equipped item*, which a weapon swap changes without ever touching the action table —
//! which is why [`super::feed`] refreshes these every frame rather than on the identity gate, and
//! why `ui_spellbook` pre-resolves them once per page.
use benilla_formats::SpellDisplay;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore};
/// Equipment slot 15 = `EQUIPMENT_SLOT_MAINHAND` (vmangos `EquipmentSlots`).
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
/// Equipment slot 17 = `EQUIPMENT_SLOT_RANGED` — the ranged helper `0x4e6990`'s read
/// (`[ecx+0x88]`, `0x88 = 17×8`; wow-re `attack-icon-substitution.md` §5).
const EQUIPMENT_SLOT_RANGED: u8 = 17;
/// Weapon subclass 16 = thrown — the ranged icon helper's skip (`0x4e6990`'s `0x5d9f90 == 0x10`
/// test): a thrown weapon never substitutes its icon, so Throw keeps the spell's own face.
const ITEM_SUBCLASS_THROWN: u32 = 16;
/// The client's unarmed/disarmed auto-attack icon (wow-re `attack-icon-substitution.md`, the
/// hardcoded string at `0x84bf58`) — what the melee auto-attack shows when there is no main-hand
/// weapon to borrow from, instead of spell 6603's `Temp` placeholder (decision 0231).
const SPELL_RESET_ICON: &str = "Interface\\Buttons\\Spell-Reset";
/// The equipped main-hand weapon's inventory icon (slot 15 → the item's `ItemDisplayInfo` icon,
/// the chain the bags/paper doll use). `None` when unarmed or the item hasn't streamed yet.
fn main_hand_weapon_icon(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
let guid = store.0.player_inv_slot(EQUIPMENT_SLOT_MAINHAND)?;
let entry = items.object(guid)?.object_entry()?;
let display = items.template(entry, guid, commands)?.display_info_id;
icons?.catalog.get(display)?.icon.clone()
}
/// The character's melee auto-attack icon (decision 0231; the client's melee helper `0x4e6870`):
/// the equipped main-hand weapon's icon, or [`SPELL_RESET_ICON`] when unarmed. Character-level —
/// independent of WHICH auto-attack spell (they all show this), so the spellbook can pre-resolve it
/// once for its whole page. (The verdict's ranged auto-repeat / shapeshift-form / disarmed cases
/// are deferred — see decision 0231; this covers the common melee, armed-or-unarmed case.)
pub(crate) fn melee_auto_attack_icon(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> String {
main_hand_weapon_icon(store, items, icons, commands)
.unwrap_or_else(|| SPELL_RESET_ICON.to_string())
}
/// The equipped ranged weapon's inventory icon (slot 17 → `ItemDisplayInfo`), for the ranged
/// icon substitution (`0x4e6990`, decision 0231's deferred case — wow-re
/// `attack-icon-substitution.md` §5): a **thrown** weapon is skipped (the helper's
/// `0x5d9f90 == 0x10` test), and `None` — missing weapon, thrown, or an unstreamed item — falls
/// back to the spell's OWN icon at the caller, never `Spell-Reset` (the helper's `0x4e6a44` null
/// return hands over to the normal SpellIconID path).
pub(crate) fn ranged_weapon_icon(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
let guid = store.0.player_inv_slot(EQUIPMENT_SLOT_RANGED)?;
let entry = items.object(guid)?.object_entry()?;
let template = items.template(entry, guid, commands)?;
if template.subclass == ITEM_SUBCLASS_THROWN {
return None;
}
let display = template.display_info_id;
icons?.catalog.get(display)?.icon.clone()
}
/// Whether `spell` substitutes an equipped weapon's icon at all — the two resolvers' shared
/// pre-test (melee: the effect trigger; ranged: the paired attribute bits). The per-frame icon
/// refresh keys on this, so a ranged-weapon swap re-feeds Auto Shot like a main-hand swap
/// re-feeds Attack.
pub(super) fn substitutes_weapon_icon(spell: &SpellDisplay) -> bool {
spell.is_melee_auto_attack() || spell.ranged_icon_substitution()
}
/// The icon `spell` shows on the action bar when it substitutes an equipped weapon's
/// ([`substitutes_weapon_icon`]): the melee auto-attack shows [`melee_auto_attack_icon`]
/// (weapon or `Spell-Reset`); a ranged auto-repeat shot ([`SpellDisplay::ranged_icon_substitution`])
/// shows [`ranged_weapon_icon`]. `None` for any other spell, for a ranged shot with no
/// substitutable weapon, or when there is no character to read the weapon from — the caller uses
/// the spell's own icon.
pub(super) fn auto_attack_icon(
spell: &SpellDisplay,
store: Option<&ObjectStore>,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
let store = store?;
if spell.is_melee_auto_attack() {
return Some(melee_auto_attack_icon(store, items, icons, commands));
}
if spell.ranged_icon_substitution() {
return ranged_weapon_icon(store, items, icons, commands);
}
None
}
+6 -2
View File
@@ -426,7 +426,7 @@ fn slot_view(
.filter(|&g| g != 0)
.and_then(|g| names.resolve(g, commands).map(str::to_string));
let t = items.template(entry, guid, commands);
let (name, quality, display, link, equip_slots, class) = match t {
let (name, quality, display, link, equip_slots, class, bar_placeable) = match t {
Some(t) => (
Some(t.name.clone()),
t.quality as i32,
@@ -439,8 +439,9 @@ fn slot_view(
)),
find_equip_slot(t.inventory_type),
t.class,
t.placeable_on_action_bar(),
),
None => (None, 0, 0, None, Vec::new(), 0),
None => (None, 0, 0, None, Vec::new(), 0, false),
};
// The quiver's bag-bar count is what's INSIDE it: the ref's `GetInventoryItemCount("player",
// bagSlot)` returns the ammo left in a worn ammo container — the "162" on the bag bar — and
@@ -471,6 +472,7 @@ fn slot_view(
flags,
locked: pending.contains(EQUIPMENT_BAG, live_id),
equip_slots,
bar_placeable,
creator,
})
}
@@ -552,6 +554,8 @@ fn inventory_slots(
// fit — both stay the inert default.
locked: false,
equip_slots: Vec::new(),
// Ammo is a named deferral too: no drag path reaches the bar from this slot.
bar_placeable: false,
creator: None,
});
}
+93 -4
View File
@@ -470,6 +470,15 @@ pub(super) fn drain_chat_input(
// over the same binding the popup row calls, so it enters the same intent queue
// (decision 0646 §3; the `/duel` reasoning above, verbatim).
ParsedChat::Pvp => script.queue_pvp_toggle(),
// Run the reference's own slash body (see the variant's doc). The argument is a Lua
// string literal, so it is escaped: a `/who` filter legitimately contains quotes
// (`z-"Elwynn Forest"`), and a newline would end the statement.
ParsedChat::Social { verb, arg } => {
let lua = format!("{}(\"{}\")", verb.lua_fn(), escape_lua_string(&arg));
if let Err(e) = script.run(&lua) {
warn!("ui_chat(social): {e}");
}
}
ParsedChat::Help => {
// An honest benilla summary (the ref's HELP_TEXT_LINE pages are a settings-era
// nicety; this stays useful and never stale-quotes them).
@@ -557,10 +566,14 @@ pub(super) fn drain_chat_input(
}
}
ParsedChat::ChatTest => chattest_battery(&mut chat_log),
ParsedChat::Logout => match commands.0.send(ClientCommand::Logout) {
Ok(()) => info!("chat: logout requested"),
Err(_) => warn!("chat: not connected; dropped {msg:?}"),
},
// `/logout` (and `/camp`) is the reference's own `SlashCmdList["LOGOUT"]` → `Logout()`,
// so it takes the SAME route the game menu's Logout button does (decision 0674): the
// request queues on the script seam, `crate::ui_logout` sends it and narrates the
// server's answer with the CAMP countdown. It used to send `CMSG_LOGOUT_REQUEST`
// straight from here, which meant a field logout looked like nothing happening for 20 s.
ParsedChat::Logout => {
script.queue_session_request(benilla_ui::script::SessionRequest::Logout)
}
ParsedChat::Unknown => {
// HELP_TEXT_SIMPLE (the ref's unknown-command reply, ChatEdit_ParseText l.2203).
chat_log.push_event(super::event::ChatEvent::text_only(
@@ -572,6 +585,51 @@ pub(super) fn drain_chat_input(
}
}
/// Escape a player-typed string for embedding in a Lua double-quoted literal: backslashes and
/// quotes are escaped, and the two line terminators are dropped rather than escaped (nothing a
/// slash command means can contain them, and a stray one would end the statement).
fn escape_lua_string(s: &str) -> String {
s.chars()
.filter(|c| *c != '\n' && *c != '\r')
.flat_map(|c| {
let escaped = matches!(c, '\\' | '"');
escaped
.then_some('\\')
.into_iter()
.chain(std::iter::once(c))
})
.collect()
}
/// Which social slash command was typed — the selector for the Lua body it runs
/// ([`ParsedChat::Social`], decision 0668).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SocialVerb {
/// `/who [filter]` — SLASH_WHO 1-2.
Who,
/// `/friends [name]` (aliases `/friend`) — SLASH_FRIENDS 1-4.
Friends,
/// `/removefriend <name>` (alias `/remfriend`) — SLASH_REMOVEFRIEND 1-4.
RemoveFriend,
/// `/ignore [name]` — SLASH_IGNORE 1-2. Toggles: an ignored name is un-ignored.
Ignore,
/// `/unignore [name]` — SLASH_UNIGNORE 1-2.
Unignore,
}
impl SocialVerb {
/// The Lua function in `FriendsFrame.xml` holding this verb's reference body.
fn lua_fn(self) -> &'static str {
match self {
Self::Who => "BenillaSlashWho",
Self::Friends => "BenillaSlashFriends",
Self::RemoveFriend => "BenillaSlashRemoveFriend",
Self::Ignore => "BenillaSlashIgnore",
Self::Unignore => "BenillaSlashUnignore",
}
}
}
/// What a submitted, already-trimmed chat line resolves to (see [`parse_line`]). Kept separate from
/// [`ClientCommand`] so the parser is unit-testable without a live [`crate::sound::EmoteSounds`]
/// resource or a network channel.
@@ -649,6 +707,13 @@ pub(super) enum ParsedChat {
/// `/pvp` — `TogglePVP()` (decision 0646 §3). Takes no argument: the binding has no state
/// form, and the server reads the toggle from our current preference.
Pvp,
/// The social verbs (decision 0668) — `/who`, `/friends`, `/removefriend`, `/ignore`,
/// `/unignore`. Each carries the raw argument (`/who`'s is a whole filter string, not a
/// name), and each runs the reference's OWN `SlashCmdList` body, transcribed into
/// `FriendsFrame.xml` as `BenillaSlash*`: those bodies do more than send (a bare `/who`
/// 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
/// `/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
@@ -792,6 +857,30 @@ pub(super) fn parse_line(line: &str, resolve_emote: impl Fn(&str) -> Option<u32>
// The PvP flag (decision 0646; GlobalStrings' SLASH_PVP 1-2, lines 3718-3719 — both
// aliases are literally "/pvp").
"pvp" => ParsedChat::Pvp,
// The social verbs (decision 0668; aliases quoted from GlobalStrings' SLASH_WHO 1-2,
// SLASH_FRIENDS 1-4, SLASH_REMOVEFRIEND 1-4, SLASH_IGNORE 1-2, SLASH_UNIGNORE 1-2 —
// lines 3585-3794). The argument is passed WHOLE: `/who`'s is a filter expression
// (`z-"Elwynn Forest" 1-10`), not a name, so `slash_target_name` must not touch it.
"who" => ParsedChat::Social {
verb: SocialVerb::Who,
arg: args.trim().to_string(),
},
"friend" | "friends" => ParsedChat::Social {
verb: SocialVerb::Friends,
arg: args.trim().to_string(),
},
"removefriend" | "remfriend" => ParsedChat::Social {
verb: SocialVerb::RemoveFriend,
arg: args.trim().to_string(),
},
"ignore" => ParsedChat::Social {
verb: SocialVerb::Ignore,
arg: args.trim().to_string(),
},
"unignore" => ParsedChat::Social {
verb: SocialVerb::Unignore,
arg: args.trim().to_string(),
},
"h" | "help" | "?" => ParsedChat::Help,
// Not a recognized command — the EmotesText lookup against the whole slash-line
// (`/wave`, `/lol`, …), exactly as before.
+13 -5
View File
@@ -31,12 +31,11 @@
//! text belong to the `DUEL_OUTOFBOUNDS` popup, and the *enforcement* is entirely the server's
//! (`Player::CheckDuelDistance`: 75 yd out, 70 yd back in, 10 s to return).
//!
//! Two deviations, both stated rather than hidden:
//! One deviation, stated rather than hidden (the ignore branch — `0x4d4a33`'s silent
//! `CMSG_DUEL_CANCELLED` for a challenge from an ignored player — is implemented as of decision
//! 0668, which supplied the ignore list it needs):
//!
//! 1. **The ignore branch is absent.** The reference silently answers a challenge from an ignored
//! player with `CMSG_DUEL_CANCELLED` (`0x4d4a33`). benilla has no ignore list yet (it lands
//! with the friends/who slice of decision 0434), so no challenge is ever auto-declined.
//! 2. **The challenger's name is resolved asynchronously.** The reference reads it off the
//! 1. **The challenger's name is resolved asynchronously.** The reference reads it off the
//! challenger's `CGPlayer` and fires nothing at all if the object is missing (`0x4d4a72`).
//! benilla resolves through the [`NameCache`], which may need a `CMSG_NAME_QUERY` round trip —
//! so the popup can arrive a frame or two late instead of never. In practice the challenger is
@@ -162,6 +161,10 @@ pub(crate) mod apply {
/// `SMSG_DUEL_REQUESTED` — store the arbiter, and either own the challenge (error line +
/// immediate accept) or hand the popup to the feed.
///
/// `ignored` short-circuits both: a challenge from a player on the ignore list is answered
/// with `CMSG_DUEL_CANCELLED` and nothing is shown (`0x4d4a33`). This is the branch decision
/// 0633 recorded as absent for want of an ignore list; decision 0668 supplies one.
pub(crate) fn requested(
duel: &mut DuelState,
chat_log: &mut ChatLog,
@@ -169,7 +172,12 @@ pub(crate) mod apply {
arbiter: u64,
challenger: u64,
own: Option<u64>,
ignored: bool,
) {
if ignored {
let _ = commands.0.send(ClientCommand::DuelCancelled { arbiter });
return;
}
if duel.apply_requested(arbiter, challenger, own) {
error_line(chat_log, ERR_DUEL_REQUESTED);
let _ = commands.0.send(ClientCommand::DuelAccepted { arbiter });
+59 -17
View File
@@ -6,7 +6,7 @@
use bevy::prelude::*;
use benilla_protocol::messages::BAG_PLAYER_INVENTORY;
use benilla_ui::script::{ScriptValue, UiScript};
use benilla_ui::script::{ScriptValue, UiScript, EQUIPMENT_BAG};
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
@@ -70,14 +70,18 @@ pub(super) fn drain_container_autoequips(
}
/// Drain the inventory-slot ids `UseInventoryItem` queued (decision 0208 phase 1b: the doll
/// slot's right-click) and send `CMSG_USE_ITEM` directly against the equipped position (bag 255
/// plus the 0-based wire slot — `HandleUseItemOpcode` takes equipped positions the same as bag
/// ones, vmangos `ItemHandler.cpp`). Ids outside 1..=19 (ammo, the bag icons) are a no-op — the
/// engine's own queue never receives them from the shipped XML (only the 19 named slot buttons
/// wire `UseInventoryItem` this slice), but a stray Lua call is still refused rather than sent as
/// nonsense.
/// slot's right-click) and route the equipped position (bag 255 plus the 0-based wire slot —
/// `HandleUseItemOpcode` takes equipped positions the same as bag ones, vmangos `ItemHandler.cpp`)
/// through the shared use fork ([`super::item_use_command`]): the reference's doll click lands in
/// the same `CGItem::Use` a bag click does (`0x4c7af0`), quest fork included — one of the five
/// equippable quest-starters, worn and right-clicked, offers its quest instead of casting nothing.
/// Ids outside 1..=19 (ammo, the bag icons) are a no-op — the engine's own queue never receives
/// them from the shipped XML (only the 19 named slot buttons wire `UseInventoryItem` this slice),
/// but a stray Lua call is still refused rather than sent as nonsense.
pub(super) fn drain_inventory_uses(
script: Option<NonSendMut<UiScript>>,
mut items: ResMut<Items>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
@@ -89,11 +93,28 @@ pub(super) fn drain_inventory_uses(
continue;
}
let slot = (id - 1) as u8;
// The doll's own slot ids ARE the wire slots (`wire_pos`'s EQUIPMENT_BAG law), so the
// equipped instance resolves off the player's INV array directly.
let (guid, start_quest, spell_index) = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &items))
.and_then(|guid| {
let entry = items.object(guid)?.object_entry()?;
let t = items.template(entry, guid, &commands)?;
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
// template is already in hand here for `start_quest`, so name the real one.
Some((Some(guid), t.start_quest, t.use_spell_index().unwrap_or(0)))
})
.unwrap_or((None, 0, 0));
debug!("ui_items: use equipped item, lua slot {id} (wire 255/{slot})");
let _ = commands.0.send(ClientCommand::UseItem {
bag_index: BAG_PLAYER_INVENTORY,
let _ = commands.0.send(super::item_use_command(
guid,
start_quest,
BAG_PLAYER_INVENTORY,
slot,
});
spell_index,
));
}
}
@@ -216,6 +237,12 @@ pub(super) fn drain_container_uses(
// needed it for the icon); unresolved falls back to USE, whose refusal is at least visible.
// display_id feeds the synthetic pickup→place auto-equip sound (this path never moves the
// cursor; a drag already gets that pair via the cursor-payload transitions).
//
// The equip arm carries the reference's own **quest guard** (`0x4fa3bd`–`0x4fa3cc`,
// decision 0664): `Script::UseContainerItem` equips only when the cache record's
// `StartQuest` (`[rec+0x1a8]`) is 0, so a quest-starter goes to the use fork *whatever* its
// inventoryType — the five equippable ones (Pendant of Myzrael, Arena Master, …) offer their
// quest on a right-click, they don't put themselves on.
let resolved = self_q
.iter()
.next()
@@ -223,18 +250,28 @@ pub(super) fn drain_container_uses(
.and_then(|guid| {
let entry = items.object(guid)?.object_entry()?;
let t = items.template(entry, guid, &commands)?;
Some((entry, t.inventory_type, t.display_info_id))
Some((
guid,
entry,
t.inventory_type,
t.display_info_id,
t.start_quest,
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
// template is right here, so send the real one rather than assuming 0.
t.use_spell_index().unwrap_or(0),
))
});
let equippable = resolved.is_some_and(|(_, _, inv_type, _, q, _)| q == 0 && inv_type != 0);
match resolved {
// Ammo loads by entry (`CMSG_SET_AMMO`), NOT the equip swap wire — the stack stays in
// the bag and `PLAYER_AMMO_ID` references it (decision 0526). The server refuses a
// wrong/absent ranged weapon via `SMSG_INVENTORY_CHANGE_FAILURE`.
Some((entry, INVTYPE_AMMO, display_id)) => {
Some((_, entry, INVTYPE_AMMO, display_id, _, _)) if equippable => {
debug!("ui_items: set ammo entry {entry} (lua bag {bag} slot {slot})");
let _ = commands.0.send(ClientCommand::SetAmmo { entry });
equip_sound.write(crate::sound::AutoEquipSound { display_id });
}
Some((_, inv_type, display_id)) if inv_type != 0 => {
Some((_, _, _, display_id, _, _)) if equippable => {
debug!("ui_items: auto-equip (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = commands.0.send(ClientCommand::AutoEquipItem {
bag_index,
@@ -243,11 +280,16 @@ pub(super) fn drain_container_uses(
equip_sound.write(crate::sound::AutoEquipSound { display_id });
}
_ => {
debug!("ui_items: use item (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = commands.0.send(ClientCommand::UseItem {
let (guid, start_quest, spell_index) =
resolved.map_or((None, 0, 0), |(guid, _, _, _, q, i)| (Some(guid), q, i));
debug!("ui_items: use item (lua bag {bag} → wire {bag_index}/{wire_slot}, starts quest {start_quest})");
let _ = commands.0.send(super::item_use_command(
guid,
start_quest,
bag_index,
slot: wire_slot,
});
wire_slot,
spell_index,
));
}
}
}
+2
View File
@@ -469,6 +469,7 @@ fn resolve_slot(
creator,
flags,
equip_slots: find_equip_slot(t.inventory_type),
bar_placeable: t.placeable_on_action_bar(),
cooldown: t.use_spell.and_then(|u| {
let sd = spells.and_then(|s| s.get(u.spell_id));
cooldowns
@@ -756,6 +757,7 @@ mod tests {
fn slot(spell_id: u32, charges: i32) -> ItemSpellEntry {
ItemSpellEntry {
index: 0,
spell_id,
trigger: 0,
charges,
+303 -26
View File
@@ -16,9 +16,11 @@
//! mapping the Lua bag space onto the wire's (backpack = bag 255 + the player-array slot 23…;
//! equipped bags = their own array slot 19–22 + 0-based inner slot — VERIFIED vmangos `Player.h`
//! enums + `UseItem::ReadFromWorldPacket`, shared by every drain here as [`wire_pos`]) and making
//! the real client's **equip-vs-use fork**: an *equippable* item (template `inventoryType != 0`)
//! goes out as `CMSG_AUTOEQUIP_ITEM` — a helm click puts the helm on — everything else as
//! `CMSG_USE_ITEM`. Server refusals (`SMSG_INVENTORY_CHANGE_FAILURE` → [`EquipErrors`]) surface on
//! the real client's **equip-vs-use fork**: an *equippable* item (template `inventoryType != 0`,
//! and not a quest-starter) goes out as `CMSG_AUTOEQUIP_ITEM` — a helm click puts the helm on —
//! everything else through the one use fork ([`item_use_command`], our `CGItem::Use`), which sends
//! `CMSG_QUESTGIVER_QUERY_QUEST` for a quest-starter and `CMSG_USE_ITEM` for everything else
//! (decision 0664). Server refusals (`SMSG_INVENTORY_CHANGE_FAILURE` → [`EquipErrors`]) surface on
//! the red UI error line with the client's message strings. The cursor-payload drains (decision
//! 0216, whole-space since slice 2) ride the same wire map: a queued pick/place/swap move →
//! `CMSG_SWAP_INV_ITEM` (backpack-internal) or `CMSG_SWAP_ITEM` (either end an equipped bag) /
@@ -56,6 +58,9 @@ use feed::{feed_containers, feed_item_sets, feed_item_stats, feed_player_req};
/// The backpack's fixed capacity (`PLAYER_FIELD_PACK_SLOT_1..` — 16 slots on the 1.12 wire).
pub(super) const PACK_SLOTS: u8 = 16;
/// The worn-equipment slots (`INV_SLOT` 0..18 — head through tabard, vmangos `PlayerSlots`), the
/// first region of the reference's inventory walk (wow-re `action-item-slot.md` §8.2).
pub(super) const EQUIPMENT_SLOTS: u8 = 19;
/// The first equipped-bag inventory slot (`INV_SLOT` 19..22 hold bags 1..4).
pub(super) const BAG_SLOT_FIRST: u8 = 19;
/// Equipped bag count (live-API bag ids 1..=4).
@@ -233,27 +238,78 @@ pub(crate) fn count_of(store: &ObjectFields, items: &Items, entry: u32) -> u32 {
total
}
/// The FIRST bag position (wire `(bag_index, 0-based slot)`, [`wire_pos`]'s own output shape)
/// holding item `entry` — the backpack searched before the equipped bags, each in slot order
/// (matches [`count_of`]'s own walk order). `None` if nothing matches. The action-bar item-use
/// drain's resolve (decision 0216 §7, slice 4): an ITEM-kind action names an item id, not a bag
/// position, so clicking it must find SOME instance to act on. The real client's own resolve
/// order (does it prefer the backpack, or slot order at all?) is unverified — a §5 CONFIRM if a
/// future pin disagrees; this is the simplest reading that needs no new wire round-trip.
pub(crate) fn first_bag_slot(store: &ObjectFields, items: &Items, entry: u32) -> Option<(u8, u8)> {
for i in 0..PACK_SLOTS {
let guid = store.player_pack_slot(i).unwrap_or(0);
/// How far [`find_item`] looks, and which copies count — the two mode bits the reference's own
/// callers pass into the inventory walker `0x622420` (wow-re `action-item-slot.md` §8.2).
#[derive(Clone, Copy, Default)]
pub(crate) struct ItemSearch {
/// Mode `1` alone: the **equipment slots only** (0–18), no expansion. The equip-vs-use fork's
/// first stage — "is a copy of this already worn?" (`0x4e5fe7`'s `push 1`).
pub(crate) equipment_only: bool,
/// Mode bit `0x20`: skip a copy whose live `ITEM_FIELD_SPELL_CHARGES[0]` is `0` — the use
/// leg sets it when the TEMPLATE says the item has finite charges, so a click reaches a copy
/// that still has uses left instead of a spent one. Containers are never skipped by it.
pub(crate) live_charges_only: bool,
}
/// Where a copy of item `entry` is: the wire `(bag_index, 0-based slot)` pair ([`wire_pos`]'s own
/// output shape) plus the **instance guid** that occupies it, since the use fork needs it
/// ([`item_use_command`]). This is the reference's inventory search, byte-verified (wow-re
/// `action-item-slot.md` §8.2: the walker `0x622420` over `PLAYER_FIELD_INV_SLOT_HEAD`, predicate
/// `OBJECT_FIELD_ENTRY` equality).
///
/// **Order is load-bearing and is the reference's, not a guess** (it was one until 2026-07-26 —
/// decision 0666 supersedes 0216 §7's "unverified but necessary" note): a single ascending pass
/// over the player's own slot array, recursing depth-first into each container as it is passed —
///
/// > equipment 0–18 → bag slot 19 (then all of bag 1's contents) → 20 (+contents) → 21 (+contents)
/// > → 22 (+contents) → backpack 23–38 → keyring 81–112.
///
/// Two things that fall out and both matter: **equipment is searched FIRST** (the old walk never
/// looked at it at all, so an equipped trinket's action button was inert), and **a bag's contents
/// come before the backpack** (the old walk had the backpack first). Bank and buyback are never
/// searched — the walker's default mode expands to `0x47`, which omits the bank's `0x08`, and no
/// bit exists for buyback at all. The **keyring** is the one region benilla does not model (no
/// keyring container ships yet); it is last in the order, so nothing else shifts when it lands.
pub(crate) fn find_item(
store: &ObjectFields,
items: &Items,
entry: u32,
search: ItemSearch,
) -> Option<(u8, u8, u64)> {
// One candidate: the entry must match, and — under the charges filter — the instance must
// have uses left. A container is exempt from the charge test (the walker's own carve-out).
let hit = |guid: u64| -> bool {
if guid == 0 {
continue;
return false;
}
if items.object(guid).and_then(|f| f.object_entry()) == Some(entry) {
return Some((BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST + i));
let Some(f) = items.object(guid) else {
return false;
};
if f.object_entry() != Some(entry) {
return false;
}
if search.live_charges_only && f.container_num_slots().is_none_or(|n| n == 0) {
return f.item_spell_charges(0).is_none_or(|c| c != 0);
}
true
};
// Equipment 0–18, then the four equipped-bag slots 19–22 — the bag OBJECT is a candidate in
// its own right before its contents are (a bag on the bar is a real, placeable action).
for i in 0..EQUIPMENT_SLOTS {
let guid = store.player_inv_slot(i).unwrap_or(0);
if hit(guid) {
return Some((BAG_PLAYER_INVENTORY, i, guid));
}
}
for bag in 1..=BAGS {
let bag_guid = store.player_inv_slot(BAG_SLOT_FIRST + bag - 1).unwrap_or(0);
if bag_guid == 0 {
continue;
if search.equipment_only {
return None;
}
for bag in 0..BAGS {
let bag_slot = BAG_SLOT_FIRST + bag;
let bag_guid = store.player_inv_slot(bag_slot).unwrap_or(0);
if hit(bag_guid) {
return Some((BAG_PLAYER_INVENTORY, bag_slot, bag_guid));
}
let Some(bag_fields) = items.object(bag_guid) else {
continue;
@@ -261,17 +317,61 @@ pub(crate) fn first_bag_slot(store: &ObjectFields, items: &Items, entry: u32) ->
let num_slots = bag_fields.container_num_slots().unwrap_or(0).min(36) as u8;
for j in 0..num_slots {
let guid = bag_fields.container_slot(j).unwrap_or(0);
if guid == 0 {
continue;
}
if items.object(guid).and_then(|f| f.object_entry()) == Some(entry) {
return Some((SLOT_BAG_FIRST + bag - 1, j));
if hit(guid) {
return Some((bag_slot, j, guid));
}
}
}
for i in 0..PACK_SLOTS {
let guid = store.player_pack_slot(i).unwrap_or(0);
if hit(guid) {
return Some((BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST + i, guid));
}
}
None
}
/// **The item-use fork** — our `CGItem::Use` (`0x5d8d00`): the one place that decides what "using"
/// an item actually sends. The reference has exactly one such function and every use surface calls
/// it — the bag click (`Script::UseContainerItem` @ `0x4fa430`), the doll click
/// (`Script::UseInventoryItem` → `0x4c7af0`) and the action bar (`UseAction`'s engine @ `0x4e607b`)
/// — so the fork lives here rather than in any one drain (decision 0664: three call sites each
/// re-deriving it is exactly how the quest fork came to be missing from all three).
///
/// A template whose **`StartQuest`** is non-zero never goes out as `CMSG_USE_ITEM`: the client
/// sends `CMSG_QUESTGIVER_QUERY_QUEST{the ITEM's own guid, StartQuest}` (byte-verified — the
/// `[rec+0x1a8] != 0` branch at `0x5d8dcc` calls the `0x186` builder `0x5eab80` with the CGItem's
/// guid), and the server answers `SMSG_QUESTGIVER_QUEST_DETAILS` with the item as the giver
/// (vmangos `HandleQuestgiverQueryQuestOpcode` resolves an `HIGHGUID_ITEM` guid through
/// `TYPEMASK_CREATURE_GAMEOBJECT_OR_ITEM`) — i.e. the quest's accept panel. Sending `CMSG_USE_ITEM`
/// instead is what draws *"The item was not found."*: `HandleUseItemOpcode` refuses any item whose
/// `Spells[spellSlot].SpellId` is 0 with `EQUIP_ERR_ITEM_NOT_FOUND`, and **no** quest-starter
/// carries an on-use spell (0 of the 215 in live `mangos.item_template`).
///
/// `guid: None` (the template/instance didn't resolve) falls through to the plain use, whose
/// refusal is at least visible — the same fallback the equip fork makes.
pub(crate) fn item_use_command(
guid: Option<u64>,
start_quest: u32,
bag_index: u8,
slot: u8,
spell_index: u8,
) -> crate::net::ClientCommand {
match guid.filter(|_| start_quest != 0) {
Some(item) => crate::net::ClientCommand::QuestgiverQuery {
npc: item,
quest: start_quest,
},
None => crate::net::ClientCommand::UseItem {
bag_index,
slot,
// The template BLOCK ordinal the server should cast, not a flag (decision 0666); the
// callers that hold the template name it, the doll drain sends 0.
spell_index,
},
}
}
/// The client's quality→color escape for an item link (`GetItemQualityColor`'s table) — shared by
/// [`feed`]'s bag links and [`crate::ui_char`]'s doll-slot links (one table, no drifting twins).
pub(super) fn quality_color(quality: u32) -> &'static str {
@@ -451,9 +551,44 @@ impl Plugin for UiItemsPlugin {
#[cfg(test)]
mod tests {
use super::{find_equip_slot, wire_pos};
use super::{find_equip_slot, item_use_command, wire_pos};
use crate::net::ClientCommand;
use benilla_ui::script::EQUIPMENT_BAG;
/// The use fork ([`item_use_command`], decision 0664): a non-zero `StartQuest` diverts to
/// `CMSG_QUESTGIVER_QUERY_QUEST` addressed to the ITEM's guid; everything else — including an
/// item whose template never resolved — stays a plain `CMSG_USE_ITEM` on the wire position.
#[test]
fn item_use_forks_a_quest_starter_to_the_giver_query() {
// "An Unsent Letter" (entry 2874, StartQuest 373 — live `mangos.item_template`): the item
// guid is the questgiver, not the bag position.
let letter = 0x4000_0000_0000_0BAD_u64;
assert!(matches!(
item_use_command(Some(letter), 373, 255, 23, 0),
ClientCommand::QuestgiverQuery { npc, quest } if npc == letter && quest == 373
));
// A plain consumable (StartQuest 0) is the unchanged USE.
assert!(matches!(
item_use_command(Some(letter), 0, 255, 23, 1),
ClientCommand::UseItem {
bag_index: 255,
slot: 23,
// The template BLOCK ordinal rides through untouched (decision 0666).
spell_index: 1
}
));
// No resolved instance (template still in flight): the fallback is the USE whose refusal
// is at least visible — never a query against guid 0.
assert!(matches!(
item_use_command(None, 373, 19, 4, 0),
ClientCommand::UseItem {
bag_index: 19,
slot: 4,
spell_index: 0
}
));
}
/// A dozen representative `InventoryType`s (decision 0208 phase 1b's own ask), spanning the
/// single-slot rows, the two-slot rows, the weapon rows' MAINHAND/OFFHAND split, and the
/// not-equippable rows — cross-checked against vmangos
@@ -534,3 +669,145 @@ mod tests {
assert_eq!(wire_pos(EQUIPMENT_BAG, 70), None, "past the bank bags");
}
}
/// [`find_item`] — the reference's inventory walk (decision 0666). Everything here is about
/// **order**, because order is the whole finding: the walk it replaced never looked at equipment
/// at all (an equipped trinket's action button was inert) and put the backpack ahead of the bags.
#[cfg(test)]
mod find_item_tests {
use super::{find_item, ItemSearch};
use crate::items::Items;
use benilla_protocol::ObjectFields;
// Descriptor indices, raw (the module's own consts are private; the codebase's test idiom).
const ENTRY: u16 = 3; // OBJECT_FIELD_ENTRY
const CHARGES: u16 = 16; // ITEM_FIELD_SPELL_CHARGES[0]
const NUM_SLOTS: u16 = 48; // CONTAINER_FIELD_NUM_SLOTS
const SLOT_1: u16 = 50; // CONTAINER_FIELD_SLOT_1 (2 fields per guid)
const INV_SLOT_HEAD: u16 = 486; // PLAYER_FIELD_INV_SLOT_HEAD (2 per guid, 23 slots)
const PACK_SLOT_1: u16 = 532; // PLAYER_FIELD_PACK_SLOT_1 (2 per guid, 16 slots)
const TRINKET: u32 = 12_930;
const BAG: u32 = 4_500;
/// A player whose slot array points at the given `(player-array index, guid)` pairs.
fn player(slots: &[(u16, u64)]) -> ObjectFields {
let mut pairs = Vec::new();
for &(idx, guid) in slots {
let base = if idx < 23 {
INV_SLOT_HEAD + 2 * idx
} else {
PACK_SLOT_1 + 2 * (idx - 23)
};
pairs.push((base, guid as u32));
pairs.push((base + 1, (guid >> 32) as u32));
}
ObjectFields::from_pairs(&pairs)
}
/// A plain item instance of `entry` (optionally with live charges).
fn item(store: &mut Items, guid: u64, entry: u32, charges: Option<i32>) {
let mut pairs = vec![(ENTRY, entry)];
if let Some(c) = charges {
pairs.push((CHARGES, c as u32));
}
store.insert_object(guid, ObjectFields::from_pairs(&pairs));
}
/// A container instance holding `contents` at its own inner slots.
fn bag(store: &mut Items, guid: u64, entry: u32, contents: &[(u8, u64)]) {
let mut pairs = vec![(ENTRY, entry), (NUM_SLOTS, 16)];
for &(i, item_guid) in contents {
pairs.push((SLOT_1 + 2 * u16::from(i), item_guid as u32));
pairs.push((SLOT_1 + 2 * u16::from(i) + 1, (item_guid >> 32) as u32));
}
store.insert_object(guid, ObjectFields::from_pairs(&pairs));
}
const ALL: ItemSearch = ItemSearch {
equipment_only: false,
live_charges_only: false,
};
const WORN: ItemSearch = ItemSearch {
equipment_only: true,
live_charges_only: false,
};
const CHARGED: ItemSearch = ItemSearch {
equipment_only: false,
live_charges_only: true,
};
/// Equipment comes FIRST — the trinket case. A copy worn in trinket slot 13 wins over an
/// identical copy sitting in the backpack, and the wire pair is the doll's `(255, 13)`.
#[test]
fn equipment_is_searched_before_everything_else() {
let mut items = Items::default();
item(&mut items, 0xE1, TRINKET, None);
item(&mut items, 0xB1, TRINKET, None);
let store = player(&[(13, 0xE1), (23, 0xB1)]);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
Some((255, 13, 0xE1))
);
}
/// …and the equipment-only stage stops at the doll: the backpack copy is invisible to it.
/// This is the stage that decides USE-in-place vs EQUIP.
#[test]
fn the_equipment_only_stage_ignores_the_bags() {
let mut items = Items::default();
item(&mut items, 0xB1, TRINKET, None);
let store = player(&[(23, 0xB1)]);
assert_eq!(find_item(&store, &items, TRINKET, WORN), None);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
Some((255, 23, 0xB1))
);
}
/// A bag's CONTENTS come before the backpack (the walk recurses depth-first as it passes each
/// container) — the leg the old backpack-first walk had backwards.
#[test]
fn bag_contents_precede_the_backpack() {
let mut items = Items::default();
item(&mut items, 0xC1, TRINKET, None);
item(&mut items, 0xB1, TRINKET, None);
bag(&mut items, 0xBA, BAG, &[(2, 0xC1)]);
let store = player(&[(19, 0xBA), (23, 0xB1)]);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
Some((19, 2, 0xC1)),
"bag 1's inner slot 2, addressed by the bag's own player-array index"
);
}
/// The bag OBJECT is a candidate in its own right, before its contents — a bag on the action
/// bar is a real, placeable action (`InventoryType` 18 passes PlaceAction's filter).
#[test]
fn an_equipped_bag_is_found_as_itself() {
let mut items = Items::default();
bag(&mut items, 0xBA, BAG, &[]);
let store = player(&[(19, 0xBA)]);
assert_eq!(find_item(&store, &items, BAG, ALL), Some((255, 19, 0xBA)));
}
/// The mode-`0x20` charge filter skips a SPENT copy and returns one with uses left — so a
/// click on a charged item reaches a copy that still works.
#[test]
fn the_charge_filter_skips_a_spent_copy() {
let mut items = Items::default();
item(&mut items, 0xB1, TRINKET, Some(0));
item(&mut items, 0xB2, TRINKET, Some(3));
let store = player(&[(23, 0xB1), (24, 0xB2)]);
assert_eq!(
find_item(&store, &items, TRINKET, ALL),
Some((255, 23, 0xB1)),
"without the filter the first copy wins, spent or not"
);
assert_eq!(
find_item(&store, &items, TRINKET, CHARGED),
Some((255, 24, 0xB2)),
"with it, the spent copy is skipped"
);
}
}
+253
View File
@@ -0,0 +1,253 @@
//! **Leaving** — the app half of the game menu's Logout and Exit Game buttons (decision 0674):
//! the outbound request, the server's answer, the countdown dialog that narrates it, and the
//! process exit.
//!
//! The whole arc is three packets and one rule: **the server owns the clock, the client only
//! narrates it.** `Logout()` sends `CMSG_LOGOUT_REQUEST`; vmangos
//! (`WorldSession::HandleLogoutRequestOpcode`, read at MiscHandler.cpp:284) answers
//! `SMSG_LOGOUT_RESPONSE {u32 reason, u8 instant}` and that one packet decides everything:
//!
//! - `reason != 0` — **refused**, no logout starts (1 in combat, 3 jumping/falling, 2 GM-frozen).
//! - `reason == 0, instant == 1` — logged out on the spot (resting in an inn or a city, on a taxi,
//! or a GM-level account — vmangos's `CONFIG_UINT32_INSTANT_LOGOUT`); `SMSG_LOGOUT_COMPLETE`
//! follows immediately and there is no dialog at all.
//! - `reason == 0, instant == 0` — a **20-second server-side timer** now runs, during which the
//! character sits and is rooted. This is the case the CAMP dialog counts down, and the case
//! `CancelLogout()` (`CMSG_LOGOUT_CANCEL` → `SMSG_LOGOUT_CANCEL_ACK`) calls off.
//!
//! Quit is Logout plus one bit of local intent: end the *process* on completion instead of falling
//! back to character select. It is the same wire, the same clock, the same dialog under a different
//! name (QUIT_TIMER "%d %s until exit"), which is why the reference gives QUIT an "Exit now" button
//! and CAMP none — a force-quit needs no server round trip, a force-logout does.
//!
//! **Pinned vs inferred.** The wire shape is verified twice over: vmangos's packet
//! (`WorldPackets::Misc::LogoutResponse` — `u32 reason, u8 instant`) and the real client's own
//! handler (wow-re `system/net/ledger.tsv` 0x5b4630, `Handle(0x4c) — {u32, u8}`). The 1.12
//! FrameXML fixes the UI half exactly: the CAMP/QUIT dialogs, their 20 s timeouts, and their
//! `PLAYER_CAMPING` / `PLAYER_QUITING` / `LOGOUT_CANCEL` drivers (UIParent.lua l.304-315, event
//! ids 276/277/278 in wow-re's `re/events/event-catalog.tsv`). What is **INFERRED** is the join
//! between them — that the client fires PLAYER_CAMPING off exactly this packet's `instant == 0` —
//! because the 0x4c handler's body isn't RE'd. It is the only wiring consistent with what the
//! reference client observably does (no dialog when you camp in an inn, a 20 s dialog in the
//! field, matching the server's own timer), and the falsification is cheap: a dialog that appears
//! on an inn logout, or none in the field, means the trigger is elsewhere. The refusal string is
//! inferred the same way (see [`ERR_LOGOUT_FAILED`]).
use benilla_ui::script::{ScriptValue, SessionRequest, UiScript};
use bevy::prelude::*;
use crate::net::{ClientCommand, LoggedOutMessage, NetCommands, SelfGuid};
use crate::ui_script::UiInput;
/// The reference's own refusal line (GlobalStrings.lua `ERR_LOGOUT_FAILED`), shown when the server
/// answers a non-zero reason. **INFERRED**: which of 1.12's several logout-failure strings the real
/// client picks per reason code isn't RE'd, and vmangos's three reasons (combat / falling / frozen)
/// have no distinct strings in the ERR_ table — this generic one covers all three, and is the line
/// vanilla is remembered for. `PLAYER_LOGOUT_FAILED_ERROR` ("…because you can't sit down right
/// now") is the other candidate and would be the correction if a capture ever shows it.
const ERR_LOGOUT_FAILED: &str = "You can't logout now.";
/// What the wire told us to say next — drained by [`feed_logout`] into script events. A queue
/// rather than a flag: a cancel can land in the same frame as the response it cancels, and the UI
/// must see both, in order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LogoutSignal {
/// A non-instant logout started — `PLAYER_CAMPING` (the CAMP dialog).
Camping,
/// The same, for a pending Quit — `PLAYER_QUITING` (the QUIT dialog).
Quiting,
/// The server dropped the pending logout — `LOGOUT_CANCEL` (both dialogs hide).
Cancelled,
/// The server refused outright — a red error line, no dialog.
Refused,
}
/// The pending session-exit, and the signals owed to the UI.
///
/// `quitting` is the ONE piece of local state the whole arc needs: the wire cannot tell a logout
/// from a quit (both are `CMSG_LOGOUT_REQUEST`), so which dialog to show and whether to exit the
/// process at the end is remembered here. It is cleared by every terminal edge — a refusal, a
/// cancel, or the exit itself — so a cancelled quit can never leave a client that dies on the next
/// unrelated logout.
#[derive(Resource, Default)]
pub(crate) struct LogoutState {
quitting: bool,
signals: Vec<LogoutSignal>,
}
impl LogoutState {
/// `SMSG_LOGOUT_RESPONSE` — the whole decision table (module docs). Returns nothing; the
/// instant case is silent on purpose (the completion is already on its way).
pub(crate) fn apply_response(&mut self, reason: u32, instant: bool) {
// Announced: this one packet decides the entire user-visible outcome (dialog, no dialog, or
// a red line), and the three inputs behind it — combat, resting, account security — are all
// invisible from this side. A logout that "did nothing" is diagnosed from this line.
info!("logout: server answered reason={reason} instant={instant}");
if reason != 0 {
self.quitting = false;
self.signals.push(LogoutSignal::Refused);
return;
}
if instant {
return;
}
self.signals.push(if self.quitting {
LogoutSignal::Quiting
} else {
LogoutSignal::Camping
});
}
/// `SMSG_LOGOUT_CANCEL_ACK` — the countdown is off.
pub(crate) fn apply_cancelled(&mut self) {
self.quitting = false;
self.signals.push(LogoutSignal::Cancelled);
}
}
/// Fire the camp/quit events the dialogs are driven by (GameMenuFrame.xml's `BenillaLogoutDriver`).
fn feed_logout(script: Option<NonSendMut<UiScript>>, mut logout: ResMut<LogoutState>) {
let Some(mut script) = script else {
return;
};
for signal in std::mem::take(&mut logout.signals) {
match signal {
LogoutSignal::Camping => script.fire_event("PLAYER_CAMPING", Vec::new()),
LogoutSignal::Quiting => script.fire_event("PLAYER_QUITING", Vec::new()),
LogoutSignal::Cancelled => script.fire_event("LOGOUT_CANCEL", Vec::new()),
LogoutSignal::Refused => script.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str(ERR_LOGOUT_FAILED.into())],
),
}
}
}
/// Turn the Lua intents into packets — and, for the two force paths, straight into an exit.
fn drain_logout(
script: Option<NonSendMut<UiScript>>,
mut logout: ResMut<LogoutState>,
self_guid: Res<SelfGuid>,
commands: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
let Some(mut script) = script else {
return;
};
for request in script.take_session_requests() {
match request {
SessionRequest::Logout | SessionRequest::Quit => {
logout.quitting = request == SessionRequest::Quit;
// Not in the world (or the socket is gone): there is nothing to log out OF, so a
// quit is just an exit and a logout is a no-op. The reference does the same — its
// Quit() at the glue screen closes the process outright.
if self_guid.0.is_none() || commands.0.send(ClientCommand::Logout).is_err() {
if logout.quitting {
info!("logout: quit with no world session — exiting");
exit.write(AppExit::Success);
} else {
warn!("logout: no world session — logout dropped");
logout.quitting = false;
}
} else {
// Announced because what happens next is entirely the server's call — instant
// or a 20 s countdown — and a silent request looks identical to a dropped one.
info!(
"logout: requested (quitting={}) — awaiting SMSG_LOGOUT_RESPONSE",
logout.quitting
);
}
}
SessionRequest::CancelLogout => {
// The dialog's own OnHide fires this on EVERY early close, including one caused by
// a cancel that already landed — so a stray send with nothing pending is normal and
// harmless (vmangos ignores it). `quitting` clears here rather than on the ack so a
// cancel with a dead socket still can't leave a quit armed.
logout.quitting = false;
let _ = commands.0.send(ClientCommand::LogoutCancel);
}
SessionRequest::ForceQuit => {
info!("logout: force quit");
exit.write(AppExit::Success);
}
}
}
}
/// The completion edge: `SMSG_LOGOUT_COMPLETE` normally lands us at character select (decision
/// 0193), but a logout that began as Exit Game ends the process instead.
fn exit_on_logout_complete(
mut logged_out: MessageReader<LoggedOutMessage>,
mut logout: ResMut<LogoutState>,
mut exit: MessageWriter<AppExit>,
) {
if logged_out.read().count() == 0 {
return;
}
if logout.quitting {
logout.quitting = false;
info!("logout: world session ended — exiting");
exit.write(AppExit::Success);
}
}
pub struct UiLogoutPlugin;
impl Plugin for UiLogoutPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LogoutState>().add_systems(
Update,
(
feed_logout.before(UiInput),
drain_logout.after(UiInput),
exit_on_logout_complete.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The response table (module docs): refusal speaks and disarms, instant is silent, and the
/// slow path picks its dialog from the pending intent.
#[test]
fn the_response_decides_which_dialog_if_any() {
let mut s = LogoutState::default();
s.apply_response(0, true);
assert!(s.signals.is_empty(), "an instant logout shows no dialog");
s.apply_response(0, false);
assert_eq!(s.signals, vec![LogoutSignal::Camping]);
let mut s = LogoutState {
quitting: true,
signals: Vec::new(),
};
s.apply_response(0, false);
assert_eq!(s.signals, vec![LogoutSignal::Quiting], "a quit says so");
// In combat: the red line, and the pending quit is disarmed — otherwise the NEXT logout,
// minutes later and deliberate, would silently kill the process instead.
let mut s = LogoutState {
quitting: true,
signals: Vec::new(),
};
s.apply_response(1, false);
assert_eq!(s.signals, vec![LogoutSignal::Refused]);
assert!(!s.quitting);
}
/// The cancel ack disarms a quit too — the same "no armed quit survives a terminal edge" rule.
#[test]
fn a_cancel_disarms_a_pending_quit() {
let mut s = LogoutState {
quitting: true,
signals: Vec::new(),
};
s.apply_cancelled();
assert_eq!(s.signals, vec![LogoutSignal::Cancelled]);
assert!(!s.quitting);
}
}
+1
View File
@@ -649,6 +649,7 @@ mod tests {
ammo_type: 0,
ranged_mod_range: 0.0,
spells: Vec::new(),
spell_charges_0: 0,
use_spell: None,
bonding: 0,
description: String::new(),
+268 -1
View File
@@ -28,6 +28,8 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{ui_error_text, UiError};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_quest_log::QuestLog;
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
@@ -57,6 +59,11 @@ pub(crate) struct QuestGiver {
/// Per-guid dialog status (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's value, stored for a
/// later world-marker slice.
statuses: HashMap<u64, u32>,
/// Client messages the net apply queued for [`feed_quest`] to resolve and show — the
/// reference's `DisplayError(msgId)` split into (surface, GlobalStrings key + fills), so the
/// line comes from the VM's own strings and never from a hardcoded English literal
/// (decision 0669).
messages: Vec<(MsgSurface, UiError)>,
/// The re-ask epoch — see [`Self::bump_reask`].
reask: u32,
}
@@ -137,10 +144,81 @@ impl QuestGiver {
self.statuses.get(&npc).copied()
}
/// The open panel's title when the panel IS `quest_id`'s — the `%s` fill for a refusal that
/// names the quest (decision 0669). The reference reads that title off the quest record it
/// looked the refusal up in; the panel being refused is the same quest, already in hand.
pub(crate) fn view_title(&self, quest_id: u32) -> Option<String> {
match self.view.as_ref()? {
QuestView::Detail(d) if d.quest_id == quest_id => Some(d.title.clone()),
QuestView::Progress(p) if p.quest_id == quest_id => Some(p.title.clone()),
QuestView::Reward(o) if o.quest_id == quest_id => Some(o.title.clone()),
_ => None,
}
}
/// Queue one client message for [`feed_quest`] to resolve and show — the net apply's half of
/// the reference's `DisplayError(msgId)` (decision 0669).
pub(crate) fn push_message(&mut self, surface: MsgSurface, msg: UiError) {
self.messages.push((surface, msg));
}
/// Take the queued client messages (drained by [`feed_quest`], which owns the VM).
pub(crate) fn take_messages(&mut self) -> Vec<(MsgSurface, UiError)> {
std::mem::take(&mut self.messages)
}
/// Disconnect: drop the open window (mirrors the gossip/merchant session clears).
pub(crate) fn clear_session(&mut self) {
self.clear();
self.statuses.clear();
self.messages.clear();
}
}
/// Where a client message is shown — the `kind` field (`+0x04`) of the reference's message record
/// (`0xb4b498 + 20*msgId`), which `CGGameUI::DisplayError` (`0x496720`) dispatches on through the
/// four-way jump at `0x496888`: **0 → the chat window** (`0x49a870`), 1 → `AddErrorMessage(text, 0)`
/// (the yellow info line), **2 → `AddErrorMessage(text, 1)`** (the red error line), 3 → the console.
/// Only the two the quest messages use are modeled (decision 0669); the info line has its own
/// established route (`UI_INFO_MESSAGE`, decision 0340).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum MsgSurface {
/// kind 0 — a chat-window system line.
Chat,
/// kind 2 — the red `UIErrorsFrame` line.
Error,
}
/// `SMSG_QUESTGIVER_QUEST_INVALID`'s `QuestFailedReason` → its GlobalStrings key, resolved to text
/// through the VM's own `GlobalStrings.lua` at the feed. The FULL build-5875 table, read off the
/// handler `0x5dbca0` (the `0x18f` arm of the quest demux `0x5e5910`): `lea eax,[reason-1]` /
/// `cmp eax,0x15` / `movzx edx,[eax+0x5dbd30]` / `jmp [edx*4+0x5dbd14]` — a 22-byte case index into
/// a 7-way jump table, every arm a `DisplayError(msgId)`. **Everything unlisted — including
/// reason 0 and anything past 22 — falls to `ERR_QUEST_NEED_PREREQS`**, which is the ref's own
/// `ja` default, not a guess. Cross-checks against vmangos `QuestDef.h`'s own per-value comments
/// (decision 0669).
pub(crate) fn questgiver_invalid_key(reason: u32) -> &'static str {
match reason {
1 => "ERR_QUEST_FAILED_LOW_LEVEL", // msgId 142
6 => "ERR_QUEST_FAILED_WRONG_RACE", // msgId 144
12 => "ERR_QUEST_ONLY_ONE_TIMED", // msgId 146
13 => "ERR_QUEST_ALREADY_ON", // msgId 148
20 => "ERR_QUEST_FAILED_MISSING_ITEMS", // msgId 143
22 => "ERR_QUEST_FAILED_NOT_ENOUGH_MONEY", // msgId 145
_ => "ERR_QUEST_NEED_PREREQS", // msgId 147 — the `ja` default
}
}
/// `SMSG_QUESTGIVER_QUEST_FAILED`'s reason → its GlobalStrings key. The full table, read off the
/// handler `0x5dc840` (the `0x192` arm of the same demux): a three-way `cmp` chain on the reason —
/// `4`/`0x32` → BAG_FULL, `0x11` → MAX_COUNT, everything else → the plain FAILED line. All three
/// strings carry a `%s` the caller fills with the quest title (the ref pushes `questRecord+0x9c`
/// alongside the msgId). Decision 0669.
pub(crate) fn questgiver_failed_key(reason: u32) -> &'static str {
match reason {
4 | 50 => "ERR_QUEST_FAILED_BAG_FULL_S", // msgId 140 — "%s failed: Inventory is full."
17 => "ERR_QUEST_FAILED_MAX_COUNT_S", // msgId 141 — "%s failed: Duplicate item found."
_ => "ERR_QUEST_FAILED_S", // msgId 139 — "%s failed."
}
}
@@ -168,8 +246,17 @@ impl Plugin for UiQuestPlugin {
/// of the giver's service range or the giver despawns. The per-guid `statuses` store survives, like
/// every other close.
impl NpcSession for QuestGiver {
/// `None` when the open panel's giver is an **item** (decision 0664): a quest-starter item
/// opens its detail panel with the ITEM's own guid as the giver, and an item is not a world
/// unit — there is no range to walk out of and no portrait to bake. Reporting the item guid
/// here would close the panel the frame it opened, since the range guard reads a guid it can't
/// resolve to a world entity as "the giver despawned". The drain still addresses its
/// `CMSG_QUESTGIVER_*` to `self.npc` (the item guid is exactly what the server wants back —
/// vmangos resolves an `HIGHGUID_ITEM` giver through `TYPEMASK_CREATURE_GAMEOBJECT_OR_ITEM`);
/// only the *session* face, whose whole meaning is "the live NPC this window is bound to",
/// says there is none.
fn npc(&self) -> Option<u64> {
self.npc
self.npc.filter(|g| !benilla_protocol::guid::is_item(*g))
}
fn close(&mut self) {
@@ -309,6 +396,7 @@ fn feed_quest(
quest_log: Res<QuestLog>,
states: Res<crate::world_state::WorldStates>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
mut chat: ResMut<ChatLog>,
mut last: Local<Option<QuestState>>,
mut last_name: Local<Option<String>>,
mut last_npc: Local<Option<u64>>,
@@ -321,6 +409,31 @@ fn feed_quest(
if giver.take_completed_fanfare() {
script.queue_sound_kit("QUESTCOMPLETED");
}
// The queued client messages (the refusal lines the net apply staged): resolve against the
// VM's own GlobalStrings first (immutable script), then show each on the surface its message
// record names — decision 0669's `DisplayError` kind. Drained BEFORE the snapshot's early-out
// so a refusal that also closes the panel still gets its line out this frame.
let lines: Vec<(MsgSurface, String)> = giver
.take_messages()
.into_iter()
.filter_map(|(surface, msg)| {
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
ui_error_text(&msg, &get).map(|text| (surface, text))
})
.collect();
for (surface, text) in lines {
// The resolved line, greppable: the chat path has no log of its own, so without this a
// live probe can count lines but never read one (decision 0669's in-app leg).
debug!("ui_quest: message ({surface:?}) {text:?}");
match surface {
MsgSurface::Chat => {
chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
MsgSurface::Error => {
script.fire_event("UI_ERROR_MESSAGE", vec![ScriptValue::Str(text)]);
}
}
}
let player = crate::npc_text::player_identity(&self_q, &mut names, &commands);
let fresh = snapshot(
&giver,
@@ -442,6 +555,11 @@ fn drain_quest(
let _ = commands
.0
.send(ClientCommand::QuestgiverAccept { npc, quest });
// `Script::AcceptQuest` (`0x501380`) closes the window on the CLICK, not on any
// answer: send `0x189` (`0x5eac10`), then `0x501130(0,0)` — the same clear the
// refusal handlers call, firing `QUEST_FINISHED`. Ours used to leave the panel
// up waiting for a packet that never comes on a refused accept (decision 0669).
giver.clear();
}
}
QuestAction::Continue => {
@@ -489,6 +607,35 @@ mod tests {
assert!(!is_active_quest(200, &quest_log));
}
/// An item-sourced quest window (decision 0664) reports **no** NPC to the session face, so the
/// range guard can't close it the frame it opens — while the drain's own `self.npc` keeps the
/// item guid the `CMSG_QUESTGIVER_*` sends must carry.
#[test]
fn an_item_giver_is_not_an_npc_session() {
let item = 0x4000_0000_0000_0BAD_u64; // HIGHGUID_ITEM
let creature = 0xF130_0000_00C5_0001_u64; // HIGHGUID_UNIT
let detail = |npc: u64| {
QuestView::Detail(QuestDetails {
npc,
quest_id: 373,
title: "The Unsent Letter".into(),
details: String::new(),
objectives: String::new(),
auto_finish: 0,
choices: Vec::new(),
rewards: Vec::new(),
money: 0,
reward_spell: 0,
})
};
let mut giver = QuestGiver::default();
giver.open(item, detail(item));
assert_eq!(NpcSession::npc(&giver), None, "an item is not a live NPC");
assert_eq!(giver.npc, Some(item), "the wire address is unchanged");
giver.open(creature, detail(creature));
assert_eq!(NpcSession::npc(&giver), Some(creature));
}
#[test]
fn detail_snapshot_carries_text_and_rows() {
let mut giver = QuestGiver::default();
@@ -600,4 +747,124 @@ mod tests {
assert!(!giver.is_open());
assert_eq!(giver.status(0x99), Some(dialog_status::AVAILABLE));
}
/// The `0x18f` table, arm for arm off `0x5dbca0` — including the two edges that are easy to
/// get wrong: reason 0 and anything past the `cmp eax,0x15` bound fall to NEED_PREREQS (the
/// ref's `ja` default), and 2..=5 do too even though they sit INSIDE the jump table's range.
#[test]
fn questgiver_invalid_keys_match_the_reference_table() {
assert_eq!(questgiver_invalid_key(13), "ERR_QUEST_ALREADY_ON");
assert_eq!(questgiver_invalid_key(1), "ERR_QUEST_FAILED_LOW_LEVEL");
assert_eq!(questgiver_invalid_key(6), "ERR_QUEST_FAILED_WRONG_RACE");
assert_eq!(questgiver_invalid_key(12), "ERR_QUEST_ONLY_ONE_TIMED");
assert_eq!(questgiver_invalid_key(20), "ERR_QUEST_FAILED_MISSING_ITEMS");
assert_eq!(
questgiver_invalid_key(22),
"ERR_QUEST_FAILED_NOT_ENOUGH_MONEY"
);
for reason in [0, 2, 3, 4, 5, 7, 11, 14, 19, 21, 23, 99, u32::MAX] {
assert_eq!(
questgiver_invalid_key(reason),
"ERR_QUEST_NEED_PREREQS",
"reason {reason} must take the ref's `ja` default"
);
}
}
/// The `0x192` table off `0x5dc840`: the two named reasons plus the shared default.
#[test]
fn questgiver_failed_keys_match_the_reference_table() {
assert_eq!(questgiver_failed_key(4), "ERR_QUEST_FAILED_BAG_FULL_S");
assert_eq!(questgiver_failed_key(50), "ERR_QUEST_FAILED_BAG_FULL_S");
assert_eq!(questgiver_failed_key(17), "ERR_QUEST_FAILED_MAX_COUNT_S");
for reason in [0, 1, 5, 16, 18, 49, 51] {
assert_eq!(questgiver_failed_key(reason), "ERR_QUEST_FAILED_S");
}
}
/// The open panel is the `%s` fill for a refusal that names the quest — and only for ITS OWN
/// quest id (a stale panel must not name the wrong quest).
#[test]
fn view_title_answers_only_for_the_open_quest() {
let mut giver = QuestGiver::default();
assert_eq!(giver.view_title(100), None);
giver.open(
0x42,
QuestView::Detail(QuestDetails {
npc: 0x42,
quest_id: 100,
title: "A Threat Within".into(),
details: String::new(),
objectives: String::new(),
auto_finish: 0,
choices: vec![],
rewards: vec![],
money: 0,
reward_spell: 0,
}),
);
assert_eq!(giver.view_title(100).as_deref(), Some("A Threat Within"));
assert_eq!(giver.view_title(101), None);
}
/// The RUNTIME leg on the real client data (`equip_error`'s pattern): every key either table
/// can emit resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua`, the guard
/// against a typo'd key degrading a real refusal to silence. Also pins the director's repro
/// (reason 13 → "You are already on that quest") and the `%s` the FAILED family fills.
/// Skips without client data.
#[test]
fn every_quest_refusal_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
for reason in 0..=60u32 {
for key in [
questgiver_invalid_key(reason),
questgiver_failed_key(reason),
] {
let text = g(key).unwrap_or_default();
assert!(!text.is_empty(), "{key} (reason {reason}) missing");
}
// The FAILED family names the quest; the INVALID family never does.
assert!(g(questgiver_failed_key(reason))
.unwrap_or_default()
.contains("%s"));
assert!(!g(questgiver_invalid_key(reason))
.unwrap_or_default()
.contains("%s"));
}
// The director's line, end to end.
assert_eq!(
ui_error_text(&UiError::key(questgiver_invalid_key(13)), &g).as_deref(),
Some("You are already on that quest")
);
// …and the named one, filled.
assert_eq!(
ui_error_text(
&UiError {
key: questgiver_failed_key(4),
fill_s: Some("A Threat Within".into()),
fill_d: None,
},
&g
)
.as_deref(),
Some("A Threat Within failed: Inventory is full.")
);
// The log-full line is the RED one and carries no fill.
assert_eq!(
ui_error_text(&UiError::key("ERR_QUEST_LOG_FULL"), &g).as_deref(),
Some("Your quest log is full.")
);
}
}
@@ -483,6 +483,7 @@ fn shipped_bag_frame_drives_end_to_end() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
@@ -304,6 +304,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()),
count: 200,
@@ -381,6 +382,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
count: 1,
@@ -486,6 +488,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()),
count: 4,
@@ -503,6 +506,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
slots.insert(
2,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
count: 1,
@@ -694,6 +698,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
@@ -779,6 +784,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
count: 1,
@@ -906,6 +912,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
@@ -1192,6 +1199,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
// SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot).
let mut inv: benilla_ui::script::InventorySlots = Default::default();
inv[20] = Some(benilla_ui::script::InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 4496,
@@ -82,6 +82,7 @@ fn combat_stats() -> PlayerCombatStats {
fn inventory_with_head_item() -> InventorySlots {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 1234,
@@ -104,6 +105,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
slots.insert(
1,
benilla_ui::script::ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
count: 1,
+1 -1
View File
@@ -56,7 +56,7 @@ fn death_popup_counts_down_and_release_queues_repop() {
"the countdown renders through the engine's DEATH per-tick text"
);
// ESC must NOT close it (no hideOnEscape on DEATH — the ref's law).
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the DEATH popup ignores ESC"
@@ -32,6 +32,7 @@ fn one_item_backpack() -> ContainerState {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
@@ -159,7 +160,7 @@ fn delete_item_confirm_no_clears_without_destroying() {
}
/// ESC routes through the ref's `StaticPopup_EscapePressed` — DELETE_ITEM is `hideOnEscape`, so
/// its OnCancel runs with reason "clicked" exactly as a real No-click would. (`BenillaOnEscape`'s
/// its OnCancel runs with reason "clicked" exactly as a real No-click would. (`ToggleGameMenu`'s
/// existing unconditional `ClearCursor()` already empties the cursor before the popup branch, so
/// this mainly proves the popup itself closes.)
#[test]
@@ -167,7 +168,7 @@ fn escape_closes_the_delete_confirm_popup() {
let mut s = setup();
pick_up_and_drop_in_world(&mut s);
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"ESC closes the confirm popup"
+1 -1
View File
@@ -70,7 +70,7 @@ fn decline_and_escape_both_cancel() {
assert_eq!(s.take_duel_requests(), vec![DuelRequest::Cancel]);
s.fire_event("DUEL_REQUESTED", vec![ScriptValue::Str("Twomage".into())]);
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert_eq!(s.take_duel_requests(), vec![DuelRequest::Cancel]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
}
+15 -11
View File
@@ -25,6 +25,7 @@ fn one_item_backpack() -> ContainerState {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 1,
@@ -47,7 +48,7 @@ fn one_item_backpack() -> ContainerState {
}
/// Load every window and drive the whole ESC-close path (UIParent.lua ToggleGameMenu → l.1491
/// CloseAllWindows), the benilla shape (`BenillaOnEscape`): with the bag open, the loot window on
/// CloseAllWindows): with the bag open, the loot window on
/// the left slot, and an item on the cursor, ESC closes the bag AND the panel slot, releases the
/// loot (OnHide → CloseLoot), and drops the held cursor. Also asserts the host-glue precedence: no
/// EditBox focused ⇒ `key_input("ESCAPE")` does not consume, so the app runs the binding.
@@ -100,7 +101,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
);
// The escape binding (what the host runs on the unconsumed ESC).
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
@@ -159,7 +160,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
/// ESC closes an open stack-split spinner (decision 0216 §6/slice 2, StackSplit.xml): this engine
/// has no plain-frame keyboard capture (the real client's own `StackSplitFrame` OnKeyDown ESCAPE
/// arm can't be driven), so the hook rides `BenillaOnEscape`'s shared chain instead — checked
/// arm can't be driven), so the hook rides `ToggleGameMenu`'s shared chain instead — checked
/// right after the confirm popup and before the world map, both similarly transient overlays.
#[test]
fn escape_closes_an_open_stack_split_frame() {
@@ -178,6 +179,7 @@ fn escape_closes_an_open_stack_split_frame() {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
@@ -219,13 +221,13 @@ fn escape_closes_an_open_stack_split_frame() {
.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap());
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
"ESC closed the split frame"
);
// ESC's unconditional cursor-clear (BenillaOnEscape's own first line) ran too — nothing was
// ESC's unconditional cursor-clear (ToggleGameMenu's own first line) ran too — nothing was
// held anyway (the split branch already released it), so this just confirms no error.
assert!(s.cursor_item().is_none());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -269,7 +271,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
// closes and eats the press — the cast, the windows, and the target all survive.
s.set_casting(true);
s.run("DropDownList1:Show()").unwrap();
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return DropDownList1:IsShown()").unwrap(),
"ESC closed the open dropdown menu"
@@ -287,7 +289,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
// Press 1 — mid-cast (the app's per-frame IsCasting mirror): the cast dies, NOTHING else.
s.set_casting(true);
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"ESC mid-cast is eaten by SpellStopCasting — the bag stays open"
@@ -303,7 +305,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
// Press 2 — the cancel resolved (next frame's mirror push): the windows close, target stays.
s.set_casting(false);
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"idle ESC falls through SpellStopCasting's nil to CloseAllWindows"
@@ -315,15 +317,17 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
);
// Press 3 — nothing left to eat: the target drops.
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
s.take_target_clear(),
"the bare press reaches ClearTarget — the ladder's last rung"
);
// Press 4 — no target either: the whole chain is a no-op (no menu to open yet).
// Press 4 — no target either: the chain runs out. In a full UI this press opens the game menu
// (decision 0674, `game_menu_tests`); this harness deliberately loads no GameMenuFrame.xml, so
// what it pins is the rung BELOW it — ClearTarget answering nil rather than eating the press.
s.set_unit("target", None);
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.take_target_clear(),
"ClearTarget answers nil with no target — nothing queued"
@@ -0,0 +1,912 @@
//! The social window (decision 0668, `FriendsFrame.xml`): the tab strip, the two list tabs and
//! their toggle pair, the who list's columns, and the verbs each button queues — driven exactly
//! as `ui_social`'s feed drives it (a pushed [`SocialState`] snapshot, then the list event).
//!
//! What these guard that the Rust-side unit tests structurally cannot: the *window* is Lua over a
//! snapshot, so a getter whose returns are in the wrong order, a row template whose FontString is
//! misnamed, or a button wired to the wrong verb are all invisible to `ui_social`'s tests and
//! green in the parse sweep. Each test here fails on exactly one of those.
use benilla_ui::script::{FriendInfo, SocialRequest, SocialState, UiScript, WhoInfo};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error — **and on any
/// unknown-template warning**.
///
/// That second check is not decoration. `inherits=` a template this house doesn't ship is a
/// *warning*, not an error: the frame is still created, just with none of the template's art or
/// size. Every test below stayed green while all eight action buttons and the close button were
/// invisible, because a bare Button still clicks. Only a live run showed it. The guard is here so
/// the next window cannot repeat it.
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 own manifest slice, in `load_default_ui` order.
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, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
// The row right-click menu is the shared UnitPopup "FRIEND" menu, so the window's slice of
// the manifest includes it (it loads well before FriendsFrame.xml in the real order).
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
s
}
fn friend(name: &str, level: u32, class: &str, area: &str, connected: bool) -> FriendInfo {
FriendInfo {
name: name.to_string(),
level,
class: class.to_string(),
area: area.to_string(),
connected,
status: String::new(),
}
}
/// Push a snapshot and fire the list event that follows it, exactly as `feed_social` does.
fn push(s: &mut UiScript, state: SocialState, event: &str) {
s.set_social(state);
s.fire_event(event, Vec::new());
}
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — which is
/// what a character in no guild sees in the reference, and what we always are.
#[test]
fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
let s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
assert!(s
.eval::<bool>("return BenillaFriendsFrame:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaFriendsListFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameTitleText:GetText()")
.unwrap(),
"Friends List"
);
assert_eq!(
s.eval::<i64>("return BenillaFriendsFrameTab3.isDisabled or 0")
.unwrap(),
1,
"the guild tab is disabled, not absent"
);
// The two tab kinds are different templates and must not be swapped: the strip along the
// bottom is the big window tab (20px end slices), the Friends/Ignore pair inside tab 1 is the
// ref's compact TabButtonTemplate (16px). Getting this wrong is invisible to every behaviour
// assertion — it only shows up as the wrong-looking window.
assert_eq!(
s.eval::<f64>("return BenillaFriendsFrameTab1Left:GetWidth()")
.unwrap(),
20.0,
"the window tab strip keeps the big tab art"
);
assert_eq!(
s.eval::<f64>("return BenillaFriendsFrameToggleTab1Left:GetWidth()")
.unwrap(),
16.0,
"the in-panel toggle pair is the compact tab art"
);
// …and toggling the same tab again closes the window (the ref's own second branch).
s.run("ToggleFriendsFrame(1)").unwrap();
assert!(!s
.eval::<bool>("return BenillaFriendsFrame:IsVisible()")
.unwrap());
}
/// A friend row shows name/zone/status on its top line and "Level N Class" underneath; an
/// OFFLINE friend takes the greyed offline template instead. This is the test that fails if
/// `GetFriendInfo`'s six returns ever come back in the wrong order.
#[test]
fn friend_rows_render_the_online_and_offline_templates() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
push(
&mut s,
SocialState {
friends: vec![
FriendInfo {
status: "<AFK>".to_string(),
..friend("Onerogue", 60, "Rogue", "Elwynn Forest", true)
},
friend("Twomage", 0, "", "", false),
],
selected_friend: 1,
..Default::default()
},
"FRIENDLIST_UPDATE",
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameFriendButton1NameLocation:GetText()")
.unwrap(),
"Onerogue |cffffffff- Elwynn Forest|r <AFK>"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameFriendButton1Info:GetText()")
.unwrap(),
"Level 60 Rogue"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameFriendButton2NameLocation:GetText()")
.unwrap(),
"|cff999999Twomage - Offline|r"
);
assert!(
s.eval::<bool>("return BenillaFriendsFrameFriendButton2:IsVisible()")
.unwrap(),
"both rows shown"
);
assert!(
!s.eval::<bool>("return BenillaFriendsFrameFriendButton3:IsVisible()")
.unwrap(),
"rows past the list are hidden"
);
}
/// The three friend buttons follow the SELECTED friend: an offline one can be removed but not
/// whispered or invited, and an empty list disables all three.
#[test]
fn the_friend_buttons_follow_the_selection() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
push(&mut s, SocialState::default(), "FRIENDLIST_UPDATE");
for button in ["SendMessage", "GroupInvite", "RemoveFriend"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaFriendsFrame{button}Button:IsEnabled()"
))
.unwrap(),
"{button} is disabled with no friends"
);
}
push(
&mut s,
SocialState {
friends: vec![friend("Twomage", 0, "", "", false)],
selected_friend: 1,
..Default::default()
},
"FRIENDLIST_UPDATE",
);
assert!(
s.eval::<bool>("return BenillaFriendsFrameRemoveFriendButton:IsEnabled()")
.unwrap(),
"an offline friend can still be removed"
);
assert!(
!s.eval::<bool>("return BenillaFriendsFrameSendMessageButton:IsEnabled()")
.unwrap(),
"…but not whispered"
);
}
/// Remove Friend addresses the selected ROW (the wire removes by guid, which the app resolves
/// from this index), and Group Invite addresses the NAME.
#[test]
fn the_friend_buttons_queue_their_verbs() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
push(
&mut s,
SocialState {
friends: vec![
friend("Onerogue", 60, "Rogue", "Elwynn Forest", true),
friend("Twomage", 40, "Mage", "Westfall", true),
],
selected_friend: 2,
..Default::default()
},
"FRIENDLIST_UPDATE",
);
let _ = s.take_social_requests();
s.run("BenillaFriendsFrame_RemoveFriend()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::RemoveFriendIndex(2)]
);
s.run("BenillaFriendsFrame_GroupInvite()").unwrap();
assert!(s.take_party_requests().iter().any(|r| matches!(
r,
benilla_ui::script::PartyRequest::InviteName(n) if n == "Twomage"
)));
s.run("BenillaFriendsFrame_SendMessage()").unwrap();
assert_eq!(s.take_tell_requests(), vec!["Twomage".to_string()]);
}
/// The Ignore toggle-tab swaps tab 1's list without leaving the tab, and the ignore rows render
/// their names. `ShowIgnorePanel()` (what `/ignore` with no name runs) lands on the same list.
#[test]
fn the_ignore_list_is_the_other_half_of_tab_one() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
push(
&mut s,
SocialState {
ignores: vec!["Spammer".to_string(), "Ninja".to_string()],
selected_ignore: 1,
..Default::default()
},
"IGNORELIST_UPDATE",
);
s.run("BenillaFriendsFrameToggleTab2:Click()").unwrap();
assert!(s
.eval::<bool>("return BenillaIgnoreListFrame:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaFriendsListFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameTitleText:GetText()")
.unwrap(),
"Ignore List"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameIgnoreButton1Name:GetText()")
.unwrap(),
"Spammer"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameIgnoreButton2Name:GetText()")
.unwrap(),
"Ninja"
);
// Remove Player un-ignores the selected name.
let _ = s.take_social_requests();
s.run("BenillaFriendsFrame_UnIgnore()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::DelIgnore("Spammer".to_string())]
);
// Back to the friends half — through the IGNORE frame's own Friends tab, since the friends
// frame's copy is hidden with it (the ref ships the pair twice for exactly this reason).
s.run("BenillaIgnoreFrameToggleTab1:Click()").unwrap();
assert!(s
.eval::<bool>("return BenillaFriendsListFrame:IsVisible()")
.unwrap());
s.run("ShowIgnorePanel()").unwrap();
assert!(s
.eval::<bool>("return BenillaIgnoreListFrame:IsVisible()")
.unwrap());
}
/// The who list fills its four columns, the variable column follows the dropdown, and the totals
/// line uses the singular/plural template. The class/race swap between the wire and the Lua API
/// makes the column order worth pinning.
#[test]
fn who_rows_fill_their_columns() {
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
push(
&mut s,
SocialState {
who: vec![WhoInfo {
name: "Tigole".to_string(),
guild: "Legacy of Steel".to_string(),
level: 40,
race: "Human".to_string(),
class: "Rogue".to_string(),
zone: "Westfall".to_string(),
}],
who_total: 1,
..Default::default()
},
"WHO_LIST_UPDATE",
);
assert!(s
.eval::<bool>("return BenillaWhoFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Name:GetText()")
.unwrap(),
"Tigole"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Level:GetText()")
.unwrap(),
"40"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Class:GetText()")
.unwrap(),
"Rogue",
"class, not race — the API returns race first but the column is class"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Variable:GetText()")
.unwrap(),
"Westfall",
"the variable column defaults to Zone (dropdown entry 1)"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameTotals:GetText()")
.unwrap(),
"1 Person Found ",
"singular template for one hit"
);
}
/// The variable column really is variable: picking Guild re-paints it and sorts by that key.
#[test]
fn the_who_dropdown_switches_the_variable_column() {
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
push(
&mut s,
SocialState {
who: vec![WhoInfo {
name: "Tigole".to_string(),
guild: "Legacy of Steel".to_string(),
level: 40,
race: "Human".to_string(),
class: "Rogue".to_string(),
zone: "Westfall".to_string(),
}],
who_total: 1,
..Default::default()
},
"WHO_LIST_UPDATE",
);
let _ = s.take_social_requests();
s.run("UIDropDownMenu_SetSelectedID(BenillaWhoFrameDropDown, 2); BenillaWhoList_Update()")
.unwrap();
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Variable:GetText()")
.unwrap(),
"Legacy of Steel"
);
// A column header sorts by its own key.
s.run("BenillaWhoFrameColumnHeader3:Click()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::SortWho("level".to_string())]
);
}
/// The who buttons need a selected row, and selecting one enables both. A fresh answer clears
/// the selection — row 3 of the last query is not row 3 of this one.
#[test]
fn the_who_buttons_need_a_selected_row() {
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
let rows = vec![
WhoInfo {
name: "Tigole".to_string(),
level: 40,
..Default::default()
},
WhoInfo {
name: "Furor".to_string(),
level: 41,
..Default::default()
},
];
push(
&mut s,
SocialState {
who: rows.clone(),
who_total: 2,
..Default::default()
},
"WHO_LIST_UPDATE",
);
assert!(
!s.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
.unwrap(),
"nothing selected yet"
);
s.run("BenillaWhoFrameButton2:Click()").unwrap();
assert!(s
.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
.unwrap());
let _ = s.take_social_requests();
s.run("BenillaWhoFrameAddFriendButton:Click()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::AddFriend("Furor".to_string())]
);
push(
&mut s,
SocialState {
who: rows,
who_total: 2,
..Default::default()
},
"WHO_LIST_UPDATE",
);
assert!(
!s.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
.unwrap(),
"a fresh answer drops the old selection"
);
}
/// The Who frame tells the engine where the NEXT answer goes: showing it claims the results,
/// hiding it hands them back to the chat frame. Without this, a `/who` typed with the window
/// closed would print nothing at all.
#[test]
fn showing_the_who_frame_claims_the_next_answer() {
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
assert!(
s.take_social_requests()
.contains(&SocialRequest::SetWhoToUi(true)),
"showing the frame routes results to it"
);
s.run("HideUIPanel(BenillaFriendsFrame)").unwrap();
assert!(
s.take_social_requests()
.contains(&SocialRequest::SetWhoToUi(false)),
"hiding it routes them back to chat"
);
}
/// The `/who` edit box sends its filter verbatim — the string the app parses into wire fields.
#[test]
fn the_who_edit_box_sends_its_filter() {
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
let _ = s.take_social_requests();
s.run("BenillaWhoFrameEditBox:SetText(\"z-\\\"Elwynn Forest\\\" 1-10\")")
.unwrap();
s.run("BenillaWhoFrameEditBox_OnEnterPressed()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::Who("z-\"Elwynn Forest\" 1-10".to_string())]
);
}
/// The slash bodies are the reference's: a NAMED `/friends` befriends, a bare one refreshes; a
/// bare `/who` opens the panel and fills the edit box with the default filter.
#[test]
fn the_slash_bodies_match_the_reference() {
let mut s = setup();
// The WhoFrame's OnLoad routes results to chat until it is shown; drain that so each
// assertion below sees only what its own call queued.
let _ = s.take_social_requests();
s.run("BenillaSlashFriends(\"Onerogue\")").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::AddFriend("Onerogue".to_string())]
);
s.run("BenillaSlashFriends(\"\")").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::RefreshFriends]
);
// `/ignore <name>` toggles rather than adds — the ref's AddOrDelIgnore.
s.run("BenillaSlashIgnore(\"Spammer\")").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::ToggleIgnore("Spammer".to_string())]
);
// A bare `/who` opens the Who panel and sends the default filter, which it also shows.
s.run("BenillaSlashWho(\"\")").unwrap();
assert!(s
.eval::<bool>("return BenillaWhoFrame:IsVisible()")
.unwrap());
let requests = s.take_social_requests();
let sent = requests
.iter()
.find_map(|r| match r {
SocialRequest::Who(filter) => Some(filter.clone()),
_ => None,
})
.expect("a bare /who still sends a query");
assert!(
sent.starts_with("z-\""),
"the default filter is zone-scoped: {sent}"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameEditBox:GetText()")
.unwrap(),
sent,
"and the edit box shows what was sent"
);
}
/// The Add Friend button with no friendly target opens the name-entry dialog — the first
/// customer of the popup engine's `hasEditBox` capability. Accepting sends what was typed.
#[test]
fn add_friend_without_a_target_opens_the_name_dialog() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
s.run("BenillaFriendsFrameAddFriendButton:Click()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"no cooperable target → the dialog"
);
assert!(
s.eval::<bool>("return StaticPopup1EditBox:IsVisible()")
.unwrap(),
"and it has an edit box"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Enter name of friend to add:"
);
let _ = s.take_social_requests();
s.run("StaticPopup1EditBox:SetText(\"Onerogue\")").unwrap();
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::AddFriend("Onerogue".to_string())]
);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"accepting closes it"
);
}
/// A re-opened dialog starts EMPTY. Left filled, a reflexive Enter would befriend whoever was
/// typed last time.
#[test]
fn the_name_dialog_reopens_empty() {
let s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
s.run("StaticPopup_Show(\"ADD_FRIEND\")").unwrap();
s.run("StaticPopup1EditBox:SetText(\"Onerogue\")").unwrap();
s.run("StaticPopup_Hide(\"ADD_FRIEND\")").unwrap();
s.run("StaticPopup_Show(\"ADD_FRIEND\")").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1EditBox:GetText()")
.unwrap(),
""
);
}
/// Right-clicking a `/who` row opens the shared FRIEND menu addressed by NAME — the point of
/// that menu, since a who hit has no unit token behind it. Whisper and Invite are the rows that
/// survive UnitPopup's gating, and each one acts on the name.
#[test]
fn right_clicking_a_who_row_opens_the_friend_menu() {
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
push(
&mut s,
SocialState {
who: vec![WhoInfo {
name: "Tigole".to_string(),
level: 40,
..Default::default()
}],
who_total: 1,
..Default::default()
},
"WHO_LIST_UPDATE",
);
s.run("BenillaWhoFrameButton1:Click(\"RightButton\")")
.unwrap();
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the menu opens"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsDropDown.name")
.unwrap(),
"Tigole",
"addressed by name, not by a unit token"
);
// …and a right-click must NOT also select the row (the ref's two branches are exclusive).
assert!(
!s.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
.unwrap(),
"right-click does not select"
);
// Whisper acts on the name.
let whisper = r#"
for i = 1, UIDROPDOWNMENU_MAXBUTTONS do
local b = getglobal("DropDownList1Button" .. i)
if b and b:IsVisible() and b.value == "WHISPER" then b:Click() return 1 end
end
return nil"#;
assert_eq!(
s.eval::<Option<i64>>(whisper).unwrap(),
Some(1),
"the menu has a Whisper row"
);
assert_eq!(s.take_tell_requests(), vec!["Tigole".to_string()]);
}
/// An OFFLINE friend's right-click opens nothing — there is no verb to offer them, which is the
/// ref's own `connected` gate on the whole menu.
#[test]
fn right_clicking_an_offline_friend_opens_nothing() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
push(
&mut s,
SocialState {
friends: vec![friend("Twomage", 0, "", "", false)],
selected_friend: 1,
..Default::default()
},
"FRIENDLIST_UPDATE",
);
s.run("BenillaFriendsFrameFriendButton1:Click(\"RightButton\")")
.unwrap();
assert!(
!s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"nothing to offer an offline friend"
);
}
/// Selecting a row queues the selection to the app AND reads back immediately — the same-tick
/// read `FriendsList_Update` does right after `SetSelectedFriend`.
#[test]
fn selecting_a_row_reads_back_in_the_same_tick() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
push(
&mut s,
SocialState {
friends: vec![
friend("Onerogue", 60, "Rogue", "Elwynn Forest", true),
friend("Twomage", 40, "Mage", "Westfall", true),
],
selected_friend: 1,
..Default::default()
},
"FRIENDLIST_UPDATE",
);
let _ = s.take_social_requests();
s.run("BenillaFriendsFrameFriendButton2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return GetSelectedFriend()").unwrap(),
2,
"the getter answers this tick, not after the app's next push"
);
assert!(s
.take_social_requests()
.contains(&SocialRequest::SelectFriend(2)));
}
/// **The window's geometry, diffed against the reference FrameXML itself.**
///
/// Every number in `FriendsFrame.xml` is a transcription of one in the reference's own file, and a
/// wrong one is invisible to every other test here: the frame loads, the clicks work, only the
/// *look* is wrong — so it surfaces as a screenshot from the director, one tab at a time. Two
/// rounds of that is what this replaces. Four of the five it caught on its first run were the
/// FriendsListFrame's values that had leaked into the IgnoreListFrame subtree, which is exactly
/// the failure mode of building a sibling pane by copy-adapting instead of transcribing.
///
/// It scrapes both files for `<AbsDimension>` pairs per named element and compares the elements
/// that exist in both (ours carry a `Benilla` prefix). Deliberately narrow — it does not try to
/// understand the XML, only to notice that a number moved. Known-benign differences are listed
/// explicitly rather than filtered by a pattern, so a NEW difference can never hide inside an
/// exemption. Skips without the extracted reference.
#[test]
fn the_window_geometry_matches_the_reference_framexml() {
let reference = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../WoW/_extracted_framexml/FriendsFrame.xml");
if !reference.is_file() {
eprintln!("skipping: no extracted FrameXML at {}", reference.display());
return;
}
/// Named element → the `AbsDimension` pairs inside it, up to the next named element.
/// Hand-rolled rather than a regex: this crate has no regex dependency, and adding one for a
/// test that scans two files is a worse trade than twenty lines of `find`.
fn scrape(text: &str) -> Vec<(String, Vec<(f32, f32)>)> {
const TAGS: &[&str] = &[
"<Button name=\"",
"<Frame name=\"",
"<EditBox name=\"",
"<ScrollFrame name=\"",
"<FontString name=\"",
"<Texture name=\"",
"<CheckButton name=\"",
];
// Every (offset, name) where a named element starts, in document order.
let mut marks: Vec<(usize, String)> = Vec::new();
for tag in TAGS {
let mut from = 0;
while let Some(hit) = text[from..].find(tag) {
let at = from + hit;
let name_start = at + tag.len();
let Some(len) = text[name_start..].find('"') else {
break;
};
marks.push((at, text[name_start..name_start + len].to_string()));
from = name_start + len;
}
}
marks.sort_by_key(|(at, _)| *at);
// `$parent*` names repeat across templates (every row template has a `$parentName`), so a
// bare name would compare one template's column against another's. Qualify each by the
// template it lives in — the nearest preceding real name.
let mut owner = String::new();
marks
.iter()
.enumerate()
.map(|(i, (at, name))| {
let end = marks.get(i + 1).map_or(text.len(), |(next, _)| *next);
let key = if let Some(child) = name.strip_prefix("$parent") {
format!("{owner}/{child}")
} else {
owner = name.clone();
name.clone()
};
(key, dimensions(&text[*at..end]))
})
.collect()
}
/// Every `<AbsDimension x=".." y=".."/>` in `chunk`, in order.
fn dimensions(chunk: &str) -> Vec<(f32, f32)> {
const OPEN: &str = "<AbsDimension x=\"";
let mut out = Vec::new();
let mut rest = chunk;
while let Some(hit) = rest.find(OPEN) {
rest = &rest[hit + OPEN.len()..];
let Some((x, tail)) = rest.split_once('"') else {
break;
};
let Some(y_at) = tail.find("y=\"") else { break };
let Some((y, tail)) = tail[y_at + 3..].split_once('"') else {
break;
};
if let (Ok(x), Ok(y)) = (x.parse(), y.parse()) {
out.push((x, y));
}
rest = tail;
}
out
}
// Differences that are ours on purpose. Each is a *deliberate* deviation with a reason, not a
// tolerance: the list is short and every entry names why.
const EXPECTED: &[&str] = &[
// The ref omits an all-zero <Offset>; we write none at all. Same anchor, fewer bytes.
"FriendsFrameFriendButton2",
"FriendsFrameFriendButton3",
"FriendsFrameFriendButton4",
"FriendsFrameFriendButton5",
"FriendsFrameFriendButton6",
"FriendsFrameFriendButton7",
"FriendsFrameFriendButton8",
"FriendsFrameFriendButton9",
"FriendsFrameFriendButton10",
"FriendsFrameIgnoreButton2",
"FriendsFrameIgnoreButton3",
"FriendsFrameIgnoreButton4",
"FriendsFrameIgnoreButton5",
"FriendsFrameIgnoreButton6",
"FriendsFrameIgnoreButton7",
"FriendsFrameIgnoreButton8",
"FriendsFrameIgnoreButton9",
"FriendsFrameIgnoreButton10",
"FriendsFrameIgnoreButton11",
"FriendsFrameIgnoreButton12",
"FriendsFrameIgnoreButton13",
"FriendsFrameIgnoreButton14",
"FriendsFrameIgnoreButton15",
"FriendsFrameIgnoreButton16",
"FriendsFrameIgnoreButton17",
"FriendsFrameIgnoreButton18",
"FriendsFrameIgnoreButton19",
"FriendsFrameIgnoreButton20",
"WhoFrameButton2",
"WhoFrameButton3",
"WhoFrameButton4",
"WhoFrameButton5",
"WhoFrameButton6",
"WhoFrameButton7",
"WhoFrameButton8",
"WhoFrameButton9",
"WhoFrameButton10",
"WhoFrameButton11",
"WhoFrameButton12",
"WhoFrameButton13",
"WhoFrameButton14",
"WhoFrameButton15",
"WhoFrameButton16",
"WhoFrameButton17",
"FriendsFrameToggleTab2",
"IgnoreFrameToggleTab2",
"FriendsFrameTopLeft",
"FriendsFrameTopRight",
"FriendsFrameBottomLeft",
"FriendsFrameBottomRight",
"WhoFrameAddFriendButton",
"WhoFrameWhoButton",
// The ref writes an all-zero <Offset> on these two row columns; we write none.
"FriendsFrameButtonTemplate/Info",
"FriendsFrameWhoButtonTemplate/Variable",
// Our column header carries the highlight's two anchors inline (the ref's sit in a
// separate <HighlightTexture> block the scrape attributes to the same element).
"WhoFrameColumnHeaderTemplate/Right",
// The three faux-scroll frames: the ref decorates each with two UI-Character-ScrollBar
// trough textures. benilla's BenillaFauxScrollFrameTemplate draws its own complete bar
// (decisions 0247/0250/0251), so the ref's loose art would double it.
"FriendsFrameFriendsScrollFrame",
"FriendsFrameIgnoreScrollFrame",
"WhoListScrollFrame",
// No UIPanelCloseButton template ships here, so ours states the 32x32 the ref inherits.
"FriendsFrameCloseButton",
];
let ours = scrape(
&std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/FriendsFrame.xml"),
)
.unwrap(),
);
let theirs: std::collections::HashMap<String, Vec<(f32, f32)>> =
scrape(&std::fs::read_to_string(&reference).unwrap())
.into_iter()
.collect();
let mut compared = 0;
let mut drifted = Vec::new();
for (name, dims) in &ours {
let key = name.replace("Benilla", "");
let Some(ref_dims) = theirs.get(&key) else {
continue; // ours alone (the local template faces) — nothing to compare against
};
compared += 1;
if dims != ref_dims && !EXPECTED.contains(&key.as_str()) {
drifted.push(format!("{key}: ours {dims:?} != ref {ref_dims:?}"));
}
}
assert!(
compared > 60,
"only {compared} elements matched by name — the scrape or the naming broke"
);
assert!(
drifted.is_empty(),
"geometry differs from the reference FrameXML:\n {}",
drifted.join("\n ")
);
}
@@ -0,0 +1,502 @@
//! The shipped `assets/ui/GameMenuFrame.xml` — the frame ESC opens (decision 0674).
//!
//! What these guard, in order: the ref geometry (195×246 and the eight-button ladder, because the
//! whole point of shipping five dead buttons is that the frame keeps its exact silhouette); the
//! disabled five; the three live buttons' wire intents and sounds; the ESC ladder's two new rungs
//! (open when nothing is left to eat, close before everything else); the micro button's `clicked`
//! toggle; the native-center rule that makes the menu take the screen (windows close on the way in,
//! and nothing opens while it is up); and the camp/quit countdown dialogs end to end.
use benilla_ui::script::{
ContainerSlot, ContainerState, LootRow, LootState, SessionRequest, SoundRequest, UiScript,
};
/// The engine the menu needs behind it: fonts, the panel manager + popup engine, and the menu.
/// `extra` adds the files a given test wants in the way (a bag, a loot window).
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = ["Fonts.xml", "UiPanels.xml"]
.into_iter()
.chain(extra.iter().copied())
.chain(std::iter::once("GameMenuFrame.xml"))
.collect();
for file in files {
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
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
fn harness() -> UiScript {
harness_with(&[])
}
/// The eight buttons, top to bottom — the ref's own order (GameMenuFrame.xml l.48-186).
const LADDER: [&str; 8] = [
"GameMenuButtonOptions",
"GameMenuButtonSoundOptions",
"GameMenuButtonUIOptions",
"GameMenuButtonKeybindings",
"GameMenuButtonMacros",
"GameMenuButtonLogout",
"GameMenuButtonQuit",
"GameMenuButtonContinue",
];
/// The frame's geometry, quoted from the reference: 195×246 centered, each button 144×21, the first
/// centered on the frame's TOP at −37, each next 1 px under the last, and Return to Game hung 16
/// under Exit Game — the single gap that makes the last entry read as separate. Shipping the five
/// unbacked buttons disabled (rather than cutting them) is exactly what keeps this true, so it is
/// the first thing to break if someone "tidies" the ladder.
#[test]
fn the_menu_has_the_reference_frame_and_button_ladder() {
let mut s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
s.resolve();
let (w, h) = s
.eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()")
.unwrap();
assert_eq!((w, h), (195.0, 246.0), "the ref frame size");
let top = s.eval::<f64>("return GameMenuFrame:GetTop()").unwrap();
// Button i's own top, measured down from the frame's: the first is centered at −37 (so its top
// is 37 − 21/2 = 26.5 below), then a 22 px stride (21 tall + the 1 px gap), with the last
// pushed a further 15 (its −16 anchor instead of −1).
let mut expected = top - 26.5;
for (i, name) in LADDER.iter().enumerate() {
let (bw, bh, btop, bleft) = s
.eval::<(f64, f64, f64, f64)>(&format!(
"return {name}:GetWidth(), {name}:GetHeight(), {name}:GetTop(), {name}:GetLeft()"
))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(
(bw, bh),
(144.0, 21.0),
"{name} is a GameMenuButtonTemplate"
);
assert!(
(btop - expected).abs() < 0.001,
"{name} top: expected {expected}, got {btop}"
);
// Every button is centered in the frame (the ladder is one column).
assert!(
(bleft - (512.0 - 72.0)).abs() < 0.001,
"{name} is centered in the 1024-wide screen"
);
expected -= if i == 6 { 21.0 + 16.0 } else { 21.0 + 1.0 };
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The five with nothing behind them are DISABLED — the director's call: the ladder keeps the ref's
/// shape, and a button that does nothing looks like it. The three that work are enabled.
#[test]
fn the_five_unbacked_buttons_are_disabled_and_the_three_live_ones_are_not() {
let s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
for name in &LADDER[..5] {
assert!(
!s.eval::<bool>(&format!("return {name}:IsEnabled()"))
.unwrap(),
"{name} has no panel behind it and must read that way"
);
}
for name in &LADDER[5..] {
assert!(
s.eval::<bool>(&format!("return {name}:IsEnabled()"))
.unwrap(),
"{name} is live"
);
}
// The labels are the reference GlobalStrings, read back through the globals (not literals).
assert_eq!(
s.eval::<String>("return GameMenuButtonContinue:GetText()")
.unwrap(),
"Return to Game"
);
assert_eq!(
s.eval::<String>("return GameMenuButtonOptions:GetText()")
.unwrap(),
"Video Options"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The ESC ladder's two new rungs (`ToggleGameMenu`, UIParent.lua l.1485-1495): a press with
/// nothing left to eat OPENS the menu (igMainMenuOpen), and the next press closes it
/// (igMainMenuQuit — the reference's own choice of kit) without reaching any rung below.
#[test]
fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_it() {
let mut s = harness_with(&["MerchantFrame.xml", "Cooldown.xml", "BagFrame.xml"]);
s.set_money(0);
s.set_container(0, Some(backpack()));
// A press with a window open is eaten by CloseAllWindows — the menu stays down.
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the press closed the bag"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"and did NOT also open the menu — one eater per press"
);
// Nothing left: the next press opens it.
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOpen".into())));
// And the press after that closes it again.
s.run("ToggleGameMenu()").unwrap();
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuQuit".into())));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The micro button's form (`ToggleGameMenu(1)`, l.1466-1480) is a plain TOGGLE, not the ladder:
/// clicking it with a window open closes the window AND opens the menu in one go — the press-eating
/// rule is the ESC key's, not the button's.
#[test]
fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
let mut s = harness_with(&["MerchantFrame.xml", "Cooldown.xml", "BagFrame.xml"]);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
s.run("ToggleGameMenu(1)").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the click closed the bag"
);
assert!(
s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and opened the menu in the SAME click"
);
s.run("ToggleGameMenu(1)").unwrap();
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The native-center rule (`ShowUIPanel` l.697-702 + `CanOpenPanels`): opening the menu closes the
/// panel slots and the bags, and while it holds the center NOTHING else opens — which is what makes
/// "you can't open your bags with the ESC menu up" true rather than merely greyed.
#[test]
fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
let mut s = harness_with(&[
"MerchantFrame.xml",
"LootFrame.xml",
"Cooldown.xml",
"BagFrame.xml",
]);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.set_loot(Some(LootState {
rows: vec![LootRow {
item_id: 0,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
quantity: 1,
quality: Some(1),
is_coin: false,
}],
}));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s
.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaLootFrame\"")
.unwrap());
s.run("ToggleGameMenu(1)").unwrap();
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"the panel slot vacated on the way in"
);
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"and the bags closed (CloseAllBags)"
);
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == \"GameMenuFrame\"")
.unwrap(),
"the menu holds the CENTER slot, not the left one"
);
assert!(
!s.eval::<bool>("return CanOpenPanels() and true or false")
.unwrap(),
"a native-center frame is up: nothing may open"
);
// The refusal itself: a panel asked to show while the menu is up simply doesn't.
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
assert!(
!s.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap(),
"ShowUIPanel refuses a left-area panel behind the menu"
);
// …and works again the moment the menu goes down.
s.run("HideUIPanel(GameMenuFrame)").unwrap();
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
assert!(s
.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The three live buttons: each plays its own reference kit and does its one thing. Logout and Exit
/// Game both queue on the session seam — the difference between them is entirely app-side (whether
/// the completed logout ends the process), which is why they look identical from here.
#[test]
fn the_live_buttons_queue_their_intents_and_play_their_kits() {
let mut s = harness();
// Return to Game — just closes, no intent.
s.run("ToggleGameMenu()").unwrap();
let _ = s.take_sounds();
s.run("GameMenuButtonContinue:Click()").unwrap();
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuContinue".into())));
assert!(s.take_session_requests().is_empty());
// Logout — the request, and the menu goes away behind it.
s.run("ToggleGameMenu()").unwrap();
let _ = s.take_sounds();
s.run("GameMenuButtonLogout:Click()").unwrap();
assert_eq!(s.take_session_requests(), vec![SessionRequest::Logout]);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuLogout".into())));
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
// Exit Game — the same shape, its own kit.
s.run("ToggleGameMenu()").unwrap();
let _ = s.take_sounds();
s.run("GameMenuButtonQuit:Click()").unwrap();
assert_eq!(s.take_session_requests(), vec![SessionRequest::Quit]);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuQuit".into())));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The camp countdown end to end (ref StaticPopup.lua l.564-582 + UIParent.lua l.304-315): the
/// server's non-instant answer becomes `PLAYER_CAMPING`, the dialog counts down from the server's
/// own 20 s, and closing it EARLY calls the logout off. The `%d %s` text is written by the popup
/// engine's countdown branch, not by the entry — a dialog that opened blank and stayed blank would
/// mean CAMP fell out of that which-list.
#[test]
fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
let mut s = harness();
s.fire_event("PLAYER_CAMPING", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup_Visible(\"CAMP\") or \"\"")
.unwrap(),
"StaticPopup1",
"the camp dialog took an instance"
);
// One tick of the engine's countdown fills the text from CAMP_TIMER.
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.5)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"20 seconds until logout",
"the countdown text is the engine's, from the server's 20 s clock"
);
// Cancel is the only button (the ref's CAMP_NOW is commented out in 1.12).
assert_eq!(
s.eval::<String>("return StaticPopup1Button1:GetText()")
.unwrap(),
"Cancel"
);
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"no second button"
);
// Closing it early cancels the logout — twice over in the reference's own shape (the button's
// OnAccept, and the OnHide guard that catches every other way out); a doubled cancel is
// harmless on the wire, and losing it would strand the character mid-countdown.
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(
s.take_session_requests()
.contains(&SessionRequest::CancelLogout),
"the early close called the logout off"
);
assert!(s
.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A countdown that RUNS OUT must not cancel itself: the engine zeroes `timeleft` before hiding, and
/// the entry's OnHide guard reads exactly that. (Get this wrong and every logout in the field is
/// cancelled by its own dialog at t=0 — the character never leaves.)
#[test]
fn a_countdown_that_expires_does_not_cancel_the_logout() {
let mut s = harness();
s.fire_event("PLAYER_CAMPING", vec![]);
s.run("StaticPopup_OnUpdate(StaticPopup1, 25)").unwrap();
assert!(
s.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
.unwrap(),
"the dialog closed itself at zero"
);
assert!(
!s.take_session_requests()
.contains(&SessionRequest::CancelLogout),
"…and did NOT call off the logout it was counting"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The quit dialog is the camp dialog under another name, plus the one thing a quit can do without
/// the server: "Exit now". And `LOGOUT_CANCEL` (the server's cancel ack) takes either one down.
#[test]
fn player_quiting_offers_exit_now_and_logout_cancel_closes_it() {
let mut s = harness();
s.fire_event("PLAYER_QUITING", vec![]);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.5)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"20 seconds until exit"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button1:GetText()")
.unwrap(),
"Exit now"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"Cancel"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(s
.take_session_requests()
.contains(&SessionRequest::ForceQuit));
// The server's own cancel ack hides whichever is up, without queueing anything further.
s.fire_event("PLAYER_CAMPING", vec![]);
assert!(s
.eval::<bool>("return StaticPopup_Visible(\"CAMP\") ~= nil")
.unwrap());
let _ = s.take_session_requests();
s.fire_event("LOGOUT_CANCEL", vec![]);
assert!(s
.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's own OnShow guard on both live buttons: a menu re-opened over a running countdown
/// shows them dead, because re-arming a logout that is already counting is meaningless.
///
/// Opened with the micro button's form on purpose — the ESC key can't get you here at all, which is
/// the next test.
#[test]
fn logout_and_exit_read_disabled_while_a_countdown_runs() {
let mut s = harness();
s.fire_event("PLAYER_CAMPING", vec![]);
s.run("ToggleGameMenu(1)").unwrap();
assert!(
!s.eval::<bool>("return GameMenuButtonLogout:IsEnabled()")
.unwrap(),
"Logout is dead while the camp timer runs"
);
assert!(!s
.eval::<bool>("return GameMenuButtonQuit:IsEnabled()")
.unwrap());
// With the countdown gone, a re-opened menu has them back.
s.run("HideUIPanel(GameMenuFrame)").unwrap();
s.fire_event("LOGOUT_CANCEL", vec![]);
s.run("ToggleGameMenu(1)").unwrap();
assert!(s
.eval::<bool>("return GameMenuButtonLogout:IsEnabled()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// ESC with a countdown up never reaches the menu: the popup rung is FIRST in the ladder
/// (`StaticPopup_EscapePressed`, l.1482) and CAMP is `hideOnEscape`, so the press dismisses the
/// dialog — which, per the entry's own OnHide, calls the logout off. Pressing ESC to "get out of"
/// a logout you didn't mean is therefore the whole gesture, and it must not also open the menu.
#[test]
fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
let mut s = harness();
s.fire_event("PLAYER_CAMPING", vec![]);
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
.unwrap(),
"the press dismissed the countdown"
);
assert!(
s.take_session_requests()
.contains(&SessionRequest::CancelLogout),
"…which called the logout off"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"and the same press did NOT also open the menu — one eater per press"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A one-item backpack (the escape/bag tests' fixture, duplicated so this file is self-contained).
fn backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 1,
quality: Some(1),
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
},
);
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
+11 -5
View File
@@ -225,12 +225,14 @@ pub(super) fn feed_ui_input(
let chord = textkeys::chord(ev.key_code, mods, mac);
if let Some(name) = named {
let consumed = script.key_input(name);
// ESCAPE with no EditBox focused runs the escape binding — benilla's ToggleGameMenu
// analog (UiPanels.xml `BenillaOnEscape`): close the open windows (bag + panel slot) and
// drop a held cursor. A focused box consumes ESCAPE first (`key_input` returns true), so
// this never fires while typing — the real client's ESC precedence.
// ESCAPE with no EditBox focused runs the escape binding: the reference's own
// TOGGLEGAMEMENU action (UiPanels.xml `ToggleGameMenu`) — one eater per press down the
// close/cancel ladder, and the game menu when nothing is left to eat. No argument: the
// `clicked` form is the micro button's plain toggle. A focused box consumes ESCAPE
// first (`key_input` returns true), so this never fires while typing — the real
// client's ESC precedence.
if name == "ESCAPE" && !consumed {
if let Err(e) = script.run("BenillaOnEscape()") {
if let Err(e) = script.run("ToggleGameMenu()") {
warn!("ui_script(escape): {e}");
}
}
@@ -323,6 +325,9 @@ fn action_button_index(key: KeyCode) -> Option<u8> {
/// - **M** TOGGLEWORLDMAP — the fullscreen map (decision 0203 phase 2).
/// - **P** TOGGLESPELLBOOK — ref `Bindings.xml:582-583` (decision 0216 §8).
/// - **N** TOGGLETALENTS — ref `Bindings.xml`: `ToggleTalentFrame()` (decision 0304).
/// - **O** TOGGLEFRIENDSTAB — ref `Bindings.xml:618-620`: `ToggleFriendsFrame(1)`, the social
/// window on its Friends tab (decision 0668). The bare TOGGLESOCIAL binding is the same window
/// without a tab argument; 'O' is the tab-1 one in the client's default set.
fn bare_key_binding(key: KeyCode) -> Option<(&'static str, &'static str)> {
Some(match key {
KeyCode::KeyB => ("BenillaBagToggle_OnClick()", "togglebackpack"),
@@ -334,6 +339,7 @@ fn bare_key_binding(key: KeyCode) -> Option<(&'static str, &'static str)> {
KeyCode::KeyM => ("ToggleWorldMap()", "toggleworldmap"),
KeyCode::KeyP => ("ToggleSpellBook(BOOKTYPE_SPELL)", "togglespellbook"),
KeyCode::KeyN => ("ToggleTalentFrame()", "toggletalents"),
KeyCode::KeyO => ("ToggleFriendsFrame(1)", "togglesocial"),
_ => return None,
})
}
+19
View File
@@ -406,6 +406,13 @@ fn load_default_ui(script: &UiScript) {
// all already loaded above). The 'N' binding (ui_script/input.rs) calls its bare
// ToggleTalentFrame().
"TalentFrame.xml",
// The social window (decision 0668): the O-key friends/ignore/who window over the social
// API (`benilla-ui/src/script/social.rs`) + `crate::ui_social`'s feed/drain. Needs
// UiPanels (the panel manager, `CharacterFrameTabButtonTemplate`, and the StaticPopup
// engine its two name-entry dialogs register into), ScrollTemplates (three faux lists),
// GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) —
// all loaded above.
"FriendsFrame.xml",
"GossipFrame.xml",
"MerchantFrame.xml",
// The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
@@ -471,6 +478,12 @@ fn load_default_ui(script: &UiScript) {
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
"QuestLogFrame.xml",
"ChatFrame.xml",
// The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their
// event driver. LAST on purpose — it is the outermost layer of the shell, it depends on
// everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's
// UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing depends on it: the ESC
// chain and the micro button both reach it by name at call time.
"GameMenuFrame.xml",
] {
let entry = dir.join(file);
let text = match std::fs::read_to_string(&entry) {
@@ -732,6 +745,9 @@ mod tooltip_compare_tests;
#[cfg(test)]
mod escape_tests;
#[cfg(test)]
mod game_menu_tests;
#[cfg(test)]
mod delete_item_tests;
@@ -744,6 +760,9 @@ mod death_tests;
#[cfg(test)]
mod duel_tests;
#[cfg(test)]
mod friends_tests;
#[cfg(test)]
mod quest_tests;
@@ -141,7 +141,7 @@ fn showing_a_dialog_cancels_its_named_victim_with_override() {
}
/// ESC only closes hideOnEscape dialogs (ref StaticPopup_EscapePressed l.1879-1893): a
/// non-escapable entry — the DEATH release popup's law — survives BenillaOnEscape.
/// non-escapable entry — the DEATH release popup's law — survives ToggleGameMenu.
#[test]
fn escape_skips_dialogs_without_hide_on_escape() {
let s = setup();
@@ -152,7 +152,7 @@ fn escape_skips_dialogs_without_hide_on_escape() {
StaticPopup_Show("TEST_STICKY")"#,
)
.unwrap();
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a non-hideOnEscape dialog ignores ESC (the DEATH popup law)"
@@ -164,7 +164,7 @@ fn escape_skips_dialogs_without_hide_on_escape() {
StaticPopup_Show("TEST_STICKY")"#,
)
.unwrap();
s.run("BenillaOnEscape()").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the escapable variant closes"
@@ -54,6 +54,7 @@ fn harness() -> UiScript {
fn seed_items(s: &mut UiScript) {
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 1234,
@@ -96,6 +97,7 @@ fn seed_items(s: &mut UiScript) {
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
count: 1,
@@ -252,6 +254,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
bar_placeable: true,
durability: Some((0, 40)),
flags: 0,
item_id: 1234,
+480
View File
@@ -0,0 +1,480 @@
//! The social VM feed/drain — the systems half of [`super`]: resolve the wire's guids and ids
//! into the display-ready snapshot the FriendsFrame reads, fire the list events on their edges,
//! print the result lines, and turn the Lua-side [`SocialRequest`] intents into their sends.
//!
//! Everything resolved here is resolved *engine-side in the reference too* (`FriendList`'s
//! formatter `0x5ae160` reads the name cache and the race/class/area GameTables before Lua sees a
//! row) — see [`super`]'s module doc.
use benilla_formats::AreaTableCatalog;
use benilla_protocol::messages::WhoEntry;
use benilla_ui::script::{FriendInfo, SocialRequest, SocialState as VmSocial, UiScript, WhoInfo};
use bevy::prelude::*;
use crate::area::AreaTableRes;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands};
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
use crate::ui_unit::{class_names, race_names};
use super::{fill_line, result_template, status_tag, SocialState};
/// `WHO_LIST_FORMAT` / `WHO_LIST_GUILD_FORMAT` / `WHO_NUM_RESULTS(_P1)` — the chat-routed `/who`
/// output (`GlobalStrings.lua:5441-5444`). These four keys appear **nowhere** in the reference's
/// FrameXML (exhaustively grepped), which is what identifies them as engine-composed: when
/// `SetWhoToUI` is off, the engine prints the results as chat lines itself. So do we.
const WHO_LIST_FORMAT: &str = "|Hplayer:%s|h[%s]|h: Level %d %s %s - %s";
const WHO_LIST_GUILD_FORMAT: &str = "|Hplayer:%s|h[%s]|h: Level %d %s %s <%s> - %s";
const WHO_NUM_RESULTS: &str = "%d player total";
const WHO_NUM_RESULTS_P1: &str = "%d players total";
/// What the feed last announced, so the Era events fire on edges rather than every frame.
#[derive(Default)]
pub(super) struct FedSocial {
/// Has the VM been given a snapshot at all yet? The first push always fires the update
/// events, so a frame loaded after the list arrived still populates.
seeded: bool,
}
/// Build the display snapshot, push it to the VM, fire the list events, and drain the owed
/// result lines.
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
pub(super) fn feed_social(
script: Option<NonSendMut<UiScript>>,
mut social: ResMut<SocialState>,
mut names: ResMut<NameCache>,
areas: Option<Res<AreaTableRes>>,
commands: Res<NetCommands>,
mut chat_log: ResMut<ChatLog>,
mut fed: Local<FedSocial>,
) {
let Some(mut script) = script else {
return;
};
let areas = areas.as_deref().map(|a| &a.0);
// The owed system lines first: a line about a friend who just went offline should land before
// the list update that removes their zone.
drain_result_lines(&mut social, &mut names, &commands, &mut chat_log);
let (friends, display_order) = friend_rows(&social, &mut names, &commands, areas);
let (ignores, ignore_order) = ignore_rows(&social, &mut names, &commands);
let who: Vec<WhoInfo> = social.who.iter().map(|e| who_row(e, areas)).collect();
let selected_friend = index_of(&display_order, social.selected_friend);
let selected_ignore = index_of(&ignore_order, social.selected_ignore);
social.display_order = display_order;
social.ignore_display_order = ignore_order;
script.set_social(VmSocial {
friends,
selected_friend,
ignores,
selected_ignore,
who,
who_total: social.who_total,
});
// The three list events (`FriendsFrame_OnEvent`'s own arms). FRIENDLIST_SHOW is the answer to
// an explicit `ShowFriends()`; a list that simply changed fires FRIENDLIST_UPDATE.
let first = !fed.seeded;
fed.seeded = true;
if social.friends_dirty || first {
social.friends_dirty = false;
script.fire_event("FRIENDLIST_UPDATE", Vec::new());
}
if social.ignores_dirty || first {
social.ignores_dirty = false;
script.fire_event("IGNORELIST_UPDATE", Vec::new());
}
if social.who_dirty {
social.who_dirty = false;
if social.who_to_ui {
script.fire_event("WHO_LIST_UPDATE", Vec::new());
} else {
// The frame isn't listening — the engine prints the results instead.
push_who_lines(&social, &mut chat_log, areas);
}
}
}
/// Compose and push every result line whose name has resolved. A line needing a name waits (the
/// reference's resolve-then-compose order); one that doesn't goes out immediately.
fn drain_result_lines(
social: &mut SocialState,
names: &mut NameCache,
commands: &NetCommands,
chat_log: &mut ChatLog,
) {
let mut still_pending = Vec::new();
for update in std::mem::take(&mut social.pending_lines) {
let Some(template) = result_template(update.result) else {
continue; // an unknown code shows nothing
};
if !template.contains("%s") {
system_line(chat_log, template.to_string());
continue;
}
// A named line with no subject (the server answers a failed lookup with guid 0) can never
// resolve — print nothing rather than "%s added to friends." or hold it forever.
if update.guid == 0 {
continue;
}
match names.resolve(update.guid, commands).map(str::to_string) {
Some(name) => system_line(chat_log, fill_line(template, &name)),
None => still_pending.push(update),
}
}
social.pending_lines = still_pending;
}
/// The chat-routed `/who` output: the total line, then one line per row (module doc's four
/// engine-only templates).
fn push_who_lines(social: &SocialState, chat_log: &mut ChatLog, areas: Option<&AreaTableCatalog>) {
let total = social.who_total;
let template = if total == 1 {
WHO_NUM_RESULTS
} else {
WHO_NUM_RESULTS_P1
};
system_line(chat_log, template.replace("%d", &total.to_string()));
for entry in &social.who {
let row = who_row(entry, areas);
let template = if row.guild.is_empty() {
WHO_LIST_FORMAT
} else {
WHO_LIST_GUILD_FORMAT
};
// The templates are positional-by-order, not indexed: name, name, level, race, class,
// [guild,] zone.
let mut line = template.to_string();
for fill in [
row.name.as_str(),
row.name.as_str(),
&row.level.to_string(),
row.race.as_str(),
row.class.as_str(),
] {
line = replace_first_token(&line, fill);
}
if !row.guild.is_empty() {
line = replace_first_token(&line, &row.guild);
}
line = replace_first_token(&line, &row.zone);
system_line(chat_log, line);
}
}
/// Replace the first `%s` or `%d` in `line` with `fill` — the templates interleave both, so a
/// per-token replace-first walk fills them in wire order.
fn replace_first_token(line: &str, fill: &str) -> String {
match (line.find("%s"), line.find("%d")) {
(Some(s), Some(d)) if d < s => line.replacen("%d", fill, 1),
(Some(_), _) => line.replacen("%s", fill, 1),
(None, Some(_)) => line.replacen("%d", fill, 1),
(None, None) => line.to_string(),
}
}
/// The friend rows in display order (name-sorted), plus the guid order that produced them so the
/// drain can map a row index back to a player.
fn friend_rows(
social: &SocialState,
names: &mut NameCache,
commands: &NetCommands,
areas: Option<&AreaTableCatalog>,
) -> (Vec<FriendInfo>, Vec<u64>) {
let mut rows: Vec<(u64, FriendInfo)> = social
.friends
.iter()
.map(|entry| {
let name = names
.resolve(entry.guid, commands)
.map(str::to_string)
.unwrap_or_default();
let online = entry.is_online();
(
entry.guid,
FriendInfo {
name,
level: entry.level,
// An offline friend has no class/zone on the wire; leaving them empty is what
// makes the frame print its "Offline" template instead of inventing values.
class: online
.then(|| class_names(entry.class as u8))
.flatten()
.map(|(display, _)| display.to_string())
.unwrap_or_default(),
area: online
.then(|| areas.and_then(|a| a.name(entry.area)))
.flatten()
.unwrap_or_default()
.to_string(),
connected: online,
status: status_tag(entry.status).to_string(),
},
)
})
.collect();
// Name order, with the not-yet-resolved rows last so an in-flight name query doesn't park an
// empty row at the top of the list.
rows.sort_by(|(_, a), (_, b)| {
a.name
.is_empty()
.cmp(&b.name.is_empty())
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
rows.into_iter().map(|(guid, row)| (row, guid)).unzip()
}
/// The ignore rows: names only, same ordering rule.
fn ignore_rows(
social: &SocialState,
names: &mut NameCache,
commands: &NetCommands,
) -> (Vec<String>, Vec<u64>) {
let mut rows: Vec<(u64, String)> = social
.ignores
.iter()
.map(|guid| {
(
*guid,
names
.resolve(*guid, commands)
.map(str::to_string)
.unwrap_or_default(),
)
})
.collect();
rows.sort_by(|(_, a), (_, b)| {
a.is_empty()
.cmp(&b.is_empty())
.then_with(|| a.to_lowercase().cmp(&b.to_lowercase()))
});
rows.into_iter().map(|(guid, name)| (name, guid)).unzip()
}
/// One `/who` row, ids resolved. Note the Lua API returns race *before* class while the wire
/// carries class first — the swap happens here, once.
fn who_row(entry: &WhoEntry, areas: Option<&AreaTableCatalog>) -> WhoInfo {
WhoInfo {
name: entry.name.clone(),
guild: entry.guild.clone(),
level: entry.level,
race: race_names(entry.race as u8)
.map(|(display, _)| display)
.unwrap_or_default()
.to_string(),
class: class_names(entry.class as u8)
.map(|(display, _)| display)
.unwrap_or_default()
.to_string(),
zone: areas
.and_then(|a| a.name(entry.zone))
.unwrap_or_default()
.to_string(),
}
}
/// The 1-based row a guid occupies in the shown order, `0` when it isn't shown — the reference's
/// guid→index conversion (`GetSelectedFriend` `0x5ae510`).
fn index_of(order: &[u64], guid: u64) -> u32 {
if guid == 0 {
return 0;
}
order
.iter()
.position(|g| *g == guid)
.map_or(0, |i| i as u32 + 1)
}
fn system_line(chat_log: &mut ChatLog, text: String) {
chat_log.push_event(ChatEvent::text_only(ChatEventKind::System, text));
}
/// Turn the Era API's social intents into their sends. Every "by index" intent resolves through
/// the display order the feed just published, and every "by name" one through the list's own
/// resolved names — because the wire removes by **guid** (see [`super`]'s module doc).
pub(super) fn drain_social(
script: Option<NonSendMut<UiScript>>,
mut social: ResMut<SocialState>,
names: Res<NameCache>,
commands: Res<NetCommands>,
areas: Option<Res<AreaTableRes>>,
) {
let Some(mut script) = script else {
return;
};
let requests = script.take_social_requests();
if requests.is_empty() {
return;
}
for request in requests {
match request {
SocialRequest::RefreshFriends => {
let _ = commands.0.send(ClientCommand::FriendListRequest);
}
SocialRequest::AddFriend(name) => {
let _ = commands.0.send(ClientCommand::AddFriend { name });
}
SocialRequest::RemoveFriendIndex(index) => {
if let Some(guid) = row_guid(&social.display_order, index) {
let _ = commands.0.send(ClientCommand::DelFriend { guid });
}
}
SocialRequest::RemoveFriendName(name) => {
if let Some(guid) = guid_named(&social.display_order, &name, &names) {
let _ = commands.0.send(ClientCommand::DelFriend { guid });
}
}
SocialRequest::AddIgnore(name) => {
let _ = commands.0.send(ClientCommand::AddIgnore { name });
}
SocialRequest::DelIgnore(name) => {
if let Some(guid) = guid_named(&social.ignore_display_order, &name, &names) {
let _ = commands.0.send(ClientCommand::DelIgnore { guid });
}
}
SocialRequest::ToggleIgnore(name) => {
// `/ignore <name>`: un-ignore if they're already on the list, else ignore them.
match guid_named(&social.ignore_display_order, &name, &names) {
Some(guid) => {
let _ = commands.0.send(ClientCommand::DelIgnore { guid });
}
None => {
let _ = commands.0.send(ClientCommand::AddIgnore { name });
}
}
}
SocialRequest::SelectFriend(index) => {
social.selected_friend = row_guid(&social.display_order, index).unwrap_or(0);
}
SocialRequest::SelectIgnore(index) => {
social.selected_ignore = row_guid(&social.ignore_display_order, index).unwrap_or(0);
}
SocialRequest::Who(filter) => {
let request = super::who_query(&filter, areas.as_deref().map(|a| &a.0));
let _ = commands.0.send(ClientCommand::Who {
request: Box::new(request),
});
}
SocialRequest::SortWho(sort_type) => {
social.who_sort = sort_type;
sort_who(&mut social);
}
SocialRequest::SetWhoToUi(on) => social.who_to_ui = on,
}
}
}
/// The guid at a 1-based display row.
fn row_guid(order: &[u64], index: u32) -> Option<u64> {
usize::try_from(index.checked_sub(1)?)
.ok()
.and_then(|i| order.get(i))
.copied()
}
/// The guid of the listed player called `name`, case-insensitively — the name→guid direction the
/// `/removefriend` and `/unignore` verbs need before they can send anything.
fn guid_named(order: &[u64], name: &str, names: &NameCache) -> Option<u64> {
order.iter().copied().find(|guid| {
names
.peek(*guid)
.is_some_and(|n| n.eq_ignore_ascii_case(name))
})
}
/// Re-order the who results in place. Client-side, like the reference's own comparator — the
/// server sends them in whatever order its player map iterates.
fn sort_who(social: &mut SocialState) {
let key = social.who_sort.clone();
match key.as_str() {
"level" => social.who.sort_by_key(|e| e.level),
"class" => social.who.sort_by_key(|e| e.class),
"race" => social.who.sort_by_key(|e| e.race),
"zone" => social.who.sort_by_key(|e| e.zone),
"guild" => social.who.sort_by(|a, b| a.guild.cmp(&b.guild)),
// "name" and anything unrecognised: alphabetical, the list's resting order.
_ => social.who.sort_by(|a, b| a.name.cmp(&b.name)),
}
social.who_dirty = true;
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::friend_status;
/// A row index maps back through the *shown* order, and 0/past-the-end map to nothing — the
/// guard that keeps a stale click from removing a bystander.
#[test]
fn row_indices_map_through_the_shown_order() {
let order = [11u64, 22, 33];
assert_eq!(row_guid(&order, 1), Some(11));
assert_eq!(row_guid(&order, 3), Some(33));
assert_eq!(row_guid(&order, 0), None);
assert_eq!(row_guid(&order, 4), None);
}
/// Selection survives a re-order because it is stored as a guid: the same player keeps the
/// highlight even when the row under them moves.
#[test]
fn selection_follows_the_player_not_the_row() {
assert_eq!(index_of(&[11, 22, 33], 22), 2);
assert_eq!(index_of(&[22, 11, 33], 22), 1, "same player, new row");
assert_eq!(index_of(&[11, 33], 22), 0, "no longer listed");
assert_eq!(index_of(&[11, 22], 0), 0, "nothing selected");
}
/// The who templates interleave `%s` and `%d`, so the fills have to walk them in order.
#[test]
fn who_line_tokens_fill_in_wire_order() {
let mut line = WHO_LIST_GUILD_FORMAT.to_string();
for fill in [
"Tigole", "Tigole", "40", "Human", "Rogue", "Legacy", "Westfall",
] {
line = replace_first_token(&line, fill);
}
assert_eq!(
line,
"|Hplayer:Tigole|h[Tigole]|h: Level 40 Human Rogue <Legacy> - Westfall"
);
}
/// The unguilded template is the same walk minus the guild fill.
#[test]
fn the_unguilded_who_line_skips_the_guild() {
let mut line = WHO_LIST_FORMAT.to_string();
for fill in ["Solo", "Solo", "5", "Dwarf", "Priest", "Coldridge Valley"] {
line = replace_first_token(&line, fill);
}
assert_eq!(
line,
"|Hplayer:Solo|h[Solo]|h: Level 5 Dwarf Priest - Coldridge Valley"
);
}
/// An offline friend shows no class or zone — the wire sends neither, so the row must not
/// carry the values it had while online.
#[test]
fn an_offline_row_carries_no_class_or_zone() {
let entry = benilla_protocol::messages::FriendEntry {
guid: 7,
status: friend_status::OFFLINE,
area: 12,
level: 60,
class: 4,
};
// `who_row`'s sibling path, exercised through the same resolution rules the feed uses.
let online = entry.is_online();
assert!(!online);
let class = online
.then(|| class_names(entry.class as u8))
.flatten()
.map(|(d, _)| d.to_string())
.unwrap_or_default();
assert_eq!(class, "");
}
}
+444
View File
@@ -0,0 +1,444 @@
//! The social session — the friend list, the ignore list, `/who`, and the system lines they
//! print (decision 0668).
//!
//! [`SocialState`] mirrors the wire the way [`crate::ui_party`]'s `GroupState` does: the three
//! server packets replace it wholesale or patch one row, and the feed turns it into the VM
//! snapshot the FriendsFrame reads. Two client-side laws from wow-re's `FriendList.cpp` findings
//! (`system/net/scratch/w2b.md`, the 0x728-byte object at `DAT_00c28168`) shape everything here:
//!
//! - **The lists are guids.** A friend slot holds `{status, note*, guid, area, level, class}` —
//! no name — and the display name comes from the ObjectMgr name cache (`0x55f080`) at format
//! time. So does ours ([`crate::names::NameCache`]), which is why a freshly-listed friend can
//! take a name-query round trip to show a name, and why removal (a guid on the wire) has to
//! look the name up first.
//! - **Selection is a guid too** (`+0x648` friend, `+0x720` ignore), converted to an index on
//! read. Ours is stored the same way, so a list that re-sorts under a selection keeps the same
//! player selected rather than the same row.
//!
//! ## The system lines
//!
//! Every friend/ignore result prints one line, and they go to the **chat frame**, not the red
//! error line. That is byte-settled rather than assumed: `FriendList::HandleResult 0x5acab0`
//! displays through `CGGameUI::DisplayError 0x496720` with catalog ids `0x104..0x114`, and each
//! of those rows' `+0x4` **kind** field is written from the register the independently-VERIFIED
//! kind-0 row `0x8b` (`ERR_QUEST_FAILED_S` → chat) also uses — `ecx`, i.e. **kind 0 = the chat
//! composer** (`0x49a870`). Read at the bytes in the table's static initializer,
//! `0x486f78`–`0x48719c` (base `0xb4b498`, stride `0x14`); record `0x115` is the first to take a
//! different register, which is exactly where the friend band ends.
//!
//! Which result maps to which GlobalStrings key is *inferred by name* (the `ERR_FRIEND_*` /
//! `ERR_IGNORE_*` set is one-to-one with vmangos's `FriendsResult` enum, and 18 results collapse
//! to the 17 ids because both ADDED codes share `ERR_FRIEND_ADDED_S`) — the open dispatch item in
//! the record.
//!
//! ## What the ignore list is *for*
//!
//! Not the window — the silence. `FriendList::IsIgnored 0x5ae5a0` gates inbound chat and text
//! emotes (wow-re `system/ui/scratch/text-emote-composition.md`: ignored performer ⇒ dropped
//! silently, no line at all) and the duel handler's auto-decline (`0x4d4a33`). [`is_ignored`] is
//! that predicate; its callers are the chat apply arm and the duel one, which is how decision
//! 0633's stated "no ignore list yet" deviation closes.
use benilla_protocol::messages::{friend_result, friend_status, FriendEntry, FriendStatusUpdate};
use bevy::prelude::*;
use crate::ui_script::UiInput;
mod feed;
mod query;
pub(crate) use query::parse as who_query;
/// The GlobalStrings templates for the friend/ignore results, quoted verbatim from the reference
/// client's own patch chain (decision 0246 extraction; `GlobalStrings.lua` line cited per
/// constant). Composed here rather than in Lua for the same reason the party lines are
/// (decision 0434 §D2): the real client composes them engine-side, and the FrameXML never names
/// these keys.
const ERR_FRIEND_DB_ERROR: &str = "Friend lookup database error."; // GlobalStrings:1571
const ERR_FRIEND_LIST_FULL: &str = "You don't have room for any more friends."; // :1573
const ERR_FRIEND_ONLINE_SS: &str = "|Hplayer:%s|h[%s]|h has come online."; // :1576
const ERR_FRIEND_OFFLINE_S: &str = "%s has gone offline."; // :1575
const ERR_FRIEND_NOT_FOUND: &str = "Player not found."; // :1574
const ERR_FRIEND_REMOVED_S: &str = "%s removed from friends list."; // :1577
const ERR_FRIEND_ADDED_S: &str = "%s added to friends."; // :1569
const ERR_FRIEND_ALREADY_S: &str = "%s is already your friend."; // :1570
const ERR_FRIEND_SELF: &str = "You can't put yourself on your friend list."; // :1578
const ERR_FRIEND_WRONG_FACTION: &str = "Friends must be part of your alliance."; // :1579
const ERR_IGNORE_FULL: &str = "You can't ignore any more players."; // :1639
const ERR_IGNORE_SELF: &str = "You can't ignore yourself."; // :1642
const ERR_IGNORE_NOT_FOUND: &str = "Player not found."; // :1640
const ERR_IGNORE_ALREADY_S: &str = "%s is already being ignored."; // :1637
const ERR_IGNORE_ADDED_S: &str = "%s is now being ignored."; // :1636
const ERR_IGNORE_REMOVED_S: &str = "%s is no longer being ignored."; // :1641
const ERR_IGNORE_AMBIGUOUS: &str = "That name is ambiguous, type more of the player's server name"; // :1638
const ERR_FRIEND_ERROR: &str = "Unknown friend response from server."; // :1572
/// The away tags the friends-list template's third `%s` takes — `CHAT_FLAG_AFK` / `CHAT_FLAG_DND`
/// (`GlobalStrings.lua:766-767`), the same pair the chat frame prefixes a speaker's name with.
const CHAT_FLAG_AFK: &str = "<AFK>";
const CHAT_FLAG_DND: &str = "<DND>";
/// The social session mirror. Filled by the net drain's social arms, read by the feed, cleared on
/// disconnect beside the other per-login resources.
#[derive(Resource, Default)]
pub(crate) struct SocialState {
/// The friend list as the wire sent it (`SMSG_FRIEND_LIST` order — vmangos walks a
/// guid-keyed map). Display order is the feed's ([`Self::display_order`]).
friends: Vec<FriendEntry>,
/// The ignore list: guids, nothing else.
ignores: Vec<u64>,
/// The selected friend, stored as a **guid** like the reference's `+0x648`; `0` = none. A
/// re-sort or a roster change keeps the same *player* selected.
selected_friend: u64,
/// The selected ignore (the reference's `+0x720`).
selected_ignore: u64,
/// The friend guids in the order the feed last showed them — written by the feed, read by the
/// drain so a row index from Lua maps back to the same player the user clicked.
display_order: Vec<u64>,
/// The ignore guids in shown order, same contract.
ignore_display_order: Vec<u64>,
/// The last `/who` answer, and the server's true match total.
who: Vec<benilla_protocol::messages::WhoEntry>,
who_total: u32,
/// The current who-list sort key (`SortWho`), `"zone"` being the frame's own default column.
who_sort: String,
/// `SetWhoToUI` — does the *next* `/who` answer belong to the Who frame (true) or the chat
/// frame (false)? The WhoFrame's OnShow/OnHide drive it; a `/who` typed with the frame closed
/// prints its results as chat lines.
who_to_ui: bool,
/// Results whose line is still owed, waiting on a name query (the reference's own
/// resolve-then-compose order — `FriendList` formats from the name cache).
pending_lines: Vec<FriendStatusUpdate>,
/// Set whenever a list changed, so the feed knows to fire the Era update event.
friends_dirty: bool,
ignores_dirty: bool,
who_dirty: bool,
}
impl SocialState {
/// Is `guid` on the ignore list? The reference's `FriendList::IsIgnored 0x5ae5a0` — the
/// predicate inbound chat, text emotes and duel challenges all gate on (module doc).
pub(crate) fn is_ignored(&self, guid: u64) -> bool {
guid != 0 && self.ignores.contains(&guid)
}
/// `SMSG_FRIEND_LIST` — replace the list wholesale (it is never a delta).
fn apply_friend_list(&mut self, friends: Vec<FriendEntry>) {
self.friends = friends;
self.friends_dirty = true;
}
/// `SMSG_IGNORE_LIST` — likewise.
fn apply_ignore_list(&mut self, guids: Vec<u64>) {
self.ignores = guids;
self.ignores_dirty = true;
}
/// `SMSG_FRIEND_STATUS` — apply the result to the list, and queue its system line.
///
/// The server sends **no** fresh list after an add/remove, so the local list has to follow
/// the result codes; a presence broadcast (ONLINE/OFFLINE) patches the row in place, which is
/// what keeps a friend's zone current without a refresh.
fn apply_friend_status(&mut self, update: FriendStatusUpdate) {
match update.result {
friend_result::ADDED_ONLINE | friend_result::ADDED_OFFLINE => {
if !self.friends.iter().any(|f| f.guid == update.guid) {
let online = update.online;
self.friends.push(FriendEntry {
guid: update.guid,
status: online.map_or(friend_status::OFFLINE, |o| o.status),
area: online.map_or(0, |o| o.area),
level: online.map_or(0, |o| o.level),
class: online.map_or(0, |o| o.class),
});
}
self.friends_dirty = true;
}
friend_result::REMOVED => {
self.friends.retain(|f| f.guid != update.guid);
if self.selected_friend == update.guid {
self.selected_friend = 0;
}
self.friends_dirty = true;
}
friend_result::ONLINE => {
if let (Some(entry), Some(online)) = (self.friend_mut(update.guid), update.online) {
entry.status = online.status;
entry.area = online.area;
entry.level = online.level;
entry.class = online.class;
}
self.friends_dirty = true;
}
friend_result::OFFLINE => {
if let Some(entry) = self.friend_mut(update.guid) {
entry.status = friend_status::OFFLINE;
// The wire's own rule: an offline friend carries no area/level/class, so
// stale ones must not linger under the "Offline" row.
entry.area = 0;
entry.level = 0;
entry.class = 0;
}
self.friends_dirty = true;
}
friend_result::IGNORE_ADDED => {
if !self.ignores.contains(&update.guid) {
self.ignores.push(update.guid);
}
self.ignores_dirty = true;
}
friend_result::IGNORE_REMOVED => {
self.ignores.retain(|g| *g != update.guid);
if self.selected_ignore == update.guid {
self.selected_ignore = 0;
}
self.ignores_dirty = true;
}
// Every other code is a refusal: it says something, and changes nothing.
_ => {}
}
self.pending_lines.push(update);
}
fn friend_mut(&mut self, guid: u64) -> Option<&mut FriendEntry> {
self.friends.iter_mut().find(|f| f.guid == guid)
}
/// `SMSG_WHO` — the answer to our last query.
fn apply_who(&mut self, results: benilla_protocol::messages::WhoResults) {
self.who = results.entries;
self.who_total = results.total;
self.who_dirty = true;
}
}
/// The line one result prints, and whether it needs the subject's name first.
///
/// `%s` templates wait for the name query; the rest print immediately. This is the reference's
/// own order (resolve, then compose — wow-re's text-emote flow, VERIFIED for the sibling path).
fn result_template(result: u8) -> Option<&'static str> {
Some(match result {
friend_result::DB_ERROR => ERR_FRIEND_DB_ERROR,
friend_result::LIST_FULL => ERR_FRIEND_LIST_FULL,
friend_result::ONLINE => ERR_FRIEND_ONLINE_SS,
friend_result::OFFLINE => ERR_FRIEND_OFFLINE_S,
friend_result::NOT_FOUND => ERR_FRIEND_NOT_FOUND,
friend_result::REMOVED => ERR_FRIEND_REMOVED_S,
friend_result::ADDED_ONLINE | friend_result::ADDED_OFFLINE => ERR_FRIEND_ADDED_S,
friend_result::ALREADY => ERR_FRIEND_ALREADY_S,
friend_result::SELF => ERR_FRIEND_SELF,
friend_result::ENEMY => ERR_FRIEND_WRONG_FACTION,
friend_result::IGNORE_FULL => ERR_IGNORE_FULL,
friend_result::IGNORE_SELF => ERR_IGNORE_SELF,
friend_result::IGNORE_NOT_FOUND => ERR_IGNORE_NOT_FOUND,
friend_result::IGNORE_ALREADY => ERR_IGNORE_ALREADY_S,
friend_result::IGNORE_ADDED => ERR_IGNORE_ADDED_S,
friend_result::IGNORE_REMOVED => ERR_IGNORE_REMOVED_S,
friend_result::IGNORE_AMBIGUOUS => ERR_IGNORE_AMBIGUOUS,
friend_result::UNKNOWN => ERR_FRIEND_ERROR,
// An unknown code shows nothing — GlobalStrings data-suppression, the same face an
// absent key wears everywhere else in this client.
_ => return None,
})
}
/// Fill a result template with the subject's name. `ERR_FRIEND_ONLINE_SS` takes it **twice**
/// (the `|Hplayer:%s|h[%s]|h` link the comment in GlobalStrings explicitly warns not to
/// localize), so a plain replace-all is the right substitution, not a positional one.
fn fill_line(template: &str, name: &str) -> String {
template.replace("%s", name)
}
/// The `<AFK>`/`<DND>` tag a friend row's third `%s` takes.
fn status_tag(status: u8) -> &'static str {
match status {
friend_status::AFK => CHAT_FLAG_AFK,
friend_status::DND => CHAT_FLAG_DND,
_ => "",
}
}
/// The net drain's `SessionEvent::Friend*`/`Who*` arms, factored here so the wire laws live
/// beside the state they drive ([`crate::ui_duel::apply`]'s shape).
pub(crate) mod apply {
use super::*;
/// `SMSG_FRIEND_LIST`.
pub(crate) fn friend_list(social: &mut SocialState, friends: Vec<FriendEntry>) {
social.apply_friend_list(friends);
}
/// `SMSG_IGNORE_LIST`.
pub(crate) fn ignore_list(social: &mut SocialState, guids: Vec<u64>) {
social.apply_ignore_list(guids);
}
/// `SMSG_FRIEND_STATUS`.
pub(crate) fn friend_status(social: &mut SocialState, update: FriendStatusUpdate) {
social.apply_friend_status(update);
}
/// `SMSG_WHO`.
pub(crate) fn who(social: &mut SocialState, results: benilla_protocol::messages::WhoResults) {
social.apply_who(results);
}
}
/// The social window's session: the wire mirror, the VM feed, and the outbound intents.
pub(crate) struct UiSocialPlugin;
impl Plugin for UiSocialPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SocialState>().add_systems(
Update,
(
feed::feed_social.before(UiInput),
feed::drain_social.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::FriendOnline;
fn status(result: u8, guid: u64) -> FriendStatusUpdate {
FriendStatusUpdate {
result,
guid,
online: None,
}
}
/// The server sends no fresh list after an add or a remove, so the result codes have to keep
/// the local list honest — otherwise a friend added stays invisible until the next login.
#[test]
fn add_and_remove_results_maintain_the_list() {
let mut social = SocialState::default();
social.apply_friend_status(FriendStatusUpdate {
result: friend_result::ADDED_ONLINE,
guid: 7,
online: Some(FriendOnline {
status: friend_status::ONLINE,
area: 12,
level: 60,
class: 4,
}),
});
assert_eq!(social.friends.len(), 1);
assert_eq!(social.friends[0].level, 60);
// The same add arriving twice must not duplicate the row.
social.apply_friend_status(status(friend_result::ADDED_OFFLINE, 7));
assert_eq!(social.friends.len(), 1);
social.apply_friend_status(status(friend_result::REMOVED, 7));
assert!(social.friends.is_empty());
}
/// A presence broadcast patches the row in place — and going offline clears the level/zone,
/// because the wire stops sending them (a stale "Level 60 Rogue" under an Offline row would
/// be the client inventing data).
#[test]
fn presence_broadcasts_patch_the_row() {
let mut social = SocialState::default();
social.apply_friend_list(vec![FriendEntry {
guid: 7,
..Default::default()
}]);
social.apply_friend_status(FriendStatusUpdate {
result: friend_result::ONLINE,
guid: 7,
online: Some(FriendOnline {
status: friend_status::AFK,
area: 1519,
level: 42,
class: 8,
}),
});
assert_eq!(social.friends[0].status, friend_status::AFK);
assert_eq!(social.friends[0].area, 1519);
social.apply_friend_status(status(friend_result::OFFLINE, 7));
assert!(!social.friends[0].is_online());
assert_eq!(
(social.friends[0].area, social.friends[0].level),
(0, 0),
"offline carries no zone or level"
);
}
/// Removing the selected friend clears the selection rather than leaving it pointing at a
/// player who is no longer listed.
#[test]
fn removing_the_selected_friend_clears_the_selection() {
let mut social = SocialState::default();
social.apply_friend_list(vec![FriendEntry {
guid: 7,
..Default::default()
}]);
social.selected_friend = 7;
social.apply_friend_status(status(friend_result::REMOVED, 7));
assert_eq!(social.selected_friend, 0);
}
/// The ignore results maintain the ignore list, and [`SocialState::is_ignored`] is what the
/// chat/duel gates read.
#[test]
fn ignore_results_maintain_the_ignore_list() {
let mut social = SocialState::default();
assert!(!social.is_ignored(9));
social.apply_friend_status(status(friend_result::IGNORE_ADDED, 9));
assert!(social.is_ignored(9));
assert!(!social.is_ignored(0), "guid 0 is never ignored");
social.apply_friend_status(status(friend_result::IGNORE_REMOVED, 9));
assert!(!social.is_ignored(9));
}
/// A refusal changes nothing — but still owes a line.
#[test]
fn a_refusal_changes_no_state_but_prints() {
let mut social = SocialState::default();
social.apply_friend_status(status(friend_result::ALREADY, 7));
assert!(social.friends.is_empty());
assert_eq!(social.pending_lines.len(), 1);
}
/// Every result vmangos can send has a line, and the two ADDED codes share one — which is
/// exactly why 18 results fit the 17 catalog ids `0x104..0x114`.
#[test]
fn every_result_code_maps_to_a_line() {
for result in 0x00..=0x11u8 {
assert!(
result_template(result).is_some(),
"result {result:#04x} has no line"
);
}
assert_eq!(
result_template(friend_result::ADDED_ONLINE),
result_template(friend_result::ADDED_OFFLINE),
);
assert_eq!(
result_template(friend_result::UNKNOWN),
Some(ERR_FRIEND_ERROR)
);
assert_eq!(result_template(0x77), None, "an unknown code shows nothing");
}
/// The online line takes the name twice (the player link); the rest take it once.
#[test]
fn the_online_line_fills_the_name_twice() {
assert_eq!(
fill_line(ERR_FRIEND_ONLINE_SS, "Bob"),
"|Hplayer:Bob|h[Bob]|h has come online."
);
assert_eq!(
fill_line(ERR_FRIEND_ADDED_S, "Bob"),
"Bob added to friends."
);
assert_eq!(fill_line(ERR_FRIEND_SELF, "Bob"), ERR_FRIEND_SELF);
}
}
+283
View File
@@ -0,0 +1,283 @@
//! The `/who` **filter parser** — the string the player types into the wire fields the server
//! reads (decision 0668).
//!
//! This is engine work in the real client too: the FrameXML hands `SendWho` a raw string
//! (`WhoFrame_GetDefaultWhoCommand` builds `z-"Elwynn Forest" 55-63`), and nothing in Lua turns
//! that into `CMSG_WHO`'s level bounds, masks and **zone ids**. The id part is why the parse
//! belongs here rather than in `benilla-protocol`: `z-` names a zone, the wire wants an
//! `AreaTable.dbc` row id, so the parser needs the DBC projection the app already loads
//! ([`crate::area::AreaTableRes`]).
//!
//! The grammar, from the reference's own vocabulary (`GlobalStrings.lua`'s `WHO_TAG_*` and the
//! filters `WhoFrame_GetDefaultWhoCommand` emits):
//!
//! | token | field |
//! |---|---|
//! | `n-<name>` | name substring |
//! | `g-<guild>` | guild substring |
//! | `z-<zone>` | a zone id (name resolved through `AreaTable`) |
//! | `c-<class>` | a bit in the class mask |
//! | `r-<race>` | a bit in the race mask |
//! | `<lo>-<hi>` | level range |
//! | `<n>` | that exact level (`lo = hi = n`) |
//! | anything else | a search term the server matches against name, guild **and** zone |
//!
//! Values may be quoted (`z-"Elwynn Forest"`), which is how a two-word zone survives the split.
//! Everything is case-insensitive; unmatched class/race words fall through to search terms rather
//! than narrowing the mask to nothing, so a typo returns too much rather than nothing.
//!
//! **INTERIM** (decision 0668): the tag set and the fall-through are transcribed from the
//! reference's strings and its own emitted filters, not from that TU's disassembly — the one
//! place in this slice where the *client* end is inferred rather than byte-read. The dispatch
//! item is named in the record.
use benilla_formats::AreaTableCatalog;
use benilla_protocol::messages::WhoRequest;
use crate::ui_unit::{class_names, race_names};
/// The 1.12 class ids that exist (`class_names`' domain) — the mask universe. Class 6 (Death
/// Knight) and 10 are absent in 1.12, so a mask of "everything" must not claim them: the server
/// tests `classMask & (1 << classId)` against real ids only, but an honest all-classes mask is
/// the union of the ones that exist.
const CLASS_IDS: [u8; 9] = [1, 2, 3, 4, 5, 7, 8, 9, 11];
/// The 1.12 playable race ids (`race_names`' domain).
const RACE_IDS: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];
/// Parse a `/who` filter string into the wire request.
///
/// `areas` resolves `z-` names; without it (no DBCs loaded, e.g. a unit test) a `z-` term
/// degrades to a plain search term, which the server still matches against the zone name — a
/// wider query, never a wrong one.
pub(crate) fn parse(filter: &str, areas: Option<&AreaTableCatalog>) -> WhoRequest {
let mut request = WhoRequest::default();
let mut class_mask = 0u32;
let mut race_mask = 0u32;
for token in tokenize(filter) {
let (tag, value) = split_tag(&token);
match tag {
Some('n') => request.player_name = value.to_string(),
Some('g') => request.guild_name = value.to_string(),
Some('z') => match areas.and_then(|areas| zone_id(areas, value)) {
Some(id) => request.zones.push(id),
// Unresolvable zone name: keep it as a search term (the server matches zone
// names too) rather than dropping the player's intent on the floor.
None => request.search_terms.push(value.to_string()),
},
Some('c') => match mask_bit(value, &CLASS_IDS, class_names) {
Some(bit) => class_mask |= bit,
None => request.search_terms.push(value.to_string()),
},
Some('r') => match mask_bit(value, &RACE_IDS, race_names) {
Some(bit) => race_mask |= bit,
None => request.search_terms.push(value.to_string()),
},
_ => {
if let Some((lo, hi)) = level_range(value) {
request.level_min = lo;
request.level_max = hi;
} else if !value.is_empty() {
request.search_terms.push(value.to_string());
}
}
}
}
// An empty mask means "no c-/r- term was given" — every class/race, the default.
if class_mask != 0 {
request.class_mask = class_mask;
}
if race_mask != 0 {
request.race_mask = race_mask;
}
request
}
/// Split a filter string into tokens, honouring double quotes so `z-"Elwynn Forest"` survives as
/// one token. An unterminated quote runs to the end of the string (the forgiving read — the
/// player is mid-typing, not writing a config file).
fn tokenize(filter: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quoted = false;
for ch in filter.chars() {
match ch {
'"' => quoted = !quoted,
c if c.is_whitespace() && !quoted => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
c => current.push(c),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
/// Split `c-warrior` into `(Some('c'), "warrior")`. Only a single ASCII letter followed by `-`
/// counts, so a bare `1-10` level range and a hyphenated name are not mistaken for tags.
fn split_tag(token: &str) -> (Option<char>, &str) {
let mut chars = token.chars();
match (chars.next(), chars.next()) {
(Some(tag), Some('-')) if tag.is_ascii_alphabetic() => {
(Some(tag.to_ascii_lowercase()), &token[2..])
}
_ => (None, token),
}
}
/// `1-10` → `(1, 10)`, `40` → `(40, 40)`; anything else is not a level term. Bounds are clamped
/// to the client's own 1..=100 window (its un-set max is 100 — see [`WhoRequest::level_max`]), so
/// a typo'd `1-9999` still asks a question the server answers.
fn level_range(value: &str) -> Option<(u32, u32)> {
let clamp = |n: u32| n.clamp(1, 100);
match value.split_once('-') {
Some((lo, hi)) => {
let (lo, hi) = (lo.parse::<u32>().ok()?, hi.parse::<u32>().ok()?);
Some((clamp(lo), clamp(hi.max(lo))))
}
None => {
let n = clamp(value.parse::<u32>().ok()?);
Some((n, n))
}
}
}
/// Match a class/race word against the display names and return its `1 << id` bit.
fn mask_bit(
value: &str,
ids: &[u8],
names: fn(u8) -> Option<(&'static str, &'static str)>,
) -> Option<u32> {
ids.iter().copied().find_map(|id| {
let (display, token) = names(id)?;
// Both spellings answer: "night elf" (the display name) and "nightelf" (the file token).
(display.eq_ignore_ascii_case(value) || token.eq_ignore_ascii_case(value))
.then_some(1u32 << id)
})
}
/// Resolve a zone name to its `AreaTable` id, case-insensitively (the catalog prefers a
/// top-level row over a same-named subzone). Exact names only — there is no fuzzy fallback,
/// because a wrong id is a query about the *wrong zone*; the caller degrades a miss to a search
/// term instead, which the server still matches against zone names.
fn zone_id(areas: &AreaTableCatalog, name: &str) -> Option<u32> {
areas.id_for_name(name)
}
#[cfg(test)]
mod tests {
use super::*;
/// A bare `/who` asks the widest question the client ever asks.
#[test]
fn an_empty_filter_is_the_default_query() {
assert_eq!(parse("", None), WhoRequest::default());
assert_eq!(parse(" ", None), WhoRequest::default());
}
/// The tagged forms fill their own fields; the level range is untagged.
#[test]
fn tags_route_to_their_fields() {
let q = parse("n-bob g-\"Legacy of Steel\" 10-20", None);
assert_eq!(q.player_name, "bob");
assert_eq!(q.guild_name, "Legacy of Steel");
assert_eq!((q.level_min, q.level_max), (10, 20));
assert!(q.search_terms.is_empty(), "tagged terms are not also terms");
}
/// A single number is an exact level, not a search term.
#[test]
fn a_bare_number_is_an_exact_level() {
let q = parse("60", None);
assert_eq!((q.level_min, q.level_max), (60, 60));
assert!(q.search_terms.is_empty());
}
/// Class and race words become mask bits, by display name or file token, case-insensitively.
#[test]
fn class_and_race_terms_become_mask_bits() {
let q = parse("c-Warrior r-\"night elf\"", None);
assert_eq!(q.class_mask, 1 << 1, "warrior is class 1");
assert_eq!(q.race_mask, 1 << 4, "night elf is race 4");
let token_spelling = parse("r-nightelf", None);
assert_eq!(token_spelling.race_mask, 1 << 4);
// Two of a kind union rather than overwrite.
let both = parse("c-mage c-warlock", None);
assert_eq!(both.class_mask, (1 << 8) | (1 << 9));
}
/// An unrecognised class/race word must not narrow the mask to nothing — it falls through to
/// a search term, so the query returns too much rather than a silent empty list.
#[test]
fn an_unknown_class_word_falls_through_to_a_search_term() {
let q = parse("c-necromancer", None);
assert_eq!(q.class_mask, u32::MAX, "still every class");
assert_eq!(q.search_terms, vec!["necromancer".to_string()]);
}
/// Without an AreaTable a `z-` term degrades to a search term — the server matches zone names
/// against those too, so the query widens instead of breaking.
#[test]
fn an_unresolvable_zone_degrades_to_a_search_term() {
let q = parse("z-\"Elwynn Forest\"", None);
assert!(q.zones.is_empty());
assert_eq!(q.search_terms, vec!["Elwynn Forest".to_string()]);
}
/// Quotes hold multi-word values together; an unterminated quote runs to the end.
#[test]
fn quotes_hold_a_value_together() {
assert_eq!(
tokenize("z-\"Elwynn Forest\" 1-10"),
vec!["z-Elwynn Forest".to_string(), "1-10".to_string()]
);
assert_eq!(tokenize("n-\"bo"), vec!["n-bo".to_string()]);
}
/// A hyphenated word is not a tag (only a single letter + `-` is), and a reversed range is
/// read as ascending rather than as an empty window.
#[test]
fn hyphens_that_are_not_tags() {
let q = parse("well-met", None);
assert_eq!(q.search_terms, vec!["well-met".to_string()]);
let reversed = parse("20-10", None);
assert_eq!((reversed.level_min, reversed.level_max), (20, 20));
}
/// The level window is clamped to the client's own 1..=100.
#[test]
fn levels_clamp_to_the_clients_window() {
let q = parse("0-9999", None);
assert_eq!((q.level_min, q.level_max), (1, 100));
}
/// The zone name resolves against the **real** `AreaTable.dbc` — the fact the `z-` path
/// rests on, and the one a synthetic catalog could not catch. Skips without client data.
#[test]
fn zone_names_resolve_against_real_area_data() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc");
let q = parse("z-\"elwynn forest\" 1-10", Some(&areas));
assert_eq!(q.zones.len(), 1, "one zone id, resolved case-insensitively");
assert_eq!(
areas.name(q.zones[0]),
Some("Elwynn Forest"),
"and it is the right row"
);
assert!(q.search_terms.is_empty(), "resolved, so not a search term");
}
}