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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ®ion.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 ®ion.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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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))],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user