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::>("return ASKED").unwrap().as_deref(), + Some("player:82"), + "the stock arm asks for key 1's inventory slot" + ); + assert!(s.eval::("return GameTooltip:IsVisible()").unwrap()); + assert_eq!( + s.eval::("return GameTooltipTextLeft1:GetText()") + .unwrap(), + "The Scarlet Key" + ); + unhover(&mut s); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + /// Dropping a held key on the button files it in the first free keyring slot (stock /// `PutKeyInKeyRing`); a full keyring refuses the drop. #[test] diff --git a/crates/benilla-app/src/ui_script/escape_tests.rs b/crates/benilla-app/src/ui_script/escape_tests.rs index 902e87d6..d57cf4e1 100644 --- a/crates/benilla-app/src/ui_script/escape_tests.rs +++ b/crates/benilla-app/src/ui_script/escape_tests.rs @@ -65,6 +65,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_keeps_the_held_item() { // Open the bag and the loot window; drain their open sounds. s.run("MainMenuBarBackpackButton:Click()").unwrap(); s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: vec![Some(LootRow { diff --git a/crates/benilla-app/src/ui_script/game_menu_tests.rs b/crates/benilla-app/src/ui_script/game_menu_tests.rs index e3a3e243..e14b3887 100644 --- a/crates/benilla-app/src/ui_script/game_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs @@ -237,6 +237,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() { s.set_container(0, Some(backpack())); s.run("MainMenuBarBackpackButton:Click()").unwrap(); s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: vec![Some(LootRow { diff --git a/crates/benilla-app/src/ui_script/loot_tests.rs b/crates/benilla-app/src/ui_script/loot_tests.rs index 6d86beda..0283751d 100644 --- a/crates/benilla-app/src/ui_script/loot_tests.rs +++ b/crates/benilla-app/src/ui_script/loot_tests.rs @@ -42,6 +42,7 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> { fn coin_and_two_items() -> LootState { LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: vec![ @@ -279,6 +280,7 @@ fn loot_empty_roll_plays_the_empty_open_kit() { assert!(s.take_sounds().is_empty(), "loot close is silent (C-side)"); s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: vec![], @@ -373,6 +375,7 @@ fn shipped_loot_frame_pages_five_items() { }) .collect(); s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: rows.clone(), @@ -413,6 +416,7 @@ fn shipped_loot_frame_pages_five_items() { cleared[1] = None; cleared[2] = None; s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: cleared, @@ -577,6 +581,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() { } s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: vec![ @@ -710,6 +715,7 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() { }) }; s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: vec![Some("Thrall".into()), Some("Cairne".into())], rows: vec![row("Wool Cloth", 2), row("Thunderfury", 4)], @@ -850,6 +856,7 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() { candidates[1] = Some("Cairne".to_string()); candidates[10] = Some("Sylvanas".to_string()); s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: candidates, rows: vec![Some(LootRow { @@ -1031,6 +1038,7 @@ fn loot_row_awaiting_its_template_opens_clean() { // One row in flight: the wire gives the icon (by display id) and the count, nothing else. s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: vec![Some(LootRow { @@ -1091,6 +1099,7 @@ fn loot_row_awaiting_its_template_opens_clean() { // (`0x4c2ac0`) fires `LOOT_OPENED` as the pending count falls to zero. s.fire_event("LOOT_CLOSED", vec![]); s.set_loot(Some(LootState { + source_unit: false, fishing: false, master_candidates: Vec::new(), rows: vec![Some(LootRow { diff --git a/crates/benilla-app/src/ui_script/merchant_tests.rs b/crates/benilla-app/src/ui_script/merchant_tests.rs index 78c0bd35..9df537fc 100644 --- a/crates/benilla-app/src/ui_script/merchant_tests.rs +++ b/crates/benilla-app/src/ui_script/merchant_tests.rs @@ -424,6 +424,66 @@ fn vendor_leaves_an_already_open_backpack_alone() { ); } +/// A centre panel opened over a vendor keeps a backpack the player had open before it: the stock +/// `ShowUIPanel` calls `CloseAllBags` (`UIParent.lua:699-702`), whose `CloseBackpack()` honours +/// the was-open memory (`ContainerFrame.lua:702-707`, `:210-218`), and then closes bags 1 and up, +/// the ones the vendor opened included. +#[test] +fn a_centre_panel_over_a_vendor_keeps_the_backpack_the_player_had_open() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + for file in crate::ui_script::test_ui::production_order(&[ + BAG_UI, + &[ + "Interface\\FrameXML\\CharacterFrameTemplates.xml", + "Interface\\FrameXML\\MerchantFrame.xml", + "ScrollTemplates.xml", // our scroll kits + ], + ]) { + load_xml(&s, file); + } + s.set_money(0); + equip_bag(&mut s, 0, "Backpack", 16); + equip_bag(&mut s, 1, "Small Pouch", 6); + equip_bag(&mut s, 4, "Small Pouch", 6); + + s.run("MainMenuBarBackpackButton:Click()").unwrap(); + assert!( + bag_open(&s, 0), + "fixture: the player's own backpack is up first" + ); + s.set_merchant(Some(MerchantState::default())); + s.fire_event("MERCHANT_SHOW", vec![]); + assert!( + bag_open(&s, 1) && bag_open(&s, 4), + "fixture: the vendor opens the equipped bags" + ); + + // A centre-area panel, as the Help micro button's `HelpFrame` is (`UIParent.lua:35`). + s.run( + r#"local f = CreateFrame("Frame", "CentrePanelProbe", UIParent) + f:Hide() + UIPanelWindows["CentrePanelProbe"] = { area = "center", pushable = 0 } + ShowUIPanel(CentrePanelProbe)"#, + ) + .unwrap(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert!( + s.eval::("return CentrePanelProbe:IsShown() == 1") + .unwrap(), + "fixture: the centre panel opened" + ); + assert!( + bag_open(&s, 0), + "the backpack the player had open before the vendor stays open" + ); + assert!( + !bag_open(&s, 1) && !bag_open(&s, 4), + "bags 1-4 close, the vendor's included" + ); +} + /// Deviation: the vendor opens every equipped bag, not only the backpack as the stock /// `OpenBackpack` does, so every bag is up to sell from. `ContainerFrameAdapters.xml` replaces the /// verb after the stock file loads; with the stock body live, bag 2 would not open. diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 611319d4..d3388ab3 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -1181,14 +1181,6 @@ mod tests { // Open gaps, each with the window and the click that reach it. The assertion below // refuses an entry whose gap has closed. const KNOWN: &[(&str, &str, &str)] = &[ - ( - "ContainerFrame.xml", - "KeyRingButtonIDToInvSlotID", - "an engine binding (`1.12-globals.tsv`). `ContainerFrame.lua:617` hovers a KEYRING \ - slot with it, so the raise needs the keyring open and a key hovered. Ours drives \ - keyring tooltips through `ContainerFrameAdapters.xml`'s wrapper, which is \ - why nothing has hit it — the wrapper answers first for our own rows.", - ), ( "SkillFrame.xml", "BuySkillTier", @@ -1216,13 +1208,6 @@ mod tests { step/rank cost is non-zero — which no 1.12 server sends. Unreachable until the \ skill-point wire exists; not built.", ), - ( - "DurabilityFrame.xml", - "UpdateInventoryAlertStatus", - "an engine binding. `DurabilityFrame.lua:81` calls it from the armor guy's own \ - update; our `inventory_alerts` snapshot is recomputed on every inventory push \ - instead, so the recompute exists and only the Lua verb that forces one does not.", - ), ( "Blizzard_GMSurveyUI.xml", "GMSurveyAnswerSubmit", diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 8cf66f4e..7eaf53ef 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -823,6 +823,11 @@ pub(crate) fn snapshot( // byte against `ChrClasses.dbc` field 16; without the player test a class-2 NPC answers 1. has_relic_slot: matches!(store.0.object_type(), Some(ObjectType::Player)) && class_id.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c)))), + // `GetDamageBonusStat` (`0x48b520`): the class byte against `ChrClasses.dbc` field 2, + // read for the active player only (TYPEMASK_PLAYER, `0x48b538`). + damage_bonus_stat: matches!(store.0.object_type(), Some(ObjectType::Player)) + .then(|| class_id.and_then(|c| classes.and_then(|t| t.damage_bonus_stat(u32::from(c))))) + .flatten(), // Gender byte 0 male, 1 female, on `UnitSex`'s scale: 2 male, 3 female, 0 unknown (nil). sex: match store.0.unit_gender() { Some(0) => 2, diff --git a/crates/benilla-app/src/ui_world_map.rs b/crates/benilla-app/src/ui_world_map.rs index 1176d59f..e2ff9ce4 100644 --- a/crates/benilla-app/src/ui_world_map.rs +++ b/crates/benilla-app/src/ui_world_map.rs @@ -29,7 +29,7 @@ use benilla_world::world_map::CurrentMap; /// The app's copy of the pushed catalog's projection data (rects, world-sheet constants), in the /// engine's order: the indices must agree. -#[derive(Resource)] +#[derive(Resource, Clone)] pub(crate) struct WorldMapUiData { continents: Vec, /// The instance maps no continent owns, `0x4a5d00`'s third array in `WorldMapArea.dbc` file @@ -37,6 +37,7 @@ pub(crate) struct WorldMapUiData { direct: Vec, } +#[derive(Clone)] pub(crate) struct ContinentEntry { map_id: u32, proj: Option, @@ -44,6 +45,7 @@ pub(crate) struct ContinentEntry { zones: Vec, } +#[derive(Clone)] struct ZoneEntry { area_id: u32, rect: ZoneRect, @@ -51,6 +53,7 @@ struct ZoneEntry { /// One instance map, selected directly rather than through a continent and zone; 1.12 ships /// three, the battlegrounds. +#[derive(Clone)] struct DirectAreaEntry { /// The `WorldMapArea` row id, which the third selection cell `[0x845074]` holds (`0x4a6717`); /// never a list index. @@ -527,6 +530,14 @@ pub(crate) fn project_on_displayed( } } +/// Hand the engine `GetWorldLocMapPosition`'s projection (`0x4a7360`) over a copy of the catalog. +fn install_world_loc_projector(script: &mut UiScript, data: &WorldMapUiData) { + let copy = data.clone(); + script.set_world_loc_projector(Box::new(move |selection, map, x, y| { + project_on_displayed(©, selection, map, x, y) + })); +} + /// [`feed_world_map`]'s memos under one [`crate::ui_script::VmMemo`], so a new VM resets them all. #[derive(Default)] struct FeedMemos { @@ -534,6 +545,8 @@ struct FeedMemos { landmarks: Option, /// The world-enter sync has run: the reference's `old == 0` gate. map_synced: bool, + /// `GetWorldLocMapPosition`'s projection is installed over the current catalog. + projector: bool, } fn feed_world_map( @@ -593,6 +606,16 @@ fn feed_world_map( } } + // `GetWorldLocMapPosition` projects synchronously with the engine's own selection, so the + // engine holds the projection over its own copy of the catalog. + { + let memo = memo.get(&script); + if !memo.projector || data.is_changed() { + memo.projector = true; + install_world_loc_projector(&mut script, &data); + } + } + let selection = script.world_map_selection(); let project = |pos_map: u32, px: f32, py: f32| project_on_displayed(&data, selection, pos_map, px, py); @@ -1164,6 +1187,29 @@ mod player_zone_tests { ); } + /// The engine's `GetWorldLocMapPosition` answers what the feed's own projection answers, on + /// the engine's selection. + #[test] + fn world_loc_map_position_projects_on_the_displayed_map() { + let mut script = UiScript::new().unwrap(); + install_world_loc_projector(&mut script, &data()); + script.sync_world_map_to_player_zone(0, 0, Some(443)); + assert_eq!(script.world_map_selection(), (0, 0, Some(443))); + assert_eq!( + script + .eval::<(f64, f64)>("return GetWorldLocMapPosition(489, 0, 0)") + .unwrap(), + (0.5, 0.5) + ); + assert_eq!( + script + .eval::<(f64, f64)>("return GetWorldLocMapPosition(0, 0, 0)") + .unwrap(), + (0.0, 0.0), + "a position on another map is off this one" + ); + } + #[test] fn a_battleground_takes_the_orphan_leg_and_projects_on_its_own_rect() { let d = data(); diff --git a/crates/benilla-formats/src/chr_classes.rs b/crates/benilla-formats/src/chr_classes.rs index 15c6399b..153b4a0e 100644 --- a/crates/benilla-formats/src/chr_classes.rs +++ b/crates/benilla-formats/src/chr_classes.rs @@ -1,6 +1,10 @@ //! `ChrClasses.dbc`, the per-class table, narrowed to the three columns the reference reads off //! its class-indexed record table (`0xc0def4`, max id `0xc0def8`, loader `0x542360`). //! +//! Field 2, the damage bonus stat: `GetDamageBonusStat` (`0x48b520`) reads it at `rec + 8` for the +//! active player's class byte and answers it plus one, a 1-based `UnitStat` index. Strength (0) +//! for every row but the Hunter's and the Rogue's Agility (1). +//! //! Field 4, the pet name token: the second return of `HasPetSpells` (`0x4b4410`), `"PET"` on every //! row but the Warlock's `"DEMON"`. FrameXML resolves it with `getglobal("PET_TYPE_"..token)` //! (`SpellBookFrame.lua:173`), so the token is a key, never display text, and is not localized. @@ -30,6 +34,9 @@ const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc"; /// never read one column short. const CHR_CLASSES_FIELDS: usize = 17; +/// Field 2, read at `rec + 8` by `GetDamageBonusStat` (`0x48b569`). +const DAMAGE_BONUS_STAT_FIELD: usize = 0x8 / 4; + /// Field 4, read at `rec + 0x10` by `HasPetSpells` (`0x4b447c`). const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4; @@ -45,6 +52,7 @@ pub const PET_NAME_TOKEN_FALLBACK: &str = "PET"; #[derive(Debug, Clone)] struct ChrClass { + damage_bonus_stat: u32, pet_name_token: Option, has_relic_slot: bool, spell_family: u32, @@ -64,6 +72,12 @@ impl ChrClasses { .unwrap_or(PET_NAME_TOKEN_FALLBACK) } + /// The class's damage bonus stat, 0-based (Strength 0, Agility 1); `None` for a class with + /// no row, where `GetDamageBonusStat` answers 0 (`0x48b58a`). + pub fn damage_bonus_stat(&self, class: u32) -> Option { + self.0.get(&class).map(|c| c.damage_bonus_stat) + } + /// Whether the class's INVSLOT 17 is a relic slot, `UnitHasRelicSlot` whole. A class with no /// row answers false, as the reference's bound check against `0xc0def8` falls to its nil leg. pub fn has_relic_slot(&self, class: u32) -> bool { @@ -110,6 +124,7 @@ pub fn load_chr_classes(chain: &mut Chain) -> Result { by_id.insert( id, ChrClass { + damage_bonus_stat: u32_at(r, DAMAGE_BONUS_STAT_FIELD).unwrap_or(0), pet_name_token: str_at(&rs, r, PET_NAME_TOKEN_FIELD), has_relic_slot: u32_at(r, RELIC_SLOT_FIELD).is_some_and(|v| v != 0), spell_family: u32_at(r, SPELL_FAMILY_FIELD).unwrap_or(0), @@ -165,6 +180,28 @@ mod tests { assert!(!t.has_relic_slot(0)); } + /// Hunters and Rogues scale their damage off Agility, everyone else off Strength. + #[test] + fn hunters_and_rogues_take_their_damage_bonus_from_agility() { + let Some(mut chain) = chain() else { return }; + let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc"); + for (class, who) in [(3, "Hunter"), (4, "Rogue")] { + assert_eq!(t.damage_bonus_stat(class), Some(1), "{who}"); + } + for (class, who) in [ + (1, "Warrior"), + (2, "Paladin"), + (5, "Priest"), + (7, "Shaman"), + (8, "Mage"), + (9, "Warlock"), + (11, "Druid"), + ] { + assert_eq!(t.damage_bonus_stat(class), Some(0), "{who}"); + } + assert_eq!(t.damage_bonus_stat(6), None); + } + /// The whole column pins field 15: nine distinct values matching vmangos `SpellFamilyNames` /// are a shape no neighbour has (field 14 is the class's file name, field 16 the relic flag). #[test] diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 91a12667..f8bde36e 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -96,6 +96,8 @@ mod stable_slot_prices; pub use stable_slot_prices::{load_stable_slot_prices, StableSlotPrices}; mod page_text_material; pub use page_text_material::{load_page_text_material_catalog, PageTextMaterialCatalog}; +mod packages; +pub use packages::{load_packages, PackageRow}; mod stationery; pub use stationery::{ load_stationery_catalog, StationeryCatalog, StationeryRow, STATIONERY_DEFAULT, diff --git a/crates/benilla-formats/src/packages.rs b/crates/benilla-formats/src/packages.rs new file mode 100644 index 00000000..eeb55f84 --- /dev/null +++ b/crates/benilla-formats/src/packages.rs @@ -0,0 +1,76 @@ +//! `Package.dbc`: the send tab's gift packages, 12 columns of `0x30` bytes (the loader's checks, +//! `0x54b4ae` and `0x54b4e6`). The reference keeps the rows in file order (`[0xc0da28]`, count +//! `[0xc0da2c]`) and indexes them by position: `GetPackageInfo` reads the icon at `+4`, the cost at +//! `+8` and the localized name at `+0xc`, `SelectPackage` stores the id at `+0`. 1.12 ships one +//! row, "Test Package". + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::chain::Chain; +use crate::dbc::{parse, str_at, u32_at}; + +const PACKAGE: &str = "DBFilesClient\\Package.dbc"; + +/// The column count the loader requires. +const PACKAGE_FIELDS: usize = 12; + +/// One `Package.dbc` row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageRow { + pub id: u32, + /// The icon's basename, which the reference prefixes with `Interface\Icons\`. + pub icon: String, + /// Copper, read as a signed dword (`0x4ae50d` `fild`). + pub cost: i32, + /// The enUS name. + pub name: String, +} + +/// Load `Package.dbc`'s rows, in file order. +pub fn load_packages(chain: &mut Chain) -> Result> { + let bytes = chain.read_file(PACKAGE).context("reading Package.dbc")?; + let mut schema = Schema::new("Package"); + for i in 0..PACKAGE_FIELDS { + let ty = if i == 1 || i == 3 { + FieldType::String + } else { + FieldType::UInt32 + }; + schema.add_field(SchemaField::new(format!("F{i}"), ty)); + } + let set = parse(&bytes, schema, "Package.dbc")?; + Ok(set + .records() + .iter() + .filter_map(|r| { + Some(PackageRow { + id: u32_at(r, 0)?, + icon: str_at(&set, r, 1).unwrap_or_default(), + cost: u32_at(r, 2)? as i32, + name: str_at(&set, r, 3).unwrap_or_default(), + }) + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_one_shipped_package_is_the_test_package() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let rows = load_packages(&mut chain).expect("load Package.dbc"); + assert_eq!( + rows, + [PackageRow { + id: 2, + icon: "INV_BOX_04".into(), + cost: 10, + name: "Test Package".into(), + }] + ); + } +} diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index 6e94354b..8e08e734 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -160,6 +160,48 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // GetActionAutocast(slot) (`0x4e6f90`) → autoCastAllowed, autoCastEnabled: 1 or nil each, + // always two values. Only a pet spell has them: the slot resolver (`0x4e5a50`) writes its + // book flag 0 for a spell or an item and a macro's `[rec+0x568]`, and only a set flag looks the + // spell up in the pet's words (`0x4bd160`, low 16 bits), reading bits 31 and 30. An empty, + // out-of-range or missing-macro slot leaves the flag unwritten, and the stale non-zero local + // looks up spell 0, which no pet word carries. A non-number raises. + g.set( + "GetActionAutocast", + lua.create_function(|lua, slot: Value| { + let slot = super::binding_abi::number_arg(lua, slot, "Usage: GetActionAutocast(slot)")?; + let model = lua.app_data_ref::().expect("model app_data"); + // `(spell, pet_book)` as the resolver leaves them. + let resolved = u32::try_from(slot) + .ok() + .filter(|s| (1..=120).contains(s)) + .and_then(|s| model.actions.get(&s)) + .and_then(|a| match a.kind & 0xf0 { + ACTION_KIND_MACRO => model.macros.get(a.action as usize).map(|_| { + let b = model + .macro_bindings + .get(&a.action) + .copied() + .unwrap_or_default(); + (b.spell as u32, b.pet_book) + }), + _ => Some((a.action, false)), + }) + .unwrap_or((0, true)); + let autocast = match resolved { + (spell, true) => model + .pet_book + .slots + .iter() + .find(|s| s.spell_id & 0xffff == spell) + .and_then(|s| s.autocast), + (_, false) => None, + }; + let (allowed, enabled) = autocast.unwrap_or((false, false)); + Ok((flag(allowed), flag(enabled))) + })?, + )?; + g.set( "GetActionTexture", lua.create_function(|lua, action: u32| { @@ -353,6 +395,85 @@ mod tests { use super::ActionSlot; use crate::script::UiScript; + /// `GetActionAutocast` answers a pet spell's two bits only for a macro bound out of the pet's + /// book (`0x4e6fcb`), never for a spell slot of the same id, and always two values. + #[test] + fn action_autocast_reads_a_pet_book_macro_only() { + use crate::script::{MacroBinding, MacroState, MacroView, PetBookState, SpellSlotView}; + let mut s = UiScript::new().unwrap(); + s.set_pet_book(PetBookState { + token: Some("PET".into()), + slots: vec![SpellSlotView { + spell_id: 3009, // Claw + autocast: Some((true, false)), + ..Default::default() + }], + }); + s.set_macros(MacroState { + account: vec![ + MacroView { + name: "claw".into(), + body: "/cast Claw".into(), + ..Default::default() + }, + MacroView { + name: "fb".into(), + body: "/cast Fireball".into(), + ..Default::default() + }, + ], + character: Vec::new(), + }); + s.set_macro_bindings(std::collections::HashMap::from([ + ( + 1, + MacroBinding { + spell: 3009, + pet_book: true, + }, + ), + ( + 2, + MacroBinding { + spell: 133, + pet_book: false, + }, + ), + ])); + let slot = |kind, action| ActionSlot { + kind, + action, + ..Default::default() + }; + s.set_action(1, Some(slot(super::ACTION_KIND_MACRO, 1))); + s.set_action(2, Some(slot(super::ACTION_KIND_MACRO, 2))); + s.set_action(3, Some(slot(super::ACTION_KIND_SPELL, 3009))); + s.set_action(4, Some(slot(super::ACTION_KIND_MACRO, 9))); + + let ask = |s: &UiScript, n: u32| { + s.eval::<(i64, Option, Option)>(&format!( + "local f = function(...) return arg.n, arg[1], arg[2] end \ + return f(GetActionAutocast({n}))" + )) + .unwrap() + }; + assert_eq!(ask(&s, 1), (2, Some(1), None), "the pet-book macro"); + for n in [2, 3, 4, 5, 0, 121] { + assert_eq!(ask(&s, n), (2, None, None), "slot {n}"); + } + s.set_pet_book(PetBookState { + token: Some("PET".into()), + slots: vec![SpellSlotView { + spell_id: 3009, + autocast: Some((true, true)), + ..Default::default() + }], + }); + assert_eq!(ask(&s, 1), (2, Some(1), Some(1)), "autocasting"); + let err = s.run("GetActionAutocast()").unwrap_err().to_string(); + assert!(err.contains("Usage: GetActionAutocast(slot)"), "{err}"); + } + fn uses(s: &mut UiScript) -> Vec<(u32, bool)> { s.take_action_uses() .into_iter() diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index d4d28b96..cf7bb10c 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -312,15 +312,8 @@ impl super::UiScript { pub fn set_inventory_slots(&mut self, slots: InventorySlots) { { let mut model = self.model_mut(); - let alerts: [u8; 12] = std::array::from_fn(|i| { - if i == 11 { - ammo_alert_status(&slots[ALERT_SLOTS[i]]) - } else { - alert_status(&slots[ALERT_SLOTS[i]]) - } - }); model.inventory_slots = slots; - model.inventory_alerts = alerts; + recompute_inventory_alerts(&mut model); } // Every recompute fires it, never diffed against the last (`0x4c7ee0`); the app pushes // only on a real change. @@ -348,6 +341,19 @@ impl super::UiScript { } } +/// The 12 alert statuses off the doll snapshot (`0x4c7ee0`'s recompute), without the event. +pub(super) fn recompute_inventory_alerts(model: &mut Model) { + let slots = &model.inventory_slots; + let alerts: [u8; 12] = std::array::from_fn(|i| { + if i == 11 { + ammo_alert_status(&slots[ALERT_SLOTS[i]]) + } else { + alert_status(&slots[ALERT_SLOTS[i]]) + } + }); + model.inventory_alerts = alerts; +} + fn with_unit_stats( lua: &Lua, token: &Option, @@ -394,15 +400,29 @@ const BANK_BAG_INV_SLOTS: std::ops::RangeInclusive = 64..=69; /// The vault's container id; must match `ui_items::BANK_CONTAINER` in the app. const BANK_CONTAINER: i64 = -1; +/// The keyring's live-API ids: `KeyRingButtonIDToInvSlotID` adds `0x51` to a 1-based button id +/// (`0x4c818b`), and the slot reader takes 0-based 81..=112 (`0x4c8567`). +pub(super) const KEYRING_INV_SLOTS: std::ops::RangeInclusive = 82..=113; + +/// The keyring's container id (`KEYRING_CONTAINER`, `MainMenuBarBagButtons.lua:1`); must match +/// `ui_items::KEYRING_CONTAINER` in the app, which feeds the keys as that container. +const KEYRING_CONTAINER: i64 = -2; + impl Model { /// The item `token` exposes at live-API id `slot`, the one routing the `GetInventoryItem*` /// getters and `GameTooltip:SetInventoryItem` share. Stock paints the bank through this API - /// (`BankFrame.lua:35`), so the bank band is answered from the container snapshot. + /// (`BankFrame.lua:35`) and hovers a key through it (`ContainerFrame.lua:616-619`), so the + /// bank and keyring bands are answered from the container snapshots. pub(super) fn inv_slot(&self, token: &str, slot: usize) -> Option { if token.eq_ignore_ascii_case("player") { if let Some(view) = self.bank_inv_slot(slot) { return Some(view); } + if KEYRING_INV_SLOTS.contains(&slot) { + let n = (slot - KEYRING_INV_SLOTS.start() + 1) as u32; + let keyring = self.containers.get(&KEYRING_CONTAINER)?; + return keyring.slots.get(&n).map(InvSlotView::from_container_slot); + } return self.inventory_slots.get(slot)?.clone(); } self.inspect @@ -413,12 +433,16 @@ impl Model { .clone() } - /// The player's repair cost at live id `slot`, the vault band by its container slot. + /// The player's repair cost at live id `slot`, the vault and keyring bands by their container + /// slots. pub(super) fn inv_repair_cost(&self, slot: usize) -> u32 { let costs = &self.repair_costs; let cost = if BANK_INV_SLOTS.contains(&slot) { let n = (slot - BANK_INV_SLOTS.start() + 1) as u32; costs.bags.get(&(BANK_CONTAINER, n)) + } else if KEYRING_INV_SLOTS.contains(&slot) { + let n = (slot - KEYRING_INV_SLOTS.start() + 1) as u32; + costs.bags.get(&(KEYRING_CONTAINER, n)) } else { u32::try_from(slot) .ok() @@ -446,7 +470,7 @@ impl Model { /// The inventory-slot reader's whitelist (`0x4c8520`) on its 0-based slot (`0x4c8546`); outside /// it the binding raises "Invalid inventory slot in …". The backpack's item slots (23..=38) and /// buyback (69..=80) are container-API slots, not in it. -fn inventory_slot_reader_accepts(slot0: i32) -> bool { +pub(super) fn inventory_slot_reader_accepts(slot0: i32) -> bool { slot0 == -1 // Lua 0 the ammo leg || (0x00..=0x16).contains(&slot0) // Lua 1..=23 the doll + the four equipped bags || (0x27..=0x3e).contains(&slot0) // Lua 40..=63 the bank vault diff --git a/crates/benilla-ui/src/script/inventory_verbs.rs b/crates/benilla-ui/src/script/inventory_verbs.rs new file mode 100644 index 00000000..1340ce0d --- /dev/null +++ b/crates/benilla-ui/src/script/inventory_verbs.rs @@ -0,0 +1,357 @@ +//! Four verbs of the reference's inventory registrar (`0x8484d0`) beside the paper-doll getters in +//! [`super::char_stats`]: the keyring's slot map, the doll's sell cursor, the doll portrait setter +//! and the alert recompute. + +use mlua::{Lua, MultiValue, Value}; + +use super::binding_abi::number_arg; +use super::char_stats::{inventory_slot_reader_accepts, recompute_inventory_alerts}; +use super::container::UiCursorMode; +use super::region::{clear_texture, this_texture}; +use super::Model; + +/// `0x4c8520`, the registrar's shared slot reader: `is-number`, `tonumber`, the truncating cast, +/// then `dec` into the 0-based slot and the whitelist; a failure of either raises `message`. The +/// 0-based slot, `-1` for Lua 0, the ammo leg. +fn inventory_slot_arg(lua: &Lua, v: Value, message: &'static str) -> mlua::Result { + let slot0 = number_arg(lua, v, message)?.wrapping_sub(1); + if !inventory_slot_reader_accepts(slot0) { + return Err(mlua::Error::RuntimeError(message.into())); + } + Ok(slot0) +} + +/// Register the four globals. +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + let g = lua.globals(); + + // KeyRingButtonIDToInvSlotID(buttonID) (`0x4c8150`): the truncated button id plus `0x51` + // (`0x4c818b`), so key 1 is live-API slot 82, with no range check; a non-number raises. + g.set( + "KeyRingButtonIDToInvSlotID", + lua.create_function(|lua, id: Value| { + let id = number_arg(lua, id, "Usage: KeyRingButtonIDToInvSlotID(buttonID)")?; + Ok(i64::from(id.wrapping_add(0x51))) + })?, + )?; + + // ShowInventorySellCursor(slot) (`0x4c90b0`): the Buy cursor over a worn item. The armed-spell + // and base-cursor gates run before the argument is read (`0x4c90b7`, `0x4c90c9`), so under + // either a bad slot does not raise; past them a slot outside the reader's whitelist raises, + // and the ammo slot, an empty slot or a locked item leave the cursor as it was. Zero returns. + // The stock doll's call is commented out (`PaperDollFrame.lua:754-756`). + g.set( + "ShowInventorySellCursor", + lua.create_function(|lua, slot: Value| { + { + let model = lua.app_data_ref::().expect("model app_data"); + // `[0xbe2c4c] == 1`: repair mode (`0x4fbce3`) and the targeting arm move the base. + if model.spell_targeting || model.repair_mode { + return Ok(()); + } + } + let slot0 = inventory_slot_arg( + lua, + slot, + "Invalid inventory slot in ShowInventorySellCursor", + )?; + if slot0 == -1 { + return Ok(()); + } + let mut model = lua.app_data_mut::().expect("model app_data"); + let sellable = usize::try_from(slot0 + 1) + .ok() + .and_then(|slot| model.inv_slot("player", slot)) + .is_some_and(|v| v.item_id != 0 && !v.locked); + if sellable { + model.ui_cursor = Some(UiCursorMode::Buy); + model.ui_cursor_dirty = true; + } + Ok(()) + })?, + )?; + + // SetInventoryPortaitTexture(texture, unit, slot) (`0x4c9150`, the reference's spelling): + // the texture is cleared (`0x4c91ed`) before arguments 2 and 3 are read, each of which raises + // on a bad value. The unit is then read from argument 1 (`0x4c9247`), the texture itself, + // which `lua_tostring` answers NULL for, so the token resolves no unit (`0x515940`) and the + // item's icon is never painted: the call only blanks the texture. No stock caller. + g.set( + "SetInventoryPortaitTexture", + lua.create_function(|lua, (texture, unit, slot): (Value, Value, Value)| { + let rh = this_texture(lua, &texture)?; + clear_texture( + &mut lua.app_data_mut::().expect("model app_data"), + rh, + ); + if !matches!( + unit, + Value::String(_) | Value::Number(_) | Value::Integer(_) + ) { + return Err(mlua::Error::runtime( + "Usage: SetInventoryPortaitTexture(texure, unit, slot)", + )); + } + inventory_slot_arg( + lua, + slot, + "Invalid inventory slot in SetInventoryPortaitTexture", + )?; + Ok(()) + })?, + )?; + + // UpdateInventoryAlertStatus() (`0x4c9560`): the alert recompute (`0x4c7ee0`), which fires + // UPDATE_INVENTORY_ALERTS unconditionally; zero returns. Stock calls it when the durability + // figure's enchant timer runs out (`DurabilityFrame.lua:81`). + g.set( + "UpdateInventoryAlertStatus", + lua.create_function(|lua, ()| { + let mut model = lua.app_data_mut::().expect("model app_data"); + recompute_inventory_alerts(&mut model); + model + .pending_events + .push(("UPDATE_INVENTORY_ALERTS".to_string(), Vec::new())); + Ok(MultiValue::new()) + })?, + )?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::script::{ContainerSlot, ContainerState, InvSlotView, UiCursorMode, UiScript}; + + fn keyring_with_key() -> ContainerState { + let mut slots = std::collections::HashMap::new(); + slots.insert( + 2, + ContainerSlot { + item_id: 5396, + texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()), + count: 1, + link: Some("|cffffffff|Hitem:5396:0:0:0|h[Key to Searing Gorge]|h|r".into()), + ..Default::default() + }, + ); + ContainerState { + name: None, + num_slots: 4, + slots, + } + } + + #[test] + fn keyring_button_ids_map_past_the_buyback_band() { + let s = UiScript::new().unwrap(); + assert_eq!( + s.eval::("return KeyRingButtonIDToInvSlotID(1)") + .unwrap(), + 82 + ); + assert_eq!( + s.eval::("return KeyRingButtonIDToInvSlotID(32)") + .unwrap(), + 113 + ); + // No range check, a truncating cast and a numeric string, as `0x4c8150` reads them. + assert_eq!( + s.eval::("return KeyRingButtonIDToInvSlotID(0)") + .unwrap(), + 81 + ); + assert_eq!( + s.eval::("return KeyRingButtonIDToInvSlotID(-2.9)") + .unwrap(), + 79 + ); + assert_eq!( + s.eval::("return KeyRingButtonIDToInvSlotID('3')") + .unwrap(), + 84 + ); + let err = s + .run("KeyRingButtonIDToInvSlotID()") + .unwrap_err() + .to_string(); + assert!( + err.contains("Usage: KeyRingButtonIDToInvSlotID(buttonID)"), + "{err}" + ); + } + + /// The keyring band answers the inventory API from the keyring container, so the stock hover + /// (`ContainerFrame.lua:617`) reads a key through `GetInventoryItem*` and the tooltip. + #[test] + fn a_key_reads_through_its_inventory_slot() { + let mut s = UiScript::new().unwrap(); + s.set_container(-2, Some(keyring_with_key())); + assert_eq!( + s.eval::>( + "return GetInventoryItemTexture('player', KeyRingButtonIDToInvSlotID(2))" + ) + .unwrap() + .as_deref(), + Some("Interface\\Icons\\INV_Misc_Key_03") + ); + assert_eq!( + s.eval::>("return GetInventoryItemTexture('player', 82)") + .unwrap(), + None, + "key slot 1 is empty" + ); + assert_eq!( + s.eval::("return GetInventoryItemCount('player', 83)") + .unwrap(), + 1 + ); + } + + #[test] + fn inventory_sell_cursor_buys_over_a_worn_item_only() { + let mut s = UiScript::new().unwrap(); + let mut slots: crate::script::InventorySlots = Default::default(); + slots[16] = Some(InvSlotView { + item_id: 2361, + ..Default::default() + }); + slots[17] = Some(InvSlotView { + item_id: 2362, + locked: true, + ..Default::default() + }); + s.set_inventory_slots(slots); + + for quiet in ["0", "1", "17"] { + s.run(&format!("ShowInventorySellCursor({quiet})")).unwrap(); + assert_eq!(s.ui_cursor(), None, "slot {quiet}: ammo, empty or locked"); + } + s.run("ShowInventorySellCursor(16)").unwrap(); + assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy)); + assert_eq!( + s.eval::( + "local n = function(...) return arg.n end return n(ShowInventorySellCursor(16))" + ) + .unwrap(), + 0 + ); + + for bad in ["24", "39", "70", "114", "-1", "'x'"] { + let err = s + .run(&format!("ShowInventorySellCursor({bad})")) + .unwrap_err() + .to_string(); + assert!( + err.contains("Invalid inventory slot in ShowInventorySellCursor"), + "{bad}: {err}" + ); + } + } + + /// The two cursor gates come before the argument read (`0x4c90b7`, `0x4c90c9`). + #[test] + fn inventory_sell_cursor_under_repair_mode_is_silent() { + let mut s = UiScript::new().unwrap(); + s.set_merchant(Some(crate::script::MerchantState { + can_repair: true, + ..Default::default() + })); + s.run("ShowRepairCursor()").unwrap(); + s.run("ShowInventorySellCursor(999)").unwrap(); + assert_eq!(s.ui_cursor(), None); + } + + #[test] + fn inventory_portrait_setter_only_blanks_the_texture() { + let s = UiScript::new().unwrap(); + s.run( + r#"local f = CreateFrame("Frame", "PortHost", UIParent) + PortTex = f:CreateTexture("PortTex") + PortTex:SetTexture("Interface\\Icons\\INV_Misc_Bag_08") + PortText = f:CreateFontString("PortText")"#, + ) + .unwrap(); + s.run("SetInventoryPortaitTexture(PortTex, 'player', 1)") + .unwrap(); + assert_eq!( + s.eval::>("return PortTex:GetTexture()") + .unwrap(), + None + ); + assert_eq!( + s.eval::("local n = function(...) return arg.n end return n(SetInventoryPortaitTexture(PortTex, 'player', 1))") + .unwrap(), + 0 + ); + + // The clear precedes the argument checks (`0x4c91ed`). + s.run(r#"PortTex:SetTexture("Interface\\Icons\\INV_Misc_Bag_08")"#) + .unwrap(); + let err = s + .run("SetInventoryPortaitTexture(PortTex, nil, 1)") + .unwrap_err() + .to_string(); + assert!( + err.contains("Usage: SetInventoryPortaitTexture(texure, unit, slot)"), + "{err}" + ); + assert_eq!( + s.eval::>("return PortTex:GetTexture()") + .unwrap(), + None + ); + let err = s + .run("SetInventoryPortaitTexture(PortTex, 'player', 30)") + .unwrap_err() + .to_string(); + assert!( + err.contains("Invalid inventory slot in SetInventoryPortaitTexture"), + "{err}" + ); + + for (call, message) in [ + ( + "SetInventoryPortaitTexture(1, 'player', 1)", + "non-table object", + ), + ( + "SetInventoryPortaitTexture({}, 'player', 1)", + "non-framescript object", + ), + ( + "SetInventoryPortaitTexture(PortText, 'player', 1)", + "Wrong object type for member function", + ), + ( + "SetInventoryPortaitTexture(PortHost, 'player', 1)", + "Wrong object type for member function", + ), + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains(message), "{call}: {err}"); + } + } + + #[test] + fn update_inventory_alert_status_fires_the_recompute_event() { + let mut s = UiScript::new().unwrap(); + s.run( + r#"ALERTS = 0 + local f = CreateFrame("Frame") + f:RegisterEvent("UPDATE_INVENTORY_ALERTS") + f:SetScript("OnEvent", function() ALERTS = ALERTS + 1 end)"#, + ) + .unwrap(); + assert_eq!( + s.eval::( + "local n = function(...) return arg.n end return n(UpdateInventoryAlertStatus())" + ) + .unwrap(), + 0 + ); + s.run("UpdateInventoryAlertStatus()").unwrap(); + s.tick(0.0); + assert_eq!(s.eval::("return ALERTS").unwrap(), 2); + } +} diff --git a/crates/benilla-ui/src/script/loot.rs b/crates/benilla-ui/src/script/loot.rs index a011e31d..31cad993 100644 --- a/crates/benilla-ui/src/script/loot.rs +++ b/crates/benilla-ui/src/script/loot.rs @@ -72,8 +72,16 @@ pub struct LootState { /// dropdown labels "Group N" (`LootFrame.lua:197-213`). A `None` (an empty slot, or a name not /// yet resolved) answers nil. pub master_candidates: Vec>, + /// The loot source (`[0xb71b48]`) resolves as a unit object (`0x468460`, typemask 8): a + /// corpse, not a chest, a bobber or an item. `SetLootPortrait` bakes it under the token + /// [`LOOT_PORTRAIT_UNIT`]. + pub source_unit: bool, } +/// The portrait key `SetLootPortrait` binds a texture to, which the app bakes from the loot +/// source. Upper case, so no `SetPortraitTexture` token, which is lowercased, can name it. +pub const LOOT_PORTRAIT_UNIT: &str = "LOOT"; + impl super::UiScript { /// Push (or clear, with `None`) the open loot. pub fn set_loot(&mut self, state: Option) { @@ -108,6 +116,28 @@ impl super::UiScript { pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); + // SetLootPortrait(texture) (`0x4c2b40`): the loot source's 3D portrait, the unit-frame bake + // (`0x524f60`), and the number 1; a source that is no unit object, or no loot, clears the + // texture (`0x4c2c0d`) and answers nil. A non-Texture raises the member prologue's errors. No + // stock caller: `LootFrame.lua` paints its own portrait art. + g.set( + "SetLootPortrait", + lua.create_function(|lua, texture: Value| { + let rh = super::region::this_texture(lua, &texture)?; + let mut model = lua.app_data_mut::().expect("model app_data"); + if !model.loot.as_ref().is_some_and(|l| l.source_unit) { + super::region::clear_texture(&mut model, rh); + return Ok(Value::Nil); + } + let data = model.region_data.entry(rh).or_default(); + data.portrait_unit = Some(LOOT_PORTRAIT_UNIT.to_string()); + data.texture = None; + data.fill = None; + data.circular = true; + Ok(Value::Integer(1)) + })?, + )?; + // GetNumLootItems(): cleared slots included, so it holds still while a window is open. g.set( "GetNumLootItems", @@ -306,8 +336,61 @@ mod tests { use super::{LootRow, LootState}; use crate::script::UiScript; + /// The region data behind a named texture: its portrait key and file. + fn portrait_of(s: &UiScript, name: &str) -> (Option, Option) { + let model = s.model_ref(); + let id = model.region_names[name]; + let rh = *model.id_to_region.get(&id).expect("a region"); + let data = &model.region_data[&rh]; + (data.portrait_unit.clone(), data.texture.clone()) + } + + /// `SetLootPortrait` (`0x4c2b40`): a unit source binds the loot portrait and answers 1; + /// anything else clears the texture and answers nil. + #[test] + fn loot_portrait_binds_a_unit_source_and_clears_otherwise() { + let mut s = UiScript::new().unwrap(); + s.run( + r#"local f = CreateFrame("Frame", "LootHost", UIParent) + f:CreateTexture("LootTex") + LootTex:SetTexture("Interface\TargetingFrame\TargetDead")"#, + ) + .unwrap(); + assert!(s + .eval::("return SetLootPortrait(LootTex) == nil") + .unwrap()); + assert_eq!(portrait_of(&s, "LootTex"), (None, None), "no loot: cleared"); + + s.run(r#"LootTex:SetTexture("Interface\TargetingFrame\TargetDead")"#) + .unwrap(); + s.set_loot(Some(LootState { + source_unit: true, + ..loot() + })); + assert_eq!(s.eval::("return SetLootPortrait(LootTex)").unwrap(), 1); + assert_eq!( + portrait_of(&s, "LootTex"), + (Some(super::LOOT_PORTRAIT_UNIT.to_string()), None) + ); + + s.set_loot(Some(loot())); + assert!(s + .eval::("return SetLootPortrait(LootTex) == nil") + .unwrap()); + assert_eq!(portrait_of(&s, "LootTex"), (None, None), "a chest: cleared"); + + let err = s.run("SetLootPortrait(LootHost)").unwrap_err().to_string(); + assert!( + err.contains("Wrong object type for member function"), + "{err}" + ); + let err = s.run("SetLootPortrait()").unwrap_err().to_string(); + assert!(err.contains("non-table object"), "{err}"); + } + fn loot() -> LootState { LootState { + source_unit: false, master_candidates: Vec::new(), rows: vec![ Some(LootRow { diff --git a/crates/benilla-ui/src/script/macros.rs b/crates/benilla-ui/src/script/macros.rs index 39678c0f..4ecef239 100644 --- a/crates/benilla-ui/src/script/macros.rs +++ b/crates/benilla-ui/src/script/macros.rs @@ -36,6 +36,16 @@ pub struct MacroView { pub local_only: bool, } +/// A macro record's cached cast, the reference's `([rec+0x564], [rec+0x568])` pair (`0x4efe00`): +/// the bound spell and the book it resolved from, which the app derives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MacroBinding { + /// The bound spell id; 0 for none, -1 for a `/cast` that did not resolve. + pub spell: i32, + /// The spell came from the pet's book (`0x4b3b51` writes 1), not the player's. + pub pet_book: bool, +} + /// The macro table: the account and character lists. #[derive(Clone, Debug, Default, PartialEq)] pub struct MacroState { @@ -135,6 +145,11 @@ impl super::UiScript { self.model_mut().macros.clone() } + /// Push every macro's cached cast by 1-based index, replacing the last push. + pub fn set_macro_bindings(&mut self, bindings: std::collections::HashMap) { + self.model_mut().macro_bindings = bindings; + } + /// Whether a script changed the table since the last call; the app then saves it and fires /// `UPDATE_MACROS` (`0x452460`). pub fn take_macros_dirty(&mut self) -> bool { diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index 99adb517..eccf7a43 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -97,6 +97,20 @@ pub struct StationeryView { pub texture: String, } +/// One `Package.dbc` row, as `GetPackageInfo` answers it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageView { + /// The row id, which `SelectPackage` stores and `CMSG_SEND_MAIL` carries. + pub id: u32, + /// The localized name (field 3 on). + pub name: String, + /// The icon as a full path: `StringLookups.dbc` row 3's folder, `\` and field 1 + /// (`0x4ae4ab`-`0x4ae4eb`). + pub icon: String, + /// Field 2, in copper. + pub cost: i32, +} + /// A drained `SendMail` intent, which the app turns into `CMSG_SEND_MAIL`. #[derive(Clone, Debug, PartialEq)] pub struct MailSendRequest { @@ -110,6 +124,27 @@ pub struct MailSendRequest { /// The attached item's `(bag, 1-based slot)`; the app resolves its guid when the send fires, /// as the reference re-reads the slot then. pub item: Option<(i64, u32)>, + /// The selected `Package.dbc` id, sent only with an attached item (`0x4ae8f3`). + pub package: u32, +} + +/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing +/// `ITEM_LOCK_CHANGED`), then money, COD, stationery and package. Returns the lock event to fire. +fn clear_compose(model: &mut Model) -> Option<(String, Vec)> { + let lock = model.mail_send_item.take().map(|item| { + ( + "ITEM_LOCK_CHANGED".to_string(), + vec![ + super::ScriptValue::Int(item.bag), + super::ScriptValue::Int(i64::from(item.slot)), + ], + ) + }); + model.mail_send_money = 0; + model.mail_send_cod = 0; + model.mail_stationery = 0; + model.mail_package = 0; + lock } impl super::UiScript { @@ -172,27 +207,37 @@ impl super::UiScript { money: model.mail_send_money, cod: model.mail_send_cod, item, + package: model.mail_package, }) } - /// The reference's compose-tab reset (`0x4acdc0`): clear the attachment, money and COD, then - /// fire `SEND_MAIL_MONEY_CHANGED`, `SEND_MAIL_COD_CHANGED` and `MAIL_SEND_SUCCESS` in that - /// order. The events must follow the clear, as `SendMailFrame_Reset` re-reads - /// `GetSendMailItem`. `MAIL_SEND_SUCCESS` means the form is clean: opening a mailbox fires it. + /// The reference's compose-tab reset (`0x4acdc0(1)`), run on mailbox open and a sent mail: + /// clear the attachment (its lock lifted), money, COD, stationery and package, then fire + /// `SEND_MAIL_MONEY_CHANGED`, `SEND_MAIL_COD_CHANGED` and `MAIL_SEND_SUCCESS` in that order. + /// The events must follow the clear, as `SendMailFrame_Reset` re-reads `GetSendMailItem` and + /// selects stationery row 1. `MAIL_SEND_SUCCESS` means the form is clean: opening a mailbox + /// fires it. pub fn reset_compose_tab(&mut self) { - { - let mut model = self.model_mut(); - model.mail_send_item = None; - model.mail_send_money = 0; - model.mail_send_cod = 0; - } + let lock = clear_compose(&mut self.model_mut()); // Fired now, not queued: the order against the caller's `MAIL_SHOW`/`MAIL_FAILED` matters. + if let Some((event, args)) = lock { + self.fire_event(&event, args); + } // One literal call each, as the event census (`ui_script::reference_ui`) reads them. self.fire_event("SEND_MAIL_MONEY_CHANGED", Vec::new()); // 0x4ace14 self.fire_event("SEND_MAIL_COD_CHANGED", Vec::new()); // 0x4ace1e self.fire_event("MAIL_SEND_SUCCESS", Vec::new()); // 0x4ace28 } + /// The close core's silent reset (`0x4acdc0(0)`, from `0x4acd50`): the same clears, and only + /// the attachment's `ITEM_LOCK_CHANGED`, which `0x495420` fires on either leg. + pub fn reset_compose_tab_silently(&mut self) { + let lock = clear_compose(&mut self.model_mut()); + if let Some((event, args)) = lock { + self.fire_event(&event, args); + } + } + /// `SendMail`'s abort when the attached item left its slot (`ERR_ITEM_NOT_FOUND`, no packet, /// `MAIL_SEND_INFO_UPDATE` at `0x4ae98d`): drop the attachment, keep the money and COD. pub fn drop_send_mail_item(&mut self) { @@ -209,10 +254,9 @@ impl super::UiScript { self.model_mut().mail_stationeries = list; } - /// Clear the stationery selection, as the reference does on mailbox open and close - /// (`0x4ace07`). - pub fn clear_stationery(&mut self) { - self.model_mut().mail_stationery = 0; + /// Push `Package.dbc`'s rows, in file order. + pub fn set_mail_packages(&mut self, packages: Vec) { + self.model_mut().mail_packages = packages; } /// Push `HasNewMail()`'s answer, independent of any open mailbox. The app sets it before each @@ -689,6 +733,72 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), queued in + // its order; zero returns. + g.set( + "ClearSendMail", + lua.create_function(|lua, ()| { + let mut model = lua.app_data_mut::().expect("model app_data"); + let lock = clear_compose(&mut model); + model.pending_events.extend(lock); + for event in [ + "SEND_MAIL_MONEY_CHANGED", + "SEND_MAIL_COD_CHANGED", + "MAIL_SEND_SUCCESS", + ] { + model.pending_events.push((event.to_string(), Vec::new())); + } + Ok(MultiValue::new()) + })?, + )?; + + // GetNumPackages() (`0x4ae430`): `Package.dbc`'s raw row count, unfiltered. + g.set( + "GetNumPackages", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok(model.mail_packages.len() as i64) + })?, + )?; + + // GetPackageInfo(index) (`0x4ae450`) → name, icon, cost for a 1-based row; out of range it + // pushes nil, nil, 0 (`0x4ae525`), and a non-number raises. + g.set( + "GetPackageInfo", + lua.create_function(|lua, index: Value| { + let index = + crate::script::binding_abi::number_arg(lua, index, "Usage: GetPackageInfo(index)")?; + let model = lua.app_data_ref::().expect("model app_data"); + let row = usize::try_from(index.wrapping_sub(1)) + .ok() + .and_then(|i| model.mail_packages.get(i)); + Ok(match row { + Some(row) => MultiValue::from_vec(vec![ + Value::String(lua.create_string(&row.name)?), + Value::String(lua.create_string(&row.icon)?), + Value::Integer(i64::from(row.cost)), + ]), + None => MultiValue::from_vec(vec![Value::Nil, Value::Nil, Value::Integer(0)]), + }) + })?, + )?; + + // SelectPackage(index) (`0x4ae550`): stores the 1-based row's id, or 0 out of range + // (`0x4adc90`); a non-number raises. Zero returns. + g.set( + "SelectPackage", + lua.create_function(|lua, index: Value| { + let index = + crate::script::binding_abi::number_arg(lua, index, "Usage: SelectPackage(index)")?; + let mut model = lua.app_data_mut::().expect("model app_data"); + model.mail_package = usize::try_from(index.wrapping_sub(1)) + .ok() + .and_then(|i| model.mail_packages.get(i)) + .map_or(0, |row| row.id); + Ok(MultiValue::new()) + })?, + )?; + Ok(()) } @@ -1077,6 +1187,112 @@ mod tests { assert_eq!((req.money, req.cod, req.item), (0, 0, None)); } + /// `ClearSendMail()` (`0x4adee0`) is the compose reset with its events, the attachment's lock + /// first, then the three in `0x4acdc0`'s order; stationery and package clear with the rest. + #[test] + fn clear_send_mail_resets_the_form_and_fires_its_events() { + use crate::script::cursor::{CursorItem, CursorPayload}; + let mut s = UiScript::new().unwrap(); + select_default_stationery(&mut s); + s.set_mail_packages(vec![PackageView { + id: 2, + name: "Test Package".into(), + icon: "Interface\\Icons\\INV_BOX_04".into(), + cost: 10, + }]); + s.run("SelectPackage(1)").unwrap(); + s.model_mut().cursor = Some(CursorPayload::Item(CursorItem { + bar_placeable: true, + bag: 0, + slot: 3, + item_id: 1, + texture: None, + link: None, + count: None, + quality: None, + equip_slots: Vec::new(), + })); + s.run("ClickSendMailItemButton() SetSendMailMoney(99) SetSendMailCOD(5)") + .unwrap(); + s.tick(0.0); + s.run( + r#"FIRED = {} + local f = CreateFrame("Frame") + for _, e in { "ITEM_LOCK_CHANGED", "SEND_MAIL_MONEY_CHANGED", + "SEND_MAIL_COD_CHANGED", "MAIL_SEND_SUCCESS" } do + f:RegisterEvent(e) + end + f:SetScript("OnEvent", function() table.insert(FIRED, event) end)"#, + ) + .unwrap(); + assert_eq!( + s.eval::("local n = function(...) return arg.n end return n(ClearSendMail())") + .unwrap(), + 0 + ); + s.tick(0.0); + assert_eq!( + s.eval::>("return FIRED").unwrap(), + [ + "ITEM_LOCK_CHANGED", + "SEND_MAIL_MONEY_CHANGED", + "SEND_MAIL_COD_CHANGED", + "MAIL_SEND_SUCCESS" + ] + ); + assert!(s + .eval::("return GetSendMailItem() == nil and GetSendMailMoney() == 0") + .unwrap()); + s.run("SendMail('x', 'y', 'z')").unwrap(); + assert!(s.take_mail_send().is_none(), "the stationery cleared too"); + select_default_stationery(&mut s); + s.run("SendMail('x', 'y', 'z')").unwrap(); + assert_eq!(s.take_mail_send().map(|r| r.package), Some(0)); + } + + /// `Package.dbc`'s one shipped row, through the three package verbs (`0x4ae430`, `0x4ae450`, + /// `0x4ae550`). + #[test] + fn packages_list_describe_and_select_by_row() { + let mut s = UiScript::new().unwrap(); + assert_eq!(s.eval::("return GetNumPackages()").unwrap(), 0); + s.set_mail_packages(vec![PackageView { + id: 2, + name: "Test Package".into(), + icon: "Interface\\Icons\\INV_BOX_04".into(), + cost: 10, + }]); + assert_eq!(s.eval::("return GetNumPackages()").unwrap(), 1); + let (name, icon, cost): (String, String, i64) = s.eval("return GetPackageInfo(1)").unwrap(); + assert_eq!( + (name.as_str(), icon.as_str(), cost), + ("Test Package", "Interface\\Icons\\INV_BOX_04", 10) + ); + for miss in ["0", "2", "-1"] { + assert!( + s.eval::(&format!( + "local a, b, c = GetPackageInfo({miss}) return a == nil and b == nil and c == 0" + )) + .unwrap(), + "{miss}" + ); + } + for call in ["GetPackageInfo()", "SelectPackage('x')"] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains("Usage: "), "{call}: {err}"); + } + + select_default_stationery(&mut s); + s.run("SelectPackage(1) SendMail('x', 'y', 'z')").unwrap(); + assert_eq!(s.take_mail_send().map(|r| r.package), Some(2)); + s.run("SelectPackage(5) SendMail('x', 'y', 'z')").unwrap(); + assert_eq!( + s.take_mail_send().map(|r| r.package), + Some(0), + "out of range stores 0" + ); + } + #[test] fn clearing_the_mail_empties_it() { let mut s = UiScript::new().unwrap(); @@ -1184,7 +1400,7 @@ mod stationery_tests { s.run("SelectStationery(1) SendMail(\"Bob\", \"hi\", \"body\")") .unwrap(); assert_eq!(s.take_mail_send().map(|r| r.stationery), Some(41)); - s.clear_stationery(); + s.reset_compose_tab_silently(); s.run("SendMail(\"Bob\", \"hi\", \"body\")").unwrap(); assert!(s.take_mail_send().is_none()); } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 180a00cb..64317779 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -74,6 +74,7 @@ mod surface; pub use surface::widget_method_census; mod input_verbs; mod inspect; +mod inventory_verbs; mod item_stats; mod item_text; pub mod keybind; @@ -213,15 +214,17 @@ pub use inspect::{InspectView, UnitReach}; pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState}; pub use item_text::ItemTextState; pub use layout_cache::{FrameLayout, LayoutPoint}; -pub use loot::{LootRow, LootState}; +pub use loot::{LootRow, LootState, LOOT_PORTRAIT_UNIT}; pub use loot_roll::{LootRollEntry, LootRollsState}; -pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME}; -pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState, StationeryView}; +pub use macros::{MacroBinding, MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME}; +pub use mail::{ + MailInboxRow, MailInvoice, MailSendRequest, MailState, PackageView, StationeryView, +}; pub use measure::TextMeasure; pub use merchant::{ItemStatsHead, MerchantItem, MerchantState, RepairCosts}; pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs; pub(crate) use model::Model; -pub use model::{FontProbe, TextureProbe, TextureSizeProbe}; +pub use model::{FontProbe, TextureProbe, TextureSizeProbe, WorldLocProjector}; pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo}; pub use pet::{PetActionView, PetStats}; pub use pvp::{HonorState, InspectHonorData}; @@ -567,6 +570,7 @@ impl UiScript { skills::install(&lua)?; item_stats::install(&lua)?; char_stats::install(&lua)?; + inventory_verbs::install(&lua)?; weapon_enchant::install(&lua)?; loot::install(&lua)?; loot_roll::install(&lua)?; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 156d096d..91be6bfe 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -17,6 +17,12 @@ use super::{ /// The host's answer to whether a texture path resolves to a file. pub type TextureProbe = Box bool>; +/// The host's world-to-map projection (`0x4a7360`): the displayed map's selection as +/// [`super::UiScript::world_map_selection`] reads it, a map id and a world `(x, y)`, to the map UV, +/// `None` where the reference's outputs stay at their `(0, 0)`. +pub type WorldLocProjector = + Box), u32, f32, f32) -> Option<(f32, f32)>>; + /// The host's answer to a texture path's size in texels. pub type TextureSizeProbe = Box Option<(u32, u32)>>; @@ -76,6 +82,8 @@ pub(crate) struct Model { /// Whether a texture path resolves (patch chain or loose addon file), so the path form of /// `SetTexture` returns the reference's 1 or nil inline (`0x79bb40`). `None` answers nil. pub(crate) texture_probe: Option, + /// `GetWorldLocMapPosition`'s projection; `None` answers `(0, 0)`. + pub(crate) world_loc_projector: Option, /// A path's texel size, which an axis authored as 0 takes, one texel per unit, as the client's /// `GetWidth` (`0x770720`) and `GetHeight` (`0x770790`) do; `None` leaves it as authored. pub(crate) texture_size_probe: Option, @@ -431,6 +439,8 @@ pub(crate) struct Model { pub(crate) macros_dirty: bool, /// Bumped by every seed and change, for readers that must not drain `macros_dirty`. pub(crate) macros_generation: u64, + /// Each macro's cached cast by 1-based index, from the app; an absent macro reads unbound. + pub(crate) macro_bindings: HashMap, /// The macro icon paths from `SpellIcon.dbc`, behind `GetMacroIconInfo`. pub(crate) macro_icons: Vec, /// `ToggleSpellAutocast` ids for `CMSG_PET_SPELL_AUTOCAST` (`0x2F3`), which names a spell. @@ -709,6 +719,10 @@ pub(crate) struct Model { pub(crate) mail_stationeries: Vec, /// `SelectStationery`'s `Stationery.dbc` id; 0 is none, which silences `SendMail`. pub(crate) mail_stationery: u32, + /// `Package.dbc`'s rows in file order, `GetPackageInfo`'s list. + pub(crate) mail_packages: Vec, + /// `SelectPackage`'s `Package.dbc` id (`[0xb6efb8]`); 0 is none. + pub(crate) mail_package: u32, /// `HasNewMail()` (`0x4afea0`), from `MSG_QUERY_NEXT_MAIL_TIME` and `SMSG_RECEIVED_MAIL`. pub(crate) has_new_mail: bool, @@ -998,6 +1012,7 @@ impl Model { addons_chain_reader: None, measurer: None, texture_probe: None, + world_loc_projector: None, texture_size_probe: None, font_probe: None, addons_saved_account: None, @@ -1150,6 +1165,7 @@ impl Model { macros: macros::MacroState::default(), macros_dirty: false, macros_generation: 0, + macro_bindings: HashMap::new(), macro_icons: Vec::new(), pet_spell_autocasts: Vec::new(), casting: false, @@ -1291,6 +1307,8 @@ impl Model { mail_send_item: None, mail_stationeries: Vec::new(), mail_stationery: 0, + mail_packages: Vec::new(), + mail_package: 0, has_new_mail: false, auction: None, auction_item_classes: Vec::new(), diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs index 69732839..dfee9d48 100644 --- a/crates/benilla-ui/src/script/pet.rs +++ b/crates/benilla-ui/src/script/pet.rs @@ -120,6 +120,9 @@ pub(crate) struct PetBarState { pub(crate) can_be_abandoned: bool, /// `PetCanBeRenamed()`, ANDed with the above for the rename row; set until the first rename. pub(crate) can_be_renamed: bool, + /// The charm or possess expiry `[0xb714a8]` on the `GetTime` clock, in seconds: the last + /// `SMSG_PET_SPELLS` duration past its arrival, `None` for a packet whose duration is 0. + pub(crate) expiry: Option, } impl super::UiScript { @@ -160,6 +163,12 @@ impl super::UiScript { bar.stats = stats; } + /// Push the pet's expiry on the `GetTime` clock, `None` for none: `SetPet` (`0x4bc7e0`) + /// stores `OsTick() + duration` for each `SMSG_PET_SPELLS` whose duration is not 0, else 0. + pub fn set_pet_expiry(&mut self, expiry: Option) { + self.model_mut().pet_bar.expiry = expiry; + } + /// Push the menu's two predicates, which move with the pet's `UNIT_FIELD_FLAGS`. pub fn set_pet_menu(&mut self, can_be_abandoned: bool, can_be_renamed: bool) { let bar = &mut self.model_mut().pet_bar; @@ -235,6 +244,28 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let flag = |b: bool| if b { Value::Integer(1) } else { Value::Nil }; + // GetPetTimeRemaining() (`0x4be600`): nil with no expiry, else the milliseconds to it as an + // unsigned 32-bit tick difference (`fild qword` over a zero high dword), so one past its + // expiry wraps to about 4.29e9 until the next packet. The stock caller is commented out + // (`PetFrame.lua:91-97`). + g.set( + "GetPetTimeRemaining", + lua.create_function(|lua, ()| { + let expiry = lua + .app_data_ref::() + .expect("model app_data") + .pet_bar + .expiry; + let Some(expiry) = expiry else { + return Ok(Value::Nil); + }; + let now = crate::script::clock::now(lua); + #[allow(clippy::cast_possible_truncation)] // a tick difference, wrapped as the u32 is + let ms = ((expiry - now) * 1000.0).round() as i64 as u32; + Ok(Value::Number(f64::from(ms))) + })?, + )?; + g.set( "PetHasActionBar", lua.create_function(move |lua, ()| { @@ -546,6 +577,39 @@ mod tests { use super::{PetActionView, PetStats}; use crate::script::UiScript; + /// A charmed or possessed unit's time left in ms, nil for none, and the unsigned wrap once + /// the expiry has passed (`0x4be634`). + #[test] + fn pet_time_remaining_counts_down_to_the_expiry() { + let mut s = UiScript::new().unwrap(); + assert!(s + .eval::>("return GetPetTimeRemaining()") + .unwrap() + .is_none()); + s.tick(10.0); + s.set_pet_expiry(Some(12.5)); + assert_eq!( + s.eval::("return GetPetTimeRemaining()").unwrap(), + 2500.0 + ); + s.tick(2.0); + assert_eq!( + s.eval::("return GetPetTimeRemaining()").unwrap(), + 500.0 + ); + s.tick(1.0); + assert_eq!( + s.eval::("return GetPetTimeRemaining()").unwrap(), + f64::from(u32::MAX - 499), + "0.5 s past it" + ); + s.set_pet_expiry(None); + assert!(s + .eval::>("return GetPetTimeRemaining()") + .unwrap() + .is_none()); + } + /// Attack (a token, attacking), Claw (a spell, autocasting, cooling down), an empty slot. fn slots() -> Vec { vec![ diff --git a/crates/benilla-ui/src/script/petition.rs b/crates/benilla-ui/src/script/petition.rs index 3c918d51..1e80165e 100644 --- a/crates/benilla-ui/src/script/petition.rs +++ b/crates/benilla-ui/src/script/petition.rs @@ -66,6 +66,11 @@ pub enum PetitionRequest { TurnIn, /// `CloseGuildRegistrar()`: sends nothing (`0x4f5010`). CloseRegistrar, + /// `GetTabardInfo()` (`0x4f5300`), not a getter: with a player, `MSG_TABARDVENDOR_ACTIVATE` + /// for the registrar's guid unless the designer is already open on that guid, or the + /// `ERR_EMBLEMERROR_NOTABARDGEOSET` refusal while the player's display id is not its native + /// one (`0x5e00e0`); then the registrar closes (`0x4f5355` → `0x4f5010`). + TabardInfo, /// `SignPetition([n])`: a wire byte defaulting to 1, not 0 (`0x4f46d9`); vmangos skips it. Sign(i8), /// `OfferPetition()`: the app resolves the current target (`CGGameUI`'s selection pair, not its @@ -222,10 +227,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // ── The verbs ──────────────────────────────────────────────────────────────────────────── - // The four that carry and push nothing; none may touch the snapshot, which the server owns. + // The five that carry and push nothing; none may touch the snapshot, which the server owns. + // `GetTabardInfo`'s stock caller is commented out (`GuildRegistrarFrame.xml:190`). for (global, request) in [ ("TurnInGuildCharter", PetitionRequest::TurnIn), ("CloseGuildRegistrar", PetitionRequest::CloseRegistrar), + ("GetTabardInfo", PetitionRequest::TabardInfo), ("OfferPetition", PetitionRequest::Offer), ("ClosePetition", PetitionRequest::ClosePetition), ] { @@ -529,6 +536,21 @@ mod tests { } } + /// `GetTabardInfo()` pushes nothing (`0x4f535f`) and queues the designer's opener. + #[test] + fn get_tabard_info_is_an_intent_with_no_returns() { + let mut s = UiScript::new().unwrap(); + assert_eq!( + s.eval::("local n = function(...) return arg.n end return n(GetTabardInfo())") + .unwrap(), + 0 + ); + assert_eq!( + s.take_petition_requests(), + vec![PetitionRequest::TabardInfo] + ); + } + #[test] fn dropping_close_intents_leaves_the_other_verbs_untouched() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index 672ab3f5..0b9b2e3a 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -26,6 +26,39 @@ pub(super) fn region_handle_of(lua: &Lua, this: &Table) -> mlua::Result mlua::Result { + let Value::Table(t) = this else { + return Err(mlua::Error::runtime( + "Attempt to find 'this' in non-table object (used '.' instead of ':' ?)", + )); + }; + if decode_id(t).is_err() { + return Err(mlua::Error::runtime( + "Attempt to find 'this' in non-framescript object", + )); + } + let wrong = || mlua::Error::runtime("Wrong object type for member function"); + let rh = region_handle_of(lua, t).map_err(|_| wrong())?; + let model = lua.app_data_ref::().expect("model app_data"); + if model.arena.region(rh).map(|r| r.kind) != Some(RegionKind::Texture) { + return Err(wrong()); + } + Ok(rh) +} + +/// `CSimpleTexture::SetTexture(NULL)` (`0x770300` with 0): the texture slot emptied, a file, a +/// solid fill and a baked unit portrait alike; the shader slot, and so the desaturation, stays. +pub(super) fn clear_texture(model: &mut Model, rh: RegionHandle) { + let data = model.region_data.entry(rh).or_default(); + data.texture = None; + data.fill = None; + data.portrait_unit = None; +} + /// Apply the font parts an XML element supplies (`font=`, ``, `outline=`), any of /// which may be absent. Not the `SetFont` binding: the reference applies XML fonts in `LoadXML`, /// and its `SetFont` requires a path and a height (`0x87c69c`). Each part is an explicit set, so it diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs index 953d029f..e4133906 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs @@ -608,6 +608,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() { }, ); s.set_loot(Some(LootState { + source_unit: false, master_candidates: Vec::new(), rows: vec![Some(LootRow { name: Some("Bloodrazor of the Monkey".into()), @@ -689,6 +690,7 @@ fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() { }, ); s.set_loot(Some(LootState { + source_unit: false, master_candidates: Vec::new(), rows: vec![Some(LootRow { name: Some("Bloodrazor".into()), diff --git a/crates/benilla-ui/src/script/tests/worldmap.rs b/crates/benilla-ui/src/script/tests/worldmap.rs index 5986c3fd..099b6f6c 100644 --- a/crates/benilla-ui/src/script/tests/worldmap.rs +++ b/crates/benilla-ui/src/script/tests/worldmap.rs @@ -623,3 +623,49 @@ fn worldmap_overlays_reveal_by_explored_bits() { s.run("SetMapZoom(2)").unwrap(); assert_eq!(s.eval::("return GetNumMapOverlays()").unwrap(), 0); } + +/// `GetWorldLocMapPosition` (`0x4a88f0`) hands the displayed selection, the truncated map id and +/// the f32 position to the host's projection, answers `(0, 0)` without one, and raises on any +/// non-number argument. +#[test] +fn world_loc_map_position_projects_through_the_host() { + let mut s = script(); + push_catalog(&mut s); + assert_eq!( + s.eval::<(f64, f64)>("return GetWorldLocMapPosition(0, 100, 200)") + .unwrap(), + (0.0, 0.0), + "no projection: the zeroed outputs" + ); + let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = seen.clone(); + s.set_world_loc_projector(Box::new(move |sel, map, x, y| { + log.borrow_mut().push((sel, map, x, y)); + (map == 0).then_some((0.25, 0.75)) + })); + s.run("SetMapZoom(2, 1)").unwrap(); + assert_eq!( + s.eval::<(f64, f64)>("return GetWorldLocMapPosition(0.9, '-8913.2', 554.6)") + .unwrap(), + (0.25, 0.75) + ); + assert_eq!( + s.eval::<(f64, f64)>("return GetWorldLocMapPosition(-1, 0, 0)") + .unwrap(), + (0.0, 0.0) + ); + let seen = seen.borrow(); + assert_eq!(seen[0], ((2, 1, None), 0, -8913.2f32, 554.6f32)); + assert_eq!(seen[1].1, u32::MAX, "a negative map id matches nothing"); + for call in [ + "GetWorldLocMapPosition(0, 1)", + "GetWorldLocMapPosition('x', 1, 1)", + "GetWorldLocMapPosition(0, nil, 1)", + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!( + err.contains("Usage: GetWorldLocMapPosition(continent, x, y)"), + "{call}: {err}" + ); + } +} diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 81ff3eff..89ffe791 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -723,6 +723,19 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // GetDamageBonusStat() (`0x48b520`): the active player's `ChrClasses.dbc` field 2 plus one, + // the 1-based `UnitStat` index its melee damage scales with; 0 with no player or no class row + // (`0x48b58a`). No stock caller. + g.set( + "GetDamageBonusStat", + lua.create_function(|lua, ()| { + let stat = with_unit(lua, &Some("player".to_string()), None, |u| { + u.damage_bonus_stat + })?; + Ok(stat.map_or(0, |s| i64::from(s) + 1)) + })?, + )?; + // UnitSex(unit): 2 male, 3 female, or 1 neuter, which no feed sends. A unit that does not // resolve answers 2, never nil (`0x517f9f`). g.set( diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 1d446520..25dbda1e 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -107,6 +107,9 @@ pub struct UnitState { /// `UnitHasRelicSlot` (`0x519e50`): INVSLOT 17 is a relic slot, from `ChrClasses.dbc` field /// 16. Players only, true for Paladin, Shaman and Druid. pub has_relic_slot: bool, + /// `ChrClasses.dbc` field 2 for a player's class, 0-based (Strength 0, Agility 1), which + /// `GetDamageBonusStat` (`0x48b520`) reads for the active player; `None` with no class row. + pub damage_bonus_stat: Option, /// `UnitSex`'s scale, 2 male and 3 female, from `UNIT_FIELD_BYTES_0` byte 2 (0 male, 1 /// female). The unfilled 0 answers 2, the reference's unresolved answer (`0x517f9f`). pub sex: u8, diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index e05a619e..184bde29 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -1379,6 +1379,35 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() { assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err()); } +/// `GetDamageBonusStat()` (`0x48b520`) is the player's class row's field 2 plus one, 0 with no +/// player or no row; it takes no unit, so another unit's class never answers. +#[test] +fn damage_bonus_stat_is_the_player_class_stat_one_based() { + let mut s = UiScript::new().unwrap(); + assert_eq!(s.eval::("return GetDamageBonusStat()").unwrap(), 0); + + let mut rogue = player(); + rogue.damage_bonus_stat = Some(1); + s.set_unit("target", Some(rogue.clone())); + assert_eq!( + s.eval::("return GetDamageBonusStat()").unwrap(), + 0, + "the target is not the player" + ); + s.set_unit("player", Some(rogue)); + assert_eq!(s.eval::("return GetDamageBonusStat()").unwrap(), 2); + + let mut warrior = player(); + warrior.damage_bonus_stat = Some(0); + s.set_unit("player", Some(warrior)); + assert_eq!(s.eval::("return GetDamageBonusStat()").unwrap(), 1); + + let mut rowless = player(); + rowless.damage_bonus_stat = None; + s.set_unit("player", Some(rowless)); + assert_eq!(s.eval::("return GetDamageBonusStat()").unwrap(), 0); +} + /// Stock calls it unconditionally (`PaperDollFrame.lua:429`, `PaperDollFrame.lua:580`), so the /// global must exist for every class. #[test] diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index 56649979..4daaf89b 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -418,6 +418,11 @@ impl super::UiScript { (wm.selection.0, wm.selection.1, wm.direct_area) } + /// Install the host's world-to-map projection behind `GetWorldLocMapPosition`. + pub fn set_world_loc_projector(&mut self, projector: super::WorldLocProjector) { + self.model_mut().world_loc_projector = Some(projector); + } + /// Moves the selection from the engine, as the zone updater `0x494780` calls the setter /// `0x4a67a0`: a fresh login shows the player's map before any Lua runs, not the world sheet, /// whose `GetPlayerMapPosition` is sheet UV (`0x4a7360`). It takes the resolver's whole @@ -602,6 +607,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // GetWorldLocMapPosition(continent, x, y) (`0x4a88f0`): a world position on the displayed + // map, through the projection `GetCorpseMapPosition` uses (`0x4a7360`). The first argument is + // the position's map id, however the usage names it (`0x4a7320` matches it against + // `WorldMapContinent.dbc`'s map ids); x and y narrow to f32. Each argument must be a number, + // else the usage raises; off the displayed map the answer is (0, 0). No stock caller. + g.set( + "GetWorldLocMapPosition", + lua.create_function(|lua, (map, x, y): (Value, Value, Value)| { + const USAGE: &str = "Usage: GetWorldLocMapPosition(continent, x, y)"; + let (Some(map), Some(x), Some(y)) = ( + lua.coerce_number(map)?, + lua.coerce_number(x)?, + lua.coerce_number(y)?, + ) else { + return Err(mlua::Error::RuntimeError(USAGE.into())); + }; + // The truncating cast, then the map id compared as an unsigned dword. + let map = map as i64 as i32 as u32; + let model = lua.app_data_ref::().expect("model app_data"); + let wm = &model.worldmap; + let selection = (wm.selection.0, wm.selection.1, wm.direct_area); + let (u, v) = model + .world_loc_projector + .as_ref() + .and_then(|project| project(selection, map, x as f32, y as f32)) + .unwrap_or((0.0, 0.0)); + Ok((f64::from(u), f64::from(v))) + })?, + )?; + // GetCorpseMapPosition() → map UV, or (0, 0), which WorldMapFrame.lua:445 hides. g.set( "GetCorpseMapPosition", diff --git a/docs/MAP.md b/docs/MAP.md index af26a535..8901547f 100644 --- a/docs/MAP.md +++ b/docs/MAP.md @@ -104,7 +104,7 @@ - **benilla-adt** (lib.rs): combined_alpha - **benilla-app** (lib.rs): addon_harness area area_poi area_trigger asset_churn aura_visual bindings blob_shadow bowstring camera_shake capture char_create char_select chat_bubble chr_classes cinematic combat_log combat_text console crash creature_anim cursor cvars death debug_panel dev doodad_events entities fishing_line footprints game_plugins game_tip glue glue_strings go_anim go_templates hover_log items loading_screen local_state login minimap name_persist nameplates names net npc_text opaque2d pending_item_ops perf pipe_warm player poi_marker portrait preflight probe_shield query_cache quest_markers raid_marks ranged_flex realm_select realmlist run_mode screen_fade screenshot script_calls shaders shutdown smart_rect sound spell swing_refusal target test_support text_filter text_reshape textinput transport tutorial ui_action ui_auction ui_aura ui_bank ui_battlefield ui_battlefield_positions ui_battlefield_score ui_bind_confirm ui_binder ui_cast ui_char ui_chat ui_craft ui_dialog_verbs ui_dressup ui_duel ui_follow ui_gamma ui_gm_ticket ui_gossip ui_guild ui_hide ui_honor ui_inspect ui_instance ui_item_text ui_items ui_layout ui_logout ui_loot ui_loot_roll ui_macro ui_mail ui_merchant ui_mirror ui_models ui_net ui_party ui_pass ui_pet ui_pet_book ui_pet_doll ui_pet_stats ui_petition ui_quest ui_quest_log ui_quest_share ui_reputation ui_saved ui_script ui_session ui_shapeshift ui_social ui_spellbook ui_stable ui_summon ui_tabard ui_talent ui_talent_wipe ui_taxi ui_text ui_tooltip ui_trade ui_tradeskill ui_trainer ui_unit ui_world_map video vplates weapon_trail world_backdrop world_state world_state_ui - **benilla-assets** (lib.rs): adt anim_rng blp column_grid coords gpu_blp m2 materials minimap_grid model spatial_cache terrain tex_filter trace wdt wmo world_assets -- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map +- **benilla-formats** (lib.rs): anim_data area_poi area_sound area_table area_trigger auction_house bank_bag_slot_prices camera_shakes cfg_categories chain characters chat_channels chr_classes cinematics creature_families creature_sound creature_types creatures dbc death_thud durability elevators emit_timing emote_text emotes environmental_damage exhaustion factions footsteps game_tips gameobjects garble gm_ticket_category ground_effects install item_random_properties item_sounds item_visuals itembagfamily itemclass items itemsets itemsubclass languages light liquid loading_screen lock lock_type macro_icons maps material minimap_translate models npc_greeting packages page_text_material particles pet_stats quest_headers quest_info race_pvp_team race_sound ribbons server_messages sheathe skill_lines sound_entries sound_provider sound_water spell_focus spell_mechanic spell_visual spells stable_slot_prices stationery talents taxi taxi_nodes taxi_path terrain text_filter_lists tga transport_period transports unit_blood value_track vocal_ui_sounds wdl weapon_impact weapon_swing wmo_area world_map_area world_map_continent world_map_overlay world_state_ui wow_ini zone_map - **benilla-m2** (lib.rs): error model skin tests track - **benilla-mpq** (lib.rs): crypto - **benilla-protocol** (lib.rs): auth events guid messages wire world