diff --git a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml
index 8a7882ff..dbf4261d 100644
--- a/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml
+++ b/crates/benilla-app/assets/ui/ContainerFrameAdapters.xml
@@ -1,56 +1,18 @@
diff --git a/crates/benilla-app/assets/ui/layer.toc b/crates/benilla-app/assets/ui/layer.toc
index 95fd90bd..09c679b3 100644
--- a/crates/benilla-app/assets/ui/layer.toc
+++ b/crates/benilla-app/assets/ui/layer.toc
@@ -13,7 +13,7 @@ KeyBindingsPage.xml
OptionsFrame.xml
# The ESC menu's era look on the stock `GameMenuFrame`, whose Options rung opens ours.
GameMenuAdapters.xml
-# Wraps the stock bag verbs; the keyring is a container (id -2).
+# `OpenBackpack` and `CloseBackpack` over every equipped bag, for the vendor and the mailbox.
ContainerFrameAdapters.xml
# The pet page's autocast widgets in the pet action button's proportions.
SpellBookAdapters.xml
diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs
index e44c5b16..92de7054 100644
--- a/crates/benilla-app/src/entities.rs
+++ b/crates/benilla-app/src/entities.rs
@@ -888,6 +888,10 @@ fn setup_entities(
Ok(catalog) => commands.insert_resource(crate::ui_mail::Stationery(catalog)),
Err(e) => warn!("stationery catalog unavailable, mail uses the default backdrop: {e:#}"),
}
+ match benilla_formats::load_packages(&mut chain) {
+ Ok(rows) => commands.insert_resource(crate::ui_mail::Packages(rows)),
+ Err(e) => warn!("mail packages unavailable, GetNumPackages answers 0: {e:#}"),
+ }
match benilla_formats::load_page_text_material_catalog(&mut chain) {
Ok(catalog) => commands.insert_resource(crate::ui_item_text::PageMaterials(catalog)),
Err(e) => warn!("page text materials unavailable, books read on parchment: {e:#}"),
diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs
index 99457603..d4d63e79 100644
--- a/crates/benilla-app/src/net.rs
+++ b/crates/benilla-app/src/net.rs
@@ -1402,6 +1402,8 @@ pub(crate) enum ClientCommand {
body: String,
/// The `Stationery.dbc` id, the sixth field.
stationery: u32,
+ /// The `Package.dbc` id, the seventh field, 0 without an item.
+ package: u32,
item_guid: u64,
money: u32,
cod: u32,
diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs
index 328ba575..869b97c4 100644
--- a/crates/benilla-app/src/net/io.rs
+++ b/crates/benilla-app/src/net/io.rs
@@ -1232,14 +1232,15 @@ fn writer_loop(
subject,
body,
stationery,
+ package,
item_guid,
money,
cod,
} => w.send_mail(
mailbox, &receiver, &subject, &body,
- // The chosen stationery and package 0, as the reference sends; vmangos
- // stores `MAIL_STATIONERY_DEFAULT` (41) regardless.
- stationery, 0, item_guid, money, cod,
+ // vmangos stores `MAIL_STATIONERY_DEFAULT` (41) regardless of the chosen
+ // stationery, and reads the package id (`Packets/Mail.cpp:10`) unused.
+ stationery, package, item_guid, money, cod,
),
ClientCommand::MailTakeMoney { mailbox, mail_id } => {
w.mail_take_money(mailbox, mail_id)
diff --git a/crates/benilla-app/src/portrait/mod.rs b/crates/benilla-app/src/portrait/mod.rs
index 6fdeab7a..27545750 100644
--- a/crates/benilla-app/src/portrait/mod.rs
+++ b/crates/benilla-app/src/portrait/mod.rs
@@ -64,8 +64,9 @@ use light::{model_pane_light, studio_light, BoothLight};
pub(crate) mod test_bake;
/// The round portrait slots, each with its own layer and camera. `"npc"` is an interaction
-/// window's NPC ([`crate::ui_session::InteractNpc`]); `"targettarget"` resolves only while drawn.
-const SLOTS: [&str; 9] = [
+/// window's NPC ([`crate::ui_session::InteractNpc`]); `"targettarget"` resolves only while drawn,
+/// and so does the loot source `SetLootPortrait` binds ([`benilla_ui::script::LOOT_PORTRAIT_UNIT`]).
+const SLOTS: [&str; 10] = [
"player",
"target",
"targettarget",
@@ -75,6 +76,7 @@ const SLOTS: [&str; 9] = [
"party2",
"party3",
"party4",
+ benilla_ui::script::LOOT_PORTRAIT_UNIT,
];
/// The character window's full-body pane: the dressed player, sampled square.
const PAPERDOLL_SLOT: &str = "paperdoll";
@@ -658,8 +660,10 @@ pub(crate) struct PartyBooths<'w, 's> {
palettes: ResMut<'w, benilla_world::rig_palette::RigPalettes>,
pet_bar: Res<'w, crate::ui_pet::PetBar>,
names: Res<'w, crate::names::NameCache>,
- /// What the UI drew last frame, read by `"targettarget"` alone.
+ /// What the UI drew last frame, read by `"targettarget"` and the loot source.
panes: Res<'w, BoothPanes>,
+ /// The open loot, whose source `SetLootPortrait` bakes (`0x4c2bc5`, `[0xb71b48]`).
+ loot: Option>,
/// The guid-keyed bake cache, and a fresh target plus the camera's [`RenderTarget`] for the
/// handover.
bakes: ResMut<'w, PortraitBakes>,
@@ -1326,6 +1330,19 @@ fn sync_portraits(
entity
}
"npc" => interact_npc.0,
+ // Gated on the UI drawing it, as `"targettarget"`: no stock file binds it.
+ benilla_ui::script::LOOT_PORTRAIT_UNIT => party
+ .panes
+ .0
+ .contains_key(token)
+ .then(|| {
+ party
+ .loot
+ .as_ref()
+ .and_then(|l| l.source())
+ .and_then(|g| party.index.0.get(&g).copied())
+ })
+ .flatten(),
// Out of range, the stand-in's race and sex come from the name cache.
tok => {
let member = tok
diff --git a/crates/benilla-app/src/ui_loot/mod.rs b/crates/benilla-app/src/ui_loot/mod.rs
index c91b67e4..99c2ef21 100644
--- a/crates/benilla-app/src/ui_loot/mod.rs
+++ b/crates/benilla-app/src/ui_loot/mod.rs
@@ -523,6 +523,8 @@ fn snapshot(
rows,
fishing: loot.fishing,
master_candidates: who.names_for(loot),
+ // The feed's, which holds the object manager.
+ source_unit: false,
})
}
@@ -637,6 +639,30 @@ fn drain_receives(
}
/// Emits the queued pushes, then pushes the loot snapshot and fires the loot events on a change.
+/// The object manager as `SetLootPortrait` asks it (`0x468460`, typemask 8): whether the loot
+/// source is a unit object we hold.
+#[derive(bevy::ecs::system::SystemParam)]
+struct LootSourceObjects<'w, 's> {
+ index: Option>,
+ stores: Query<'w, 's, &'static crate::net::ObjectStore>,
+}
+
+impl LootSourceObjects<'_, '_> {
+ fn is_unit(&self, guid: u64) -> bool {
+ use benilla_protocol::messages::ObjectType;
+ self.index
+ .as_ref()
+ .and_then(|index| index.0.get(&guid))
+ .and_then(|&e| self.stores.get(e).ok())
+ .is_some_and(|s| {
+ matches!(
+ s.0.object_type(),
+ Some(ObjectType::Unit | ObjectType::Player)
+ )
+ })
+ }
+}
+
fn feed_loot(
script: Option>,
mut loot: ResMut,
@@ -653,6 +679,7 @@ fn feed_loot(
enchants: Option>,
group: Res,
names: Res,
+ objects: LootSourceObjects,
) {
let Some(mut script) = script else {
return;
@@ -676,7 +703,10 @@ fn feed_loot(
group: &group,
names: &names,
};
- let fresh = snapshot(&loot, &items, icons.as_deref(), &commands, rolls, who);
+ let fresh = snapshot(&loot, &items, icons.as_deref(), &commands, rolls, who).map(|mut snap| {
+ snap.source_unit = loot.source().is_some_and(|g| objects.is_unit(g));
+ snap
+ });
if fresh == *last {
return;
}
@@ -1137,6 +1167,59 @@ mod tests {
(app, rx)
}
+ /// `SetLootPortrait` answers 1 only while the loot source is a unit object we hold
+ /// (`0x4c2bd1`-`0x4c2be9`): a creature's corpse, never a chest or a source out of view.
+ #[test]
+ fn the_loot_portrait_follows_a_unit_source_only() {
+ use benilla_protocol::ObjectFields;
+ for (unit, expected) in [(true, true), (false, false)] {
+ let (tx, _rx) = crossbeam_channel::unbounded();
+ let mut app = App::new();
+ app.add_message::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::>()
+ .init_resource::()
+ .init_resource::()
+ .insert_resource(NetCommands(tx))
+ .add_systems(bevy::prelude::Update, feed_loot);
+ // OBJECT_FIELD_TYPE (2): TYPEMASK_OBJECT | TYPEMASK_UNIT for a creature, | 0x20 for a
+ // game object.
+ let kind = if unit { 0x9 } else { 0x21 };
+ let e = app
+ .world_mut()
+ .spawn(crate::net::ObjectStore(ObjectFields::from_pairs(&[(
+ 2, kind,
+ )])))
+ .id();
+ app.world_mut()
+ .resource_mut::()
+ .0
+ .insert(0x42, e);
+ app.world_mut().resource_mut::().open(
+ 0x42,
+ loot_type::CORPSE,
+ 5,
+ Vec::new(),
+ );
+ let script = UiScript::new().unwrap();
+ script
+ .run(r#"CreateFrame("Frame", "LootHost"):CreateTexture("LootTex")"#)
+ .unwrap();
+ app.insert_non_send_resource(script);
+ app.update();
+ let answered = app
+ .world_mut()
+ .non_send_resource_mut::()
+ .eval::("return SetLootPortrait(LootTex) == 1")
+ .unwrap();
+ assert_eq!(answered, expected, "unit source: {unit}");
+ }
+ }
+
/// `0x4c2ac0` fires `LOOT_OPENED` once, at the last answer; here a negative one opens it too.
#[test]
fn the_window_waits_for_every_item_template() {
diff --git a/crates/benilla-app/src/ui_macro/mod.rs b/crates/benilla-app/src/ui_macro/mod.rs
index 51049bfe..b426263b 100644
--- a/crates/benilla-app/src/ui_macro/mod.rs
+++ b/crates/benilla-app/src/ui_macro/mod.rs
@@ -172,7 +172,7 @@ fn rebind_macro_spells(
mut bound: ResMut,
mut last_generation: Local>>,
) {
- let (Some(script), Some(table)) = (script, table) else {
+ let (Some(mut script), Some(table)) = (script, table) else {
return;
};
// Per VM: a fresh VM restarts its generation at 0, which a bare memo would gate off for good.
@@ -191,6 +191,27 @@ fn rebind_macro_spells(
};
fresh.insert(index, run::bound_spell(&table, &m.body, &book));
}
+ // The engine's copy, for `GetActionAutocast`, re-pushed on every recompute so a fresh VM has
+ // it. The book flag stays the player's: `run::bound_spell` resolves the player's book only.
+ script.set_macro_bindings(
+ fresh
+ .iter()
+ .map(|(&index, b)| {
+ let spell = match *b {
+ BoundSpell::None => 0,
+ BoundSpell::Unresolved => -1,
+ BoundSpell::Spell(id) => id as i32,
+ };
+ (
+ index,
+ benilla_ui::script::MacroBinding {
+ spell,
+ pet_book: false,
+ },
+ )
+ })
+ .collect(),
+ );
if fresh != bound.0 {
let spells = fresh
.values()
diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs
index 8dbf4fca..e4e68a69 100644
--- a/crates/benilla-app/src/ui_mail/mod.rs
+++ b/crates/benilla-app/src/ui_mail/mod.rs
@@ -51,6 +51,21 @@ const STATIONERY_ICON_GM: &str = "Interface\\Icons\\Mail_GMIcon";
#[derive(Resource)]
pub(crate) struct Stationery(pub(crate) benilla_formats::StationeryCatalog);
+/// `Package.dbc`'s rows, in file order; without them `GetNumPackages()` answers 0.
+#[derive(Resource)]
+pub(crate) struct Packages(pub(crate) Vec);
+
+/// A package row as the engine answers it: the icon under `StringLookups.dbc` row 3's folder
+/// (`0x4ae4ab`, the `"%s%s"` of folder and `\`, then the basename appended).
+fn package_view(row: &benilla_formats::PackageRow) -> benilla_ui::script::PackageView {
+ benilla_ui::script::PackageView {
+ id: row.id,
+ name: row.name.clone(),
+ icon: format!("Interface\\Icons\\{}", row.icon),
+ cost: row.cost,
+ }
+}
+
/// A `SEND` result queued for [`feed_mail`]. `MAIL_FAILED` fires on every one, success included:
/// the reference uses it as "the send resolved" (`0x4ad15f`), and the stock handler only
/// re-enables the button.
@@ -422,6 +437,9 @@ struct MailFeedExtras<'w, 's> {
enchants: Option>,
sink: crate::ui_action::MessageSink<'w>,
stationeries: Local<'s, crate::ui_script::VmMemo>>,
+ packages: Option>,
+ /// The package rows went to this VM.
+ packages_pushed: Local<'s, crate::ui_script::VmMemo>,
}
fn feed_mail(
@@ -451,7 +469,16 @@ fn feed_mail(
enchants,
mut sink,
stationeries: mut last_stationeries,
+ packages,
+ mut packages_pushed,
} = extras;
+ if let Some(packages) = packages.as_deref() {
+ let pushed = packages_pushed.get(&script);
+ if !*pushed {
+ *pushed = true;
+ script.set_mail_packages(packages.0.iter().map(package_view).collect());
+ }
+ }
let rolls = crate::items::RollCatalogs {
props: props.as_deref(),
enchants: enchants.as_deref(),
@@ -538,14 +565,14 @@ fn feed_mail(
script.set_mail_stationeries(usable);
}
if opened {
- // The selection clears on open and close (`0x4ace07`); `MAIL_SHOW`'s reset picks row 1.
- script.clear_stationery();
- // The open core (`0x4acd10`) resets the compose tab before `MAIL_SHOW`; the reset's
+ // The open core (`0x4acd10`) resets the compose tab before `MAIL_SHOW`, the stationery
+ // selection with it (`0x4ace07`), which `MAIL_SHOW`'s reset re-picks as row 1; the reset's
// `MAIL_SEND_SUCCESS`, page-turn sound and all, is the reference's too.
script.reset_compose_tab();
script.fire_event("MAIL_SHOW", vec![]);
} else if closed {
- script.clear_stationery();
+ // The close core's silent reset (`0x4acd50` → `0x4acdc0(0)`).
+ script.reset_compose_tab_silently();
script.fire_event("MAIL_CLOSED", vec![]);
// The close core's tail (`0x4acdad`, `0x4acdb1`): after a read, or an arrival while open,
// re-ask the server, stamping "no mail". `CloseMail` and the range close both land here.
@@ -699,6 +726,8 @@ fn drain_mail(
item_guid: item_guid.unwrap_or(0),
money: req.money,
cod: req.cod,
+ // The package id rides only with an item (`0x4ae8f3`).
+ package: if item_guid.is_some() { req.package } else { 0 },
});
}
}
@@ -713,6 +742,22 @@ mod tests {
use super::*;
use benilla_protocol::messages::MailAttachment;
+ /// `GetPackageInfo`'s icon is the basename under `Interface\Icons` (`0x4ae4c6`, `0x4ae4eb`).
+ #[test]
+ fn a_package_row_answers_its_icon_under_the_icon_folder() {
+ let view = package_view(&benilla_formats::PackageRow {
+ id: 2,
+ icon: "INV_BOX_04".into(),
+ cost: 10,
+ name: "Test Package".into(),
+ });
+ assert_eq!(view.icon, "Interface\\Icons\\INV_BOX_04");
+ assert_eq!(
+ (view.id, view.cost, view.name.as_str()),
+ (2, 10, "Test Package")
+ );
+ }
+
/// The result table at `0x4ad17c` (indexed at `0x4ad1a0`), asserted as the message ids its
/// arms push.
#[test]
diff --git a/crates/benilla-app/src/ui_pet/bar.rs b/crates/benilla-app/src/ui_pet/bar.rs
index b7ca095c..00c29fe5 100644
--- a/crates/benilla-app/src/ui_pet/bar.rs
+++ b/crates/benilla-app/src/ui_pet/bar.rs
@@ -202,6 +202,11 @@ pub(super) fn feed_pet_bar(
// but its buttons work, so possession stays out of `usable`.
let pickup_allowed = pet_flags.unwrap_or(0) & UNIT_FLAG_POSSESSED == 0;
let pet_attacking = bar.attacking;
+ // `GetPetTimeRemaining`'s expiry on the VM clock, signed both ways around the anchor sample.
+ script.set_pet_expiry(bar.expires.map(|t| match t.checked_duration_since(anchor) {
+ Some(ahead) => ui_now + ahead.as_secs_f64(),
+ None => ui_now - anchor.duration_since(t).as_secs_f64(),
+ }));
let fresh: Vec = if has_bar {
bar.spells
diff --git a/crates/benilla-app/src/ui_pet/mod.rs b/crates/benilla-app/src/ui_pet/mod.rs
index 1be97890..adc3f61d 100644
--- a/crates/benilla-app/src/ui_pet/mod.rs
+++ b/crates/benilla-app/src/ui_pet/mod.rs
@@ -3,6 +3,8 @@
//! data (vmangos `CharmInfo::InitPetActionBar`), not a layout: a possessed or charmed unit fills
//! the same ten words differently, so nothing here assumes them.
+use std::time::Instant;
+
use bevy::prelude::*;
use benilla_protocol::messages::PetSpells;
@@ -47,6 +49,9 @@ pub(crate) struct PetBar {
/// `0x4bc960`), and `OnClick`'s `SetChecked(0)` needs that repaint to relight a press on the
/// current mode, so the count is in the feed's dedup key. Wrapping: only a change is read.
pub(crate) bar_signals: u32,
+ /// `[0xb714a8]`: the charm or possess expiry, the packet's duration past its arrival; `None`
+ /// when the duration is 0, as for a hunter's or warlock's own pet.
+ pub(crate) expires: Option,
}
impl PetBar {
diff --git a/crates/benilla-app/src/ui_pet/net.rs b/crates/benilla-app/src/ui_pet/net.rs
index f7cc309b..95623c14 100644
--- a/crates/benilla-app/src/ui_pet/net.rs
+++ b/crates/benilla-app/src/ui_pet/net.rs
@@ -154,6 +154,9 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
.join(" · ")
);
let now = Instant::now();
+ // `SetPet` (`0x4bc7e0`) re-arms the expiry on every packet, and a 0 duration clears it.
+ bar.expires = (spells.duration_ms != 0)
+ .then(|| now + std::time::Duration::from_millis(u64::from(spells.duration_ms)));
bar.cooldowns = crate::spell::Cooldowns::default();
for cd in &spells.cooldowns {
let display = catalog.and_then(|c| c.catalog.get(cd.spell_id));
@@ -282,6 +285,28 @@ mod tests {
assert!(!bar.has_bar());
}
+ /// `SetPet` (`0x4bc7e0`): each packet re-arms the expiry from its own duration, 0 clears it,
+ /// and the teardown clears it too.
+ #[test]
+ fn each_pet_packet_rearms_or_clears_the_expiry() {
+ let mut bar = PetBar::default();
+ let mut charm = a_bar(0x2A);
+ charm.duration_ms = 60_000;
+ let before = Instant::now();
+ pet_spells(charm.clone(), None, &mut bar);
+ let left = bar.expires.expect("armed").duration_since(before);
+ assert!(
+ left >= std::time::Duration::from_secs(60) && left < std::time::Duration::from_secs(61)
+ );
+
+ pet_spells(a_bar(0x2A), None, &mut bar);
+ assert_eq!(bar.expires, None, "a 0 duration clears it");
+ pet_spells(charm, None, &mut bar);
+ assert!(bar.expires.is_some());
+ pet_spells(PetSpells::default(), None, &mut bar);
+ assert_eq!(bar.expires, None, "the teardown clears it");
+ }
+
#[test]
fn pet_mode_only_writes_its_own_bar() {
let mut bar = PetBar::default();
diff --git a/crates/benilla-app/src/ui_petition/feed.rs b/crates/benilla-app/src/ui_petition/feed.rs
index 38b6147e..32bc555b 100644
--- a/crates/benilla-app/src/ui_petition/feed.rs
+++ b/crates/benilla-app/src/ui_petition/feed.rs
@@ -173,6 +173,8 @@ pub(super) fn drain_petition(
self_guid: Res,
selection: Res,
commands: Res,
+ tabard: Res,
+ mut errors: ResMut,
) {
let Some(mut script) = script else {
return;
@@ -212,6 +214,19 @@ pub(super) fn drain_petition(
}
// Sends nothing.
PetitionRequest::CloseRegistrar => registrar.close(),
+ PetitionRequest::TabardInfo => {
+ let npc = registrar.npc().unwrap_or(0);
+ if let Some(store) = self_q.iter().next() {
+ tabard_vendor_activate(
+ npc,
+ tabard.npc().unwrap_or(0),
+ store,
+ &commands,
+ &mut errors,
+ );
+ }
+ registrar.close();
+ }
PetitionRequest::ClosePetition => {
petition.decline_on_close(me, &commands);
petition.close();
@@ -263,3 +278,77 @@ pub(super) fn drain_petition(
}
}
}
+
+/// `0x5e00e0`, the designer's opener, for `GetTabardInfo` with the registrar's guid `npc`
+/// (`0x4f5338`): nothing while the designer is open on that guid (`0x5e00e6`-`0x5e00fc`, so two
+/// zero guids send nothing), the refusal while the player's `UNIT_FIELD_DISPLAYID` is not its
+/// `UNIT_FIELD_NATIVEDISPLAYID` (`0x5e0102`-`0x5e0116`, error `0x130`), else the packet.
+fn tabard_vendor_activate(
+ npc: u64,
+ designer: u64,
+ player: &ObjectStore,
+ commands: &NetCommands,
+ errors: &mut crate::ui_action::UiErrorKeys,
+) {
+ if designer == npc {
+ return;
+ }
+ if player.0.unit_displayid() != player.0.unit_native_displayid() {
+ errors.0.push(crate::ui_action::UiError::key(
+ "ERR_EMBLEMERROR_NOTABARDGEOSET",
+ ));
+ return;
+ }
+ let _ = commands.0.send(ClientCommand::TabardVendorActivate { npc });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ui_action::UiErrorKeys;
+ use benilla_protocol::ObjectFields;
+
+ /// The player's display ids, `UNIT_FIELD_DISPLAYID` (131) and `UNIT_FIELD_NATIVEDISPLAYID`.
+ fn player(display: u32, native: u32) -> ObjectStore {
+ ObjectStore(ObjectFields::from_pairs(&[(131, display), (132, native)]))
+ }
+
+ fn run(npc: u64, designer: u64, me: &ObjectStore) -> (Vec, Vec<&'static str>) {
+ let (tx, rx) = crossbeam_channel::unbounded();
+ let mut errors = UiErrorKeys::default();
+ tabard_vendor_activate(npc, designer, me, &NetCommands(tx), &mut errors);
+ (
+ rx.try_iter().collect(),
+ errors.0.iter().map(|e| e.key).collect(),
+ )
+ }
+
+ #[test]
+ fn get_tabard_info_opens_the_designer_for_the_registrar() {
+ let (sent, errors) = run(0x42, 0, &player(49, 49));
+ assert!(
+ matches!(
+ sent.as_slice(),
+ [ClientCommand::TabardVendorActivate { npc: 0x42 }]
+ ),
+ "{sent:?}"
+ );
+ assert!(errors.is_empty());
+ }
+
+ #[test]
+ fn get_tabard_info_sends_nothing_to_a_designer_already_open_on_that_npc() {
+ let (sent, errors) = run(0x42, 0x42, &player(49, 49));
+ assert!(sent.is_empty() && errors.is_empty());
+ // With no registrar and no designer both guids are 0: equal, so nothing.
+ let (sent, _) = run(0, 0, &player(49, 49));
+ assert!(sent.is_empty());
+ }
+
+ #[test]
+ fn get_tabard_info_refuses_a_shapeshifted_player() {
+ let (sent, errors) = run(0x42, 0, &player(2281, 49));
+ assert!(sent.is_empty());
+ assert_eq!(errors, ["ERR_EMBLEMERROR_NOTABARDGEOSET"]);
+ }
+}
diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs
index 6ee902bb..81e314ab 100644
--- a/crates/benilla-app/src/ui_script/bag_tests.rs
+++ b/crates/benilla-app/src/ui_script/bag_tests.rs
@@ -1647,6 +1647,52 @@ fn the_keyring_button_opens_a_keyring_window() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+/// A hovered key's tooltip comes through the stock keyring arm of `ContainerFrameItemButton_OnEnter`
+/// (`ContainerFrame.lua:616-619`): `SetInventoryItem("player", KeyRingButtonIDToInvSlotID(id))`,
+/// the key's inventory slot 82 and up, answered from the keyring container.
+#[test]
+fn a_hovered_key_shows_its_tooltip_through_its_inventory_slot() {
+ let _data = benilla_formats::wow_data_or_skip!();
+ let mut s = UiScript::new().unwrap();
+ s.set_screen_size(1024.0, 768.0);
+ s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
+ keyring_surface(&s);
+ s.set_money(0);
+ s.set_has_key(true);
+ seat_player_at_level(&mut s, 44);
+ s.set_container(-2, Some(keyring(8, true)));
+ s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
+ s.run("KeyRingButton:Click()").unwrap();
+ assert!(open(&s, -2), "fixture: the keyring is open");
+ // Which slot the tooltip is asked for: the frame's own field shadows the method table.
+ s.run(
+ r#"local method = GameTooltip.SetInventoryItem
+ GameTooltip.SetInventoryItem = function(self, unit, slot)
+ ASKED = unit .. ":" .. slot
+ return method(self, unit, slot)
+ end"#,
+ )
+ .unwrap();
+ s.resolve();
+
+ let key = bag_slot_button(&s, -2, 1);
+ hover(&mut s, &key);
+ assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
+ assert_eq!(
+ s.eval::