From c887170b377c4f826fd2f6623940e807a547455a Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 26 Jul 2026 19:19:06 +0700 Subject: [PATCH] the social window, the escape menu, and items that work from the bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/benilla-formats/src/area_table.rs | 21 + .../src/bin/benilla-world/main.rs | 16 +- .../src/bin/benilla-world/probes/mod.rs | 2 + .../bin/benilla-world/probes/quest_item.rs | 218 +++ crates/benilla-protocol/src/events.rs | 48 +- crates/benilla-protocol/src/events/decode.rs | 17 +- crates/benilla-protocol/src/messages/items.rs | 51 +- crates/benilla-protocol/src/messages/mod.rs | 7 + .../benilla-protocol/src/messages/opcode.rs | 20 + .../benilla-protocol/src/messages/packet.rs | 52 +- crates/benilla-protocol/src/messages/parse.rs | 24 +- .../benilla-protocol/src/messages/social.rs | 601 +++++++++ .../messages/update_object/fields/player.rs | 11 + crates/benilla-protocol/src/world/session.rs | 6 +- .../src/world/writer/lifecycle.rs | 8 + .../benilla-protocol/src/world/writer/mod.rs | 2 + .../src/world/writer/social.rs | 50 + crates/benilla-protocol/tests/items.rs | 5 + crates/benilla-ui/src/script/action.rs | 9 + crates/benilla-ui/src/script/char_stats.rs | 3 + crates/benilla-ui/src/script/container.rs | 8 + crates/benilla-ui/src/script/cursor.rs | 13 + crates/benilla-ui/src/script/cursor/bar.rs | 132 ++ crates/benilla-ui/src/script/cursor/doll.rs | 8 + crates/benilla-ui/src/script/cursor/drag.rs | 5 + crates/benilla-ui/src/script/mail.rs | 2 + crates/benilla-ui/src/script/mod.rs | 6 + crates/benilla-ui/src/script/model.rs | 31 +- crates/benilla-ui/src/script/session.rs | 78 ++ crates/benilla-ui/src/script/social.rs | 425 ++++++ crates/benilla-ui/src/script/spellbook.rs | 1 + .../src/script/tests/tooltip_item/bindings.rs | 1 + crates/benilla-ui/src/script/trade.rs | 2 + crates/benilla-visual/src/lib.rs | 2 + crates/benilla-visual/src/main.rs | 131 +- crates/benilla-visual/src/relight.rs | 248 ++++ crates/benilla/assets/ui/BagFrame.xml | 42 +- crates/benilla/assets/ui/FriendsFrame.xml | 1180 +++++++++++++++++ crates/benilla/assets/ui/GameMenuFrame.xml | 306 +++++ crates/benilla/assets/ui/MicroMenu.xml | 37 +- crates/benilla/assets/ui/StackSplit.xml | 2 +- crates/benilla/assets/ui/UiPanels.xml | 425 +++++- crates/benilla/src/capture/depth_probe.rs | 510 +++++++ crates/benilla/src/capture/fixtures.rs | 3 + crates/benilla/src/capture/mod.rs | 4 + crates/benilla/src/capture/phase_probe.rs | 257 ++++ crates/benilla/src/capture/pick_probe.rs | 140 +- crates/benilla/src/capture/probes.rs | 38 +- crates/benilla/src/items.rs | 1 + crates/benilla/src/lighting/global_light.rs | 49 +- crates/benilla/src/lighting/resolve.rs | 15 +- crates/benilla/src/main.rs | 32 + crates/benilla/src/mesh_tag.rs | 46 + crates/benilla/src/model_render.rs | 5 + crates/benilla/src/net.rs | 43 +- crates/benilla/src/net/apply.rs | 53 +- crates/benilla/src/net/apply/quests.rs | 173 ++- crates/benilla/src/net/apply/session.rs | 5 + crates/benilla/src/net/io.rs | 14 +- crates/benilla/src/player/camera.rs | 28 + crates/benilla/src/target/click.rs | 4 +- crates/benilla/src/terrain.rs | 17 +- crates/benilla/src/ui_action/cast_send.rs | 281 ++++ crates/benilla/src/ui_action/drain.rs | 311 +++++ crates/benilla/src/ui_action/drain_tests.rs | 75 ++ crates/benilla/src/ui_action/errors.rs | 22 +- crates/benilla/src/ui_action/feed.rs | 367 +++++ crates/benilla/src/ui_action/feed_tests.rs | 26 +- crates/benilla/src/ui_action/mod.rs | 947 +------------ crates/benilla/src/ui_action/weapon_icon.rs | 113 ++ crates/benilla/src/ui_char.rs | 8 +- crates/benilla/src/ui_chat/input.rs | 97 +- crates/benilla/src/ui_duel.rs | 18 +- crates/benilla/src/ui_items/drain.rs | 76 +- crates/benilla/src/ui_items/feed.rs | 2 + crates/benilla/src/ui_items/mod.rs | 329 ++++- crates/benilla/src/ui_logout.rs | 253 ++++ crates/benilla/src/ui_merchant.rs | 1 + crates/benilla/src/ui_quest.rs | 269 +++- .../benilla/src/ui_script/action_bar_tests.rs | 1 + crates/benilla/src/ui_script/bag_tests.rs | 8 + .../benilla/src/ui_script/character_tests.rs | 2 + crates/benilla/src/ui_script/death_tests.rs | 2 +- .../src/ui_script/delete_item_tests.rs | 5 +- crates/benilla/src/ui_script/duel_tests.rs | 2 +- crates/benilla/src/ui_script/escape_tests.rs | 26 +- crates/benilla/src/ui_script/friends_tests.rs | 912 +++++++++++++ .../benilla/src/ui_script/game_menu_tests.rs | 502 +++++++ crates/benilla/src/ui_script/input.rs | 16 +- crates/benilla/src/ui_script/mod.rs | 19 + .../src/ui_script/static_popup_tests.rs | 6 +- .../src/ui_script/tooltip_compare_tests.rs | 3 + crates/benilla/src/ui_social/feed.rs | 480 +++++++ crates/benilla/src/ui_social/mod.rs | 444 +++++++ crates/benilla/src/ui_social/query.rs | 283 ++++ 95 files changed, 10437 insertions(+), 1162 deletions(-) create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs create mode 100644 crates/benilla-protocol/src/messages/social.rs create mode 100644 crates/benilla-protocol/src/world/writer/social.rs create mode 100644 crates/benilla-ui/src/script/session.rs create mode 100644 crates/benilla-ui/src/script/social.rs create mode 100644 crates/benilla-visual/src/relight.rs create mode 100644 crates/benilla/assets/ui/FriendsFrame.xml create mode 100644 crates/benilla/assets/ui/GameMenuFrame.xml create mode 100644 crates/benilla/src/capture/depth_probe.rs create mode 100644 crates/benilla/src/capture/phase_probe.rs create mode 100644 crates/benilla/src/ui_action/cast_send.rs create mode 100644 crates/benilla/src/ui_action/drain.rs create mode 100644 crates/benilla/src/ui_action/drain_tests.rs create mode 100644 crates/benilla/src/ui_action/feed.rs create mode 100644 crates/benilla/src/ui_action/weapon_icon.rs create mode 100644 crates/benilla/src/ui_logout.rs create mode 100644 crates/benilla/src/ui_script/friends_tests.rs create mode 100644 crates/benilla/src/ui_script/game_menu_tests.rs create mode 100644 crates/benilla/src/ui_social/feed.rs create mode 100644 crates/benilla/src/ui_social/mod.rs create mode 100644 crates/benilla/src/ui_social/query.rs diff --git a/crates/benilla-formats/src/area_table.rs b/crates/benilla-formats/src/area_table.rs index ee70dccf..ede7e5c5 100644 --- a/crates/benilla-formats/src/area_table.rs +++ b/crates/benilla-formats/src/area_table.rs @@ -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 { + 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() } diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs index 5f7dcea3..f392d3ef 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/main.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -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)); } diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs index ac9b605f..e1d7ebfc 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs new file mode 100644 index 00000000..df97d5f6 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/quest_item.rs @@ -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 Option>; + +/// 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, + secs: u64, + mut f: QuestEventHandler| + -> Option { + 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(()) + } +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 6eb71fdd..442c8ccd 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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 }, + /// The whole ignore list (`SMSG_IGNORE_LIST`) — the same replace-everything shape, guids only. + IgnoreList { guids: Vec }, + /// 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 diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 2ba5973e..c09dcd3e 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -25,6 +25,10 @@ pub fn decode(packet: ServerPacket) -> Vec { 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 { 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 { 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 }] diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 8d3ebae4..c9376265 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -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, + /// 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 { + 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, diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 03ad89ff..73d3f8b7 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index 27ff401e..cc34ee3b 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 19d60ce3..9e5c09c7 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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, + }, + /// `SMSG_IGNORE_LIST` — the whole ignore list: guids and nothing else. + IgnoreList { + guids: Vec, + }, + /// `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") } diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index a3877f95..aac9a7a3 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -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::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 { 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. diff --git a/crates/benilla-protocol/src/messages/social.rs b/crates/benilla-protocol/src/messages/social.rs new file mode 100644 index 00000000..c07a9c8d --- /dev/null +++ b/crates/benilla-protocol/src/messages/social.rs @@ -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 `` / `` 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> { + 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> { + 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, +} + +/// 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 { + 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 { + 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, +} + +/// 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 { + 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, + /// Untagged words: the server matches each against name, guild **and** zone name (any hit + /// keeps the row). Refused past 4. + pub search_terms: Vec, +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + guid.to_le_bytes().to_vec() +} + +/// A body that is exactly one NUL-terminated string. +fn cstring_body(s: &str) -> Vec { + 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, 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()); + } +} diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index ac85b9a4..0fb662b3 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -14,6 +14,17 @@ impl ObjectFields { pub fn item_stack_count(&self) -> Option { 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 { + (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; diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs index 1b105e4b..8bc460af 100644 --- a/crates/benilla-protocol/src/world/session.rs +++ b/crates/benilla-protocol/src/world/session.rs @@ -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 diff --git a/crates/benilla-protocol/src/world/writer/lifecycle.rs b/crates/benilla-protocol/src/world/writer/lifecycle.rs index 012e81fc..92531fa2 100644 --- a/crates/benilla-protocol/src/world/writer/lifecycle.rs +++ b/crates/benilla-protocol/src/world/writer/lifecycle.rs @@ -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`] diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index e72a79ee..9a6ce39e 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -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; diff --git a/crates/benilla-protocol/src/world/writer/social.rs b/crates/benilla-protocol/src/world/writer/social.rs new file mode 100644 index 00000000..c59cf02a --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/social.rs @@ -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)) + } +} diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index e091cc15..94145eea 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -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, diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index f237a294..7c166c26 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -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 diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index e4ca92d3..766de85a 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -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, /// Stack count (`GetInventoryItemCount` — 1 for equipment, the bag-summed count for ammo). diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 079e1c8f..93e9b35d 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -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, + /// 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, diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 749304e0..0cd6650b 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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, + /// 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, + /// `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, diff --git a/crates/benilla-ui/src/script/cursor/bar.rs b/crates/benilla-ui/src/script/cursor/bar.rs index ccfbe0ba..e54f76ca 100644 --- a/crates/benilla-ui/src/script/cursor/bar.rs +++ b/crates/benilla-ui/src/script/cursor/bar.rs @@ -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::("return shows").unwrap(), 1); assert_eq!(s.eval::("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::("return PlaceAction(4)").unwrap()); + assert!(!s.eval::("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::("return PlaceAction(4)").unwrap()); + assert!(!s.eval::("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::("return PlaceAction(4)").unwrap()); + assert!(s.eval::("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::("return PlaceAction(4)").unwrap()); + assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]); + } } diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index 3ccab73c..f6505b39 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -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, diff --git a/crates/benilla-ui/src/script/cursor/drag.rs b/crates/benilla-ui/src/script/cursor/drag.rs index d1b3d619..1af5f4c3 100644 --- a/crates/benilla-ui/src/script/cursor/drag.rs +++ b/crates/benilla-ui/src/script/cursor/drag.rs @@ -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, diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index 75fb393e..14a8e529 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -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, diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 97cd89a0..5017b2c7 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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)?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 806df3f4..0de27c57 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, + /// 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, /// 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 ` (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, + /// 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, /// 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 ; 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, diff --git a/crates/benilla-ui/src/script/session.rs b/crates/benilla-ui/src/script/session.rs new file mode 100644 index 00000000..81b929c1 --- /dev/null +++ b/crates/benilla-ui/src/script/session.rs @@ -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 { + 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::().expect("model app_data"); + model.session_requests.push(request); + Ok(()) + })?, + )?; + } + + Ok(()) +} diff --git a/crates/benilla-ui/src/script/social.rs b/crates/benilla-ui/src/script/social.rs new file mode 100644 index 00000000..fc1c4cd3 --- /dev/null +++ b/crates/benilla-ui/src/script/social.rs @@ -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 `` 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: `""`, `""`, or `""`. + 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, + /// 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, + /// The selected ignore, same 1-based scale. + pub selected_ignore: u32, + /// The last `/who` answer's rows (≤ 49). + pub who: Vec, + /// 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 { + 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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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| { + let mut model = lua.app_data_mut::().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::().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::().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 + } +} diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index b84f6d91..ae6d1d70 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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); diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index ccc7c541..4c77fb63 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -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, diff --git a/crates/benilla-ui/src/script/trade.rs b/crates/benilla-ui/src/script/trade.rs index 1d107859..956fd0b4 100644 --- a/crates/benilla-ui/src/script/trade.rs +++ b/crates/benilla-ui/src/script/trade.rs @@ -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, diff --git a/crates/benilla-visual/src/lib.rs b/crates/benilla-visual/src/lib.rs index 08ed4676..4ca1ec5b 100644 --- a/crates/benilla-visual/src/lib.rs +++ b/crates/benilla-visual/src/lib.rs @@ -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 diff --git a/crates/benilla-visual/src/main.rs b/crates/benilla-visual/src/main.rs index c1ad1b0f..3f963a45 100644 --- a/crates/benilla-visual/src/main.rs +++ b/crates/benilla-visual/src/main.rs @@ -60,6 +60,7 @@ struct Opts { amplify: u32, toggle_delta: u8, pad: u32, + at: Option, } 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 ")?; 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 \",[;,…]\"")?; + 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 = names + .iter() + .map(|n| load(&dir.join(n))) + .collect::>()?; + 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 = 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> { + spec.split(';') + .filter(|s| !s.trim().is_empty()) + .map(|pair| { + let (x, y) = pair.split_once(',').context("--at wants \",;…\"")?; + 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 = 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 = (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 [--out ] [--fail ] [--amplify ]\n \ benilla-visual flicker [--out ] [--fail ] [--amplify ] [--toggle-delta ]\n \ benilla-visual hotspot --out [--toggle-delta ] [--pad ]\n \ + benilla-visual series --at \",[;,…]\"\n \ benilla-visual compose-dir --out (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." ); } diff --git a/crates/benilla-visual/src/relight.rs b/crates/benilla-visual/src/relight.rs new file mode 100644 index 00000000..ab8d2236 --- /dev/null +++ b/crates/benilla-visual/src/relight.rs @@ -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 { + 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 { + extreme_step(region, frames, false) +} + +fn extreme_step(region: &Region, frames: &[RgbImage], want_max: bool) -> Option { + 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)); + } +} diff --git a/crates/benilla/assets/ui/BagFrame.xml b/crates/benilla/assets/ui/BagFrame.xml index 6c832866..090df770 100644 --- a/crates/benilla/assets/ui/BagFrame.xml +++ b/crates/benilla/assets/ui/BagFrame.xml @@ -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 ]]> diff --git a/crates/benilla/assets/ui/FriendsFrame.xml b/crates/benilla/assets/ui/FriendsFrame.xml new file mode 100644 index 00000000..1223706a --- /dev/null +++ b/crates/benilla/assets/ui/FriendsFrame.xml @@ -0,0 +1,1180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self.updateFunc = BenillaFriendsList_Update + + + + BenillaFauxScrollFrame_OnMouseWheel(BenillaFriendsFrameFriendsScrollFrame, arg1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self.updateFunc = BenillaIgnoreList_Update + + + + BenillaFauxScrollFrame_OnMouseWheel(BenillaFriendsFrameIgnoreScrollFrame, arg1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:ClearFocus() + BenillaWhoFrameEditBox_OnEnterPressed() + this:ClearFocus() + + + + + + + + self.updateFunc = BenillaWhoList_Update + + + + + SetWhoToUI(0) + SetWhoToUI(1) + SetWhoToUI(0) + BenillaFauxScrollFrame_OnMouseWheel(BenillaWhoListScrollFrame, arg1) + + + + + + + + + + + BenillaFriendsFrame_OnLoad() + BenillaFriendsFrame_OnShow() + BenillaFriendsFrame_OnHide() + BenillaFriendsFrame_OnEvent() + + + diff --git a/crates/benilla/assets/ui/GameMenuFrame.xml b/crates/benilla/assets/ui/GameMenuFrame.xml new file mode 100644 index 00000000..ae857099 --- /dev/null +++ b/crates/benilla/assets/ui/GameMenuFrame.xml @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GameMenuFrameTitle:SetText(MAIN_MENU) + + if UpdateMicroButtons then UpdateMicroButtons() end + if Disable_BagButtons then Disable_BagButtons() end + + + if UpdateMicroButtons then UpdateMicroButtons() end + if Enable_BagButtons then Enable_BagButtons() end + + + + + + + + + + + self:RegisterEvent("PLAYER_CAMPING") + self:RegisterEvent("PLAYER_QUITING") + self:RegisterEvent("LOGOUT_CANCEL") + + BenillaLogoutDriver_OnEvent() + + + diff --git a/crates/benilla/assets/ui/MicroMenu.xml b/crates/benilla/assets/ui/MicroMenu.xml index 94aedd8a..d9800a54 100644 --- a/crates/benilla/assets/ui/MicroMenu.xml +++ b/crates/benilla/assets/ui/MicroMenu.xml @@ -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 @@ - @@ -317,13 +319,16 @@ - + diff --git a/crates/benilla/assets/ui/StackSplit.xml b/crates/benilla/assets/ui/StackSplit.xml index b0ed462d..6426b56a 100644 --- a/crates/benilla/assets/ui/StackSplit.xml +++ b/crates/benilla/assets/ui/StackSplit.xml @@ -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 diff --git a/crates/benilla/assets/ui/UiPanels.xml b/crates/benilla/assets/ui/UiPanels.xml index 34c8127a..81262bff 100644 --- a/crates/benilla/assets/ui/UiPanels.xml +++ b/crates/benilla/assets/ui/UiPanels.xml @@ -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 @@ + + + +