diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index f526a879c..1d649a771 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1597,6 +1597,7 @@ fn seed_cooldown_filmstrip( cooldown: Some(((now_ms - fraction * span_ms) as i64, span_ms as u32, true)), readable: false, creator: None, + gift_creator: None, flags: 0, already_bound: false, occupied_seats: None, @@ -1657,6 +1658,7 @@ fn seed_bag_window( cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, already_bound: false, occupied_seats: None, @@ -1751,6 +1753,7 @@ fn seed_equipped_bags( cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, already_bound: false, occupied_seats: None, diff --git a/crates/benilla-app/src/items.rs b/crates/benilla-app/src/items.rs index a437cd26a..00f41b366 100644 --- a/crates/benilla-app/src/items.rs +++ b/crates/benilla-app/src/items.rs @@ -222,6 +222,22 @@ pub(crate) fn enchant_lines( lines } +/// The name behind a wrapped item's `ITEM_WRAPPED_BY` line: only an item carrying WRAPPED `0x8` +/// takes the gift arm (`0x52b789`, `0x52e1b9`), which reads `ITEM_FIELD_GIFTCREATOR` (`0x52e32a`) +/// and asks the name cache (`0x52e29d`). A zero guid, an unwrapped item or a name not yet +/// answered gives `None`, and only the first two ask nothing. +pub(crate) fn gift_creator_name( + fields: &ObjectFields, + names: &crate::names::NameCache, + commands: &NetCommands, +) -> Option { + if fields.item_flags()? & benilla_protocol::messages::ITEM_DYNFLAG_WRAPPED == 0 { + return None; + } + let giver = fields.item_gift_creator()?; + names.resolve_player(giver, commands).map(str::to_string) +} + /// `0x5da2c0`: already bound when `ITEM_FIELD_FLAGS & 1` is set or a live enchant slot names a /// binding `SpellItemEnchantment` row (`0x5da300` to `0x5da320`). The enchant cursor's bind /// question (gate `0x495d60`) and the tooltip's Soulbound line both read it. It reads the raw @@ -1073,6 +1089,35 @@ mod tests { ObjectFields::from_pairs(&[(21, flags), (22, slot0)]) } + /// The gift arm (`0x52e1b9`) asks the name cache for the gift creator of a wrapped item and no + /// other: an unwrapped item, a zero guid and a name still in flight add no name. + #[test] + fn only_a_wrapped_item_names_its_gift_creator() { + let (tx, rx) = crossbeam_channel::unbounded(); + let cmds = NetCommands(tx); + let mut names = crate::names::NameCache::default(); + let giver = + |flags: u32, guid: u32| ObjectFields::from_pairs(&[(21, flags), (12, guid), (13, 0)]); + // Unwrapped, even with a guid set: no ask, no name. + assert_eq!(gift_creator_name(&giver(0, 5), &names, &cmds), None); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); + // Wrapped, no giver: nothing to ask. + assert_eq!(gift_creator_name(&giver(0x8, 0), &names, &cmds), None); + assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty))); + // Wrapped, the name in flight: asked once, no name yet. + assert_eq!(gift_creator_name(&giver(0x8, 5), &names, &cmds), None); + assert!(matches!( + rx.try_recv(), + Ok(ClientCommand::NameQuery { guid: 5 }) + )); + // Answered. + names.insert_player(5, "Santa".to_string(), None); + assert_eq!( + gift_creator_name(&giver(0x8, 5), &names, &cmds).as_deref(), + Some("Santa") + ); + } + /// `0x5da2c0`: the Firestone shape binds and prints no line, so a predicate read off /// [`enchant_lines`] would call it unbound. #[test] diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 28dd5d5f1..fccf81422 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -547,6 +547,7 @@ fn slot_view( .item_creator() .filter(|&g| g != 0) .and_then(|g| names.resolve_player(g, commands).map(str::to_string)); + let gift_creator = crate::items::gift_creator_name(obj, names, commands); // All seven enchant slots: our own gear streams as item objects, where an inspected player's // descriptor carries only two. let enchants = crate::items::enchant_lines( @@ -625,6 +626,7 @@ fn slot_view( bar_placeable, consumable, creator, + gift_creator, enchants, duration_ms, }) @@ -715,6 +717,7 @@ fn inventory_slots( bar_placeable: false, consumable: false, creator: None, + gift_creator: None, // No ammo instance streams, only its template id: no enchants, no countdown. enchants: Vec::new(), duration_ms: None, diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index ba4f1ea05..45b7b7c7e 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -1786,6 +1786,7 @@ mod bind_confirm_tests { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index ae65b9ffc..2a97ab232 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -412,7 +412,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, locks): CasterReads, + (spell_mods, self_q): CasterReads, skill_lines: Option>, // The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`. home_bind: Option>, @@ -489,7 +489,7 @@ pub(super) fn feed_item_stats( } else { mod_sensitive.remove(&id); } - let mut view = template_view( + let view = template_view( &t, spell_res, Some(&spell_mods), @@ -503,9 +503,6 @@ pub(super) fn feed_item_stats( 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() @@ -515,24 +512,12 @@ 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`). +/// What the `$`-tokens read of the caster. 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. @@ -708,6 +693,7 @@ fn resolve_slot( rolls.enchants, ), ); + let gift_creator = crate::items::gift_creator_name(fields, names, commands); // A container's filled seats, which `ClickTradeButton` counts (`0x4bff50`). let occupied_seats = fields.is_container().then(|| { let n = fields.container_num_slots().unwrap_or(0).min(36) as u8; @@ -732,6 +718,7 @@ fn resolve_slot( count, readable, creator, + gift_creator, flags, already_bound, occupied_seats, @@ -778,6 +765,7 @@ fn resolve_slot( guid, readable, creator, + gift_creator, flags, already_bound, occupied_seats, @@ -1484,23 +1472,6 @@ mod tests { 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); - } - /// The view carries what `GetTradeSkillItemStats` reads beyond the tooltip: the item level, and /// the five spell slots whose id names a `Spell.dbc` row with their charges, in slot order. #[test] diff --git a/crates/benilla-app/src/ui_items/lock_openers.rs b/crates/benilla-app/src/ui_items/lock_openers.rs index 196434bb0..e3782100a 100644 --- a/crates/benilla-app/src/ui_items/lock_openers.rs +++ b/crates/benilla-app/src/ui_items/lock_openers.rs @@ -1,6 +1,6 @@ -//! The inputs of the item tooltip's lock check (`0x5da5e0`): `Lock.dbc` pushed whole once, and what -//! the active player opens locks with, pushed on change. The walk and the colour are the engine's -//! ([`benilla_ui::script::check_lock`]). +//! The inputs of the item tooltip's lock check (`0x5da5e0`): `Lock.dbc` and `LockType.dbc`'s names +//! pushed whole once, and what the active player opens locks with, pushed on change. The walk and +//! the colour are the engine's ([`benilla_ui::script::check_lock`]). use std::collections::{BTreeSet, HashMap, HashSet}; @@ -11,10 +11,12 @@ use benilla_ui::script::{LockOpener, LockOpeners, LockSlotView, UiScript}; use crate::ui_script::gate; -/// Pushes every `Lock.dbc` row once per VM, empty slots dropped: the walk skips a type 0 slot. +/// Pushes every `Lock.dbc` row and `LockType.dbc` name once per VM. Both load together; without +/// `LockType.dbc` every lock kind reads `UNKNOWN`, as a missing row does. pub(super) fn feed_lock_rows( script: Option>, locks: Option>, + lock_types: Option>, mut pushed: Local>, ) { let Some(mut script) = script else { @@ -28,18 +30,24 @@ pub(super) fn feed_lock_rows( }; let rows = row_views(&locks.0); script.set_lock_rows(rows); + script.set_lock_type_names( + lock_types + .iter() + .flat_map(|t| t.0.names()) + .map(|(id, name)| (id, name.to_string())) + .collect(), + ); *pushed.get(&script) = true; } -/// Every row as the walk reads it, empty slots dropped (a type 0 slot is skipped) and the rest in -/// slot order. +/// Every row as the reference holds it, all eight slots in order: the walk skips a type 0 slot, +/// and the line under `LOCKED` reads the first slot's type whatever it is (`0x52be5d`). fn row_views(catalog: &LockCatalog) -> HashMap> { catalog .rows() .map(|(id, slots)| { let slots = slots .iter() - .filter(|s| s.key_type != benilla_formats::LOCK_KEY_NONE) .map(|s| LockSlotView { key_type: s.key_type, index: s.index, @@ -181,7 +189,7 @@ mod tests { } #[test] - fn a_rows_empty_slots_are_dropped_and_the_rest_keep_their_order() { + fn a_row_keeps_all_its_slots_in_place() { let mut slots = [LockSlot::default(); MAX_LOCK_SLOTS]; slots[1] = LockSlot { key_type: 1, @@ -199,21 +207,28 @@ mod tests { (7, slots), (8, [LockSlot::default(); MAX_LOCK_SLOTS]), ])); + assert_eq!(rows[&7].len(), MAX_LOCK_SLOTS); + assert_eq!(rows[&7][0], LockSlotView::default(), "slot 0 stays empty"); assert_eq!( - rows[&7], - [ - LockSlotView { - key_type: 1, - index: 5060, - skill: 0 - }, - LockSlotView { - key_type: 2, - index: 1, - skill: 25 - } - ] + rows[&7][1], + LockSlotView { + key_type: 1, + index: 5060, + skill: 0 + } + ); + assert_eq!( + rows[&7][3], + LockSlotView { + key_type: 2, + index: 1, + skill: 25 + } + ); + assert_eq!( + rows[&8].len(), + MAX_LOCK_SLOTS, + "a row of empty slots stays a row" ); - assert!(rows[&8].is_empty(), "a row of empty slots stays a row"); } } 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 141e85a4e..161ddf6f7 100644 --- a/crates/benilla-app/src/ui_script/action_bar_tests.rs +++ b/crates/benilla-app/src/ui_script/action_bar_tests.rs @@ -1229,6 +1229,7 @@ fn shipped_bag_frame_drives_end_to_end() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index 1c9d188e9..f3e19e0d6 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -407,6 +407,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -492,6 +493,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -607,6 +609,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -637,6 +640,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -832,6 +836,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -926,6 +931,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -1070,6 +1076,7 @@ pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -1482,6 +1489,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() { guid: 0x70d, equip_slots: vec![20], creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs index e64469835..eb8068a4a 100644 --- a/crates/benilla-app/src/ui_script/character_tests.rs +++ b/crates/benilla-app/src/ui_script/character_tests.rs @@ -72,6 +72,7 @@ fn inventory_with_head_item() -> InventorySlots { guid: 0x70e, equip_slots: vec![1], creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); @@ -106,6 +107,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index 651a7c1b3..98a29d513 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -404,6 +404,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: 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 1f971a44a..04cb8dbfd 100644 --- a/crates/benilla-app/src/ui_script/delete_item_tests.rs +++ b/crates/benilla-app/src/ui_script/delete_item_tests.rs @@ -36,6 +36,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, diff --git a/crates/benilla-app/src/ui_script/escape_tests.rs b/crates/benilla-app/src/ui_script/escape_tests.rs index 526a373fa..306124a03 100644 --- a/crates/benilla-app/src/ui_script/escape_tests.rs +++ b/crates/benilla-app/src/ui_script/escape_tests.rs @@ -34,6 +34,7 @@ fn one_item_backpack() -> ContainerState { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -283,6 +284,7 @@ fn escape_closes_an_open_stack_split_frame() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: 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 b3a0d5755..c5219dd56 100644 --- a/crates/benilla-app/src/ui_script/game_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs @@ -716,6 +716,7 @@ fn backpack() -> ContainerState { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, 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 b78547fdb..496fdd636 100644 --- a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs @@ -84,6 +84,7 @@ fn seed_items(s: &mut UiScript) { guid: 0x715, equip_slots: vec![1], creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); @@ -139,6 +140,7 @@ fn seed_items(s: &mut UiScript) { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -214,6 +216,7 @@ fn doll_hover_renders_the_live_instance() { guid: 0x717, equip_slots: vec![1], creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); diff --git a/crates/benilla-app/src/ui_trade/mod.rs b/crates/benilla-app/src/ui_trade/mod.rs index eda3f2a30..db0b97ed6 100644 --- a/crates/benilla-app/src/ui_trade/mod.rs +++ b/crates/benilla-app/src/ui_trade/mod.rs @@ -590,6 +590,7 @@ fn resolve_slot( item: &TradeItem, items: &Items, icons: Option<&ItemDisplays>, + names: &NameCache, commands: &NetCommands, ) -> TradeSlotItem { let template = items.template(item.entry, 0, commands); @@ -610,6 +611,12 @@ fn resolve_slot( (Some(n), Some(q)) => Some(crate::ui_items::item_link(item.entry, n, q)), _ => None, }; + // Only a wrapped gift asks for its giver, and a zero guid asks nothing (`0x52e288`, + // `0x52e29d`). + let gift_creator = (item.wrapped && item.gift_creator != 0) + .then(|| names.resolve_player(item.gift_creator, commands)) + .flatten() + .map(str::to_string); TradeSlotItem { item_id: item.entry, name, @@ -619,6 +626,8 @@ fn resolve_slot( inventory_type, lock_id: item.lock_id, object_flags: None, + wrapped: item.wrapped, + gift_creator, enchantment: None, link, } @@ -652,6 +661,9 @@ fn own_item( let fields = objects.object(guid)?; let entry = fields.object_entry()?; let flags = fields.item_flags().unwrap_or(0); + // The placed item's WRAPPED flag and giver, which its slot's tooltip reads off the object + // (`0x52b789`, `0x52e32a`). + let wrapped = flags & benilla_protocol::messages::ITEM_DYNFLAG_WRAPPED != 0; let display_id = items .template(entry, guid, commands) .map(|t| t.display_info_id) @@ -661,8 +673,8 @@ fn own_item( entry, display_id, count, - wrapped: false, - gift_creator: 0, + wrapped, + gift_creator: fields.item_gift_creator().unwrap_or(0), perm_enchant: 0, creator: 0, charges: 0, @@ -687,12 +699,13 @@ fn resolve_side( enchant: Option<&str>, items: &Items, icons: Option<&ItemDisplays>, + names: &NameCache, 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| { - let mut row = resolve_slot(&it, items, icons, commands); + let mut row = resolve_slot(&it, items, icons, names, commands); // Our items are live objects; the partner's are blocks (`0x5342ed`, `0x53456b`). row.object_flags = ours.then_some(offer.object_flags[i]); if i == ENCHANT_SLOT { @@ -712,6 +725,7 @@ fn snapshot( items: &Items, icons: Option<&ItemDisplays>, spells: Option<&crate::ui_action::Spells>, + names: &NameCache, commands: &NetCommands, ) -> Option { if !trade.open { @@ -732,6 +746,7 @@ fn snapshot( name(trade.their.enchant_spell_id), items, icons, + names, commands, ), // Our window carries the spell we cast on theirs (`0x4c02b7`, side 0). @@ -741,6 +756,7 @@ fn snapshot( name(trade.our.enchant_spell_id), items, icons, + names, commands, ), }) @@ -789,6 +805,7 @@ fn resignal_trade_items( objects: Objects, items: Res, icons: Option>, + names: Res, ) { use crate::item_watch::TradeTouch; let touches: Vec = cursor @@ -839,7 +856,7 @@ fn resignal_trade_items( slot: wire_slot, }, ); - let mut row = resolve_slot(&item, &items, icons.as_deref(), &commands); + let mut row = resolve_slot(&item, &items, icons.as_deref(), &names, &commands); row.object_flags = Some(flags); script.replace_trade_slot(slot, row); } @@ -889,6 +906,7 @@ fn feed_trade( &items, icons.as_deref(), spells.as_deref(), + &names, &commands, ); // A close is the core's, which shut the VM's window and left both offers in place; a @@ -1249,7 +1267,13 @@ mod tests { 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); + let row = resolve_slot( + &wire_item(1234), + &items, + None, + &NameCache::default(), + &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. @@ -1258,15 +1282,85 @@ mod tests { Some("Interface\\Icons\\INV_Misc_QuestionMark") ); assert_eq!( - resolve_slot(&wire_item(1236), &items, None, &commands).texture, + resolve_slot( + &wire_item(1236), + &items, + None, + &NameCache::default(), + &commands + ) + .texture, None ); - let row = resolve_slot(&wire_item(1235), &items, None, &commands); + let row = resolve_slot( + &wire_item(1235), + &items, + None, + &NameCache::default(), + &commands, + ); assert_eq!((row.quality, row.inventory_type), (Some(1), 0)); - let row = resolve_slot(&wire_item(1236), &items, None, &commands); + let row = resolve_slot( + &wire_item(1236), + &items, + None, + &NameCache::default(), + &commands, + ); assert_eq!((row.quality, row.inventory_type), (None, 0)); } + /// A wrapped gift's giver rides both columns: the partner's block carries the wire's wrapped + /// word and gift creator (`0x534559`, `0x534574`), our placed item's object the same; only a + /// wrapped item asks for the name, a zero guid asks nothing, and a name in flight is `None`. + #[test] + fn a_wrapped_gifts_giver_reaches_both_columns() { + let (tx, rx) = crossbeam_channel::unbounded(); + let commands = NetCommands(tx); + let mut items = Items::default(); + items.insert_template(1234, Some(crate::items::test_template("Gift"))); + let mut names = NameCache::default(); + names.insert_player(0x7, "Santa".to_string(), None); + let gift = |wrapped, giver| TradeItem { + wrapped, + gift_creator: giver, + ..wire_item(1234) + }; + let mut s = TradeSession::default(); + s.begin(0x9); + s.open_window(); + s.place_own_item(1, gift(true, 0x7), 0x8); + s.place_own_item(2, gift(false, 0x7), 0); + s.place_own_item(3, gift(true, 0), 0x8); + s.place_own_item(4, gift(true, 0x8), 0x8); + s.their.slots[0] = Some(gift(true, 0x7)); + s.their.slots[1] = Some(gift(false, 0x7)); + let state = snapshot(&s, &items, None, None, &names, &commands).expect("open"); + let giver = |side: &TradeSideState, i: usize| { + let row = side.slots[i].as_ref().expect("filled"); + (row.wrapped, row.gift_creator.clone()) + }; + assert_eq!(giver(&state.target, 0), (true, Some("Santa".into()))); + assert_eq!( + giver(&state.target, 1), + (false, None), + "unwrapped asks nothing" + ); + assert_eq!(giver(&state.player, 0), (true, Some("Santa".into()))); + assert_eq!(giver(&state.player, 1), (false, None)); + assert_eq!( + giver(&state.player, 2), + (true, None), + "a zero guid names no one" + ); + assert_eq!(giver(&state.player, 3), (true, None), "a name in flight"); + let asked: Vec<_> = rx.try_iter().collect(); + assert!( + matches!(asked.as_slice(), [ClientCommand::NameQuery { guid: 0x8 }]), + "only the wrapped, nonzero, uncached giver is asked: {asked:?}" + ); + } + #[test] fn open_close_state_machine() { let mut s = TradeSession::default(); @@ -2900,6 +2994,48 @@ mod tests { assert!(seen(&mut app).is_empty()); } + /// The placed item is a live object: its WRAPPED flag and `ITEM_FIELD_GIFTCREATOR` reach the + /// slot when the watchers place it again (`0x52b789`, `0x52e32a`), the giver by name. + #[test] + fn a_wrapped_offer_placed_again_carries_its_giver() { + use benilla_protocol::field::FIELD_ITEM_STACK_COUNT; + use benilla_protocol::ObjectFields; + let (mut app, _rx) = resignal_app(); + app.world_mut() + .resource_mut::() + .insert_player(0x77, "Santa".to_string(), None); + let linen = app.world().resource::().0[&LINEN]; + // ITEM_FIELD_GIFTCREATOR is the guid at fields 12 and 13, ITEM_FIELD_FLAGS field 21. + app.world_mut() + .get_mut::(linen) + .unwrap() + .0 + .merge(ObjectFields::from_pairs(&[ + (FIELD_ITEM_STACK_COUNT, 2), + (12, 0x77), + (13, 0), + (21, 0x8), + ])); + notify( + &mut app, + crate::item_watch::ItemNotice::StackCount { + guid: LINEN, + entry: 2589, + }, + ); + let slots = app + .world() + .non_send_resource::() + .trade_player_slots() + .expect("open trade"); + let row = slots[0].as_ref().expect("the slot is filled"); + assert_eq!(row.object_flags, Some(0x8)); + assert_eq!( + (row.wrapped, row.gift_creator.as_deref()), + (true, Some("Santa")) + ); + } + /// `0x4bfb60` from the stack and enchantment watchers: the same item placed again from its /// position, the slot showing the live count, and one signal; a placement of an item that /// is no longer an object sends and signals nothing (`0x5d43da`). diff --git a/crates/benilla-app/tests/trade_frame.rs b/crates/benilla-app/tests/trade_frame.rs index 9098a9a94..c8eaa4b49 100644 --- a/crates/benilla-app/tests/trade_frame.rs +++ b/crates/benilla-app/tests/trade_frame.rs @@ -43,6 +43,8 @@ fn item(item_id: u32, name: &str, count: u32, quality: u32) -> TradeSlotItem { inventory_type: 0, lock_id: 0, object_flags: None, + wrapped: false, + gift_creator: None, enchantment: None, link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")), } diff --git a/crates/benilla-formats/src/dbc.rs b/crates/benilla-formats/src/dbc.rs index ad4b024d3..70b661e8a 100644 --- a/crates/benilla-formats/src/dbc.rs +++ b/crates/benilla-formats/src/dbc.rs @@ -62,13 +62,23 @@ pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option { /// The column's raw dword is the offset, as the client reads it, whatever the schema typed it: the /// other locales' columns are often left untyped. pub(crate) fn loc_str(rs: &RecordSet, r: &Record, first: usize, locale: Locale) -> Option { + loc_str_raw(rs, r, first, locale).filter(|s| !s.is_empty()) +} + +/// [`loc_str`] for a reader that tells an empty name from no column: `Some("")` is a name the +/// file leaves blank, `None` a column or offset that cannot be read. +pub(crate) fn loc_str_raw( + rs: &RecordSet, + r: &Record, + first: usize, + locale: Locale, +) -> Option { let off = match r.get_value(first + locale.index())? { Value::StringRef(StringRef(off)) | Value::UInt32(off) => *off, Value::Int32(off) => *off as u32, Value::Float32(f) => f.to_bits(), }; - let s = rs.get_string(StringRef(off)).ok()?; - (!s.is_empty()).then(|| s.to_string()) + rs.get_string(StringRef(off)).ok().map(|s| s.to_string()) } /// `SpellIcon.dbc`: id → extensionless texture path (`Interface\Icons\…`). diff --git a/crates/benilla-formats/src/lock_type.rs b/crates/benilla-formats/src/lock_type.rs index 92deb79fd..46a5eafe5 100644 --- a/crates/benilla-formats/src/lock_type.rs +++ b/crates/benilla-formats/src/lock_type.rs @@ -10,7 +10,7 @@ use crate::Chain; use anyhow::{Context, Result}; use benilla_dbc::{FieldType, Schema, SchemaField}; -use crate::dbc::{loc_str, parse, str_at, u32_at}; +use crate::dbc::{loc_str_raw, parse, str_at, u32_at}; const LOCK_TYPE: &str = "DBFilesClient\\LockType.dbc"; /// The file's column count, which `benilla-dbc` checks against the header. @@ -34,9 +34,19 @@ impl LockTypeCatalog { } /// A lock kind's `Name` ("Herbalism"), filled into "Requires %s" (`0x5f34f9`); the caller - /// supplies the reference's `"UNKNOWN"` for a missing row. + /// supplies the reference's `"UNKNOWN"` for a missing row, and a blank name is none here. pub fn name(&self, lock_type_id: u32) -> Option<&str> { - self.names.get(&lock_type_id).map(String::as_str) + self.names + .get(&lock_type_id) + .map(String::as_str) + .filter(|n| !n.is_empty()) + } + + /// Every row's `Name`, by `LockType` index, a blank one included: the item tooltip answers + /// `UNKNOWN` only for a row that is missing (`0x52bede`..`0x52bef2`), else the name as it is, + /// blank too (`0x52bef4`). + pub fn names(&self) -> impl Iterator { + self.names.iter().map(|(&id, name)| (id, name.as_str())) } pub fn len(&self) -> usize { @@ -67,8 +77,11 @@ pub fn load_lock_type_catalog(chain: &mut Chain) -> Result { let bytes = chain .read_file(LOCK_TYPE) .with_context(|| format!("reading {LOCK_TYPE}"))?; - let rs = parse(&bytes, schema(), "LockType.dbc")?; - let locale = chain.locale(); + catalog_from_bytes(&bytes, chain.locale()) +} + +fn catalog_from_bytes(bytes: &[u8], locale: crate::Locale) -> Result { + let rs = parse(bytes, schema(), "LockType.dbc")?; let mut cursors = HashMap::new(); let mut names = HashMap::new(); for r in rs.records() { @@ -76,7 +89,7 @@ pub fn load_lock_type_catalog(chain: &mut Chain) -> Result { if let Some(name) = str_at(&rs, r, CURSOR_NAME_FIELD) { cursors.insert(id, name); } - if let Some(name) = loc_str(&rs, r, NAME_FIELD, locale) { + if let Some(name) = loc_str_raw(&rs, r, NAME_FIELD, locale) { names.insert(id, name); } } @@ -87,6 +100,45 @@ pub fn load_lock_type_catalog(chain: &mut Chain) -> Result { mod tests { use super::*; + /// Two WDBC rows of the shipped shape: type 1 named, type 2 with a blank name column. + fn synthetic() -> Vec { + let strings = b"\0Pick Lock\0".to_vec(); + let mut bytes = b"WDBC".to_vec(); + for w in [ + 2, + LOCK_TYPE_FIELDS as u32, + LOCK_TYPE_FIELDS as u32 * 4, + strings.len() as u32, + ] { + bytes.extend_from_slice(&w.to_le_bytes()); + } + for (id, name_at) in [(1u32, 1u32), (2, 0)] { + let mut row = vec![0u32; LOCK_TYPE_FIELDS]; + row[0] = id; + row[NAME_FIELD] = name_at; + for f in row { + bytes.extend_from_slice(&f.to_le_bytes()); + } + } + bytes.extend_from_slice(&strings); + bytes + } + + /// A blank `Name` is a row with no word, not a missing row: the item tooltip answers `UNKNOWN` + /// only for a missing row (`0x52bede`..`0x52bef2`) and the empty string for a blank one + /// (`0x52bef4`), so [`LockTypeCatalog::names`] lists it; the toast's [`LockTypeCatalog::name`] + /// still reads it as none. + #[test] + fn a_blank_name_is_a_row_not_a_missing_one() { + let locale = crate::Locale::from_index(0).unwrap(); + let cat = catalog_from_bytes(&synthetic(), locale).unwrap(); + let mut names: Vec<_> = cat.names().collect(); + names.sort(); + assert_eq!(names, vec![(1, "Pick Lock"), (2, "")]); + assert_eq!(cat.name(1), Some("Pick Lock")); + assert_eq!(cat.name(2), None); + } + /// The 5875 rows: a column slip lands on another column and fails. #[test] fn real_lock_types_name_their_cursors() { diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index 74955e36b..fbbc4c5cd 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -36,6 +36,11 @@ impl ObjectFields { pub fn item_creator(&self) -> Option { self.get_guid(10).filter(|&g| g != 0) } + /// `ITEM_FIELD_GIFTCREATOR` (field 12, `[item+0x114]+0x18` at `0x52e32a`): who wrapped the + /// item, read only while it carries the WRAPPED flag. + pub fn item_gift_creator(&self) -> Option { + self.get_guid(12).filter(|&g| g != 0) + } /// `ITEM_FIELD_ITEM_TEXT_ID` (field 45, owner only): a mailed letter copy's text; nonzero /// makes a right-click read it (`CMSG_ITEM_TEXT_QUERY`) instead of using the item. pub fn item_text_id(&self) -> Option { diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index bc9ae2a38..ca079aa28 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -213,6 +213,8 @@ pub struct InvSlotView { pub equip_slots: Vec, /// The resolved `ITEM_FIELD_CREATOR` name, the tooltip's "" line. pub creator: Option, + /// The resolved `ITEM_FIELD_GIFTCREATOR` name, the tooltip's `ITEM_WRAPPED_BY` line. + pub gift_creator: Option, /// The resolved enchant slots in slot order. An inspected player's record has all 7 too, but /// vmangos fills only PERM and TEMP (`Player.cpp:10594-10595`). pub enchants: Vec, @@ -538,6 +540,7 @@ impl InvSlotView { link: slot.link.clone(), guid: slot.guid, creator: slot.creator.clone(), + gift_creator: slot.gift_creator.clone(), enchants: slot.enchants.clone(), // The item's own flags (UNLOCKED, WRAPPED) and expiry, which the tooltip builder reads. flags: slot.flags, diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 41f1c33f4..0967f80a7 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -72,6 +72,9 @@ pub struct ContainerSlot { pub readable: bool, /// The `ITEM_FIELD_CREATOR` name for the "Written by" or "" line, once resolved. pub creator: Option, + /// The `ITEM_FIELD_GIFTCREATOR` name for a wrapped item's `ITEM_WRAPPED_BY` line, once + /// resolved; only a wrapped item asks for it, as the gift arm does (`0x52e29d`). + pub gift_creator: Option, /// `ITEM_FIELD_FLAGS`; the tooltip reads UNLOCKED `0x4` and WRAPPED `0x8`. pub flags: u32, /// Bound at runtime (`0x5da2c0`): `ITEM_FIELD_FLAGS & 1`, or an enchant whose @@ -1059,6 +1062,7 @@ mod tests { spell_0: false, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), }, @@ -1757,6 +1761,7 @@ mod tests { spell_0: false, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), }, diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 484f6f7f3..380eb9254 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -1030,6 +1030,7 @@ mod tests { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index ee1f364ed..25450f885 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -375,6 +375,7 @@ mod tests { guid: 0x10c, equip_slots: vec![1], creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); @@ -399,6 +400,7 @@ mod tests { guid: 0x10d, equip_slots: vec![11, 12], // either finger: the doll-to-doll swap creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); @@ -423,6 +425,7 @@ mod tests { guid: 0x10e, equip_slots: vec![19], creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); @@ -456,6 +459,7 @@ mod tests { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -615,6 +619,7 @@ mod tests { guid: 0x110, equip_slots: vec![20, 21, 22, 23], creator: None, + gift_creator: None, enchants: Vec::new(), spell_0: false, }); @@ -660,6 +665,7 @@ mod tests { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, diff --git a/crates/benilla-ui/src/script/lock_check.rs b/crates/benilla-ui/src/script/lock_check.rs index 69775614a..e579c92c2 100644 --- a/crates/benilla-ui/src/script/lock_check.rs +++ b/crates/benilla-ui/src/script/lock_check.rs @@ -1,8 +1,10 @@ //! The item tooltip's lock check, the reference's `0x5da5e0`: whether the active player can open -//! an item's lock, and by what. It colours the `LOCKED` line (`0x52be0c`..`0x52be3d`). +//! an item's lock, and by what. It colours the `LOCKED` line (`0x52be0c`..`0x52be3d`) and decides +//! the `LOCKED_WITH_SPELL_KNOWN` line under it (`0x52be98`). //! -//! The engine reads no DBCs, so the app pushes the `Lock.dbc` rows ([`LockSlotView`]) once and the -//! player's means of opening ([`LockOpeners`]) on change; the walk is here. +//! The engine reads no DBCs, so the app pushes the `Lock.dbc` rows ([`LockSlotView`]) once, the +//! `LockType.dbc` names once and the player's means of opening ([`LockOpeners`]) on change; the +//! walk is here. use std::collections::HashSet; @@ -10,7 +12,8 @@ use super::UiScript; /// One `Lock.dbc` slot as `0x5da5e0` reads it: the key type at `lock+0x4`, the index at `+0x24` and /// the skill at `+0x44`, each 8 wide. Its action column (`+0x64`) is not read: unlike the game -/// object walk (`0x5f83d0`), an item's lock has no state to gate a slot on. +/// object walk (`0x5f83d0`), an item's lock has no state to gate a slot on. A row keeps its empty +/// slots, since the sub-line under `LOCKED` reads the first slot's type whatever it is (`0x52be5d`). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct LockSlotView { /// 0 empty, 1 a key item, 2 a skill. @@ -54,60 +57,115 @@ pub enum LockCheck { Open, } +/// What `0x5da5e0` reports through its out parameters and return value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(in crate::script) struct LockWalk { + /// The return value is nonzero (`0x5da64e`, `0x5da725`): the lock has a key or skill slot and + /// none opened it. + pub closed: bool, + /// A held key opened it: the walk set the item out parameter (`0x5da78f`). + pub key_held: bool, + /// The spell out parameters (`*a1` nonzero, `*a2` points, `*a3` required, `0x5da6cc`.. + /// `0x5da6f4`) of the last known open-lock spell the walk tried on a skill slot of its lock + /// type, whether or not its skill reached the requirement. A held key overwrites `*a1` with + /// its use spell (`0x5da781`; `0x5d8c80` returns one when the key has one), so this clears: + /// the colour call ignores `*a1` once `*a4` is set (`0x52be1c`), and no `Lock.dbc` row has a + /// key slot after a skill slot, so the second walk never loses a spell to a key. + pub spell: Option, +} + +/// One open-lock spell tried against one skill slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::script) struct SpellTry { + /// The slot's requirement. + pub required: i32, + /// The spell's skill for the player. + pub points: i32, +} + /// `0x5da5e0(lock, level)`. `level` is the item record's `+0x38`, the fallback behind a slot whose -/// skill is 0. `openers` is `None` with no active player, which opens everything. +/// skill is 0. `openers` is `None` with no active player, which opens everything and tells +/// nothing (`0x5da60b`). /// /// Walks the slots in order: a skill slot (type 2) scans the known spells for an open-lock effect /// whose misc value is its index and whose points reach its requirement (`0x5da6f8 jge`); a key /// slot (type 1) looks for the item. The first to succeed ends the walk. A slot of any other type /// is skipped. A type 1 or 2 slot makes the lock closed unless one succeeds (`0x5da64e`, /// `0x5da725`). -pub fn check_lock(slots: &[LockSlotView], level: u32, openers: Option<&LockOpeners>) -> LockCheck { +pub(in crate::script) fn walk_lock( + slots: &[LockSlotView], + level: u32, + openers: Option<&LockOpeners>, +) -> LockWalk { let Some(openers) = openers else { - return LockCheck::Open; + return LockWalk::default(); }; - let mut closed = false; + let mut walk = LockWalk::default(); for slot in slots { match slot.key_type { 2 => { - closed = true; + walk.closed = true; let required = if slot.skill != 0 { slot.skill as i32 } else { level.wrapping_mul(5) as i32 }; for spell in &openers.spells { - if spell.lock_type == slot.index && spell.points >= required { - return LockCheck::Skill { + if spell.lock_type == slot.index { + walk.spell = Some(SpellTry { required, points: spell.points, - }; + }); + if spell.points >= required { + walk.closed = false; + return walk; + } } } } 1 => { - closed = true; + walk.closed = true; if openers.held.contains(&slot.index) { - return LockCheck::Open; + return LockWalk { + closed: false, + key_held: true, + spell: None, + }; } } _ => {} } } - if closed { - LockCheck::Closed - } else { - LockCheck::Open + walk +} + +/// `0x5da5e0` read the way the `LOCKED` line's colour reads it (`0x52be11`..`0x52be3d`). +pub fn check_lock(slots: &[LockSlotView], level: u32, openers: Option<&LockOpeners>) -> LockCheck { + let walk = walk_lock(slots, level, openers); + match (walk.closed, walk.key_held, walk.spell) { + (true, _, _) => LockCheck::Closed, + (false, false, Some(SpellTry { required, points })) => { + LockCheck::Skill { required, points } + } + _ => LockCheck::Open, } } impl UiScript { /// Pushes `Lock.dbc` whole, by lock id, once at load: a clicked tooltip cannot repaint on a late - /// answer. The reference reads its loaded store (`0xc0dae4`). + /// answer. The reference reads its loaded store (`0xc0dae4`), and a lock id without a row is no + /// lock to the item builder (`0x52bd74`). pub fn set_lock_rows(&mut self, rows: std::collections::HashMap>) { self.model_mut().lock_rows = rows; } + /// Pushes `LockType.dbc`'s names, by index, once at load: the `LOCKED_WITH_SPELL_KNOWN` line + /// names the lock kind off the store `0xc0dad0` (`0x52bef4`); an index without a row reads + /// `UNKNOWN` (`0x52bf00`). + pub fn set_lock_type_names(&mut self, names: std::collections::HashMap) { + self.model_mut().lock_type_names = names; + } + /// Pushes what the active player opens locks with, on change. Never pushed, there is no active /// player and every lock reads as open. pub fn set_lock_openers(&mut self, openers: LockOpeners) { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index ca0e4dc27..04e168c5b 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -993,9 +993,11 @@ pub(crate) struct Model { pub(crate) player_req: PlayerReqState, /// `Lock.dbc` by lock id, pushed whole at startup, and what the active player opens locks with /// (`None` before the first push: no active player). The item tooltip's `LOCKED` colour walks - /// them ([`super::lock_check::check_lock`]). + /// them ([`super::lock_check::check_lock`]); `LockType.dbc`'s names by index name its + /// `LOCKED_WITH_SPELL_KNOWN` line. pub(crate) lock_rows: HashMap>, pub(crate) lock_openers: Option, + pub(crate) lock_type_names: HashMap, /// Spell id to its tooltip view, and the misses asked for. pub(crate) spell_tooltips: HashMap, pub(crate) spell_tooltip_asks: HashSet, @@ -1684,6 +1686,7 @@ impl Model { player_req: PlayerReqState::default(), lock_rows: HashMap::new(), lock_openers: None, + lock_type_names: HashMap::new(), spell_tooltips: HashMap::new(), spell_tooltip_asks: HashSet::new(), pet_spell_tooltips: HashMap::new(), diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index fd12e4aea..8cd7d30d7 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -86,6 +86,7 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() { cooldown: None, readable: false, creator: None, + gift_creator: None, flags: 0, enchants: Vec::new(), spell_0: false, @@ -223,6 +224,7 @@ fn set_inventory_item_name_only_is_the_trimmed_build() { ..Default::default() }); s.set_inventory_slots(inv); + s.set_lock_rows(HashMap::from([(7, Vec::new())])); s.set_item_template( 8100, ItemTemplateView { @@ -790,6 +792,7 @@ fn locked_is_drawn_for_a_locked_object_and_sets_the_builder_flag() { slots: HashMap::from([(1, slot(0)), (2, slot(0x4))]), }), ); + s.set_lock_rows(HashMap::from([(7, Vec::new())])); s.set_item_template( 8300, ItemTemplateView { @@ -854,6 +857,7 @@ fn bank_vault_items_keep_their_flags_and_expiry() { ]), }), ); + s.set_lock_rows(HashMap::from([(7, Vec::new())])); s.set_item_template( 8300, ItemTemplateView { diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs index 8d1e4dbe5..c55304a27 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/law.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/law.rs @@ -580,6 +580,7 @@ fn instance_tail_creator_and_readable() { ..Default::default() }, ); + s.set_lock_rows(HashMap::from([(7, Vec::new())])); s.set_item_template( 2589, ItemTemplateView { @@ -690,6 +691,187 @@ fn instance_tail_creator_and_readable() { assert!(s.take_errors().is_empty()); } +/// A wrapped item (`ITEM_FIELD_FLAGS & 0x8`, `0x52b789`) takes the gift arm (`0x52e1b9`): it names +/// its giver off `ITEM_FIELD_GIFTCREATOR` in white (`0x52e2cb`) and never prints the creator's +/// line. A giver not yet resolved adds nothing, and an unwrapped item ignores the field. +#[test] +fn a_wrapped_item_names_its_giver_and_hides_the_creator_line() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(r#"ITEM_WRAPPED_BY = "[WRAPPED_BY %s]""#).unwrap(); + s.set_item_template( + 5042, + ItemTemplateView { + name: "Gift".into(), + quality: 1, + ..Default::default() + }, + ); + let slot = |flags: u32, creator: Option<&str>, gift: Option<&str>| ContainerSlot { + item_id: 5042, + count: 1, + quality: Some(1), + creator: creator.map(str::to_string), + gift_creator: gift.map(str::to_string), + flags, + ..Default::default() + }; + let mut slots = HashMap::new(); + slots.insert(1, slot(0x8, Some("Geoffrey"), Some("Santa"))); // wrapped, giver known + slots.insert(2, slot(0x8, Some("Geoffrey"), None)); // wrapped, giver zero or in flight + slots.insert(3, slot(0, Some("Geoffrey"), Some("Santa"))); // unwrapped keeps its creator + s.set_container( + 0, + Some(ContainerState { + name: Some("Backpack".into()), + num_slots: 3, + slots, + }), + ); + s.run( + r#" + local a = CreateFrame("Button", "SlotG"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) + CreateFrame("GameTooltip", "TT", nil, "TestTooltipTemplate") + TT:SetOwner(a, "ANCHOR_RIGHT"); TT:SetBagItem(0, 1) + "#, + ) + .unwrap(); + let lines = lines_of(&mut s); + let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(texts, vec!["Gift", "[WRAPPED_BY Santa]"]); + assert_eq!(lines[1].1, [1.0; 4], "ITEM_WRAPPED_BY is white"); + for (n, want) in [ + (2, vec!["Gift"]), + (3, vec!["Gift", "|cff00ff00[CREATED_BY Geoffrey]|r"]), + ] { + s.run(&format!( + r#"TT:SetOwner(getglobal("SlotG"), "ANCHOR_RIGHT"); TT:SetBagItem(0, {n})"# + )) + .unwrap(); + let texts: Vec = lines_of(&mut s).into_iter().map(|(t, _)| t).collect(); + assert_eq!(texts, want, "slot {n}"); + } + + // A worn wrapped item reaches the same arm through the doll's view. + let mut inv: InventorySlots = Default::default(); + inv[16] = Some(InvSlotView { + item_id: 5042, + name: Some("Gift".into()), + quality: 1, + flags: 0x8, + creator: Some("Geoffrey".into()), + gift_creator: Some("Santa".into()), + ..Default::default() + }); + s.set_inventory_slots(inv); + s.run(r#"TT:SetOwner(getglobal("SlotG"), "ANCHOR_RIGHT"); TT:SetInventoryItem("player", 16)"#) + .unwrap(); + let texts: Vec = lines_of(&mut s).into_iter().map(|(t, _)| t).collect(); + assert_eq!(texts, vec!["Gift", "[WRAPPED_BY Santa]"]); + assert!(s.take_errors().is_empty()); +} + +/// A wrapped gift in a trade names its giver on both sides. The player's slot is a live item +/// object: its WRAPPED flag takes the gift arm (`0x52b789`) and the object's `ITEM_FIELD_GIFTCREATOR` +/// names the giver, whether the slot came from a click or from the app. The partner's is a block: +/// its wrapped word (`0x534559`) takes the same arm and its gift creator (`0x534574`) names the +/// giver. A block whose wrapped word is clear ignores the giver, and an unresolved one adds nothing. +#[test] +fn a_wrapped_gift_in_trade_names_its_giver_on_both_sides() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(r#"ITEM_WRAPPED_BY = "[WRAPPED_BY %s]" ITEM_CREATED_BY = "[CREATED_BY %s]""#) + .unwrap(); + s.set_item_template( + 5042, + ItemTemplateView { + name: "Gift".into(), + quality: 1, + ..Default::default() + }, + ); + s.run( + r#" + local a = CreateFrame("Button", "SlotT"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) + CreateFrame("GameTooltip", "TT", nil, "TestTooltipTemplate") + "#, + ) + .unwrap(); + let row = |object_flags: Option, wrapped: bool, gift: Option<&str>| TradeSlotItem { + item_id: 5042, + name: Some("Gift".into()), + count: 1, + quality: Some(1), + object_flags, + wrapped, + gift_creator: gift.map(str::to_string), + ..Default::default() + }; + let mut st = TradeState::default(); + st.player.slots[0] = Some(row(Some(0x8), true, Some("Santa"))); + st.player.slots[1] = Some(row(Some(0), false, Some("Santa"))); + st.target.slots[0] = Some(row(None, true, Some("Santa"))); + st.target.slots[1] = Some(row(None, false, Some("Santa"))); + st.target.slots[2] = Some(row(None, true, None)); + s.set_trade(Some(st)); + let texts = |s: &mut UiScript, call: &str| -> Vec { + s.run(&format!("TT:SetOwner(SlotT, 'ANCHOR_RIGHT') {call}")) + .unwrap(); + lines_of(s).into_iter().map(|(t, _)| t).collect() + }; + let gift = vec!["Gift".to_string(), "[WRAPPED_BY Santa]".to_string()]; + assert_eq!(texts(&mut s, "TT:SetTradePlayerItem(1)"), gift, "player"); + assert_eq!(texts(&mut s, "TT:SetTradeTargetItem(1)"), gift, "target"); + assert_eq!( + texts(&mut s, "TT:SetTradePlayerItem(2)"), + vec!["Gift"], + "unwrapped" + ); + assert_eq!( + texts(&mut s, "TT:SetTradeTargetItem(2)"), + vec!["Gift"], + "unwrapped block" + ); + assert_eq!( + texts(&mut s, "TT:SetTradeTargetItem(3)"), + vec!["Gift"], + "giver unresolved" + ); + + // The player's click takes the giver from the bag record it places. + s.open_trade_window(); + s.set_trade(Some(TradeState::default())); + s.set_container( + 0, + Some(ContainerState { + name: Some("Backpack".into()), + num_slots: 1, + slots: HashMap::from([( + 1, + ContainerSlot { + item_id: 5042, + guid: 0x5042, + count: 1, + quality: Some(1), + link: Some("|cffffffff|Hitem:5042:0:0:0|h[Gift]|h|r".into()), + creator: Some("Geoffrey".into()), + gift_creator: Some("Santa".into()), + flags: 0x8, + ..Default::default() + }, + )]), + }), + ); + s.run("PickupContainerItem(0, 1) ClickTradeButton(1)") + .unwrap(); + assert_eq!( + texts(&mut s, "TT:SetTradePlayerItem(1)"), + gift, + "placed by a click" + ); + assert!(s.take_errors().is_empty()); +} + /// Bonding `[record+0x194]` in 1..5 decides whether a bind line prints; a runtime-bound instance /// (`0x5da2c0`: the soulbound flag, or a live enchant slot that binds) prints `ITEM_SOULBOUND`, /// or `ITEM_BIND_QUEST` for the quest kinds; else the jump table `0x52e4fc` picks 1 picked up, diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/lock.rs b/crates/benilla-ui/src/script/tests/tooltip_item/lock.rs index 394c79f0c..092dd2fa4 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/lock.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/lock.rs @@ -37,7 +37,21 @@ fn key_slot(entry: u32) -> LockSlotView { /// A locked box (item level 20) in the backpack, whose lock is `slots`, with the player's means /// of opening it `openers`. fn fixture(slots: Vec, openers: Option) -> UiScript { + fixture_rows(LOCK_ID, vec![(LOCK_ID, slots)], openers) +} + +/// [`fixture`] with the box's template naming lock `template_lock` and the store holding `rows`. +fn fixture_rows( + template_lock: u32, + rows: Vec<(u32, Vec)>, + openers: Option, +) -> UiScript { let mut s = item_script(); + s.run( + r#"LOCKED_WITH_ITEM = "[LOCKED_WITH_ITEM %s]" + LOCKED_WITH_SPELL_KNOWN = "[LOCKED_WITH_SPELL_KNOWN %s]""#, + ) + .unwrap(); s.set_screen_size(800.0, 600.0); s.run( r#" @@ -53,7 +67,7 @@ fn fixture(slots: Vec, openers: Option) -> UiScript { name: "Lockbox".into(), quality: 1, item_level: 20, - lock_id: LOCK_ID, + lock_id: template_lock, ..Default::default() }, ); @@ -74,7 +88,8 @@ fn fixture(slots: Vec, openers: Option) -> UiScript { )]), }), ); - s.set_lock_rows(HashMap::from([(LOCK_ID, slots)])); + s.set_lock_rows(rows.into_iter().collect()); + s.set_lock_type_names(HashMap::from([(1, "Pick Lock".to_string())])); if let Some(o) = openers { s.set_lock_openers(o); } @@ -175,3 +190,262 @@ fn the_first_slot_to_open_the_lock_decides_the_colour() { let mut s = fixture(vec![skill_slot(1, 25), key_slot(KEY)], Some(strong)); assert_eq!(locked_color(&mut s), ORANGE, "the skill is first"); } + +/// The tooltip's lines under a bag hover. +fn bag_lines(s: &mut UiScript) -> Vec<(String, [f32; 4])> { + s.run("TT:SetOwner(Slot, 'ANCHOR_RIGHT') TT:SetBagItem(0, 1)") + .unwrap(); + lines_of(s) +} + +fn line<'a>(lines: &'a [(String, [f32; 4])], text: &str) -> Option<&'a (String, [f32; 4])> { + lines.iter().find(|(t, _)| t == text) +} + +fn key_template(s: &mut UiScript) { + s.set_item_template( + KEY, + ItemTemplateView { + name: "Skeleton Key".into(), + quality: 1, + ..Default::default() + }, + ); +} + +/// A key slot (`0x52bf30`) names the key from the item cache in white (`0x52bf93`), under LOCKED, +/// whether or not the player holds it. +#[test] +fn a_key_lock_names_its_key_in_white_under_locked() { + for held in [false, true] { + let mut openers = LockOpeners::default(); + if held { + openers.held.insert(KEY); + } + let mut s = fixture(vec![key_slot(KEY)], Some(openers)); + key_template(&mut s); + let lines = bag_lines(&mut s); + let at = |t: &str| lines.iter().position(|(l, _)| l == t); + assert_eq!( + at("[LOCKED_WITH_ITEM Skeleton Key]"), + at("[LOCKED]").map(|i| i + 1), + "{lines:?}" + ); + assert_eq!( + line(&lines, "[LOCKED_WITH_ITEM Skeleton Key]").unwrap().1, + [1.0; 4] + ); + } +} + +/// A key the item cache lacks adds nothing (`0x52bf67`) and is asked for, so the hover's repaint +/// draws it on arrival. +#[test] +fn a_key_the_cache_lacks_adds_no_line_and_is_asked_for() { + let mut s = fixture(vec![key_slot(KEY)], Some(LockOpeners::default())); + let lines = bag_lines(&mut s); + assert!(line(&lines, "[LOCKED]").is_some()); + assert!( + lines.iter().all(|(t, _)| !t.starts_with("[LOCKED_WITH")), + "{lines:?}" + ); + assert!(s.take_item_stat_asks().contains(&KEY)); +} + +/// A skill slot names the lock type, in the ramp colour of the spell the walk tried last +/// (`0x52beae`), the same colour `LOCKED` takes when the spell reaches the requirement. +#[test] +fn a_skill_lock_names_its_lock_type_in_the_ramp_colour() { + for (points, want) in [(25, ORANGE), (50, YELLOW), (75, LOCK_GREEN), (125, GRAY)] { + let mut s = fixture(vec![skill_slot(1, 25)], Some(picker(points))); + let lines = bag_lines(&mut s); + let sub = line(&lines, "[LOCKED_WITH_SPELL_KNOWN Pick Lock]") + .unwrap_or_else(|| panic!("no sub-line at {points}: {lines:?}")); + assert_eq!(sub.1, want, "{points} points"); + assert_eq!(line(&lines, "[LOCKED]").unwrap().1, want); + } +} + +/// The line shows for any known opener of the lock type, even one whose skill falls short +/// (`0x5da6cc` records it before the compare): red, under a red `LOCKED`. +#[test] +fn a_known_opener_below_the_skill_still_names_the_lock_type_in_red() { + let mut s = fixture(vec![skill_slot(1, 25)], Some(picker(24))); + let lines = bag_lines(&mut s); + assert_eq!(line(&lines, "[LOCKED]").unwrap().1, RED); + assert_eq!( + line(&lines, "[LOCKED_WITH_SPELL_KNOWN Pick Lock]") + .unwrap() + .1, + RED + ); +} + +/// No known spell of the lock type, or no active player (the walk returns before it reads a +/// slot, `0x5da60b`), adds nothing (`0x52bea2`). +#[test] +fn a_skill_lock_with_no_known_opener_adds_no_line() { + for openers in [ + Some(LockOpeners::default()), + // A herbalism spell against a lockpicking lock. + Some(LockOpeners { + spells: vec![LockOpener { + lock_type: 2, + points: 300, + }], + held: HashSet::new(), + }), + None, + ] { + let mut s = fixture(vec![skill_slot(1, 25)], openers.clone()); + let lines = bag_lines(&mut s); + assert!(line(&lines, "[LOCKED]").is_some()); + assert!( + lines.iter().all(|(t, _)| !t.starts_with("[LOCKED_WITH")), + "{openers:?}: {lines:?}" + ); + } +} + +/// A lock type the store lacks reads `UNKNOWN` (`0x52bf00`); the line is the first slot's alone, +/// so a lock whose first slot is empty adds none (`0x52be5d`). +#[test] +fn the_sub_line_reads_the_first_slot_and_names_a_missing_type_unknown() { + let mut s = fixture( + vec![skill_slot(9, 25)], + Some(LockOpeners { + spells: vec![LockOpener { + lock_type: 9, + points: 25, + }], + held: HashSet::new(), + }), + ); + let lines = bag_lines(&mut s); + assert!( + line(&lines, "[LOCKED_WITH_SPELL_KNOWN UNKNOWN]").is_some(), + "{lines:?}" + ); + let mut s = fixture( + vec![LockSlotView::default(), skill_slot(1, 25)], + Some(picker(300)), + ); + let lines = bag_lines(&mut s); + assert!(line(&lines, "[LOCKED]").is_some()); + assert!( + lines.iter().all(|(t, _)| !t.starts_with("[LOCKED_WITH")), + "{lines:?}" + ); +} + +/// A record whose lock id has no row falls back to the block's row (`0x52bd76`): the trade +/// target's lock names the row, and `LOCKED` and its sub-line follow that row. With no row in +/// either, there is no lock to draw (`0x52bda9`). +#[test] +fn a_missing_record_row_falls_back_to_the_blocks_row() { + const MISSING: u32 = 99; + let mut s = fixture_rows( + MISSING, + vec![(LOCK_ID, vec![skill_slot(1, 25)])], + Some(picker(25)), + ); + let trade = |lock_id: u32| { + let mut st = TradeState::default(); + st.target.slots[0] = Some(TradeSlotItem { + item_id: LOCKBOX, + name: Some("Lockbox".into()), + count: 1, + lock_id, + ..Default::default() + }); + st + }; + s.set_trade(Some(trade(LOCK_ID))); + s.run("TT:SetOwner(Slot, 'ANCHOR_RIGHT') TT:SetTradeTargetItem(1)") + .unwrap(); + let lines = lines_of(&mut s); + assert_eq!(line(&lines, "[LOCKED]").unwrap().1, ORANGE, "{lines:?}"); + assert!(line(&lines, "[LOCKED_WITH_SPELL_KNOWN Pick Lock]").is_some()); + + // Neither row exists: no LOCKED, no sub-line. + s.set_trade(Some(trade(MISSING))); + s.run("TT:SetOwner(Slot, 'ANCHOR_RIGHT') TT:SetTradeTargetItem(1)") + .unwrap(); + let lines = lines_of(&mut s); + assert!(line(&lines, "[LOCKED]").is_none(), "{lines:?}"); + + // A bag item (no block) whose record names a missing row is not locked either. + let lines = bag_lines(&mut s); + assert!(line(&lines, "[LOCKED]").is_none(), "{lines:?}"); +} + +/// A lock type whose name is blank is a row, not a missing one: only a missing row reads `UNKNOWN` +/// (`0x52bede`..`0x52bef2`), a blank name fills the line as it is (`0x52bef4`). +#[test] +fn a_blank_lock_type_name_fills_the_line_and_is_not_unknown() { + let mut s = fixture(vec![skill_slot(5, 25)], Some(picker_for(5, 25))); + s.set_lock_type_names(HashMap::from([(5, String::new())])); + let lines = bag_lines(&mut s); + assert!( + line(&lines, "[LOCKED_WITH_SPELL_KNOWN ]").is_some(), + "{lines:?}" + ); + assert!(line(&lines, "[LOCKED_WITH_SPELL_KNOWN UNKNOWN]").is_none()); +} + +fn picker_for(lock_type: u32, points: i32) -> LockOpeners { + LockOpeners { + spells: vec![LockOpener { lock_type, points }], + held: HashSet::new(), + } +} + +/// OPENABLE reads the record's raw lock id: a nonzero one needs the object UNLOCKED (`0x52e2fc`), +/// with no row check, so a lock id without a Lock row (no LOCKED line to draw) still withholds it. +#[test] +fn openable_waits_for_unlocked_on_a_lock_id_with_no_row() { + const MISSING: u32 = 99; + let mut s = fixture_rows(MISSING, Vec::new(), None); + s.set_item_template( + LOCKBOX, + ItemTemplateView { + name: "Lockbox".into(), + quality: 1, + flags: 0x4, + lock_id: MISSING, + ..Default::default() + }, + ); + let slot = |flags| ContainerSlot { + item_id: LOCKBOX, + count: 1, + quality: Some(1), + flags, + ..Default::default() + }; + let hover = |s: &mut UiScript, flags| { + s.set_container( + 0, + Some(ContainerState { + name: Some("Backpack".into()), + num_slots: 16, + slots: HashMap::from([(1, slot(flags))]), + }), + ); + bag_lines(s) + }; + let lines = hover(&mut s, 0); + assert!( + line(&lines, "[LOCKED]").is_none(), + "no row, no lock: {lines:?}" + ); + assert!( + line(&lines, "[ITEM_OPENABLE]").is_none(), + "locked by id, not unlocked: {lines:?}" + ); + let lines = hover(&mut s, 0x4); + assert!( + line(&lines, "[ITEM_OPENABLE]").is_some(), + "UNLOCKED: {lines:?}" + ); +} diff --git a/crates/benilla-ui/src/script/tests/tooltip_setter_flags.rs b/crates/benilla-ui/src/script/tests/tooltip_setter_flags.rs index f75cc128a..65f4e6e0f 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_setter_flags.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_setter_flags.rs @@ -339,6 +339,10 @@ fn live_objects_and_blocks_reach_the_flag() { let mut s = fixture(); s.run(r#"ITEM_DURATION_HOURS = "[DURATION %d h]" LOCKED = "[LOCKED]""#) .unwrap(); + s.set_lock_rows(std::collections::HashMap::from([ + (7, Vec::new()), + (9, Vec::new()), + ])); s.set_item_template( 8300, ItemTemplateView { diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index 36f4dac66..21457cd7c 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -148,6 +148,7 @@ fn slot_instance(s: &crate::script::ContainerSlot, openable_source: bool) -> ren name: s.link.as_deref().and_then(link_name).map(str::to_string), durability: s.durability, creator: s.creator.clone(), + gift_creator: s.gift_creator.clone(), has_text: s.readable, flags: s.flags, already_bound: s.already_bound, @@ -157,6 +158,7 @@ fn slot_instance(s: &crate::script::ContainerSlot, openable_source: bool) -> ren duration_ms: s.duration_ms, object: true, block_lock_id: 0, + block_wrapped: false, cooldown_ms: 0, } } @@ -182,6 +184,7 @@ pub(super) fn owned_instance(model: &Model, item_id: u32) -> Option mlua::Result<()> { name: s.name.clone(), durability: s.durability, creator: s.creator.clone(), + gift_creator: s.gift_creator.clone(), has_text: false, flags: s.flags, already_bound: s.already_bound, @@ -465,6 +469,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { // inspected unit's item is a block (`0x532ee0`). object: unit.eq_ignore_ascii_case("player"), block_lock_id: 0, + block_wrapped: false, // The player's own item answers the cooldown query // (`0x5331ec`), unless it is charged and spent // (`0x53318a`-`0x53319b`, the feed's `None`); an inspected @@ -657,10 +662,13 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { clear(lua, h); let inst = render::ItemInstance { name: item.name.clone(), - // The target side's block carries the partner's lock id (`0x534563`); the - // player's is zero (`0x534341`), but its slot is a live item object, whose - // flags the LOCKED gate reads (`0x5342ed`, `0x5343da`). + // The target side's block carries the partner's lock id (`0x534563`), wrapped + // word (`0x534559`) and gift creator (`0x534574`); the player's lock id is + // zero (`0x534341`), but its slot is a live item object, whose flags the + // LOCKED and wrapped gates read (`0x5342ed`, `0x5343da`, `0x52b789`). block_lock_id: if player_side { 0 } else { item.lock_id }, + block_wrapped: !player_side && item.wrapped, + gift_creator: item.gift_creator.clone(), object: item.object_flags.is_some(), flags: item.object_flags.unwrap_or(0), ..Default::default() diff --git a/crates/benilla-ui/src/script/tooltip_item/render.rs b/crates/benilla-ui/src/script/tooltip_item/render.rs index bbce5df22..64729ae65 100644 --- a/crates/benilla-ui/src/script/tooltip_item/render.rs +++ b/crates/benilla-ui/src/script/tooltip_item/render.rs @@ -25,6 +25,9 @@ pub(in crate::script) struct ItemInstance { /// The resolved `ITEM_FIELD_CREATOR` name (the reference's `0x55f080`); `None` while it is /// unresolved, which prints no line either (`0x52e209`). pub creator: Option, + /// The resolved `ITEM_FIELD_GIFTCREATOR` name (`0x55f080` at `0x52e29d`) of a wrapped item; + /// `None` while it is unresolved (`0x52e2a4`) or the field is 0 (`0x52e288`): no line either way. + pub gift_creator: Option, /// `ITEM_FIELD_ITEM_TEXT_ID` ≠ 0, a letter's copy: the creator line reads WRITTEN_BY /// (`0x52e223`) and READABLE shows (`0x52e348`). pub has_text: bool, @@ -54,6 +57,10 @@ pub(in crate::script) struct ItemInstance { /// none or no block. With no lock row from the template it names one (`0x52bd78`..`0x52bda9`), /// and a nonzero one draws LOCKED even without an object (`0x52bdd2`). pub block_lock_id: u32, + /// The block's wrapped word (`[this+0x42c]`, set by the trade target's setter, `0x534559`): it + /// takes the gift arm and the no-id-source fork like an object's WRAPPED flag (`0x52b789`, + /// `0x52c991`). + pub block_wrapped: bool, /// The remaining use cooldown in ms (`[this+0x408]`, `start + duration - now`), 0 for none. Only /// the three setters that query the item's cooldown fill it, `SetBagItem` (`0x53488c`), /// `SetInventoryItem` (`0x533226`) and `SetAction`'s item leg (`0x5324d5`), and each does so @@ -61,6 +68,13 @@ pub(in crate::script) struct ItemInstance { pub cooldown_ms: u32, } +impl ItemInstance { + /// The wrapped test the builder runs once: the object's WRAPPED flag, or the block's word. + fn wrapped(&self) -> bool { + self.flags & 0x8 != 0 || self.block_wrapped + } +} + /// The set block's blank gold line, the reference's literal `0x854b2c`: a space and a newline. An /// empty string would be no row, as `AddLine` (`0x530270`) drops an empty left text uncounted /// (`0x5302a9`). `" \n"` is one row: the stepper `0x5c7470` consumes the break with the space @@ -86,8 +100,7 @@ pub(super) struct BuilderFlags { /// (`0x52ce0d`). The /// setters push it as 1 or nil; the stock buttons arm `updateTooltip` from it. The LOCKED line /// sets it on every lock path (`0x52bfa6`, after `0x52be58`, whether or not a `LOCKED_WITH_*` -/// sub-line follows), as ITEM_COOLDOWN_TIME does (`0x52e140`). Not built: ITEM_WRAPPED_BY and the -/// `LOCKED_WITH_*` sub-line. +/// sub-line follows), as ITEM_COOLDOWN_TIME does (`0x52e140`). pub(super) fn render_view_flagged( lua: &Lua, this: &Table, @@ -199,22 +212,17 @@ pub(super) fn render_view_flagged( if v.start_quest != 0 { keyed("ITEM_STARTS_QUEST", &[], WHITE, false)?; } - // LOCKED (`0x52bdde`..`0x52be58`), for a lock row the template or the block names - // (`0x52bd57`..`0x52bda9`) and a source that passes the gate (`0x52bdb3`..`0x52bdd8`): an - // item object lacking UNLOCKED `0x4`, or a block with a lock id of its own. A template - // source has neither, so it draws no LOCKED. Every lock path then sets the builder's - // return value (`0x52bfa6`). - let block_lock = inst.map_or(0, |i| i.block_lock_id); - let has_row = v.lock_id != 0 || block_lock != 0; - let passes = block_lock != 0 || inst.is_some_and(|i| i.object && i.flags & 0x4 == 0); - if has_row && passes { - // The record's row first, else the block's (`0x52bd57`, `0x52bd78`). - let row = if v.lock_id != 0 { - v.lock_id - } else { - block_lock - }; - keyed("LOCKED", &[], lock_color(lua, row, v.item_level), false)?; + // LOCKED (`0x52bdde`..`0x52be58`) and the line under it. The lock row is the record's own, + // else the block's (`0x52bd57`..`0x52bda9`): a row the store lacks falls through to the + // block, and with neither there is no lock. The source must pass the gate (`0x52bdb3`.. + // `0x52bdd8`): an item object lacking UNLOCKED `0x4`, or a block with a lock id of its + // own. A template source has neither, so it draws no LOCKED. Every lock path then sets + // the builder's return value (`0x52bfa6`). + if let Some(lock) = lock_lines(lua, v, inst) { + keyed("LOCKED", &[], lock.color, false)?; + if let Some(sub) = &lock.sub { + keyed(sub.key, &[Arg::S(&sub.arg)], sub.color, false)?; + } live = true; } } @@ -428,7 +436,7 @@ pub(super) fn render_view_flagged( // No id source (`0x52c991`): a wrapped gift, or no item object and no instance block // (`+0x440 == 0`), here a hover passing no [`ItemInstance`]. The fork tests the block's // presence, not its contents (`0x52c9a3`), so a block source never shows the placeholder. - let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0); + let no_id_source = inst.is_none_or(ItemInstance::wrapped); if no_id_source && !signable && v.random_property != 0 { keyed("ITEM_RANDOM_ENCHANT", &[], GREEN, false)?; } @@ -662,19 +670,23 @@ pub(super) fn render_view_flagged( // source (`None`) stops, so a link, loot, mail or auction hover, which has an instance but no // object, still reaches READABLE. let Some(inst) = inst else { return Ok(live) }; - // The creator line (`0x52e1b1..0x52e2db`), white `0xc0cf60`: ITEM_WRITTEN_BY for a letter, else - // ITEM_CREATED_BY, green by its own `|cff00ff00`. A wrapped instance (`0x8`, gate `0x52b7b0`) - // takes ITEM_WRAPPED_BY off `ITEM_FIELD_GIFTCREATOR` in the reference; that is not built, so a - // wrapped instance prints no creator line. - if inst.flags & 0x8 == 0 { - if let Some(name) = &inst.creator { - let key = if inst.has_text { - "ITEM_WRITTEN_BY" - } else { - "ITEM_CREATED_BY" - }; - keyed(key, &[Arg::S(name)], WHITE, false)?; + // The creator line (`0x52e1b1..0x52e2db`), white `0xc0cf60`. The arm is picked once, at + // `0x52b78f`..`0x52b7b0`: an item object with WRAPPED `0x8` (`0x52b789`) takes the gift arm, + // and so does a block whose `+0x42c` is set (the trade target's). The gift arm adds + // ITEM_WRAPPED_BY off `ITEM_FIELD_GIFTCREATOR` (`0x52e32a`) or the block's `+0x418`, and never + // the creator's line. Any other source adds + // ITEM_WRITTEN_BY for a letter, else ITEM_CREATED_BY, green by its own `|cff00ff00`. + if inst.wrapped() { + if let Some(name) = &inst.gift_creator { + keyed("ITEM_WRAPPED_BY", &[Arg::S(name)], WHITE, false)?; } + } else if let Some(name) = &inst.creator { + let key = if inst.has_text { + "ITEM_WRITTEN_BY" + } else { + "ITEM_CREATED_BY" + }; + keyed(key, &[Arg::S(name)], WHITE, false)?; } // One green line, OPENABLE winning (`0x52e2f2..0x52e35d`). Openable: a p6=0 source and either // the loot flag `0x4` behind its lock gate (a lock needs UNLOCKED) or a wrapped gift (`0x200` @@ -691,21 +703,87 @@ pub(super) fn render_view_flagged( Ok(live) } -/// The colour of `LOCKED` for lock row `row` of an item of `item_level` (`0x52bdde`..`0x52be3d`): -/// red unless the lock check `0x5da5e0` finds an opener, then the margin ramp `0x529fa0` for a -/// known spell, else the green `0xc0d420` for a held key or a lock with no key or skill slot. A -/// row the app never pushed (no `Lock.dbc`) has nothing to open it, so it stays red. -fn lock_color(lua: &Lua, row: u32, item_level: u32) -> [f32; 4] { +/// What the lock block draws: `LOCKED`'s colour and the line under it. +struct LockLines { + color: [f32; 4], + sub: Option, +} + +/// The line under `LOCKED`, `key` filled with `arg`. +struct LockSubLine { + key: &'static str, + arg: String, + color: [f32; 4], +} + +/// The lock block of the item builder (`0x52bd57`..`0x52bfa6`), `None` when it draws nothing. +/// +/// `LOCKED`'s colour is red unless the lock check `0x5da5e0` finds an opener, then the margin ramp +/// `0x529fa0` for a known spell, else the green `0xc0d420` for a held key or a lock with no key or +/// skill slot (`0x52bdde`..`0x52be3d`). The first slot's type then picks the line under it +/// (`0x52be5d`): +/// - a key (1) names the key item from the item cache, in white, and nothing on a miss +/// (`0x52bf30`..`0x52bfa1`); a miss asks for the template, and the line appears on a redraw, +/// which benilla runs only where stock re-runs `OnEnter` (a bag hover does, the trade +/// target's does not); +/// - a skill (2) walks the lock a second time (`0x52be98`) and names the lock type in the ramp's +/// colour for the last open-lock spell it tried, even one whose skill falls short; no known +/// opener of the type adds nothing (`0x52bea2`); +/// - anything else adds nothing. +fn lock_lines(lua: &Lua, v: &ItemTemplateView, inst: Option<&ItemInstance>) -> Option { + use crate::script::lock_check::walk_lock; use crate::script::{check_lock, LockCheck}; - let model = lua.app_data_ref::().expect("model app_data"); - let Some(slots) = model.lock_rows.get(&row) else { - return RED; + let mut guard = lua.app_data_mut::().expect("model app_data"); + let model = &mut *guard; + let block_lock = inst.map_or(0, |i| i.block_lock_id); + let row = if model.lock_rows.contains_key(&v.lock_id) { + v.lock_id + } else if block_lock != 0 && model.lock_rows.contains_key(&block_lock) { + block_lock + } else { + return None; }; - match check_lock(slots, item_level, model.lock_openers.as_ref()) { + let passes = block_lock != 0 || inst.is_some_and(|i| i.object && i.flags & 0x4 == 0); + if !passes { + return None; + } + let slots = &model.lock_rows[&row]; + let openers = model.lock_openers.as_ref(); + let color = match check_lock(slots, v.item_level, openers) { LockCheck::Closed => RED, LockCheck::Open => LOCK_GREEN, LockCheck::Skill { required, points } => skill_color(required, points), - } + }; + let sub = + match slots.first() { + Some(first) if first.key_type == 1 => { + let entry = first.index; + let name = model.item_templates.get(&entry).map(|t| t.name.clone()); + if name.is_none() && entry != 0 { + model.item_stat_asks.insert(entry); + } + name.map(|arg| LockSubLine { + key: "LOCKED_WITH_ITEM", + arg, + color: WHITE, + }) + } + Some(first) if first.key_type == 2 => walk_lock(slots, v.item_level, openers) + .spell + .map(|t| LockSubLine { + key: "LOCKED_WITH_SPELL_KNOWN", + // `UNKNOWN` (`0x838044`) only for a missing row (`0x52bede`..`0x52bef2`); a + // blank name stays blank (`0x52bef4`). + arg: model + .lock_type_names + .get(&first.index) + .cloned() + .unwrap_or_else(|| "UNKNOWN".to_string()), + color: skill_color(t.required, t.points), + }), + _ => None, + }; + Some(LockLines { color, sub }) } /// [`render_view_flagged`] for a caller that does not answer the flag. diff --git a/crates/benilla-ui/src/script/trade.rs b/crates/benilla-ui/src/script/trade.rs index aa98f7116..5c7102964 100644 --- a/crates/benilla-ui/src/script/trade.rs +++ b/crates/benilla-ui/src/script/trade.rs @@ -38,6 +38,14 @@ pub struct TradeSlotItem { /// slot holds a live item object, which the tooltip builder's LOCKED gate reads (`0x5342ed`); /// the partner's side has none. pub object_flags: Option, + /// The wire's wrapped word: the partner's block `+0x42c` (`0x534559`), which the tooltip's + /// wrapped gates read (`0x52c991`, `0x52cc5b`). The player's side reads its object's + /// `ITEM_FIELD_FLAGS` instead, so this only mirrors them there. + pub wrapped: bool, + /// The resolved name of the giver of a wrapped gift (`ITEM_FIELD_GIFTCREATOR` on the player's + /// side, the block's `+0x418` on the partner's, `0x534574`); `None` while unresolved or when + /// the guid is 0, which prints no `ITEM_WRAPPED_BY` line. + pub gift_creator: Option, /// Slot 7 only: the name of the spell being cast on the item, which stock shows in green /// (`TradeFrame.lua:62`); `None` on every other slot and while nothing is cast. pub enchantment: Option, @@ -477,6 +485,8 @@ fn trade_slot_item(model: &Model, item: &cursor::CursorItem) -> TradeSlotItem { inventory_type: s.inventory_type, lock_id: 0, object_flags: Some(s.flags), + wrapped: s.flags & 0x8 != 0, + gift_creator: s.gift_creator.clone(), enchantment: None, link: s.link.clone(), }) @@ -760,6 +770,8 @@ mod tests { inventory_type: 0, lock_id: 0, object_flags: None, + wrapped: false, + gift_creator: None, enchantment: None, link: Some(format!( "|cffffffff|Hitem:{item_id}:0:0:0|h[Linen Cloth]|h|r" @@ -804,6 +816,8 @@ mod tests { inventory_type: 0, lock_id: 0, object_flags: None, + wrapped: false, + gift_creator: None, enchantment: None, link: None, }); diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 2249f812b..463df8510 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -636,7 +636,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - An id outside the seven built windows raises, where the reference serves ten records and silently ignores any other id (`0x4a0be1`), because benilla keeps only the seven and every caller loops `1, NUM_CHAT_WINDOWS`. ([`src/script/chat_window.rs:220`](../crates/benilla-ui/src/script/chat_window.rs#L220)) - One past `i32::MAX` clamps where the reference keeps the low 32 bits, for the colours' reason. ([`src/script/chat_window.rs:430`](../crates/benilla-ui/src/script/chat_window.rs#L430)) - With no wheel it scales by the strip's, because the reference reads the missing wheel unguarded. ([`src/script/colorselect.rs:364`](../crates/benilla-ui/src/script/colorselect.rs#L364)) -- A row that no longer resolves is refused, as `0x4c7300` does, because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. ([`src/script/container.rs:658`](../crates/benilla-ui/src/script/container.rs#L658)) +- A row that no longer resolves is refused, as `0x4c7300` does, because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. ([`src/script/container.rs:661`](../crates/benilla-ui/src/script/container.rs#L661)) - A last `spell_id` key breaks the remaining ties (Charge's six ranks all have `spellLevel` 0), because the reference leaves them to its qsort over an array benilla does not reproduce; ascending id is ascending rank in every 1.12 pet-ability family. ([`src/script/craft.rs:104`](../crates/benilla-ui/src/script/craft.rs#L104)) - On `Terrain` the reference keeps a spell, action, macro or pet action on the left button; we clear them, because otherwise a spell has no left-click way off the cursor. ([`src/script/cursor.rs:523`](../crates/benilla-ui/src/script/cursor.rs#L523)) - Every lock lifts here, bag contents included. The reference's walk of a bag's contents indexes the bag by its own player-array index (`0x495528`, `0x495535`), at least 19, past a bag's slots in every case but the biggest bags, so it normally unlocks none of them; a lock it strands lasts until the item's seat or stack moves, which this does not copy. ([`src/script/cursor.rs:694`](../crates/benilla-ui/src/script/cursor.rs#L694)) @@ -694,8 +694,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The reference reads uninitialised fields before the seed; we answer the first legal design because that value is undefined. ([`src/script/tabard.rs:103`](../crates/benilla-ui/src/script/tabard.rs#L103)) - Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4dcd7c`), so Lua 5.0 hands back the stack top, the node argument: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/taxi.rs:138`](../crates/benilla-ui/src/script/taxi.rs#L138)) - A handler that unregisters the saved next ends the dispatch, because the reference then reads a freed node. ([`src/script/tick.rs:30`](../crates/benilla-ui/src/script/tick.rs#L30)) -- While the template is in flight the reference leaves the tooltip empty (`0x52b6a3`); this shows the slot's name, because a blank plate under an on-screen name reads as broken. ([`src/script/tooltip_item/mod.rs:611`](../crates/benilla-ui/src/script/tooltip_item/mod.rs#L611)) -- The reference's padding repeats a mana stat once per zero entry; not emulated, since only the internal test ring 6674 has one. ([`src/script/tooltip_item/render.rs:375`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L375)) +- While the template is in flight the reference leaves the tooltip empty (`0x52b6a3`); this shows the slot's name, because a blank plate under an on-screen name reads as broken. ([`src/script/tooltip_item/mod.rs:616`](../crates/benilla-ui/src/script/tooltip_item/mod.rs#L616)) +- The reference's padding repeats a mana stat once per zero entry; not emulated, since only the internal test ring 6674 has one. ([`src/script/tooltip_item/render.rs:383`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L383)) - The unknown-skill "Requires %s" shares it, though its reference red is `0xffff0000`, because a 32/255 difference in G and B does not show. ([`src/script/tooltip_unit.rs:30`](../crates/benilla-ui/src/script/tooltip_unit.rs#L30)) - The selection clears to 0, because the header the reference re-selects (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike. ([`src/script/trainer/mod.rs:197`](../crates/benilla-ui/src/script/trainer/mod.rs#L197)) - A header clears it too, because it has no spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the stock window's `> 1` test treats the same as 0. ([`src/script/trainer/mod.rs:871`](../crates/benilla-ui/src/script/trainer/mod.rs#L871))