diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 5c785c3c..a42645f6 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1584,6 +1584,7 @@ fn seed_cooldown_filmstrip( texture: texture.clone(), count: 1, quality: Some(1), + inventory_type: 0, item_id: 6948, link: Some("|cffffffff|Hitem:6948|h[Hearthstone]|h|r".into()), locked: false, @@ -1636,6 +1637,7 @@ fn seed_bag_window( texture: icon(disp), count, quality: Some(quality), + inventory_type: 0, item_id: id, link: Some(format!("|cffffffff|Hitem:{id}|h[{name}]|h|r")), locked: false, @@ -1722,6 +1724,7 @@ fn seed_equipped_bags( texture: icon(disp), count, quality: Some(quality), + inventory_type: 0, item_id: 0, link: Some(format!("|cffffffff|Hitem:0|h[{name}]|h|r")), locked: false, diff --git a/crates/benilla-app/src/ui_action/feed.rs b/crates/benilla-app/src/ui_action/feed.rs index 830e98c2..4fb76c3e 100644 --- a/crates/benilla-app/src/ui_action/feed.rs +++ b/crates/benilla-app/src/ui_action/feed.rs @@ -24,7 +24,7 @@ use super::{ /// An item action's icon when its template has not answered or its display id has no /// `ItemDisplayInfo` row: the reference's literal `0x847fe4`, from the resolver's `0x5d8927`. -pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark"; +pub(crate) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark"; /// The feed's memory of what it last pushed, for per-slot change events. #[derive(Default)] diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 538680f0..9765e2c5 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -23,6 +23,7 @@ mod drain_tests; pub(crate) mod drop_item; mod errors; mod feed; +pub(crate) use feed::MISSING_ITEM_ICON; #[cfg(test)] mod feed_tests; mod net; diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index adc01aad..76004e05 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -1296,6 +1296,7 @@ mod bind_confirm_tests { texture: Some("Interface\\Icons\\INV_Axe_01".into()), count: 1, quality: Some(quality), + inventory_type: 0, item_id: FLURRY_AXE, link: Some("|cffa335ee|Hitem:871:0:0:0|h[Flurry Axe]|h|r".into()), locked: false, diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index ea330dee..5ff534a8 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -399,10 +399,7 @@ pub(super) fn feed_item_stats( commands: Res, spells: Option>, // The caster the `$`-tokens read: its spell modifiers and its skills. - (spell_mods, self_q): ( - Res, - Query<&ObjectStore, With>, - ), + (spell_mods, self_q, locks): CasterReads, skill_lines: Option>, // The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`. home_bind: Option>, @@ -479,23 +476,24 @@ pub(super) fn feed_item_stats( } else { mod_sensitive.remove(&id); } - Some(( - id, - template_view( - &t, - spell_res, - Some(&spell_mods), - &skill, - skill_catalog, - home_area, - &|| gender, - factions.as_deref().map(|f| f.catalog()), - sub_classes.as_deref().map(|s| &s.0), - classes.as_deref().map(|c| &c.0), - icons.as_deref(), - &get, - ), - )) + let mut view = template_view( + &t, + spell_res, + Some(&spell_mods), + &skill, + skill_catalog, + home_area, + &|| gender, + factions.as_deref().map(|f| f.catalog()), + sub_classes.as_deref().map(|s| &s.0), + classes.as_deref().map(|c| &c.0), + icons.as_deref(), + &get, + ); + // A lock id with no `Lock.dbc` row is no lock to the builder (`0x52bd6b`.. + // `0x52bda9`); with the catalog unloaded the id stands. + view.lock_id = known_lock_id(view.lock_id, locks.as_deref().map(|l| &l.0)); + Some((id, view)) }) .collect() }; @@ -504,6 +502,24 @@ pub(super) fn feed_item_stats( } } +/// What the `$`-tokens read of the caster, and the `Lock.dbc` the LOCKED line needs a row from +/// (`0x52bd6b`). +type CasterReads<'w, 's> = ( + Res<'w, crate::spell::SpellModifiers>, + Query<'w, 's, &'static ObjectStore, With>, + Option>, +); + +/// The template's lock id when `Lock.dbc` holds its row, else 0: the builder's LOCKED line starts +/// from that row (`0x52bd57`..`0x52bd76`), and a missing one is no lock. With the catalog not +/// loaded the id stands. +fn known_lock_id(lock_id: u32, locks: Option<&benilla_formats::LockCatalog>) -> u32 { + match locks { + Some(l) if l.slots(lock_id).is_none() => 0, + _ => lock_id, + } +} + /// The player state the tooltip's red requirement lines check: level, class, race, skills, /// proficiencies (`SMSG_SET_PROFICIENCY`) and reputation ranks (the DBC base plus the /// `SMSG_INITIALIZE_FACTIONS` standing), pushed on change. @@ -611,7 +627,8 @@ pub(super) fn feed_player_req( } /// One bag slot from its item guid: instance, template, icon and use-spell cooldown. `None` is an -/// empty slot; an occupied one not yet resolved is `Some` with empty fields. +/// empty slot or one whose item object has not streamed; an occupied one whose template is not yet +/// resolved is `Some` with empty fields. fn resolve_slot( guid: u64, objects: &Objects, @@ -637,48 +654,47 @@ fn resolve_slot( let enchant_ms: [Option; crate::items::ENCHANT_SLOTS] = std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32))); let duration_ms = countdowns.and_then(|c| c.lifetime_remaining_display_ms()); - let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) = - match objects.object(guid) { - Some(fields) => ( - fields.object_entry().unwrap_or(0), - fields.item_stack_count().unwrap_or(1), - // Max 0 is indestructible: no line. - fields - .item_durability() - .zip(fields.item_max_durability()) - .filter(|&(_, max)| max > 0), - // Letter text on the instance, which the hover magnifier keys off; a template - // `PageText` book does not set it. - fields.item_text_id().is_some_and(|id| id != 0), - // The creator's name for "" and "Written by %s", `None` until the - // name query answers. - fields - .item_creator() - .filter(|&g| g != 0) - .and_then(|g| names.resolve(g, commands).map(str::to_string)), - // The tooltip's unlocked (0x4) and wrapped (0x8) bits. - fields.item_flags().unwrap_or(0), - // `0x5da2c0`: soulbound, or carrying a binding enchant, off the raw descriptor. - crate::items::already_bound(fields, rolls.enchants), - // The roll behind the name's suffix; its enchants are already in slots 2..6. - fields.item_random_properties_id(), - // All seven enchant slots with charges and countdowns: our own items stream whole, - // where others' carry two slots. - crate::items::enchant_lines( - (0..7).map(|s| { - ( - s, - fields.item_enchant(s).unwrap_or(0), - fields.item_enchant_charges(s), - enchant_ms[usize::from(s)], - ) - }), - rolls.enchants, - ), - ), - // A guid whose create has not landed: occupied, unresolved. - None => return Some(ContainerSlot::default()), - }; + // A guid whose create has not landed: the reference finds no item object (`0x6228a0` returns + // NULL), so `GetContainerItemInfo` pushes nothing (`0x4f96dc`, `0x4f96e0`, `0x4f9810`) and + // the slot reads empty. + let fields = objects.object(guid)?; + let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) = ( + fields.object_entry().unwrap_or(0), + fields.item_stack_count().unwrap_or(1), + // Max 0 is indestructible: no line. + fields + .item_durability() + .zip(fields.item_max_durability()) + .filter(|&(_, max)| max > 0), + // Letter text on the instance, which the hover magnifier keys off; a template + // `PageText` book does not set it. + fields.item_text_id().is_some_and(|id| id != 0), + // The creator's name for "" and "Written by %s", `None` until the + // name query answers. + fields + .item_creator() + .filter(|&g| g != 0) + .and_then(|g| names.resolve(g, commands).map(str::to_string)), + // The tooltip's unlocked (0x4) and wrapped (0x8) bits. + fields.item_flags().unwrap_or(0), + // `0x5da2c0`: soulbound, or carrying a binding enchant, off the raw descriptor. + crate::items::already_bound(fields, rolls.enchants), + // The roll behind the name's suffix; its enchants are already in slots 2..6. + fields.item_random_properties_id(), + // All seven enchant slots with charges and countdowns: our own items stream whole, + // where others' carry two slots. + crate::items::enchant_lines( + (0..7).map(|s| { + ( + s, + fields.item_enchant(s).unwrap_or(0), + fields.item_enchant_charges(s), + enchant_ms[usize::from(s)], + ) + }), + rolls.enchants, + ), + ); if entry == 0 { return Some(ContainerSlot::default()); } @@ -717,6 +733,7 @@ fn resolve_slot( count, durability, quality: Some(t.quality), + inventory_type: t.inventory_type, item_id: entry, // The link carries the roll in `randomPropertyId` and in the suffixed name (`0x5d8b00`); // the tooltip reads its name back off it. @@ -1408,10 +1425,82 @@ fn diff_and_push( #[cfg(test)] mod tests { use super::{ - apply_container_source, charges_count, ContainerSlot, FeedMemory, SlotGuids, + apply_container_source, charges_count, resolve_slot, ContainerSlot, FeedMemory, SlotGuids, BANK_CONTAINER, BANK_SLOTS, }; + /// A lock id with no `Lock.dbc` row is no lock to the item builder (`0x52bd6b`..`0x52bda9`). + #[test] + fn a_lock_id_without_a_row_is_no_lock() { + use benilla_formats::{LockCatalog, LockSlot, LOCK_KEY_ITEM}; + let mut slots = [LockSlot::default(); benilla_formats::MAX_LOCK_SLOTS]; + slots[0].key_type = LOCK_KEY_ITEM; + let catalog = LockCatalog::from_rows([(7, slots)]); + assert_eq!(super::known_lock_id(7, Some(&catalog)), 7); + assert_eq!(super::known_lock_id(8, Some(&catalog)), 0); + assert_eq!( + super::known_lock_id(8, None), + 8, + "no catalog, the id stands" + ); + assert_eq!(super::known_lock_id(0, Some(&catalog)), 0); + } + + /// A bag slot carries the template's quality and InventoryType, which `GetContainerItemInfo` + /// gates its fourth value on (`0x4f9794`); an unanswered template leaves them empty. + #[test] + fn a_resolved_slot_carries_the_templates_quality_and_inventory_type() { + use crate::items::{test_template, Items, RollCatalogs}; + use crate::names::NameCache; + use crate::net::NetCommands; + use crate::ui_items::TestObjects; + use benilla_protocol::ObjectFields; + + const ENTRY: u16 = 3; // OBJECT_FIELD_ENTRY + let (tx, _rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let mut objs = TestObjects::new(); + for (guid, entry) in [(0xA1_u64, 1234_u32), (0xA2, 1235), (0xA3, 1236)] { + objs.spawn(guid, ObjectFields::from_pairs(&[(ENTRY, entry)])); + } + let mut items = Items::default(); + let mut sword = test_template("Sword"); + sword.quality = 4; + sword.inventory_type = 13; + items.insert_template(1234, Some(sword)); + items.insert_template(1235, Some(test_template("Healing Potion"))); + + let names = NameCache::default(); + let cooldowns = crate::spell::Cooldowns::default(); + let mut petitions = crate::ui_petition::PetitionState::default(); + let mut slot = |guid: u64| { + resolve_slot( + guid, + &objs.get(), + &items, + None, + RollCatalogs::NONE, + &commands, + &cooldowns, + None, + &names, + &mut petitions, + std::time::Instant::now(), + 0.0, + false, + ) + }; + // A guid whose item object has not streamed reads as no item at all (`0x6228a0` finds + // none), not as an occupied blank. + assert!(slot(0xA9).is_none()); + let sword = slot(0xA1).expect("an occupied slot"); + assert_eq!((sword.quality, sword.inventory_type), (Some(4), 13)); + let potion = slot(0xA2).expect("an occupied slot"); + assert_eq!((potion.quality, potion.inventory_type), (Some(1), 0)); + let pending = slot(0xA3).expect("an occupied slot"); + assert_eq!((pending.quality, pending.inventory_type), (None, 0)); + } + /// No bags and an empty vault. const NO_BAGS: SlotGuids = SlotGuids { bags: [0; 10], diff --git a/crates/benilla-app/src/ui_script/action_bar_tests.rs b/crates/benilla-app/src/ui_script/action_bar_tests.rs index 398c24b4..615ff15a 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -320,6 +320,109 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// `SetAction` answers 1 when the spell tooltip it built holds a line that changes while shown +/// (the chance line, or the remaining cooldown of a spell whose recovery is a minute or more: +/// `0x52f68c`, `0x52f581`), else nil, and the stock `ActionButton_SetTooltip` arms `updateTooltip` +/// from it (`ActionButton.lua:377-381`); `ActionButton_OnUpdate` then rebuilds the hovered +/// tooltip. Run through the stock bar. +#[test] +fn a_spell_tooltip_with_a_live_line_arms_the_stock_refresh() { + benilla_formats::wow_data_or_skip!(); + use benilla_ui::script::{ActionState, SpellTooltipView}; + + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + load_action_bar(&s); + s.set_action( + 1, + Some(ActionSlot { + texture: Some("Interface\\Icons\\Ability_Warrior_ShieldWall".into()), + kind: 0x00, + action: 871, + count: 0, + consumable: false, + }), + ); + s.fire_event("PLAYER_ENTERING_WORLD", vec![]); + s.tick(10.0); // a nonzero GetTime epoch + let view = |cast: &str, recovery_ms: u32, chance: Option<&str>| SpellTooltipView { + name: "Shield Wall".into(), + cast_time: Some(cast.into()), + recovery_ms, + chance: chance.map(str::to_string), + ..Default::default() + }; + // The value the stock button saved from the setter's answer. + let armed = |s: &mut UiScript| -> Option { + s.eval::>("return ActionButton1.updateTooltip") + .unwrap() + }; + let line2 = |s: &mut UiScript| -> String { + s.eval::("return GameTooltipTextLeft2:GetText() or ''") + .unwrap() + }; + let running = |duration_ms: u32| ActionState { + usable: true, + // Started at 9.4 s on the GetTime clock, so it is running at 10 s. + cooldown: Some((9_400, duration_ms, true)), + ..Default::default() + }; + let leave_and_enter = |s: &mut UiScript| { + super::test_ui::unhover(s); + s.tick(0.0); + super::test_ui::hover(s, "ActionButton1"); + }; + + // No cooldown, no chance line: the setter answers nil, and nothing is armed. + s.set_spell_tooltip(871, view("Instant", 1_800_000, None)); + super::test_ui::hover(&mut s, "ActionButton1"); + assert!(s.eval::("return GameTooltip:IsVisible()").unwrap()); + assert_eq!(armed(&mut s), None); + + // A 30 minute recovery running: 1, so the button arms the stock refresh. + s.set_action_state(1, Some(running(1_800_000))); + leave_and_enter(&mut s); + assert_eq!( + armed(&mut s), + s.eval::>("return TOOLTIP_UPDATE_TIME").unwrap() + ); + assert!(armed(&mut s).is_some_and(|t| t > 0.0)); + assert_eq!(line2(&mut s), "Instant"); + // The refresh rebuilds the hovered tooltip from the current view. + s.set_spell_tooltip(871, view("Next melee", 1_800_000, None)); + s.tick(0.5); + assert_eq!( + line2(&mut s), + "Next melee", + "ActionButton_OnUpdate rebuilt it" + ); + + // The same cooldown on a spell whose recovery is under a minute: nil. + s.set_spell_tooltip(871, view("Instant", 30_000, None)); + s.set_action_state(1, Some(running(30_000))); + leave_and_enter(&mut s); + assert_eq!(armed(&mut s), None); + + // A chance line alone does not: `SetAction` reads the builder's value only with a cooldown + // running (`0x532597`; the other leg pushes nil, `0x532690`), so a passive answers nil. + s.set_action_state( + 1, + Some(ActionState { + usable: true, + ..Default::default() + }), + ); + s.set_spell_tooltip(871, view("Instant", 0, Some("5.00% chance to parry"))); + leave_and_enter(&mut s); + assert_eq!(armed(&mut s), None); + // With a cooldown running it does, whatever the spell's recovery. + s.set_action_state(1, Some(running(30_000))); + leave_and_enter(&mut s); + assert!(armed(&mut s).is_some_and(|t| t > 0.0)); + + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + /// `CooldownFrame_SetTimer` hides the pane unless `start > 0 and duration > 0 and enable > 0` /// (Cooldown.lua:3): a running cooldown whose start predates a restarted `GetTime` never draws. #[test] @@ -1061,6 +1164,7 @@ fn shipped_bag_frame_drives_end_to_end() { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 5, quality: Some(1), + inventory_type: 0, item_id: 117, link: None, locked: false, diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index b91b7d73..2e2b7505 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -393,6 +393,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() { texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()), count: 200, quality: Some(1), + inventory_type: 0, item_id: 2947, link: Some("|cffffffff|Hitem:2947|h[Small Throwing Knife]|h|r".into()), locked: false, @@ -470,6 +471,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { texture: Some("Interface\\Icons\\INV_Sword_04".into()), count: 1, quality: Some(1), + inventory_type: 0, item_id: 25, link: Some("|cffffffff|Hitem:25|h[Worn Shortsword]|h|r".into()), locked: false, @@ -577,6 +579,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()), count: 4, quality: Some(1), + inventory_type: 0, item_id: 2318, link: Some("|cffffffff|Hitem:2318|h[Light Leather]|h|r".into()), locked: false, @@ -599,6 +602,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()), count: 1, quality: Some(1), + inventory_type: 0, item_id: 9999, link: Some("|cffffffff|Hitem:9999|h[Shadowforge Key]|h|r".into()), locked: false, @@ -786,6 +790,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 5, quality: Some(3), + inventory_type: 0, item_id: 117, link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()), locked: false, @@ -872,6 +877,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() { texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()), count: 1, quality: Some(2), + inventory_type: 0, item_id: 200, link: Some("|cffffffff|Hitem:200|h[Shiny Gem]|h|r".into()), locked: false, @@ -1008,6 +1014,7 @@ pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 5, quality: Some(1), + inventory_type: 0, item_id: 117, link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()), locked: false, diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs index 89e98d84..4d2c3b0e 100644 --- a/crates/benilla-app/src/ui_script/character_tests.rs +++ b/crates/benilla-app/src/ui_script/character_tests.rs @@ -86,6 +86,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState { texture: Some("Interface\\Icons\\INV_Helmet_02".into()), count: 1, quality: Some(3), + inventory_type: 0, item_id: 2000, link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()), locked: false, diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index 394f910c..86d5770c 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -360,6 +360,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 5, quality: Some(1), + inventory_type: 0, item_id: 117, link: None, locked: false, diff --git a/crates/benilla-app/src/ui_script/delete_item_tests.rs b/crates/benilla-app/src/ui_script/delete_item_tests.rs index 2d139b7e..98849e45 100644 --- a/crates/benilla-app/src/ui_script/delete_item_tests.rs +++ b/crates/benilla-app/src/ui_script/delete_item_tests.rs @@ -22,6 +22,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 5, quality: Some(quality), + inventory_type: 0, item_id, link: Some(format!("|cffffffff|Hitem:{item_id}|h[{name}]|h|r")), locked: false, diff --git a/crates/benilla-app/src/ui_script/escape_tests.rs b/crates/benilla-app/src/ui_script/escape_tests.rs index d57cf4e1..978b9601 100644 --- a/crates/benilla-app/src/ui_script/escape_tests.rs +++ b/crates/benilla-app/src/ui_script/escape_tests.rs @@ -20,6 +20,7 @@ fn one_item_backpack() -> ContainerState { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 1, quality: Some(1), + inventory_type: 0, item_id: 117, link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()), locked: false, @@ -260,6 +261,7 @@ fn escape_closes_an_open_stack_split_frame() { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 5, quality: Some(1), + inventory_type: 0, item_id: 117, link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()), locked: false, 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 e14b3887..1890994c 100644 --- a/crates/benilla-app/src/ui_script/game_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs @@ -702,6 +702,7 @@ fn backpack() -> ContainerState { texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 1, quality: Some(1), + inventory_type: 0, item_id: 117, link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()), locked: false, diff --git a/crates/benilla-app/src/ui_script/shape_gate.rs b/crates/benilla-app/src/ui_script/shape_gate.rs index dfb172ff..861ce548 100644 --- a/crates/benilla-app/src/ui_script/shape_gate.rs +++ b/crates/benilla-app/src/ui_script/shape_gate.rs @@ -2,15 +2,44 @@ //! registers, against what this client answers. //! //! A 1.12 predicate answers `1` or `nil`, never a boolean (`IsPetAttackActive` aside), so kinds -//! are gated with arity. The table says per row what to trust: `arity_conf = exact`, kinds only -//! where `kinds_conf = agree`, never `argc_conf`. Only query verbs are probed, a call that raises -//! is skipped, and a name registered from several tables (`GetBuildInfo` is 5 in the glue table -//! `0x8373b8`, 3 in-game at `0x83de68`) is read at its non-glue row. The boolean gate alone reads every `global` row, whatever its -//! `arity_conf`, and adds each row whose kinds are `(nil) | (number)` to the query verbs. +//! are gated with arity. The table says per row what to trust: `arity_conf = exact` or `set` (a +//! call is right when its count is a member, so `GetContainerItemInfo`'s `0|5` is measured), +//! kinds only where `kinds_conf = agree`, never `argc_conf`. Every global is probed except those +//! that end the session, each on every rung of [`ARG_LADDER`] (an indexed getter raises with none +//! and answers its miss tuple for `0`, `99` or `-1`); a rung that raises is skipped. A name +//! registered from several tables (`GetBuildInfo` is 5 in the glue table `0x8373b8`, 3 in-game at +//! `0x83de68`) is read at its non-glue row. The boolean gate also adds each row whose kinds are +//! `(nil) | (number)` to the query verbs. /// Globals known to answer the wrong arity: a name, the reference's arity and the reason. The /// list may only shrink. -const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[]; +const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[ + ( + "ClearCursor", + 1, + "Deviation: the handler ends `mov eax,1` with nothing pushed (0x4895bc), handing back the \ + stack top; copying a stack-top leak would copy a bug", + ), + ( + "TaxiNodeSetCurrent", + 1, + "Deviation: the same stack-top return (0x4dcd7c)", + ), +]; + +/// The query verbs the negative-index rule reads: a verb that acts on its index (`PickupAction`) +/// raises on a negative in this client, and the reference's verbs fall out of range quietly too, +/// but those are a separate sweep. +const QUERY_PREFIXES: &[&str] = &["Get", "Is", "Has", "Can", "Unit", "Num"]; + +/// Getters whose negative index raises in the reference too, each with the bytes that say so: a +/// slot reader with a whitelist raises outside it, so `-1` raises where `99` answers. Verified +/// entries only. +const NEGATIVE_RAISES: &[(&str, &str)] = &[( + "IsInventoryItemLocked", + "its slot reader raises \"Invalid inventory slot\" outside {0, 1-23, 40-69, 82-113} (0x4c8520, \ + 0x4c8e7c): 99 is the keyring's slot 98 and answers, -1 raises", +)]; /// The kind gate's list, same rules: a name, the wrong kinds it answers and the reason. An entry /// whose binding comes into agreement fails the gate. @@ -29,7 +58,9 @@ const GLUE_TABLE: &str = "0x8373b8"; struct Row { name: String, table_va: String, - arity: usize, + /// The return counts the reference can push: one for an `exact` row, the set for a `set` row + /// (`0|5`: a miss and a hit that differ). A call is right when its count is a member. + arities: Vec, /// The `kinds` column verbatim, `|`-separated alternatives; empty unless trustworthy. kinds: String, } @@ -38,7 +69,7 @@ fn rows() -> Vec { rows_of_kind("global") } -/// The `arity_conf = exact` rows of one `table_kind`: `global` is the registrar surface, +/// The `arity_conf = exact` or `set` rows of one `table_kind`: `global` is the registrar surface, /// `baselib` the 36-entry Lua base array looped into `_G` at `0x811e28`. fn rows_of_kind(kind: &str) -> Vec { let tsv = concat!( @@ -51,7 +82,11 @@ fn rows_of_kind(kind: &str) -> Vec { .filter_map(|l| { let f: Vec<&str> = l.split('\t').collect(); // name fn pair_va table_va table_kind argc argc_conf arity arity_conf kinds … - if f.len() < 9 || f[4] != kind || f[8] != "exact" { + if f.len() < 9 || f[4] != kind || !(f[8] == "exact" || f[8] == "set") { + return None; + } + let arities: Vec = f[7].split('|').filter_map(|n| n.parse().ok()).collect(); + if arities.is_empty() || (f[8] == "exact" && arities.len() != 1) { return None; } // A `delegated-push` row's callee pushes on its behalf, unseen by the push trace, so @@ -60,7 +95,7 @@ fn rows_of_kind(kind: &str) -> Vec { Some(Row { name: f[0].to_string(), table_va: f[3].to_string(), - arity: f[7].parse().ok()?, + arities, // Kinds are advisory unless `kinds_conf` is `agree`, so such a row carries none. kinds: if f.len() > 10 && f[10] == "agree" && !delegated { f[9].to_string() @@ -103,10 +138,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() { for (name, rs) in &by_name { // Query verbs only: the gate calls what it measures. - if !["Get", "Is", "Has", "Can", "Unit", "Num"] - .iter() - .any(|p| name.starts_with(p)) - { + if NOT_PROBED.contains(name) { continue; } let candidates: Vec<&&Row> = if rs.len() == 1 { @@ -118,47 +150,70 @@ fn every_query_binding_answers_the_reference_s_return_arity() { let Some(first) = candidates.first() else { continue; }; - if candidates.iter().any(|r| r.arity != first.arity) { + if candidates.iter().any(|r| r.arities != first.arities) { continue; } - let want = first.arity; + let want = &first.arities; - // Counted with 5.0's `arg.n` inside the `pcall`: `select` is not a 1.12 global - // (`lua50::install` removes it). - let call = if name.starts_with("Unit") { - format!(r#"{name}("player")"#) - } else { - format!("{name}()") - }; - let probe = format!( - "if type({name}) ~= 'function' then return -1 end \ - local ok, n = pcall(function() return (function(...) return arg.n end)({call}) end) \ - if not ok then return -1 end return n" - ); - let got: i64 = match s.eval(&probe) { - Ok(n) => n, - Err(_) => continue, - }; - if got < 0 { - continue; - } - checked += 1; - if got as usize != want { - if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) { - assert_eq!( - *known, want, - "{name} is on the not-yet-asserted list at a stale arity — the table now says \ - {want}" - ); + // Every rung of the argument ladder that returns is measured: an indexed getter raises + // with none and answers its miss tuple for `0` or `99`, a hit leg is not seeded here. A + // binding that raises on every rung is skipped. Counted with 5.0's `arg.n` inside the + // `pcall`: `select` is not a 1.12 global (`lua50::install` removes it). + let mut measured = false; + // Which rungs returned: an index that falls out of range answers the miss tuple whatever + // its sign, so a getter that answers a positive rung and raises its negative twin reads + // its index in a type that refuses a negative (#1016). + let mut answered: Vec<&str> = Vec::new(); + for args in ARG_LADDER { + let probe = format!( + "if type({name}) ~= 'function' then return -1 end \ + local ok, n = pcall(function() return (function(...) return arg.n end)({name}({args})) end) \ + if not ok then return -1 end return n" + ); + let got: i64 = match s.eval(&probe) { + Ok(n) => n, + Err(_) => continue, + }; + if got < 0 { continue; } - mismatches.push(format!("{name}: answers {got}, reference states {want}")); + answered.push(args); + measured = true; + if !want.contains(&(got as usize)) { + if let Some((_, known, _)) = NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) { + assert!( + want.contains(known), + "{name} is on the not-yet-asserted list at a stale arity — the table now \ + says {want:?}" + ); + continue; + } + mismatches.push(format!( + "{name}({args}): answers {got}, reference states {want:?}" + )); + break; + } + } + if measured { + checked += 1; + } + // Only the first argument: a second-argument index (`GetContainerItemInfo(bag, slot)`) or + // a spell slot (`GetSpellName`, which raises "Invalid spell slot" for one out of range) + // has its own law per binding. + if QUERY_PREFIXES.iter().any(|p| name.starts_with(p)) + && answered.contains(&"99") + && !answered.contains(&"-1") + && !NEGATIVE_RAISES.iter().any(|(n, _)| n == name) + { + mismatches.push(format!( + "{name}(-1) raises while {name}(99) answers: a negative index must fall out of range" + )); } } // A floor, not a target: raise it when coverage rises, never lower it to fit a change. assert!( - checked >= 240, + checked >= 900, "the gate measured only {checked} bindings — it has stopped covering anything" ); // The list may only shrink: an entry that now answers the reference's arity must come off. @@ -167,12 +222,20 @@ fn every_query_binding_answers_the_reference_s_return_arity() { .map(|(n, ..)| *n) .filter(|n| { by_name.get(n).is_some_and(|rs| { - let want = rs[0].arity; - s.eval::(&format!( - "local ok, n = pcall(function() return (function(...) return arg.n end)({n}()) end) \ - if not ok then return -1 end return n" - )) - .is_ok_and(|got| got >= 0 && got as usize == want) + let want = &rs[0].arities; + // Answers the reference's arity on every rung that returns, and on one at least. + let seen: Vec = ARG_LADDER + .iter() + .filter_map(|args| { + s.eval::(&format!( + "local ok, n = pcall(function() return (function(...) return arg.n end)({n}({args})) end) \ + if not ok then return -1 end return n" + )) + .ok() + .filter(|got| *got >= 0) + }) + .collect(); + !seen.is_empty() && seen.iter().all(|got| want.contains(&(*got as usize))) }) }) .collect(); @@ -264,14 +327,17 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() { continue; } checked += 1; - if got as usize != r.arity { + if !r.arities.contains(&(got as usize)) { if let Some((_, known, _)) = BASELIB_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) { - assert_eq!(*known, r.arity, "{name} is listed at a stale arity"); + assert!( + r.arities.contains(known), + "{name} is listed at a stale arity" + ); continue; } mismatches.push(format!( - "{name}: answers {got}, reference states {}", - r.arity + "{name}: answers {got}, reference states {:?}", + r.arities )); continue; } @@ -282,7 +348,7 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() { let Ok(kinds) = s.eval::(&format!( "local t = {{ {name}({args}) }} local out = '' \ for i = 1, {} do out = out .. (i > 1 and ',' or '') .. type(t[i]) end return out", - r.arity + r.arities[0] )) else { continue; }; @@ -456,6 +522,65 @@ fn every_widget_method_answers_the_reference_s_return_arity() { ); } +/// The eight `GameTooltip` setters that answer the builder's return value (`0x5325bb`, `0x532eb4` +/// and kin): one value, `1` or nil, never a boolean and never nothing. `Set*` is outside the +/// query-verb prefixes the widget gates probe, so they are named here and called with arguments +/// that reach their empty and miss legs on a bare VM. +#[test] +fn the_tooltip_flag_setters_answer_one_value_that_is_a_number_or_nil() { + const SETTERS: &[&str] = &[ + "SetAction", + "SetSpell", + "SetShapeshift", + "SetPetAction", + "SetInboxItem", + "SetAuctionItem", + "SetAuctionSellItem", + "SetSendMailItem", + ]; + const TIP_TABLE: &str = "0x854198"; + let tsv = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../reference/1.12-shapes.tsv" + ); + let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv"); + let s = benilla_ui::script::UiScript::new().expect("VM"); + s.run(KIND_HELPER).expect("kind helper"); + s.run("PGTipSetter = CreateFrame('GameTooltip', 'PGTipSetter', UIParent)") + .unwrap(); + // `Set*(slot)`, `SetSpell(slot, bookType)`, `SetAuctionItem(type, index)`, and the bare ones. + const ARGS: &[&str] = &["", "1", "1, 'spell'", "'list', 1", "0", "99", "-1"]; + for name in SETTERS { + let row = text + .lines() + .map(|l| l.split('\t').collect::>()) + .find(|f| f.len() > 9 && f[4] == "widget" && f[3] == TIP_TABLE && f[0] == *name) + .unwrap_or_else(|| panic!("{name} has no GameTooltip row")); + assert_eq!( + (row[7], row[8], row[9]), + ("1", "exact", "(nil) | (number)"), + "{name}: the table row changed under the gate" + ); + let mut measured = 0; + for args in ARGS { + let probe = format!( + "local ok, s = pcall(function() return PGKinds(PGTipSetter:{name}({args})) end) \ + if not ok then return '?' end return s" + ); + let got = s.eval::(&probe).unwrap(); + if got == "?" { + continue; + } + measured += 1; + assert!( + got == "nil" || got == "number", + "{name}({args}) answers ({got}), the reference pushes one nil or number" + ); + } + assert!(measured > 0, "{name} raised on every rung"); + } +} + /// Splits a `kinds` cell into alternatives of per-slot kinds: `(nil) | (number)` is two of one /// slot, and `()` yields none, so the kind gates skip a verb that answers nothing. fn kind_alternatives(cell: &str) -> Vec> { @@ -516,10 +641,7 @@ fn every_query_binding_answers_the_reference_s_return_kinds() { let mut checked = 0usize; let mut mismatches: Vec = Vec::new(); for (name, rs) in &by_name { - if !["Get", "Is", "Has", "Can", "Unit", "Num"] - .iter() - .any(|p| name.starts_with(p)) - { + if NOT_PROBED.contains(name) { continue; } let candidates: Vec<&&Row> = if rs.len() == 1 { @@ -538,59 +660,69 @@ fn every_query_binding_answers_the_reference_s_return_kinds() { continue; } - let call = if name.starts_with("Unit") { - format!(r#"{name}("player")"#) - } else { - format!("{name}()") - }; - let probe = format!( - "if type({name}) ~= 'function' then return '?' end \ - local ok, s = pcall(function() return PGKinds({call}) end) \ - if not ok then return '?' end return s" - ); - let Ok(got) = s.eval::(&probe) else { - continue; - }; - if got == "?" { - continue; - } - let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect(); - // An arity fault is the arity gate's to report. - if !want.iter().any(|alt| alt.len() == got.len()) { - continue; - } - checked += 1; - let agrees = want.iter().any(|alt| { - alt.len() == got.len() - && alt - .iter() - .zip(&got) - .all(|(w, g)| slot_accepts(w.as_str(), g)) - }); - let observed = got.join(","); - if !agrees { - if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) { - assert_eq!( - *known, observed, - "{name}'s not-yet-asserted row is stale: it now answers ({observed})" - ); + // Every rung of the argument ladder that returns is compared, so an indexed getter's miss + // leg is measured with the rest (see the arity gate). + let mut measured = false; + let mut disagreed = false; + for args in ARG_LADDER { + let probe = format!( + "if type({name}) ~= 'function' then return '?' end \ + local ok, s = pcall(function() return PGKinds({name}({args})) end) \ + if not ok then return '?' end return s" + ); + let Ok(got) = s.eval::(&probe) else { + continue; + }; + if got == "?" { continue; } - mismatches.push(format!( - "{name}: answers ({observed}), reference states {}", - first.kinds - )); - } else { - assert!( - !KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name), - "{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED" - ); + let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect(); + // An arity fault is the arity gate's to report. + if !want.iter().any(|alt| alt.len() == got.len()) { + continue; + } + measured = true; + let agrees = want.iter().any(|alt| { + alt.len() == got.len() + && alt + .iter() + .zip(&got) + .all(|(w, g)| slot_accepts(w.as_str(), g)) + }); + let observed = got.join(","); + if !agrees { + disagreed = true; + if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) + { + assert_eq!( + *known, observed, + "{name}'s not-yet-asserted row is stale: it now answers ({observed})" + ); + continue; + } + mismatches.push(format!( + "{name}({args}): answers ({observed}), reference states {}", + first.kinds + )); + break; + } + } + if measured { + checked += 1; + // A listed entry on which no rung disagrees has come into agreement; a rung that + // disagrees and was listed `continue`s without pushing, which is not agreement. + if !disagreed { + assert!( + !KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name), + "{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED" + ); + } } } // A floor, not a target. assert!( - checked >= 150, + checked >= 460, "the kind gate measured only {checked} bindings — it has stopped covering anything" ); assert!( @@ -714,6 +846,11 @@ fn every_widget_method_answers_the_reference_s_return_kinds() { const ARG_LADDER: &[&str] = &[ "", "\"player\"", + "0", + "99", + // A negative index truncates, decrements and compares unsigned, so it falls out of range + // (`0x4f6ed8`) where a getter that reads it as `usize` would raise. + "-1", "1", "2", "3", diff --git a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs index d7ba7536..be78a7cc 100644 --- a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs @@ -119,6 +119,7 @@ fn seed_items(s: &mut UiScript) { texture: Some("Interface\\Icons\\INV_Helmet_02".into()), count: 1, quality: Some(3), + inventory_type: 0, item_id: 2000, link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()), locked: false, diff --git a/crates/benilla-app/src/ui_tooltip/spell_feed.rs b/crates/benilla-app/src/ui_tooltip/spell_feed.rs index 234afacc..2ac68fef 100644 --- a/crates/benilla-app/src/ui_tooltip/spell_feed.rs +++ b/crates/benilla-app/src/ui_tooltip/spell_feed.rs @@ -362,6 +362,7 @@ pub(super) fn build_view( range, cast_time, cooldown, + recovery_ms, requires_item, item_met, requires_form, diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 7b3f3cd6..324be393 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -333,6 +333,7 @@ fn fireball_view_on_real_data() { v.cooldown, None, "Fireball has no recovery in either column" ); + assert_eq!(v.recovery_ms, 0); assert_eq!(v.requires_form, None); assert!( v.description.starts_with("Hurls a fiery ball that causes"), @@ -359,6 +360,7 @@ fn fireball_view_on_real_data() { assert_eq!(v.range.as_deref(), Some("8-25 yd range")); assert_eq!(v.cast_time.as_deref(), Some("Instant")); assert_eq!(v.cooldown.as_deref(), Some("15 sec cooldown")); + assert_eq!(v.recovery_ms, 15_000, "the same column, in ms"); assert_eq!(v.requires_form.as_deref(), Some("Requires Battle Stance")); assert!(!v.form_met, "form 0 (unshifted) does not satisfy the mask"); assert_eq!( @@ -435,12 +437,14 @@ fn improved_fire_blast_shortens_the_cooldown_cell() { let objects = objs.get(); let base = spell_tooltip_view(2136, &spells, &mut t.ctx(&objects, 0, None)).unwrap(); assert_eq!(base.cooldown.as_deref(), Some("8 sec cooldown")); + assert_eq!(base.recovery_ms, 8_000); t.spell_mods.set_class_family(fire_blast.spell_family); t.spell_mods .set(true, bit, crate::spell::OP_COOLDOWN, -1_500); let improved = spell_tooltip_view(2136, &spells, &mut t.ctx(&objects, 0, None)).unwrap(); assert_eq!(improved.cooldown.as_deref(), Some("6.5 sec cooldown")); + assert_eq!(improved.recovery_ms, 6_500, "the modified column, in ms"); } /// The cast cell reads `GetCastTime(1)` (`52eb4b`): op 10 applies and nothing clamps, so a diff --git a/crates/benilla-app/src/ui_trade/mod.rs b/crates/benilla-app/src/ui_trade/mod.rs index 5b6df981..f2ff1163 100644 --- a/crates/benilla-app/src/ui_trade/mod.rs +++ b/crates/benilla-app/src/ui_trade/mod.rs @@ -25,6 +25,9 @@ struct TradeOffer { gold: u32, /// The spell on this side's enchant slot, 0 for none; stored, not shown yet. enchant_spell_id: u32, + /// Our side only: each placed item's `ITEM_FIELD_FLAGS`, read off its object when it was + /// placed. The slot's tooltip passes that live object to the builder (`0x5342ed`). + object_flags: [u32; TRADE_SLOT_COUNT], } /// The trade session [`net`] fills and [`feed_trade`] reads, cleared on a close and on disconnect. @@ -128,12 +131,13 @@ impl TradeSession { /// Place our item in trade slot `id_1based` client-side: vmangos sends a placement to the /// partner only (`TradeData.cpp:81`), never back to the placer. - pub(crate) fn place_own_item(&mut self, id_1based: u32, item: TradeItem) { + pub(crate) fn place_own_item(&mut self, id_1based: u32, item: TradeItem, flags: u32) { if let Some(slot) = id_1based .checked_sub(1) .and_then(|i| self.our.slots.get_mut(i as usize)) { *slot = Some(item); + self.our.object_flags[id_1based as usize - 1] = flags; self.our_offer_changed(); } } @@ -450,9 +454,16 @@ fn resolve_slot( let template = items.template(item.entry, 0, commands); let name = template.map(|t| t.name.clone()); let quality = template.map(|t| t.quality); - let texture = icons - .and_then(|i| i.catalog.get(item.display_id)) - .and_then(|d| d.icon.clone()); + let inventory_type = template.map_or(0, |t| t.inventory_type); + // The icon comes from the template's display, not the wire's (`0x5d88b0([rec+0x18])`, + // `0x4c01f0`; `0x5d8af0` on our side, `0x4c0529`); a cached template whose display has no + // icon falls to the question mark, and an unanswered one leaves it to the getter. + let texture = template.map(|t| { + icons + .and_then(|i| i.catalog.get(t.display_info_id)) + .and_then(|d| d.icon.clone()) + .unwrap_or_else(|| crate::ui_action::MISSING_ITEM_ICON.to_string()) + }); // Nil until the template answers: the link embeds the name and the quality colour. let link = match (name.as_deref(), quality) { (Some(n), Some(q)) => Some(crate::ui_items::item_link(item.entry, n, q)), @@ -464,6 +475,9 @@ fn resolve_slot( texture, count: item.count.max(1), quality, + inventory_type, + lock_id: item.lock_id, + object_flags: None, enchantment: None, link, } @@ -478,42 +492,53 @@ fn own_item_at( objects: &Objects, items: &Items, commands: &NetCommands, -) -> Option { +) -> Option<(TradeItem, u32)> { let (guid, count) = crate::ui_items::slot_guid_count(store, bag, slot, objects); if guid == 0 { return None; } - let entry = objects.object(guid).and_then(|f| f.object_entry())?; + let fields = objects.object(guid)?; + let entry = fields.object_entry()?; + let flags = fields.item_flags().unwrap_or(0); let display_id = items .template(entry, guid, commands) .map(|t| t.display_info_id) .unwrap_or(0); - Some(TradeItem { - entry, - display_id, - count, - wrapped: false, - gift_creator: 0, - perm_enchant: 0, - creator: 0, - charges: 0, - suffix_factor: 0, - random_prop_id: 0, - lock_id: 0, - max_durability: 0, - durability: 0, - }) + Some(( + TradeItem { + entry, + display_id, + count, + wrapped: false, + gift_creator: 0, + perm_enchant: 0, + creator: 0, + charges: 0, + suffix_factor: 0, + random_prop_id: 0, + lock_id: 0, + max_durability: 0, + durability: 0, + }, + flags, + )) } fn resolve_side( offer: &TradeOffer, + ours: bool, items: &Items, icons: Option<&ItemDisplays>, commands: &NetCommands, ) -> TradeSideState { let mut slots: [Option; TRADE_SLOT_COUNT] = Default::default(); for (i, s) in offer.slots.iter().enumerate() { - slots[i] = s.map(|it| resolve_slot(&it, items, icons, commands)); + slots[i] = s.map(|it| { + let mut row = resolve_slot(&it, items, icons, commands); + // Our items are live objects; the partner's are blocks (`0x5342ed`, `0x53456b`). + row.object_flags = ours.then_some(offer.object_flags[i]); + row + }); } TradeSideState { slots, @@ -531,8 +556,8 @@ fn snapshot( return None; } Some(TradeState { - player: resolve_side(&trade.our, items, icons, commands), - target: resolve_side(&trade.their, items, icons, commands), + player: resolve_side(&trade.our, true, items, icons, commands), + target: resolve_side(&trade.their, false, items, icons, commands), }) } @@ -729,8 +754,9 @@ fn drain_trade( }; if let Some(trade_slot) = id.checked_sub(1).and_then(|n| u8::try_from(n).ok()) { // vmangos echoes a placement to the partner only, so our column is filled here. - if let Some(item) = own_item_at(bag, slot, store, &objects, &items, &commands) { - trade.place_own_item(id, item); + if let Some((item, flags)) = own_item_at(bag, slot, store, &objects, &items, &commands) + { + trade.place_own_item(id, item, flags); } info!(target: "trade", "set item: slot {id} <- bag {bag}/{slot} (wire {wire_bag}/{wire_slot}); sending CMSG_SET_TRADE_ITEM"); let _ = commands.0.send(ClientCommand::SetTradeItem { @@ -829,6 +855,37 @@ mod tests { } } + /// The row carries the template's quality and InventoryType, which `GetTradePlayerItemInfo` + /// gates its fourth value on; an unanswered template leaves both empty. + #[test] + fn a_trade_row_carries_the_templates_quality_and_inventory_type() { + let (tx, _rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let mut items = Items::default(); + let mut sword = crate::items::test_template("Sword"); + sword.quality = 4; + sword.inventory_type = 13; + items.insert_template(1234, Some(sword)); + items.insert_template(1235, Some(crate::items::test_template("Linen Cloth"))); + + let row = resolve_slot(&wire_item(1234), &items, None, &commands); + assert_eq!((row.quality, row.inventory_type), (Some(4), 13)); + // The icon is the template's display's, the question mark when that row is missing, and + // left to the getter while the template is unanswered. + assert_eq!( + row.texture.as_deref(), + Some("Interface\\Icons\\INV_Misc_QuestionMark") + ); + assert_eq!( + resolve_slot(&wire_item(1236), &items, None, &commands).texture, + None + ); + let row = resolve_slot(&wire_item(1235), &items, None, &commands); + assert_eq!((row.quality, row.inventory_type), (Some(1), 0)); + let row = resolve_slot(&wire_item(1236), &items, None, &commands); + assert_eq!((row.quality, row.inventory_type), (None, 0)); + } + #[test] fn open_close_state_machine() { let mut s = TradeSession::default(); @@ -950,7 +1007,7 @@ mod tests { s.partner_accepted(); assert_eq!((s.our_accept, s.their_accept), (true, true)); - s.place_own_item(2, wire_item(1234)); + s.place_own_item(2, wire_item(1234), 0); assert!(s.our.slots[1].is_some(), "our slot 2 shows the placed item"); assert_eq!( (s.our_accept, s.their_accept), diff --git a/crates/benilla-app/src/ui_trade/net.rs b/crates/benilla-app/src/ui_trade/net.rs index 9967a9c9..4c7b91d9 100644 --- a/crates/benilla-app/src/ui_trade/net.rs +++ b/crates/benilla-app/src/ui_trade/net.rs @@ -384,7 +384,7 @@ mod tests { { let (mut errors, commands, _rx) = sink(); let mut trade = open_trade(); - trade.place_own_item(2, item()); + trade.place_own_item(2, item(), 0); trade.set_own_gold(700); trade_status( TradeStatus::OnlyConjured { slot }, diff --git a/crates/benilla-app/tests/trade_frame.rs b/crates/benilla-app/tests/trade_frame.rs index 2b7a3492..91ca9b5f 100644 --- a/crates/benilla-app/tests/trade_frame.rs +++ b/crates/benilla-app/tests/trade_frame.rs @@ -40,6 +40,9 @@ fn item(item_id: u32, name: &str, count: u32, quality: u32) -> TradeSlotItem { texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".to_string()), count, quality: Some(quality), + inventory_type: 0, + lock_id: 0, + object_flags: None, enchantment: None, link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")), } diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index 625a3af4..c18c624c 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -9,6 +9,7 @@ use mlua::{Lua, MultiValue, Value}; use super::binding_abi::{bool_or_default, flag}; use super::Model; +use crate::script::binding_abi::Idx32; /// The kind byte, bits 24-31 of the packed slot word (vmangos `Player.h:133`). Must match /// `benilla_protocol`'s `ACTION_KIND_*`; this crate has no protocol dependency. @@ -143,7 +144,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "HasAction", - lua.create_function(|lua, action: u32| { + lua.create_function(|lua, Idx32(action): Idx32| { let model = lua.app_data_ref::().expect("model app_data"); Ok(flag(model.actions.contains_key(&action))) })?, @@ -193,7 +194,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetActionTexture", - lua.create_function(|lua, action: u32| { + lua.create_function(|lua, Idx32(action): Idx32| { let tex = { let model = lua.app_data_ref::().expect("model app_data"); model.actions.get(&action).and_then(|s| s.texture.clone()) @@ -259,7 +260,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetActionCount", - lua.create_function(|lua, action: u32| { + lua.create_function(|lua, Idx32(action): Idx32| { let model = lua.app_data_ref::().expect("model app_data"); Ok(model .actions @@ -300,7 +301,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "IsUsableAction", - lua.create_function(|lua, action: u32| { + lua.create_function(|lua, Idx32(action): Idx32| { Ok(( state_flag(lua, action, |s| s.usable), state_flag(lua, action, |s| s.not_enough_mana), @@ -310,7 +311,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "IsActionInRange", - lua.create_function(|lua, action: u32| { + lua.create_function(|lua, Idx32(action): Idx32| { let model = lua.app_data_ref::().expect("model app_data"); Ok( match model @@ -332,20 +333,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; g.set( "IsCurrentAction", - lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.current)))?, + lua.create_function(|lua, Idx32(action): Idx32| { + Ok(state_flag(lua, action, |s| s.current)) + })?, )?; g.set( "IsAutoRepeatAction", - lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.auto_repeat)))?, + lua.create_function(|lua, Idx32(action): Idx32| { + Ok(state_flag(lua, action, |s| s.auto_repeat)) + })?, )?; g.set( "IsAttackAction", - lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.is_attack)))?, + lua.create_function(|lua, Idx32(action): Idx32| { + Ok(state_flag(lua, action, |s| s.is_attack)) + })?, )?; // From the slot, not the state map: `0x4e5250` depends only on the item template. g.set( "IsConsumableAction", - lua.create_function(|lua, action: u32| { + lua.create_function(|lua, Idx32(action): Idx32| { let model = lua.app_data_ref::().expect("model app_data"); match model.actions.get(&action) { Some(s) if s.consumable => Ok(Value::Integer(1)), @@ -355,7 +362,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; g.set( "IsEquippedAction", - lua.create_function(|lua, action: u32| Ok(state_flag(lua, action, |s| s.equipped)))?, + lua.create_function(|lua, Idx32(action): Idx32| { + Ok(state_flag(lua, action, |s| s.equipped)) + })?, )?; // GetActionCooldown(action) → start, duration, enable, in `GetTime` seconds. An absent or @@ -363,7 +372,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // replay the sweep and its finish flash. g.set( "GetActionCooldown", - lua.create_function(|lua, action: u32| { + lua.create_function(|lua, Idx32(action): Idx32| { let now = crate::script::clock::now(lua); let model = lua.app_data_ref::().expect("model app_data"); Ok( diff --git a/crates/benilla-ui/src/script/auction.rs b/crates/benilla-ui/src/script/auction.rs index 948a9077..a6286a05 100644 --- a/crates/benilla-ui/src/script/auction.rs +++ b/crates/benilla-ui/src/script/auction.rs @@ -10,6 +10,7 @@ use mlua::{Lua, MultiValue, Value}; use super::binding_abi::flag; use super::cursor::{self, CursorPayload}; use super::Model; +use crate::script::binding_abi::{Idx, Idx32}; /// The three list types, in the order the app pushes them. pub const LIST: usize = 0; @@ -320,7 +321,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // GetAuctionItemInfo(type, index) → twelve values; `highBidder` is a name, `1` or nil. g.set( "GetAuctionItemInfo", - lua.create_function(|lua, (kind, index): (String, usize)| { + lua.create_function(|lua, (kind, index): (String, Value)| { + let index = crate::script::binding_abi::index_arg( + lua, + index, + "Usage: GetAuctionItemInfo(\"type\", index)", + )?; let row = { let model = lua.app_data_ref::().expect("model app_data"); row_at(&model, &kind, index) @@ -382,7 +388,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // live countdown: it moves only when a new list lands. g.set( "GetAuctionItemTimeLeft", - lua.create_function(|lua, (kind, index): (String, usize)| { + lua.create_function(|lua, (kind, Idx(index)): (String, Idx)| { let model = lua.app_data_ref::().expect("model app_data"); Ok(row_at(&model, &kind, index).map_or(0, |r| i64::from(r.time_left))) })?, @@ -390,7 +396,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { g.set( "GetAuctionItemLink", - lua.create_function(|lua, (kind, index): (String, usize)| { + lua.create_function(|lua, (kind, index): (String, Value)| { + let index = crate::script::binding_abi::index_arg( + lua, + index, + "Usage: GetAuctionItemLink(\"type\", index)", + )?; let link = { let model = lua.app_data_ref::().expect("model app_data"); row_at(&model, &kind, index).and_then(|r| r.link) @@ -429,7 +440,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // SetSelectedAuctionItem(type, index): remembers the auction at that row; out of range clears. g.set( "SetSelectedAuctionItem", - lua.create_function(|lua, (kind, index): (String, usize)| { + lua.create_function(|lua, (kind, Idx(index)): (String, Idx)| { let mut model = lua.app_data_mut::().expect("model app_data"); let Some(i) = list_index(&kind) else { return Ok(()); @@ -627,17 +638,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // GetOwnerAuctionItems([page]) / GetBidderAuctionItems([page]): unthrottled, page 0 by default. g.set( "GetOwnerAuctionItems", - lua.create_function(|lua, page: Option| { + lua.create_function(|lua, page: Option| { + // A page that is not a number is page 0 (`0x4cecaa`, `0x4cecfa`): the query never + // raises, and `trunc(n)` is the signed page (`0x40a2b0`). + let page = crate::script::binding_abi::coerced_number(lua, page); let mut model = lua.app_data_mut::().expect("model app_data"); - model.auction_owner_query = Some(page.unwrap_or(0)); + model.auction_owner_query = Some(crate::script::binding_abi::ftol(page) as u32); Ok(()) })?, )?; g.set( "GetBidderAuctionItems", - lua.create_function(|lua, page: Option| { + lua.create_function(|lua, page: Option| { + // A page that is not a number is page 0 (`0x4cecaa`, `0x4cecfa`): the query never + // raises, and `trunc(n)` is the signed page (`0x40a2b0`). + let page = crate::script::binding_abi::coerced_number(lua, page); let mut model = lua.app_data_mut::().expect("model app_data"); - model.auction_bidder_query = Some(page.unwrap_or(0)); + model.auction_bidder_query = Some(crate::script::binding_abi::ftol(page) as u32); Ok(()) })?, )?; @@ -676,7 +693,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { // CancelAuction(index), always an "owner" row. g.set( "CancelAuction", - lua.create_function(|lua, index: u32| { + lua.create_function(|lua, Idx32(index): Idx32| { let mut model = lua.app_data_mut::().expect("model app_data"); model.auction_cancels.push(index); Ok(()) @@ -808,6 +825,29 @@ fn click_auction_sell_item(model: &mut Model) { #[cfg(test)] mod tests { + /// The owner and bidder queries (`0x4cec80`, `0x4cecd0`) read `trunc(n)` as the page and take + /// anything else for page 0; they never raise. + #[test] + fn owner_and_bidder_queries_default_to_page_zero_and_never_raise() { + let mut s = crate::script::UiScript::new().unwrap(); + for (call, page) in [ + ("()", 0), + ("(2)", 2), + ("(2.9)", 2), + ("('x')", 0), + ("({})", 0), + ("(nil)", 0), + ("(-1)", u32::MAX), + ] { + s.run(&format!( + "GetOwnerAuctionItems{call} GetBidderAuctionItems{call}" + )) + .unwrap_or_else(|e| panic!("{call} raised: {e}")); + assert_eq!(s.take_auction_owner_query(), Some(page), "owner {call}"); + assert_eq!(s.take_auction_bidder_query(), Some(page), "bidder {call}"); + } + } + use super::*; use crate::script::UiScript; diff --git a/crates/benilla-ui/src/script/aura/mod.rs b/crates/benilla-ui/src/script/aura/mod.rs index 31760129..c6c8ec70 100644 --- a/crates/benilla-ui/src/script/aura/mod.rs +++ b/crates/benilla-ui/src/script/aura/mod.rs @@ -103,7 +103,7 @@ pub(crate) fn auras_of<'m>(model: &'m Model, token: &str) -> mlua::Result