diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index f8aa0dc29..4c4f3c77a 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -1133,7 +1133,7 @@ fn seat_a_session(script: &mut UiScript) { }); script.set_realm_name("Harness"); // The bind point: the server sends `SMSG_BINDPOINTUPDATE` at login, before any addon runs. - script.set_bind_location("Stormwind City"); + script.set_bind_location(Some("Stormwind City")); script.set_unit( "player", Some(benilla_ui::script::UnitState { diff --git a/crates/benilla-app/src/lanes/gossip_live_tests.rs b/crates/benilla-app/src/lanes/gossip_live_tests.rs index 253ff5b7c..507c85807 100644 --- a/crates/benilla-app/src/lanes/gossip_live_tests.rs +++ b/crates/benilla-app/src/lanes/gossip_live_tests.rs @@ -176,8 +176,9 @@ fn the_gossip_getters_read_the_world_at_the_call() { "Hello,Browse,2,A Threat Within" ); assert_eq!( - c.eval::>(false, "return GetGossipText()"), - None + c.eval::(false, "return GetGossipText()"), + "", + "with no host to ask, the empty buffer, never nil" ); // The next message's slots read at once; its text, still out, leaves the last. c.handle(vec![menu(8, "Fly")]); diff --git a/crates/benilla-app/src/player/view_subject.rs b/crates/benilla-app/src/player/view_subject.rs index 6081dbdc9..208c8a252 100644 --- a/crates/benilla-app/src/player/view_subject.rs +++ b/crates/benilla-app/src/player/view_subject.rs @@ -127,7 +127,7 @@ impl FarSight { } /// Whether far sight is on (`+0x1c70` bit 0). - fn on(&self) -> bool { + pub(crate) fn on(&self) -> bool { self.state().on } } diff --git a/crates/benilla-app/src/script_calls/live.rs b/crates/benilla-app/src/script_calls/live.rs index 8e467df9f..4b4e01ec4 100644 --- a/crates/benilla-app/src/script_calls/live.rs +++ b/crates/benilla-app/src/script_calls/live.rs @@ -154,6 +154,7 @@ pub(crate) struct UnitLive<'w, 's> { names: Option>, commands: Option>, tables: crate::ui_unit::NameTables<'w>, + families: Option>, } impl UnitLive<'_, '_> { @@ -174,6 +175,7 @@ impl UnitLive<'_, '_> { names: self.names.as_deref(), commands: self.commands.as_deref(), tables: self.tables.get(), + families: self.families.as_deref(), }); true } diff --git a/crates/benilla-app/src/script_calls/tests/selection.rs b/crates/benilla-app/src/script_calls/tests/selection.rs index b31e83a69..8350a13bb 100644 --- a/crates/benilla-app/src/script_calls/tests/selection.rs +++ b/crates/benilla-app/src/script_calls/tests/selection.rs @@ -891,3 +891,60 @@ fn a_teardowns_strike_cancel_reports_before_the_target_change() { run(&mut f, r#"ClearTarget() SEEN = table.concat(ORDER, ",")"#); assert_eq!(seen(&mut f), "SPELLCAST_STOP,PLAYER_TARGET_CHANGED"); } + +/// `UnitCreatureFamily("target")` (`0x51a310`) reads the held unit's cached creature record +/// (`0x6055e0`: `[[unit+0xb30]+0x1c]`) through `CreatureFamily.dbc` inside its call, though no +/// snapshot carries the word; a unit whose record the cache lacks answers nil. +#[test] +fn a_creature_family_is_read_off_the_held_units_record() { + const OBJECT_FIELD_TYPE: u16 = 2; + const OBJECT_FIELD_ENTRY: u16 = 3; + const BOAR: u32 = 1_126; + let mut f = frame(false); + // A unit object (`TYPEMASK_OBJECT | TYPEMASK_UNIT`) of the boar's template. + spawn_mob( + &mut f, + &[(OBJECT_FIELD_TYPE, 0x9), (OBJECT_FIELD_ENTRY, BOAR)], + ); + f.app.world_mut().resource_mut::().guid = Some(MOB); + settle(&mut f); + let family = + |f: &mut Frame| eval::>(f, r#"return UnitCreatureFamily("target")"#); + assert_eq!(family(&mut f), None, "no record cached yet"); + + let world = f.app.world_mut(); + world + .resource_mut::() + .insert_creature( + BOAR, + Some(crate::names::CreatureRecord { + name: "Stonetusk Boar".into(), + subname: None, + creature_type: 1, + pet_family: 5, + rank: 0, + type_flags: 0, + pet_spell_list: 0, + civilian: false, + racial_leader: false, + display_id: 607, + }), + ); + world.insert_resource(crate::ui_pet_stats::PetFamilyTables { + families: benilla_formats::CreatureFamilies::from_rows([( + 5, + benilla_formats::CreatureFamily { + name: "Boar".into(), + icon: "Interface\\Icons\\Ability_Hunter_Pet_Boar".into(), + pet_food_mask: 0, + min_scale: 1.0, + min_scale_level: 0, + max_scale: 1.0, + max_scale_level: 0, + skill_lines: [0, 0], + }, + )]), + foods: Default::default(), + }); + assert_eq!(family(&mut f).as_deref(), Some("Boar")); +} diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 459ad2c6a..d674f717d 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -693,9 +693,10 @@ fn spell_book( actions.spells.clear(); actions.profession_order.clear(); actions.craft_order.clear(); + actions.effect_slots = Default::default(); for spell_id in spell_ids { actions.spells.insert(spell_id); - list_learn(spell_id, actions); + list_learn(spell_id, actions, spells); } actions.dirty = true; let now = Instant::now(); @@ -731,18 +732,32 @@ fn learned_spell( if actions.spells.insert(spell_id) { actions.dirty = true; } - list_learn(spell_id, actions); + list_learn(spell_id, actions, spells); register_in_book(spell_id, spells, actions); announce_learn(spell_id, spells, errors); } /// `LearnSpell 0x5e9c20`'s list legs: the profession lists append with no duplicate check -/// (`0x5e9d72`), the craft lists only a spell they lack (`0x5e9db3`, `0x5e9e0f`). -fn list_learn(spell_id: u32, actions: &mut PlayerActions) { +/// (`0x5e9d72`), the craft lists only a spell they lack (`0x5e9db3`, `0x5e9e0f`); and a spell with +/// no `Attributes & 0x20`, no cast UI and `SPELL_EFFECT_TRADE_SKILL` first takes the player's +/// effect slot its misc value names (`0x5e9c8e`, `0x5e9d88`, `0x5e9e1c`-`0x5e9e36`), the one write +/// of `CGPlayer_C+0x1d10`; its reader indexes below 4 (`0x5ea360`). +fn list_learn(spell_id: u32, actions: &mut PlayerActions, spells: Option<&Spells>) { actions.profession_order.push(spell_id); if !actions.craft_order.contains(&spell_id) { actions.craft_order.push(spell_id); } + let slot = spells.and_then(|s| s.catalog.get(spell_id)).and_then(|d| { + let opens_window = d.attributes & benilla_formats::SPELL_ATTR_IS_TRADESKILL == 0 + && (d.cast_ui as i32) <= 0 + && d.effects[0] == benilla_formats::SPELL_EFFECT_TRADE_SKILL; + usize::try_from(d.effect_misc_value[0]) + .ok() + .filter(|&misc| opens_window && misc < 4) + }); + if let Some(slot) = slot { + actions.effect_slots[slot] = Some(spell_id); + } } /// `UnlearnSpell 0x5e9fe0`'s list legs: the profession lists' backward scan takes out the last @@ -881,7 +896,7 @@ fn superceded_spell( actions.spells.remove(&old_spell_id); list_unlearn(old_spell_id, actions); actions.spells.insert(new_spell_id); - list_learn(new_spell_id, actions); + list_learn(new_spell_id, actions, spells); actions.superseded.push((old_spell_id, new_spell_id)); actions.dirty = true; // `0x4b2f50` unlists the old rank and registers the new one at the tail; the player's own diff --git a/crates/benilla-app/src/spell/net/tests/learned.rs b/crates/benilla-app/src/spell/net/tests/learned.rs index 6f447b54e..40d09a186 100644 --- a/crates/benilla-app/src/spell/net/tests/learned.rs +++ b/crates/benilla-app/src/spell/net/tests/learned.rs @@ -81,3 +81,56 @@ fn the_known_spells_keep_their_learn_order() { superceded_spell(A, B, &mut actions, Some(&spells), &errors); assert_eq!(actions.profession_order, [C, D, B]); } + +/// `LearnSpell` (`0x5e9c20`) writes `CGPlayer_C+0x1d10[misc]` for a spell with no `Attributes & +/// 0x20`, no cast UI, `SPELL_EFFECT_TRADE_SKILL` first and a misc value below 4 (`0x5e9e1c`- +/// `0x5e9e36`): the last such learn holds the slot, an unlearn clears nothing, and the book's +/// replay empties them. +#[test] +fn a_trade_skill_opener_learn_fills_its_effect_slot() { + let opener = |misc: i32, attributes: u32, cast_ui: u32| benilla_formats::SpellDisplay { + attributes, + cast_ui, + effects: [benilla_formats::SPELL_EFFECT_TRADE_SKILL, 0, 0], + effect_misc_value: [misc, 0, 0], + ..Default::default() + }; + let mut spells = Spells::empty_for_tests(); + spells.catalog = benilla_formats::SpellCatalog::from_displays( + [ + (10, opener(0, 0, 0)), + (11, opener(0, 0, 0)), + (12, opener(3, 0, 0)), + (13, opener(1, benilla_formats::SPELL_ATTR_IS_TRADESKILL, 0)), + (14, opener(2, 0, 1)), + (15, opener(4, 0, 0)), + ] + .into_iter() + .collect(), + ); + let mut actions = PlayerActions::default(); + let errors = UiErrorKeys::default(); + for id in [10, 12, 13, 14, 15, 11] { + learned_spell(id, &mut actions, Some(&spells), &errors); + } + assert_eq!(actions.effect_slots, [Some(11), None, None, Some(12)]); + removed_spell(11, &mut actions, Some(&spells), &errors); + assert_eq!( + actions.effect_slots[0], + Some(11), + "an unlearn leaves the slot" + ); + spell_book( + vec![10], + vec![], + &mut actions, + &mut Cooldowns::default(), + ( + Some(&spells), + &crate::items::Items::default(), + &crate::spell::SpellModifiers::default(), + crate::spell::CategoryPad::for_list(false, None), + ), + ); + assert_eq!(actions.effect_slots, [Some(10), None, None, None]); +} diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 8cc019437..730496286 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -98,6 +98,9 @@ pub(crate) struct PlayerActions { /// The order the craft types' lists hold theirs (`+0x1cd0`): a learn appends a spell the list /// lacks (`0x5e9db3`, `0x5e9e0f`), an unlearn takes out its first copy (`0x5ea1b8`). pub craft_order: Vec, + /// `CGPlayer_C+0x1d10`, the spell each effect slot holds (`0x5ea360`): written by a learn + /// (`list_learn`), never cleared by an unlearn, emptied with the book. + pub effect_slots: [Option; 4], /// `[0xb700b0]`: the known spells whose `Effect[0]` is `SPELL_EFFECT_OPEN_LOCK`, in learn /// order. The login list enters in wire order (`0x5dea8b`-`0x5deab9`), each learn appends at /// the tail with no duplicate check (`0x4b2665`-`0x4b2689`), and an unlearn removes the last diff --git a/crates/benilla-app/src/ui_action/ranks.rs b/crates/benilla-app/src/ui_action/ranks.rs index bc797bebd..975020f5a 100644 --- a/crates/benilla-app/src/ui_action/ranks.rs +++ b/crates/benilla-app/src/ui_action/ranks.rs @@ -95,6 +95,7 @@ mod tests { spells: spells.iter().copied().collect(), profession_order: spells.to_vec(), craft_order: spells.to_vec(), + effect_slots: Default::default(), openers: Vec::new(), stuck_spell: None, superseded: Vec::new(), diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 435f0461c..d535bc066 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -271,6 +271,13 @@ fn feed_craft( return; }; let memo = memo.get(script); + // `GetCraftName` reads the player's effect slot of the open type, 0 when closed (`0x4f6c32`). + let slot_names = self_store + .single() + .ok() + .and_then(|_| Some(effect_slot_names(&actions, spells.as_deref()?))) + .unwrap_or_default(); + script.set_craft_slot_names(slot_names); // The list only reads the item cache: the build asks nothing (`0x4f60c0`), and a reagent // or a totem is asked by its getter's call, inside it (`0x4f75f2`, `0x4f7a0a`). let fresh = (|| -> Option { @@ -285,6 +292,10 @@ fn feed_craft( .map(|l| l.name.clone()) .unwrap_or_else(|| format!("Skill {line}")); let global = |key: &str| benilla_ui::strings::global(script.lua(), key); + // The `$z` token: the bind area the items feed pushed for `GetBindLocation`, so the + // two agree (`0x507f07`-`0x507f45`, `0x48dae0`). + let bind = script.bind_location(); + let home_area: Option<&str> = bind.as_deref(); // The player's level for a spell and its cost (`0x6e3130`, `0x6e31b0`, `0x6e3300`). let skills = crate::spell::PlayerSkills { lines: Some(&skill_lines.catalog), @@ -323,7 +334,7 @@ fn feed_craft( }, unmodified_points: false, gender: &|| store.0.unit_gender().unwrap_or(0), - home_area: &|| None, + home_area: &|| home_area, global: &global, printf: &crate::ui_script::token_printf, }; @@ -508,6 +519,16 @@ fn feed_craft( }) } +/// The names of the spells in the player's four effect slots (`CGPlayer_C+0x1d10`, `0x5ea360`), +/// which `GetCraftName` reads at the open craft type (`0x4f6c32`); a learn writes them +/// ([`PlayerActions::effect_slots`]), an unlearn leaves them. +fn effect_slot_names(actions: &PlayerActions, spells: &Spells) -> [Option; 4] { + actions.effect_slots.map(|slot| { + slot.and_then(|id| spells.catalog.get(id)) + .map(|d| d.name.clone()) + }) +} + /// What [`feed_craft`] remembers from its last frame. #[derive(Default)] struct CraftMemo { @@ -767,6 +788,77 @@ mod tests { assert_eq!(read(39, 40), (0, false), "the bank is outside mask 0"); } + /// A craft description's `$z` reads the bind area like every spell text (`0x507f07`- + /// `0x507f45`): the area's name once the bind point resolves, the `HOME_INN` text before. + #[test] + fn a_craft_description_names_the_bind_area_or_the_inn() { + const SPELL: u32 = 7_001; + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + let mut actions = PlayerActions::default(); + actions.spells.insert(SPELL); + actions.profession_order.push(SPELL); + actions.craft_order.push(SPELL); + app.init_resource::() + .init_resource::() + .insert_resource(actions) + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .insert_resource(crate::ui_action::Spells { + catalog: benilla_formats::SpellCatalog::from_displays( + [( + SPELL, + SpellDisplay { + name: "Enchant".into(), + cast_ui: 3, + description: Some("Returns you to $z.".into()), + ..Default::default() + }, + )] + .into_iter() + .collect(), + ), + ..crate::ui_action::Spells::empty_for_tests() + }) + .insert_resource(SkillLines { + catalog: benilla_formats::SkillLineCatalog::from_abilities([( + SPELL, + benilla_formats::SlaInfo { + skill_id: 333, + req_skill_value: 1, + forward_spell_id: 0, + trivial_low: 1, + trivial_high: 2, + }, + )]), + }); + app.world_mut() + .spawn((SelfPlayer, ObjectStore(Default::default()))); + let script = UiScript::new().unwrap(); + script.run("HOME_INN = 'your inn'").unwrap(); + app.insert_non_send_resource(script); + app.add_systems(Update, feed_craft); + { + let mut open = app.world_mut().resource_mut::(); + open.line = Some(333); + open.craft_type = 3; + } + let described = |app: &mut App| { + app.update(); + app.world() + .non_send_resource::() + .eval::("return GetCraftDescription(1)") + .unwrap() + }; + assert_eq!(described(&mut app), "Returns you to your inn."); + app.world_mut() + .non_send_resource_mut::() + .set_bind_location(Some("Elwynn Forest")); + assert_eq!(described(&mut app), "Returns you to Elwynn Forest."); + } + #[test] fn the_session_end_closes_the_craft_window() { let mut app = App::new(); diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index 21b95f006..816cd66e4 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -559,7 +559,7 @@ pub(super) fn feed_item_stats( .and_then(|id| area_names.as_deref()?.0.resolve(id as i32)); // Pushed on change, per VM: a rebuilt VM is fed again the frame it appears. if last_home.as_deref() != home_area { - script.set_bind_location(home_area.unwrap_or_default()); + script.set_bind_location(home_area); *last_home = home_area.map(str::to_string); // Re-substitute every held view, or one pushed before the bind point carries a raw // `$z`. diff --git a/crates/benilla-app/src/ui_mail/mod.rs b/crates/benilla-app/src/ui_mail/mod.rs index 13563e5d5..4228fd1f1 100644 --- a/crates/benilla-app/src/ui_mail/mod.rs +++ b/crates/benilla-app/src/ui_mail/mod.rs @@ -385,8 +385,15 @@ fn resolve_row( let player_mail = entry.message_type == mail_message_type::NORMAL; let can_reply = player_mail && !is_gm; - let (item_id, item_roll, item_count, item_name, item_texture, item_quality) = match &entry.item - { + let ( + item_id, + item_roll, + item_count, + item_name, + item_texture, + item_quality, + item_inventory_type, + ) = match &entry.item { Some(att) => { // The row's lookup, whose miss asks; then what `GetInboxItem` reads of the record as // it runs, read the same way. @@ -404,9 +411,10 @@ fn resolve_row( live.name(), live.texture(), live.quality(), + live.inventory_type(), ) } - None => (0, 0, 0, None, None, None), + None => (0, 0, 0, None, None, None, 0), }; // The block's charges and `(current, max)` durability (`0x53566f`, `0x535699`, `0x53568d`). let (item_charges, item_durability) = entry.item.as_ref().map_or((0, (0, 0)), |att| { @@ -542,6 +550,7 @@ fn resolve_row( item_name, item_texture, item_quality, + item_inventory_type, } } diff --git a/crates/benilla-app/src/ui_pet_stats.rs b/crates/benilla-app/src/ui_pet_stats.rs index 8995ce5ee..525a3ff4e 100644 --- a/crates/benilla-app/src/ui_pet_stats.rs +++ b/crates/benilla-app/src/ui_pet_stats.rs @@ -111,7 +111,7 @@ pub(crate) fn stats_for( tables: Option<&PetStatTables>, family: (Option, Option, Vec), ) -> (bool, PetStats) { - let (family, icon, food_types) = family; + let (_, icon, food_types) = family; let Some(fields) = pet.map(|s| &s.0) else { return (false, PetStats::default()); }; @@ -124,13 +124,12 @@ pub(crate) fn stats_for( && self_store.map(|s| ((s.0.unit_bytes_0().unwrap_or(0) >> 8) & 0xff) as u8) == Some(CLASS_HUNTER); if !hunter { - // The family word passes the gate and the diet does not (module doc): a mind-controlled - // boar under a non-hunter (family 5, food mask 63) gets "Boar" and no diet. + // The icon passes the gate and the diet does not (module doc): a mind-controlled boar + // under a non-hunter (family 5, food mask 63) gets no diet. return ( has_ui, PetStats { - family, - // The icon passes with the word; whether the reference gates it is untraced. + // Whether the reference gates the icon is untraced. icon, ..PetStats::default() }, @@ -158,7 +157,6 @@ pub(crate) fn stats_for( }), training_points: fields.unit_training_points(), experience: fields.unit_pet_experience(), - family, icon, food_types, }, @@ -489,7 +487,6 @@ mod tests { (Some("Boar".into()), None, boar_diet.clone()), ); assert!(!s.hunter_pet); - assert_eq!(s.family.as_deref(), Some("Boar"), "the word is ungated"); assert!( s.food_types.is_empty(), "…but the diet shares 0x6116e0 with the stats" @@ -504,7 +501,6 @@ mod tests { Some(&t), (Some("Boar".into()), None, boar_diet.clone()), ); - assert_eq!(s.family.as_deref(), Some("Boar")); assert_eq!(s.food_types, boar_diet); } @@ -516,7 +512,6 @@ mod tests { None, (Some("Imp".into()), None, vec![]), ); - assert_eq!(s.family, None); assert!(s.food_types.is_empty()); } diff --git a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs index 8d6776e55..ae9ab57e2 100644 --- a/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs +++ b/crates/benilla-app/src/ui_script/battlefield_minimap_tests.rs @@ -24,6 +24,8 @@ fn session() -> UiScript { // No engine behind it: `RaidFrame`'s `RequestRaidInfo()` at world entry is kept. s.keep_calls(); s.set_screen_size(1024.0, 768.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(0x5E1F); s.set_unit( "player", Some(UnitState { @@ -519,6 +521,11 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() { }), 1.0, ); + s.set_unit_guids(&benilla_ui::script::UnitGuids { + player: 0x5E1F, + party: [0x11, 0x12, 0, 0], + ..Default::default() + }); s.set_world_map_feed( Some((1, 1)), Some((0.5, 0.25)), diff --git a/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs b/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs index bba44ec8f..dd268d682 100644 --- a/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_paperdoll_tests.rs @@ -47,6 +47,7 @@ fn pet_unit() -> UnitState { max_health: 1000, level: 58, combat: pet_combat_stats(), + family: Some("Boar".into()), ..UnitState::default() } } @@ -63,7 +64,6 @@ fn hunter_pet_stats() -> PetStats { loyalty: Some("(Loyalty Level 6) Best Friend".into()), training_points: (170, 130), experience: (4200, 8000), - family: Some("Boar".into()), food_types: ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"] .map(String::from) .to_vec(), @@ -306,13 +306,16 @@ fn the_level_line_names_the_family_and_is_untouched_without_one() { let _data = benilla_formats::wow_data_or_skip!(); let with_family = |family: Option| { let mut s = load_pet_page(); - s.set_unit("pet", Some(pet_unit())); + s.set_unit( + "pet", + Some(UnitState { + family, + ..pet_unit() + }), + ); s.set_pet_stats( true, - PetStats { - family, - ..PetStats::default() // hunter_pet false: a warlock's minion - }, + PetStats::default(), // hunter_pet false: a warlock's minion ); s.fire_event("PET_BAR_UPDATE", vec![]); s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap(); diff --git a/crates/benilla-app/src/ui_script/pet_stable_tests.rs b/crates/benilla-app/src/ui_script/pet_stable_tests.rs index 6a5eb9af5..2f3d7e427 100644 --- a/crates/benilla-app/src/ui_script/pet_stable_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_stable_tests.rs @@ -58,6 +58,8 @@ fn pet(name: Option<&str>) -> UnitState { max_health: 1000, level: 58, guid: 0xF140_0000_0000_0001, + // `UnitCreatureFamily("pet")`, which `PetStable.lua:129` concatenates. + family: Some("Boar".into()), ..UnitState::default() } } @@ -69,7 +71,6 @@ fn hunter_pet_stats() -> PetStats { icon: Some(r"Interface\Icons\Ability_Hunter_Pet_Boar".into()), hunter_pet: true, loyalty: Some("(Loyalty Level 6) Best Friend".into()), - family: Some("Boar".into()), ..PetStats::default() } } diff --git a/crates/benilla-app/src/ui_script/trainer_tests.rs b/crates/benilla-app/src/ui_script/trainer_tests.rs index 8e9e7c306..dd5512af9 100644 --- a/crates/benilla-app/src/ui_script/trainer_tests.rs +++ b/crates/benilla-app/src/ui_script/trainer_tests.rs @@ -149,6 +149,7 @@ fn service( skill_step: false, step_increase: None, line_name: Some(line_name.into()), + skill_line: None, } } diff --git a/crates/benilla-app/src/ui_script/world_map_tests.rs b/crates/benilla-app/src/ui_script/world_map_tests.rs index 92600262e..becb53f94 100644 --- a/crates/benilla-app/src/ui_script/world_map_tests.rs +++ b/crates/benilla-app/src/ui_script/world_map_tests.rs @@ -37,9 +37,14 @@ fn harness() -> UiScript { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); // A world position here is already map UV, so a landmark source sits where it says. s.set_world_loc_projector(Box::new(|_, _, x, y| Some((x, y)))); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); s } +/// The active player's guid, which the position verb resolves `"player"` to. +const PLAYER: u64 = 0x5E1F; + /// The map arrow's file facts, as the app hands them over: `MinimapArrow.m2`, one looping 3.333 s /// Stand keying no bone, header box x in [-0.0127, 0.0135], y in [-0.0118, 0.0145]. fn arrow_facts(s: &mut UiScript) { @@ -255,6 +260,11 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() { } // Two members on the map and one between them off it: the app pushes None for no position. + s.set_unit_guids(&benilla_ui::script::UnitGuids { + player: PLAYER, + party: [0x11, 0x12, 0x13, 0], + ..Default::default() + }); s.set_world_map_feed( None, Some((0.5, 0.5)), @@ -313,6 +323,8 @@ fn the_player_arrow_is_the_stock_model_pane_seated_and_turned_by_the_update() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_screen_size(1600.0, 900.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); s.set_unit( "player", Some(benilla_ui::script::UnitState { @@ -465,6 +477,8 @@ fn the_maps_own_furniture_survives_the_hide_that_showing_it_performs() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_screen_size(1600.0, 900.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); // A player exists before the manifest loads: the UI loads on world entry, and the macro // window's OnLoad formats `UnitName("player")`. s.set_unit( @@ -581,6 +595,8 @@ fn the_player_arrow_draws_over_the_zones_explored_overlays() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_screen_size(1600.0, 900.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); s.set_unit( "player", Some(benilla_ui::script::UnitState { @@ -639,6 +655,8 @@ fn a_click_over_a_blip_still_answers_the_maps_uv() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_screen_size(1600.0, 900.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); s.set_unit( "player", Some(benilla_ui::script::UnitState { @@ -822,6 +840,8 @@ fn a_stale_layout_row_cannot_seat_a_stock_frame() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_screen_size(1600.0, 900.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); s.set_unit( "player", Some(benilla_ui::script::UnitState { @@ -912,6 +932,8 @@ fn the_fullscreen_quads_follow_a_resize() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_screen_size(1600.0, 900.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); // A player exists by the time the manifest loads. s.set_unit( "player", @@ -963,6 +985,8 @@ fn a_reopened_map_shows_the_players_zone_before_its_first_paint() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_screen_size(1600.0, 900.0); + // `GetPlayerMapPosition("player")` answers only for the player's own guid (`0x4a8668`). + s.seat_player_guid(PLAYER); s.set_unit( "player", Some(benilla_ui::script::UnitState { diff --git a/crates/benilla-app/src/ui_stable/call_tests.rs b/crates/benilla-app/src/ui_stable/call_tests.rs index 8f3f54e99..0cd2febc1 100644 --- a/crates/benilla-app/src/ui_stable/call_tests.rs +++ b/crates/benilla-app/src/ui_stable/call_tests.rs @@ -23,6 +23,11 @@ struct Stable { /// The whole client, headless, with us streamed (`summon` our summoned pet's guid, or none), a /// stable open on its master with one slot bought and pet 3 in it, and 1g in the purse. fn client(summon: u64) -> Stable { + client_in_slot(summon, 1) +} + +/// [`client`] with pet 3 in stable slot `slot` (1 or 2, the rebased wire byte). +fn client_in_slot(summon: u64, slot: u8) -> Stable { let mut app = crate::game_plugins::schedule_tests::headless_client_unrun(); let (tx, rx) = crossbeam_channel::unbounded(); app.insert_resource(NetCommands(tx)); @@ -56,7 +61,7 @@ fn client(summon: u64) -> Stable { level: 10, name: "Boar".into(), loyalty: 1, - slot: 1, + slot, }], ); // The boar's template and its family's icon, which the pickup needs (`0x4950c0`). @@ -147,6 +152,62 @@ fn the_stable_verbs_send_inside_their_calls() { ); } +/// The stabled pets pack in list order (`0x4cab10`, the array `0xb720c8`): with only the second +/// stable slot occupied, which happens after the first pet is taken out because the server does not +/// renumber, the pet is the first box and `UnstablePet(1)` takes it; the second box is empty. +#[test] +fn a_pet_in_the_second_slot_is_the_first_box() { + let mut s = client_in_slot(0, 2); + let name = |s: &mut Stable, i: u32| -> Option { + with_hosted_vm(s.app.world_mut(), |vm| { + vm.expect("a VM") + .eval(&format!("local _, n = GetStablePetInfo({i}) return n")) + .unwrap() + }) + }; + assert_eq!(name(&mut s, 1).as_deref(), Some("Boar")); + assert_eq!(name(&mut s, 2), None); + run(&mut s, "UnstablePet(2) UnstablePet(1)"); + assert_eq!( + sent(&s), + lines(&[ClientCommand::UnstablePet { + npc: MASTER, + pet_number: 3, + }]) + ); +} + +/// The summoned pet is the row whose raw slot byte has bit 1 clear (`0x4cabe3`), not the one with +/// byte 1: a byte of 4 is summoned too, and fills box 0. +#[test] +fn the_summoned_row_is_the_one_whose_slot_byte_has_bit_one_clear() { + use benilla_protocol::messages::StabledPet; + let pet = |slot: u8| StabledPet { + pet_number: 3, + creature_entry: 1_126, + level: 10, + name: "Boar".into(), + loyalty: 1, + slot, + }; + // Raw bytes 1, 4 and 5 are summoned; 2 and 3 stabled. + assert_eq!( + [0, 1, 2, 3, 4].map(|slot| pet(slot).is_summoned()), + [true, false, false, true, true] + ); + // A row with raw byte 4 (rebased 3) is the summoned pet: box 0, no stabled row. + let mut s = client_in_slot(0, 3); + let name = |s: &mut Stable, i: i32| -> Option { + with_hosted_vm(s.app.world_mut(), |vm| { + vm.expect("a VM") + .eval(&format!("local _, n = GetStablePetInfo({i}) return n")) + .unwrap() + }) + }; + assert_eq!(name(&mut s, 0).as_deref(), Some("Boar")); + assert_eq!(name(&mut s, 1), None); +} + /// `UnstablePet` bails while the active player holds a charm or a summon (`0x4caf73`-`0x4caf88`), /// read off its own descriptor, whatever the pet bar holds. #[test] diff --git a/crates/benilla-app/src/ui_stable/mod.rs b/crates/benilla-app/src/ui_stable/mod.rs index 4704f043e..99e5ece69 100644 --- a/crates/benilla-app/src/ui_stable/mod.rs +++ b/crates/benilla-app/src/ui_stable/mod.rs @@ -199,13 +199,23 @@ fn snapshot( ask: Ask<'_>, ) -> Option { let mut slots: [Option; NUM_STABLE_SLOTS] = Default::default(); + // The summoned pet (wire slot byte 1, bit 1 clear) takes row 0; the stabled ones (bit 1 set) + // pack behind it in list order, whatever their slot byte says, so with only the second slot + // occupied (the server does not renumber) the pet shows in box 1 and `UnstablePet(1)` takes it + // (`0x4cab10`-`0x4cac95`, the array `0xb720c8`; `0x4cb285`-`0x4cb298`, `0x4cafa5`-`0x4cafb1`). + let mut next = 1; for wire in &open.pets { - // Seat rows by their slot, not position: a petless hunter's list has no slot-0 row. - match slots.get_mut(usize::from(wire.slot)) { + let seat = if wire.is_summoned() { + 0 + } else { + next += 1; + next - 1 + }; + match slots.get_mut(seat) { Some(seat) => *seat = Some(resolve_pet(wire, names, families, stats, ask)), None => debug!( - "ui_stable: pet {} arrived in slot {} — past the {NUM_STABLE_SLOTS} the window has, dropped", - wire.pet_number, wire.slot + "ui_stable: pet {} arrived past the {NUM_STABLE_SLOTS} rows the window has, dropped", + wire.pet_number ), } } diff --git a/crates/benilla-app/src/ui_trainer/mod.rs b/crates/benilla-app/src/ui_trainer/mod.rs index 9c9acedcb..0a60683cf 100644 --- a/crates/benilla-app/src/ui_trainer/mod.rs +++ b/crates/benilla-app/src/ui_trainer/mod.rs @@ -9,14 +9,14 @@ use std::collections::BTreeSet; use benilla_formats::{ LearnEffect, SkillLineCatalog, SpellCatalog, TokenContext, SPELL_EFFECT_LEARN_PET_SPELL, - SPELL_EFFECT_SKILL_STEP, + SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_SKILL_STEP, }; use benilla_protocol::messages::{trainer_spell_state, TrainerSpell}; use bevy::prelude::*; use benilla_ui::script::{ - TrainerAbilityReq, TrainerService, TrainerServiceCategory, TrainerServiceLive, TrainerSkillReq, - TrainerState, TrainerTooltip, UiScript, TRAINER_GROUP_KNOWN, + TrainerAbilityReq, TrainerService, TrainerServiceLive, TrainerSkillReq, TrainerState, + TrainerTooltip, UiScript, TRAINER_GROUP_KNOWN, }; use crate::entities::ItemDisplays; @@ -232,6 +232,72 @@ pub(super) struct ReqBooks<'a> { /// The player's race and class, which index the taught spell's line (`0x60c920` → /// `0x6de040`). pub(super) race_class: (u8, u8), + /// The player's `PLAYER_SKILL_INFO` slots, `Some` while the player object exists: the skill + /// requirement scans them by line (`0x4d94d6`-`0x4d9524`), and the skill-line name needs the + /// player (`0x4d91fd`-`0x4d921c`). + pub(super) skills: Option<&'a [benilla_protocol::messages::PlayerSkillSlot]>, +} + +/// `GetTrainerServiceSkillReq`'s flag (`0x4d94d6`-`0x4d9575`): 1 unless the requirement names a +/// line with a row and the player's first slot for it, its value plus the permanent bonus (added +/// only to a nonzero value), falls short of the required value; no player, no requirement and an +/// unknown line keep the default 1 (`0x4d945a`, `0x4d95bc`). +fn skill_req_met( + wire: &TrainerSpell, + skill_lines: Option<&SkillLineCatalog>, + books: &ReqBooks, +) -> bool { + let Some(slots) = books.skills else { + return true; + }; + if wire.req_skill == 0 + || wire.req_skill_value == 0 + || !skill_lines.is_none_or(|c| c.line(wire.req_skill).is_some()) + { + return true; + } + let total = slots + .iter() + .find(|s| u32::from(s.skill_id) == wire.req_skill) + .map_or(0, |s| match s.value { + 0 => 0, + v => u32::from(v) + u32::from(s.perm_bonus as u16), + }); + total >= wire.req_skill_value +} + +/// `GetTrainerServiceSkillLine`'s name (`0x4d91dc`-`0x4d92c5`), needing the player: the line of +/// `FindSkillLineAbility(spell, race, class)` on the spell the first learn effect (`0x24` or +/// `0x39`) teaches, else on the sent spell, whose miss goes on to the same lookup on the spell +/// numbered by the sent record's third `EffectMiscValue`, the dword before the trigger array +/// that an index of -1 reads (`0x4d9261`). +fn skill_line_name( + wire_spell: u32, + spells: &SpellCatalog, + skill_lines: Option<&SkillLineCatalog>, + books: &ReqBooks, +) -> Option { + books.skills?; + let lines = skill_lines?; + let (race, class) = books.race_class; + let d = spells.get(wire_spell)?; + let named = |spell: u32| { + spells.get(spell)?; + let line = lines.spell_to_line(spell, race, class)?; + lines.line(line).map(|l| l.name.clone()) + }; + let learn = (0..3).find(|&i| { + matches!( + d.effects[i], + SPELL_EFFECT_LEARN_SPELL | SPELL_EFFECT_LEARN_PET_SPELL + ) + }); + match learn { + Some(i) => named(d.effect_trigger_spell[i]), + None => { + named(wire_spell).or_else(|| u32::try_from(d.effect_misc_value[2]).ok().and_then(named)) + } + } } /// Resolve one wire [`TrainerSpell`] into the Lua [`TrainerService`]; `books` colour each @@ -255,17 +321,18 @@ fn resolve_service( let taught = spells.learned_spell(wire.spell).unwrap_or(wire.spell); let display = spells.get(wire.spell); let cat = category(wire.state); - // The wire has no per-gate bit, so the skill gate follows the service's category; the 1.12 - // client compares the player's skill value itself, which is not built here. - let skill_met = cat != TrainerServiceCategory::Unavailable; - let skill_req = (wire.req_skill != 0).then(|| TrainerSkillReq { - name: skill_lines - .and_then(|c| c.line(wire.req_skill)) - .map(|l| l.name.clone()) - .unwrap_or_else(|| format!("Skill {}", wire.req_skill)), - rank: wire.req_skill_value, - met: skill_met, - }); + // The flag compares the player's skill value itself, whatever the row's colour (`0x4d9570`). + let skill_met = skill_req_met(wire, skill_lines, books); + // A zero line or value, a line `SkillLine.dbc` lacks and no player are all the miss + // `nil, 0, 1` (`0x4d9487`-`0x4d94ca` → `0x4d95bc`). + let skill_req = (wire.req_skill_value != 0 && books.skills.is_some()) + .then(|| skill_lines?.line(wire.req_skill)) + .flatten() + .map(|l| TrainerSkillReq { + name: l.name.clone(), + rank: wire.req_skill_value, + met: skill_met, + }); let (is_trade_skill, learn_spell) = service_kind(wire.spell, spells); // The wrapper is never in `SkillLineAbility`, so the skill-line arm keys on the taught spell; // a service it cannot place drops, as in the client's builder, and is logged. @@ -318,6 +385,7 @@ fn resolve_service( tooltip: service_tooltip(wire.spell, spells), skill_step: display.is_some_and(|d| d.effects.contains(&SPELL_EFFECT_SKILL_STEP)), step_increase: step_increase(wire.spell, spells, skill_lines, get), + skill_line: skill_line_name(wire.spell, spells, skill_lines, books), line_name: skill_lines .and_then(|c| c.line(group_key)) .map(|l| l.name.clone()), @@ -390,6 +458,7 @@ fn live_service( description, description_wait, ability_reqs, + skill_met: skill_req_met(wire, skill_lines, books), } } @@ -684,10 +753,16 @@ impl TrainerReads<'_, '_> { .collect(), _ => BTreeSet::new(), }); + let skill_slots: Option> = store.map(|s| { + (0..PLAYER_SKILL_SLOTS) + .filter_map(|i| s.0.player_skill(i)) + .collect() + }); let books = ReqBooks { player: &actions.spells, pet: pet_book.as_ref(), race_class: store.map_or((0, 0), |s| s.race_class()), + skills: skill_slots.as_deref(), }; Some(f(&Resolver { spells: &spells.catalog, diff --git a/crates/benilla-app/src/ui_trainer/tests.rs b/crates/benilla-app/src/ui_trainer/tests.rs index 714066e31..54d57227a 100644 --- a/crates/benilla-app/src/ui_trainer/tests.rs +++ b/crates/benilla-app/src/ui_trainer/tests.rs @@ -8,7 +8,7 @@ use benilla_formats::{ SPELL_EFFECT_SKILL_STEP, }; use benilla_protocol::messages::trainer_spell_state; -use benilla_ui::script::TrainerTooltip; +use benilla_ui::script::{TrainerServiceCategory, TrainerTooltip}; use std::collections::HashMap; /// An empty spell catalog, as before `Spell.dbc` loads. @@ -26,6 +26,7 @@ fn probe_strings(key: &str) -> Option { "TRADESKILL_SERVICE_LEARN" => Some("".into()), "KNOWN_TALENTS_HEADER" => Some("".into()), "INCREASE_POTENTIAL" => Some("Increases potential in %s by %d".into()), + "HOME_INN" => Some("".into()), _ => None, } } @@ -76,6 +77,18 @@ fn resolve_service( spells: &SpellCatalog, skill_lines: Option<&SkillLineCatalog>, known: &BTreeSet, +) -> TrainerService { + resolve_service_skilled(wire, trainer_type, spells, skill_lines, known, Some(&[])) +} + +/// [`resolve_service`] with the player's skill slots, `None` for no player object. +fn resolve_service_skilled( + wire: &TrainerSpell, + trainer_type: u32, + spells: &SpellCatalog, + skill_lines: Option<&SkillLineCatalog>, + known: &BTreeSet, + skills: Option<&[benilla_protocol::messages::PlayerSkillSlot]>, ) -> TrainerService { let deps = Deps::new(); with_text(spells, 0, 0, None, |text| { @@ -88,6 +101,7 @@ fn resolve_service( player: known, pet: None, race_class: (1, 1), + skills, }, None, &deps.items, @@ -124,6 +138,7 @@ fn snap(open: &TrainerOpen, spells: &SpellCatalog) -> Option { player: &BTreeSet::new(), pet: None, race_class: (1, 1), + skills: Some(&[]), }, None, &deps.items, @@ -533,17 +548,11 @@ fn resolve_reads_cost_state_and_gates_with_no_catalog() { assert_eq!(svc.cost, 1000); assert_eq!(svc.category, TrainerServiceCategory::Unavailable); assert_eq!(svc.level_req, 20); - // The skill gate follows the category; the ability gate reads met, as no service spell - // record reaches the known test (`0x4d9842`), and a requirement with no record answers nil - // (`0x4d97c8`, `0x4d98d1`). Every slot stays in place (`0x4d97a5`). - assert_eq!( - svc.skill_req, - Some(TrainerSkillReq { - name: "Skill 164".to_string(), - rank: 100, - met: false, - }) - ); + // A skill line with no `SkillLine.dbc` row is no requirement (`0x4d94c5` → `0x4d95bc`); the + // ability gate reads met, as no service spell record reaches the known test (`0x4d9842`), and + // a requirement with no record answers nil (`0x4d97c8`, `0x4d98d1`). Every slot stays in place + // (`0x4d97a5`). + assert_eq!(svc.skill_req, None); assert_eq!( svc.ability_reqs, vec![ @@ -878,8 +887,8 @@ fn service_description_expands_against_the_pet_for_a_learn_pet_row() { }); } -/// The `$z` token: the bind point's area name (`AreaTable.dbc`), and raw where no bind point has -/// arrived, as the item and tooltip feeds' `$z` behaves. +/// The `$z` token: the bind point's area name (`AreaTable.dbc`), and the `HOME_INN` text where no +/// bind point has arrived (`0x507f3a`), as the item and tooltip feeds' `$z` behaves. #[test] fn service_description_expands_z_to_the_bind_area() { let d = SpellDisplay { @@ -890,7 +899,7 @@ fn service_description_expands_z_to_the_bind_area() { let deps = Deps::new(); for (home, want) in [ (Some("Razor Hill"), "Returns you to Razor Hill."), - (None, "Returns you to $z."), + (None, "Returns you to ."), ] { with_text(&spells, 0, 0, home, |text| { assert_eq!( @@ -958,6 +967,7 @@ fn service_description_on_real_data_reads_astral_recalls_bind_area() { player: &BTreeSet::new(), pet: None, race_class: (1, 1), + skills: Some(&[]), }, None, &deps.items, @@ -1785,6 +1795,7 @@ fn resolve_with_pet( player, pet, race_class: (1, 1), + skills: Some(&[]), }, None, &deps.items, @@ -2254,3 +2265,155 @@ fn a_pet_rows_pet_is_the_summon_not_the_bar() { None ); } + +fn skill_slot( + skill_id: u16, + value: u16, + perm_bonus: i16, +) -> benilla_protocol::messages::PlayerSkillSlot { + benilla_protocol::messages::PlayerSkillSlot { + skill_id, + step: 0, + value, + max: 300, + temp_bonus: 0, + perm_bonus, + } +} + +/// `GetTrainerServiceSkillReq`'s flag (`0x4d9570`) compares the player's rank plus the permanent +/// bonus, added only to a nonzero rank, with the required value: a row that is red for its level +/// still reports the skill met, and an unmet skill shows on a green row. No player, no slot for +/// the line, and an unknown line keep the default 1 or fail as the scan ends (`0x4d945a`, +/// `0x4d9575`, `0x4d95bc`). +#[test] +fn skill_req_met_compares_the_players_skill_not_the_rows_colour() { + const ALCHEMY: u32 = 171; + let spells = empty_catalog(); + let lines = SkillLineCatalog::from_spell_lines([]).with_line_names([(ALCHEMY, "Alchemy")]); + let met = |state: u8, skills: Option<&[benilla_protocol::messages::PlayerSkillSlot]>| { + let mut w = wire(2018, state, 100, 10, ALCHEMY); + w.req_skill_value = 50; + resolve_service_skilled( + &w, + TRAINER_TYPE_TRADESKILL, + &spells, + Some(&lines), + &BTreeSet::new(), + skills, + ) + .skill_req + .map(|r| r.met) + }; + let red = trainer_spell_state::RED; + let green = trainer_spell_state::GREEN; + assert_eq!( + met(red, Some(&[skill_slot(171, 50, 0)])), + Some(true), + "50 of 50, red for level" + ); + assert_eq!( + met(green, Some(&[skill_slot(171, 40, 9)])), + Some(false), + "49 of 50" + ); + assert_eq!( + met(green, Some(&[skill_slot(171, 40, 10)])), + Some(true), + "bonus counts" + ); + assert_eq!( + met(green, Some(&[skill_slot(171, 0, 80)])), + Some(false), + "no bonus on rank 0" + ); + assert_eq!( + met(green, Some(&[skill_slot(164, 300, 0)])), + Some(false), + "no slot for the line" + ); + assert_eq!(met(red, None), None, "no player is the miss `nil, 0, 1`"); + // A zero value and a line with no row are the miss too (`0x4d94a0`, `0x4d94c5`). + let skill_req = |req_skill: u32, value: u32| { + let mut w = wire(2018, green, 100, 10, req_skill); + w.req_skill_value = value; + resolve_service_skilled( + &w, + TRAINER_TYPE_TRADESKILL, + &spells, + Some(&lines), + &BTreeSet::new(), + Some(&[]), + ) + .skill_req + }; + assert_eq!(skill_req(ALCHEMY, 0), None, "a zero value"); + assert_eq!(skill_req(0, 50), None, "a zero line"); + assert_eq!(skill_req(999, 50), None, "a line with no row"); + assert_eq!( + skill_req(ALCHEMY, 50).map(|r| r.name), + Some("Alchemy".into()) + ); +} + +/// `GetTrainerServiceSkillLine` (`0x4d9160`) names the line of the ability row of the spell the +/// first learn effect teaches (`0x4d91e6`), else of the sent spell, and falls on to the spell the +/// record's third `EffectMiscValue` numbers (`0x4d9261`); nil without a player or a match. It is +/// not the group's name, which at a tradeskill trainer is the learn header. +#[test] +fn skill_line_name_is_the_taught_spells_line_not_the_group() { + let learn = |taught: u32| SpellDisplay { + name: "Alchemy".into(), + effects: [SPELL_EFFECT_LEARN_SPELL, 0, 0], + effect_trigger_spell: [taught, 0, 0], + ..Default::default() + }; + let spells = SpellCatalog::from_displays(HashMap::from([ + (2018, learn(2259)), + (2019, learn(31337)), + (2259, taught_ability()), + (78, taught_ability()), + ( + 5000, + SpellDisplay { + effect_misc_value: [0, 0, 78], + ..taught_ability() + }, + ), + ])); + let lines = SkillLineCatalog::from_spell_lines([(2259, 171), (78, 26)]) + .with_line_names([(171, "Alchemy"), (26, "Arms")]); + let name = |spell: u32, trainer_type: u32, skills: Option<&[_]>| { + resolve_service_skilled( + &wire(spell, trainer_spell_state::GREEN, 100, 1, 0), + trainer_type, + &spells, + Some(&lines), + &BTreeSet::new(), + skills, + ) + .skill_line + }; + let player = Some(&[][..]); + assert_eq!( + name(2018, TRAINER_TYPE_TRADESKILL, player).as_deref(), + Some("Alchemy") + ); + assert_eq!(name(2018, TRAINER_TYPE_TRADESKILL, None), None, "no player"); + assert_eq!( + name(2019, 0, player), + None, + "a taught spell with no ability row" + ); + assert_eq!( + name(78, 0, player).as_deref(), + Some("Arms"), + "the sent spell's own row" + ); + assert_eq!( + name(5000, 0, player).as_deref(), + Some("Arms"), + "the third misc value's spell" + ); + assert_eq!(name(31337, 0, player), None, "no record"); +} diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 10b13905a..cd89da7af 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -843,12 +843,15 @@ fn feed_player_control( fn feed_farsight_focus( mut hosted_vm: crate::script_calls::HostedVm, self_q: Query<&ObjectStore, With>, + far_sight: Option>, mut focus: Local>>, ) { hosted_vm.run(|script| { let Some(script) = script else { return; }; + // `HasFullControl` refuses while the player's far-sight bit is on (`0x51a142`). + script.set_far_sight_on(far_sight.as_deref().is_some_and(|f| f.on())); let Some(store) = self_q.iter().next() else { return; }; @@ -1065,13 +1068,15 @@ pub(crate) fn unit_class_names<'a>( /// What names a held unit as the getters read it now ([`benilla_ui::script::UnitIdentityRead`]): /// its name off the name caches as `GetUnitName` (`0x609210`) reads them, asking a miss once while -/// it is pending, and its race and class bytes through the tables. +/// it is pending, its race and class bytes through the tables, and its creature family off its +/// cached record. pub(crate) struct LiveIdentity<'a> { pub(crate) guid: u64, pub(crate) store: &'a ObjectStore, pub(crate) names: Option<&'a NameCache>, pub(crate) commands: Option<&'a NetCommands>, pub(crate) tables: UnitNames<'a>, + pub(crate) families: Option<&'a crate::ui_pet_stats::PetFamilyTables>, } impl benilla_ui::script::UnitIdentityRead for LiveIdentity<'_> { @@ -1090,6 +1095,10 @@ impl benilla_ui::script::UnitIdentityRead for LiveIdentity<'_> { fn class(&self) -> Option<(String, String)> { unit_class_names(self.store, &self.tables).map(|(n, f)| (n.to_string(), f.to_string())) } + /// `UnitCreatureFamily` (`0x51a310`): the word of the record's family id, read only. + fn family(&self) -> Option { + crate::ui_pet_stats::family_for(Some(self.store), self.names?, self.families).0 + } } /// What a unit's descriptor alone answers ([`UnitObject`]), read off `store` with no table or @@ -3085,7 +3094,7 @@ mod tests { app.init_resource::() .init_resource::() .add_systems(Update, (feed_player_control, feed_farsight_focus)); - let script = UiScript::new().unwrap(); + let mut script = UiScript::new().unwrap(); script .run( r#" @@ -3098,6 +3107,15 @@ mod tests { "#, ) .unwrap(); + // The player object `HasFullControl` needs, alive. + script.set_unit( + "player", + Some(benilla_ui::script::UnitState { + exists: true, + health: 100, + ..Default::default() + }), + ); app.insert_non_send_resource(script); let me = app .world_mut() diff --git a/crates/benilla-app/tests/mail_frame.rs b/crates/benilla-app/tests/mail_frame.rs index f886ebbcf..5e80f81a6 100644 --- a/crates/benilla-app/tests/mail_frame.rs +++ b/crates/benilla-app/tests/mail_frame.rs @@ -91,6 +91,7 @@ fn row(sender: &str, subject: &str, was_read: bool, item_id: u32, cod: u32) -> M item_name: (item_id != 0).then(|| "Linen Cloth".to_string()), item_texture: (item_id != 0).then(|| "Interface\\Icons\\INV_Fabric_Linen_01".to_string()), item_quality: (item_id != 0).then_some(1), + item_inventory_type: 0, item_random_property_id: 0, item_charges: 0, item_durability: (0, 0), diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index c1b384be5..94103f310 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -50,8 +50,8 @@ pub struct TokenContext<'a> { /// branch on. Asked only where a branch expands. pub gender: &'a dyn Fn() -> u8, /// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through - /// `AreaTable.dbc`; `None` leaves the token raw. Asked only where a `$z` expands, so a caller - /// can tell which texts read the bind point. + /// `AreaTable.dbc`; `None` is the `HOME_INN` text (`507f3a`). Asked only where a `$z` expands, + /// so a caller can tell which texts read the bind point. pub home_area: &'a dyn Fn() -> Option<&'a str>, /// `GetText 0x703bf0`: a `GlobalStrings` template by key. `None` leaves the token raw. pub global: &'a dyn Fn(&str) -> Option, @@ -466,9 +466,12 @@ fn token_value( text(v.to_string(), f64::from(v)) } 'z' => { - // Player state, not spell data: the home-bind area name. - let name = (ctx.home_area)()?; - text(name.to_string(), 0.0) + // Player state, not spell data: the home-bind area name; a bind zone out of range or + // without an AreaTable row reads `HOME_INN` through GetText (`507f07`-`507f45`). + match (ctx.home_area)() { + Some(name) => text(name.to_string(), 0.0), + None => Some((keyed("HOME_INN", Vec::new()), 0.0)), + } } 'c' | 'p' => { // `507dde`/`507ded`: the cost or the per-second cost of the token's record, through @@ -741,6 +744,7 @@ mod tests { "SPELL_DURATION_MIN" => "<%.2fmin>", "INT_SPELL_POINTS_SPREAD_TEMPLATE" => "<%d..%d>", "SPELL_POINTS_SPREAD_TEMPLATE" => "<%.1f to %.1f>", + "HOME_INN" => "", _ => return None, } .into(), @@ -1631,10 +1635,11 @@ mod tests { substitute("Returns you to $z.", &d, &c), "Returns you to Goldshire." ); + // No bind area resolves: the `HOME_INN` text, never the raw token (`507f3a`). let unbound = ctx(&durations, &radii, &lookup); assert_eq!( substitute("Returns you to $z.", &d, &unbound), - "Returns you to $z." + "Returns you to ." ); } diff --git a/crates/benilla-protocol/src/messages/stable.rs b/crates/benilla-protocol/src/messages/stable.rs index 161532635..bd3d12e6f 100644 --- a/crates/benilla-protocol/src/messages/stable.rs +++ b/crates/benilla-protocol/src/messages/stable.rs @@ -20,11 +20,21 @@ pub struct StabledPet { pub name: String, /// Loyalty level 1..6 (`Pet.h:57-65`), a `PetLoyalty.dbc` row id, not a string. pub loyalty: u32, - /// The client's index: 0 is the current pet, 1..=2 the stable slots. The wire byte is one - /// higher (`SendStablePet` writes `character_pet.slot + 1`) and is rebased here, once. + /// The wire byte less one (`SendStablePet` writes `character_pet.slot + 1`, 1 for the + /// current pet), rebased here once. The client does not seat by it: bit 1 of the raw byte + /// tells the summoned pet from a stabled one ([`Self::is_summoned`]), and the stabled pets + /// take the array in list order (`0x4cabe3`). pub slot: u8, } +impl StabledPet { + /// The row the client files as the summoned pet: bit 1 of the raw slot byte clear + /// (`test al,2` at `0x4cabe3`), so a byte of 1, 4 or 5 is summoned and 2 or 3 is stabled. + pub fn is_summoned(&self) -> bool { + self.slot.wrapping_add(1) & 2 == 0 + } +} + /// `SMSG_STABLE_RESULT` codes, vmangos `StableResultCode` (`NPCHandler.cpp:40-47`). pub mod stable_result { /// `STABLE_ERR_MONEY`: not enough gold for the next slot; only a slot purchase gets it. @@ -78,8 +88,8 @@ pub fn stable_swap_pet(npc_guid: u64, pet_number: u32) -> Vec { // No `CMSG_STABLE_REVIVE_PET` (0x0274) verb: vmangos's handler is empty (`NPCHandler.cpp:731`). /// Read `MSG_LIST_STABLED_PETS` from the server (`NPCHandler.cpp:522-575`). The slot count is how -/// many are bought (0..=2), not occupied. The current pet's row may be missing, so find rows by -/// [`StabledPet::slot`], not position. +/// many are bought (0..=2), not occupied. The current pet's row may be missing: the rows are the +/// summoned pet ([`StabledPet::is_summoned`]) and the stabled ones in list order. pub(super) fn read_list_stabled_pets(r: &mut &[u8]) -> io::Result<(u64, u8, Vec)> { let npc = read_u64_le(r)?; let num_pets = read_u8(r)?; diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index dd6767fe1..326aac747 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -217,6 +217,22 @@ pub(crate) struct StoredActionState { pub(crate) cooldown: Option<(f64, f64, bool)>, } +/// Whether `UseAction(slot, 1)` sends the held payload to placement (`0x4e5f14`-`0x4e5f42`): a +/// spell (`GetCursorSpell` `0x494e10`, the cell `[0xb4e2f4]`), a mode-1 item (`GetCursorItemGuid` +/// `0x494c60`, a nonzero guid), a macro (`[0xb4e2fc]`) or a mode-7 payload with an entry +/// (`[0xb4d900] == 7` and `GetCursorAmmo` `0x495170`). Coins, a pet action, a vendor row, a +/// stabled pet and ammo fall through to using the slot (`0x4e5f52`). +fn places_held(held: &super::cursor::CursorPayload) -> bool { + use super::cursor::CursorPayload::{Action, Item, Macro, Spell}; + match held { + Spell(s) => s.spell_id != 0, + Item(i) => i.guid != 0, + Macro(m) => m.id != 0, + Action(a) => a.action != 0, + _ => false, + } +} + /// One queued `UseAction` press. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ActionUse { @@ -444,10 +460,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // UseAction(action [, checkCursor [, onSelf]]): a nonzero `checkCursor` while the cursor holds - // a payload places it, as `PlaceAction` does; otherwise the press is queued with `onSelf`, the - // self-cast flag. FrameXML passes 1 from a click and 0 from a keybind; the binding's own test - // of it is untraced, and place-on-click is the reading that split implies. + // UseAction(action [, checkCursor [, onSelf]]) (`0x4e5ee0`): a nonzero `checkCursor` while the + // cursor holds a spell, a mode-1 item, a macro or a mode-7 payload places it, as `PlaceAction` + // does ([`places_held`]); any other payload, or none, falls through to the press, queued with + // `onSelf`, the self-cast flag. FrameXML passes 1 from a click and 0 from a keybind. g.set( "UseAction", lua.create_function(|lua, (action, rest): (Value, MultiValue)| { @@ -463,7 +479,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let on_self = bool_or_default(rest.get(1), false); let may_cast = protected::gate(lua, Tag::CAST); let model = lua.app_data_ref::().expect("model app_data"); - if check_cursor && model.cursor.is_some() { + if check_cursor && model.cursor.as_ref().is_some_and(places_held) { drop(model); super::cursor::place_action(lua, action)?; } else { @@ -956,6 +972,50 @@ mod tests { assert_eq!(uses(&mut s), vec![(10, false)]); } + /// Only a spell, a mode-1 item, a macro or a mode-7 payload goes to placement (`0x4e5f14`- + /// `0x4e5f42`); coins, a pet action, a vendor row, a stabled pet and ammo go on to the use + /// (`0x4e5f52`) and stay held. + #[test] + fn use_action_check_cursor_uses_the_slot_under_a_payload_it_does_not_place() { + use crate::script::cursor::{ + CursorAmmo, CursorMerchantItem, CursorMoney, CursorPayload, CursorPetAction, + CursorStablePet, + }; + + let mut s = UiScript::new().unwrap(); + s.keep_calls(); + let held = [ + CursorPayload::Money(CursorMoney { copper: 50 }), + CursorPayload::PetAction(CursorPetAction { + src_slot: 1, + packed: 0x0700_0002, + passive: false, + texture: None, + view: Default::default(), + }), + CursorPayload::Merchant(CursorMerchantItem { + item_id: 2589, + row: 0, + texture: None, + }), + CursorPayload::StablePet(CursorStablePet { + slot: 1, + texture: "Interface\\Icons\\Ability_Hunter_Pet_Boar".into(), + }), + CursorPayload::Ammo(CursorAmmo { + item_id: 2512, + texture: None, + }), + ]; + for payload in held { + let name = format!("{payload:?}"); + s.set_cursor_for_test(payload); + s.run("UseAction(9, 1)").unwrap(); + assert_eq!(uses(&mut s), vec![(9, false)], "{name} uses the slot"); + assert!(s.cursor_payload().is_some(), "{name} stays held"); + } + } + // ── `GetActionText` (`0x4e7050`) ── #[test] diff --git a/crates/benilla-ui/src/script/client.rs b/crates/benilla-ui/src/script/client.rs index 5f3caa5c8..f5da616e0 100644 --- a/crates/benilla-ui/src/script/client.rs +++ b/crates/benilla-ui/src/script/client.rs @@ -208,13 +208,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // `GetBindLocation()`: the hearthstone's bind location name, which the instance-boot dialog - // formats into its text (`StaticPopup.lua:1742`). `""` until `SMSG_BINDPOINTUPDATE` lands, as - // the reference's empty buffer, never nil, since addons concatenate it. + // formats into its text (`StaticPopup.lua:1742`). A bind zone below 0, past the max or without + // an AreaTable row, which is every zone before `SMSG_BINDPOINTUPDATE` lands, answers the + // `HOME_INN` text (`48db19`-`48db2c`), never nil. g.set( "GetBindLocation", lua.create_function(|lua, ()| { - let model = lua.app_data_ref::().expect("model app_data"); - lua.create_string(&model.bind_location) + let named = lua + .app_data_ref::() + .expect("model app_data") + .bind_location + .clone(); + match named { + Some(name) => lua.create_string(name), + None => lua.create_string(super::glue_core::global_text(lua, "HOME_INN")), + } })?, )?; @@ -246,12 +254,19 @@ impl super::UiScript { self.take_calls_like(&super::ScriptCall::OpeningCinematic) as u32 } + /// The bind location's name as `GetBindLocation` has it pushed: `None` while the bind zone has + /// no AreaTable row, which the `$z` token reads as the `HOME_INN` text. + pub fn bind_location(&self) -> Option { + self.model_ref().bind_location.clone() + } + /// Push the bind location's name, resolved from `SMSG_BINDPOINTUPDATE`'s AreaTable id through - /// the catalog the hearthstone's `$z` uses, so the two agree; empty until the packet lands. - pub fn set_bind_location(&mut self, area_name: &str) { + /// the catalog the hearthstone's `$z` uses, so the two agree; `None` until the packet lands, or + /// for an area with no row, which `GetBindLocation` answers with the `HOME_INN` text. + pub fn set_bind_location(&mut self, area_name: Option<&str>) { let mut model = self.model_mut(); - if model.bind_location != area_name { - model.bind_location = area_name.to_string(); + if model.bind_location.as_deref() != area_name { + model.bind_location = area_name.map(str::to_string); } } diff --git a/crates/benilla-ui/src/script/craft.rs b/crates/benilla-ui/src/script/craft.rs index 32ac7b8dd..a70848ba2 100644 --- a/crates/benilla-ui/src/script/craft.rs +++ b/crates/benilla-ui/src/script/craft.rs @@ -140,7 +140,8 @@ pub struct CraftRecipe { /// The open craft window, pushed whole by the app. #[derive(Clone, Debug, PartialEq)] pub struct CraftState { - /// The title `GetCraftName` answers. + /// The window's identity for the app's open detection; `GetCraftName` reads the effect slot + /// of [`Self::craft_type`] instead ([`UiScript::set_craft_slot_names`]). pub name: String, /// `GetCraftDisplaySkillLine`'s name, with [`Self::rank`] and [`Self::max_rank`] its rank /// and maximum, each with the permanent bonus; `None` answers nil, 0, 0. @@ -385,6 +386,16 @@ impl super::UiScript { } } + /// Push the names of the spells in the player's four effect slots (`CGPlayer_C+0x1d10`), + /// which a learn writes by the spell's misc value (`0x5e9e1c`-`0x5e9e36`); `GetCraftName` + /// answers the one at the open craft type, slot 0 while none is open. + pub fn set_craft_slot_names(&mut self, names: [Option; 4]) { + let mut model = self.model_mut(); + if model.craft_slot_names != names { + model.craft_slot_names = names; + } + } + /// The open's resets (`0x4f601c`-`0x4f602c`): the selection cleared and every group expanded, /// then the build (`0x4f6036`). pub fn craft_opened(&mut self) { @@ -496,17 +507,19 @@ fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result { pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); - // GetCraftName() → the title, or "UNKNOWN" when closed (the reference's closed answer is - // untraced); the stock window sets its title unconditionally (`Blizzard_CraftUI.lua:101`). + // GetCraftName() (`0x4f6c10`) → the name of the player's effect-slot spell for the open craft + // type (`GetEffectSlotSpell 0x5ea360`, the marker `[0xbdcfb8]`, set at `0x4f601c`), type 0 with + // no window open; nil with no player, no such slot or no such spell (`0x4f6c74`). g.set( "GetCraftName", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - let name = model - .craft - .as_ref() - .map_or_else(|| "UNKNOWN".to_string(), |c| c.name.clone()); - Ok(Value::String(lua.create_string(&name)?)) + let marker = model.open_professions().craft.unwrap_or(0); + let name = usize::try_from(marker) + .ok() + .and_then(|m| model.craft_slot_names.get(m)) + .and_then(|n| n.as_deref()); + opt_str(lua, name.map(str::to_string).as_ref()) })?, )?; @@ -1072,6 +1085,7 @@ mod tests { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_craft(Some(state())); + s.set_craft_slot_names([None, None, None, Some("Enchanting".into())]); assert_eq!( s.eval::("return GetCraftName()").unwrap(), @@ -1441,14 +1455,45 @@ mod tests { assert!(s.take_craft_dos().is_empty()); } + /// `GetCraftName` reads the effect-slot spell of the open type (`0x4f601c`), type 0 with the + /// window closed (`0x4f6c32`-`0x4f6c74`): nil with no such spell, the slot's name once the + /// player holds one, and slot 0 again after a `CloseCraft` of an open window. #[test] - fn no_snapshot_shapes_unknown_name_and_nil_info() { + fn the_name_follows_the_open_marker_and_its_effect_slot() { let mut s = UiScript::new().unwrap(); s.keep_calls(); + assert!(s.eval::("return GetCraftName() == nil").unwrap()); + s.set_craft_slot_names([ + Some("Alchemy".into()), + None, + None, + Some("Enchanting".into()), + ]); assert_eq!( s.eval::("return GetCraftName()").unwrap(), - "UNKNOWN" + "Alchemy" ); + s.set_craft(Some(state())); + assert_eq!( + s.eval::("return GetCraftName()").unwrap(), + "Enchanting", + "open at type 3: slot 3" + ); + s.run("CloseCraft()").unwrap(); + assert_eq!( + s.eval::("return GetCraftName()").unwrap(), + "Alchemy", + "the marker is 0 again" + ); + s.set_craft_slot_names(Default::default()); + assert!(s.eval::("return GetCraftName() == nil").unwrap()); + } + + #[test] + fn no_snapshot_shapes_nil_name_and_nil_info() { + let mut s = UiScript::new().unwrap(); + s.keep_calls(); + assert!(s.eval::("return GetCraftName() == nil").unwrap()); assert!(s .eval::("return GetCraftDisplaySkillLine() == nil") .unwrap()); diff --git a/crates/benilla-ui/src/script/gossip.rs b/crates/benilla-ui/src/script/gossip.rs index 9c7690b8a..c881601c1 100644 --- a/crates/benilla-ui/src/script/gossip.rs +++ b/crates/benilla-ui/src/script/gossip.rs @@ -128,11 +128,13 @@ impl super::UiScript { pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); + // The static buffer `0xbbb678` (`0x4e28c0`): "" before any gossip (.bss), never nil, and what + // the last window drew after it closes. g.set( "GetGossipText", - lua.create_function(|lua, ()| match read(lua, |m| m.greeting.clone()) { - Some(t) => Ok(Value::String(lua.create_string(&t)?)), - None => Ok(Value::Nil), + lua.create_function(|lua, ()| { + let text = read(lua, |m| m.greeting.clone()).unwrap_or_default(); + lua.create_string(&text) })?, )?; @@ -274,7 +276,11 @@ mod tests { fn gossip_snapshot_reads_and_selects_queue() { let mut s = UiScript::new().unwrap(); s.keep_calls(); - assert!(s.eval::("return GetGossipText() == nil").unwrap()); + assert_eq!( + s.eval::("return GetGossipText()").unwrap(), + "", + "the buffer is empty before any gossip, never nil (`0x4e28c0`)" + ); assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0); s.set_gossip(Some(menu())); @@ -399,7 +405,7 @@ mod tests { let mut s = UiScript::new().unwrap(); s.set_gossip(Some(menu())); s.set_gossip(None); - assert!(s.eval::("return GetGossipText() == nil").unwrap()); + assert_eq!(s.eval::("return GetGossipText()").unwrap(), ""); assert_eq!(s.arity("GetGossipOptions()").unwrap(), 0); } } diff --git a/crates/benilla-ui/src/script/item_text.rs b/crates/benilla-ui/src/script/item_text.rs index 35104a42b..37b682741 100644 --- a/crates/benilla-ui/src/script/item_text.rs +++ b/crates/benilla-ui/src/script/item_text.rs @@ -62,15 +62,16 @@ pub enum ItemTextCall { pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let g = lua.globals(); + // `ItemTextGetItem()` (`0x4e38f0`): the open object's name (`0x4e391f`), nil with no object + // (`0x4e3919`), `lua_pushstring(NULL)` (`0x4e3928`). g.set( "ItemTextGetItem", lua.create_function(|lua, ()| { let model = lua.app_data_ref::().expect("model app_data"); - Ok(model - .item_text - .as_ref() - .map(|s| s.item.clone()) - .unwrap_or_default()) + match model.item_text.as_ref() { + Some(s) => Ok(Value::String(lua.create_string(&s.item)?)), + None => Ok(Value::Nil), + } })?, )?; @@ -170,8 +171,9 @@ mod tests { fn getters_mirror_the_pushed_state() { let mut s = UiScript::new().unwrap(); s.keep_calls(); - // No session: empty defaults; the frame reads these only while shown. - assert_eq!(s.eval::("return ItemTextGetItem()").unwrap(), ""); + // No session: no object to name, so nil (`0x4e3919`); the frame reads these only while + // shown. + assert!(s.eval::("return ItemTextGetItem() == nil").unwrap()); assert!(s .eval::("return ItemTextGetCreator() == nil") .unwrap()); @@ -193,6 +195,10 @@ mod tests { assert!(s .eval::("return ItemTextGetMaterial() == nil") .unwrap()); + + // The close clears the session: `if ItemTextGetItem() then` is false again. + s.set_item_text(None); + assert!(s.eval::("return ItemTextGetItem() == nil").unwrap()); } #[test] diff --git a/crates/benilla-ui/src/script/loot_roll.rs b/crates/benilla-ui/src/script/loot_roll.rs index f9a68b86c..1191f0e60 100644 --- a/crates/benilla-ui/src/script/loot_roll.rs +++ b/crates/benilla-ui/src/script/loot_roll.rs @@ -162,7 +162,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // `nil` for an unknown id or while the item template is in flight. + // No values at all for an unknown id or while the item template is in flight: both exits are + // `xor eax,eax` (`0x4c3255`, `0x4c3277` → `0x4c322c`). g.set( "GetLootRollItemLink", lua.create_function(|lua, roll_id: Value| { @@ -173,8 +174,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { find(&model, roll_id).and_then(|r| r.link.clone()) }; match link { - Some(link) => Ok(mlua::Value::String(lua.create_string(&link)?)), - None => Ok(mlua::Value::Nil), + Some(link) => Ok(mlua::MultiValue::from_vec(vec![mlua::Value::String( + lua.create_string(&link)?, + )])), + None => Ok(mlua::MultiValue::new()), } })?, )?; @@ -302,9 +305,11 @@ mod tests { s.eval::("return GetLootRollItemLink(7)").unwrap(), "|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r" ); - assert!(s - .eval::("return GetLootRollItemLink(8) == nil") - .unwrap()); + assert_eq!( + s.arity("GetLootRollItemLink(8)").unwrap(), + 0, + "an uncached item returns no values (`0x4c3277`)" + ); // An unknown id: the reference's miss tail (`0x4c31a3`). assert!(s @@ -319,9 +324,11 @@ mod tests { 5, "the reference's five returns (`0x4c31a3`)" ); - assert!(s - .eval::("return GetLootRollItemLink(99) == nil") - .unwrap()); + assert_eq!( + s.arity("GetLootRollItemLink(99)").unwrap(), + 0, + "an unknown roll returns no values (`0x4c3255`)" + ); assert_eq!(s.eval::("return GetLootRollTimeLeft(99)").unwrap(), 0); } diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index 1e7092709..eb8d79f84 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -83,6 +83,8 @@ pub struct MailInboxRow { pub item_name: Option, pub item_texture: Option, pub item_quality: Option, + /// The record's `InventoryType`, 0 while uncached; the quality shows only for a nonzero one. + pub item_inventory_type: u32, } /// One auction invoice, `GetInboxInvoiceInfo`'s answer, parsed by the app from the mail's subject @@ -619,7 +621,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // GetInboxItem(index): name, texture, count, quality, canUse (`MailFrame.lua:379`); canUse is - // the shared item-usable gate, true while the template is in flight. + // the shared item-usable gate. A template the cache lacks takes the miss exit with the five + // values `nil, nil, 0, 0, nil` (`0x4af674`-`0x4af679`, `0x4af792`); the count is the row's raw + // byte (`0x4af704`) and the quality the record's only for a nonzero inventory type, else -1 + // (`0x4af721`-`0x4af730`). g.set( "GetInboxItem", lua.create_function(|lua, index: Value| { @@ -633,15 +638,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .is_none_or(|r| super::item_stats::item_usable_by_id(lua, &model, r.item_id)); (row, usable) }; - // Five values on every path; with no item, `nil, nil, 0, 0, nil` (`0x4af5d0`). + let miss = || { + MultiValue::from_vec(vec![ + Value::Nil, + Value::Nil, + Value::Integer(0), + Value::Integer(0), + Value::Nil, + ]) + }; let Some(r) = row.filter(|r| r.item_id != 0) else { - return Ok(MultiValue::from_vec(vec![ - Value::Nil, - Value::Nil, - Value::Integer(0), - Value::Integer(0), - Value::Nil, - ])); + return Ok(miss()); }; // The attachment's record off the item cache as the call runs. let key = ItemKey::Record { @@ -651,10 +658,24 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { ..Default::default() }, }; - let (name, texture, quality) = read_live(lua, Some(key), |item| { - (item.name(), item.texture(), item.quality()) + let (cached, name, texture, quality) = read_live(lua, Some(key), |item| { + let quality = + super::item_stats::gated_quality(item.quality(), item.inventory_type()); + (item.cached(), item.name(), item.texture(), quality) }) - .unwrap_or_else(|| (r.item_name.clone(), r.item_texture.clone(), r.item_quality)); + .unwrap_or_else(|| { + let quality = + super::item_stats::gated_quality(r.item_quality, r.item_inventory_type); + ( + r.item_quality.is_some(), + r.item_name.clone(), + r.item_texture.clone(), + quality, + ) + }); + if !cached { + return Ok(miss()); + } let name = match &name { Some(n) => Value::String(lua.create_string(n)?), None => Value::Nil, @@ -663,13 +684,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Some(t) => Value::String(lua.create_string(t)?), None => Value::Nil, }; - // A number on every path, as in `GetSendMailItem`. - let quality = Value::Integer(i64::from(quality.unwrap_or(0))); Ok(MultiValue::from_vec(vec![ name, texture, - Value::Integer(i64::from(r.item_count.max(1))), - quality, + Value::Integer(i64::from(r.item_count)), + Value::Integer(quality), flag(usable), ])) })?, @@ -1411,6 +1430,7 @@ mod tests { item_texture: (item_id != 0) .then(|| "Interface\\Icons\\INV_Fabric_Linen_01".to_string()), item_quality: (item_id != 0).then_some(1), + item_inventory_type: 0, item_random_property_id: 0, item_charges: 0, item_durability: (0, 0), @@ -1704,12 +1724,35 @@ mod tests { fn get_inbox_item_and_can_delete() { let mut s = kept(); s.set_mail(Some(state())); + // Linen Cloth has no inventory type: the quality is -1 (`0x4af721`-`0x4af730`). let (name, _tex, count, quality, canuse): (String, String, i64, i64, i64) = s.eval("return GetInboxItem(1)").unwrap(); assert_eq!( (name.as_str(), count, quality, canuse), - ("Linen Cloth", 3, 1, 1) + ("Linen Cloth", 3, -1, 1) ); + // An equippable record shows its own quality. + let mut st = state(); + st.inbox[0].item_inventory_type = 4; + s.set_mail(Some(st)); + assert_eq!( + s.eval::("local _, _, _, q = GetInboxItem(1) return q") + .unwrap(), + 1 + ); + // An uncached template takes the miss exit: `nil, nil, 0, 0, nil` (`0x4af674`-`0x4af679`). + let mut st = state(); + st.inbox[0].item_quality = None; + st.inbox[0].item_name = None; + s.set_mail(Some(st)); + assert_eq!(s.arity("GetInboxItem(1)").unwrap(), 5); + assert!(s + .eval::( + "local n, t, c, q, u = GetInboxItem(1) \ + return n == nil and t == nil and c == 0 and q == 0 and u == nil" + ) + .unwrap()); + s.set_mail(Some(state())); assert!(s.eval::("return GetInboxItem(2) == nil").unwrap()); // A player's unreturned mail is returnable, not deletable. assert!(s diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 56d67ade1..d282732aa 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -703,6 +703,9 @@ pub(crate) struct Model { /// `HasFullControl`, the reference's `[0xb4b3e4]`: set by `SMSG_CLIENT_CONTROL_UPDATE` for the /// player, boots 1, and every cast, item and cursor gate refuses at 0. pub(crate) player_control: bool, + /// `+0x1c70` bit 0 of the player: far sight is on (`CMSG_FAR_SIGHT` 1 sent), which + /// `HasFullControl` refuses (`0x51a142`). + pub(crate) far_sight_on: bool, /// The on-use spells that pass the item dispatcher's control gate without control of the /// character (`0x5d8ec0`-`0x5d8f6a`), fed by the app. pub(crate) control_bypass: HashSet, @@ -861,6 +864,10 @@ pub(crate) struct Model { pub(crate) craft: Option, /// The window is open (`[0xbdcfb8]`), which a close alone zeroes (`0x4f5fe0`). pub(crate) craft_open: bool, + /// The names of the spells in the player's four effect slots (`CGPlayer_C+0x1d10`, + /// `0x5ea360`), which `GetCraftName` reads at the open marker (`[0xbdcfb8]`, 0 closed); `None` + /// is nil. + pub(crate) craft_slot_names: [Option; 4], /// The selected spell id (`0xbdcfbc`), 0 for none, which a header or an index past the rows /// stores (`0x4f6050`); the index answers by finding its row (`0x4f6080`). pub(crate) craft_selection: u32, @@ -1175,8 +1182,8 @@ pub(crate) struct Model { /// `"player"` pushes that carry it: corrected, never blanked. A per-login rebuild is unseen, as /// its verbs exist only in game (`0x850438`), not at the glue screen (`0x46abb0`). pub(crate) player_record: super::PlayerRecord, - /// `GetBindLocation()`, the bind point's area name: `""` before it lands, never nil. - pub(crate) bind_location: String, + /// `GetBindLocation()`, the bind point's area name; `None` answers the `HOME_INN` text. + pub(crate) bind_location: Option, /// `GMTicketCategory.dbc` `(id, name)` rows in file order; the ids are wire values. pub(crate) gm_ticket_categories: Vec<(u32, String)>, /// `CheckBinderDist()`: the innkeeper's question is live and in range; false hides its dialog. @@ -1578,6 +1585,7 @@ impl Model { shapeshift_forms: Vec::new(), pet_bar: super::pet::PetBarState::default(), player_control: true, + far_sight_on: false, control_bypass: HashSet::new(), containers: HashMap::new(), container_cooldowns: HashMap::new(), @@ -1637,6 +1645,7 @@ impl Model { trade_skill_repeat_count: 0, craft: None, craft_open: false, + craft_slot_names: Default::default(), craft_selection: 0, craft_rows: Vec::new(), craft_visible: 0, @@ -1717,7 +1726,7 @@ impl Model { screenshot_asks: 0, realm_name: String::new(), player_record: super::PlayerRecord::default(), - bind_location: String::new(), + bind_location: None, gm_ticket_categories: Vec::new(), binder_pending: false, summon_confirm: super::summon::SummonConfirmUiState::default(), diff --git a/crates/benilla-ui/src/script/pet.rs b/crates/benilla-ui/src/script/pet.rs index 72e6d8a07..06cdd2e66 100644 --- a/crates/benilla-ui/src/script/pet.rs +++ b/crates/benilla-ui/src/script/pet.rs @@ -103,9 +103,9 @@ pub(crate) struct StoredPetAction { } /// The pet stat block: the four hunter stat bindings and `HasPetUI`'s second return -/// (`0x4be670`), plus the family word, icon and diet. The stats share one hunter-pet gate +/// (`0x4be670`), plus the family icon and diet. The stats share one hunter-pet gate /// (`0x6116e0`) and fail their own ways: loyalty to nil, the pairs to `(0, 0)`, happiness to -/// `(nil, 100.0, 0.0)`. The family word has no gate (`0x51a310`); the diet shares the stats'. +/// `(nil, 100.0, 0.0)`. The diet shares the stats' gate. #[derive(Clone, Debug, Default, PartialEq)] pub struct PetStats { /// `HasPetUI`'s second return; false makes every gated binding answer its failure form. @@ -122,10 +122,6 @@ pub struct PetStats { pub training_points: (u16, u16), /// `GetPetExperience`: `(currXP, nextXP)`. pub experience: (u32, u32), - /// `UnitCreatureFamily("pet")`: the localized `CreatureFamily.dbc` word off the cached creature - /// record (`0x51a310`). `None` is nil: no record yet, id 0, or an id with no row (10, 13, 14, - /// 18 and 22 have none). `PetPaperDollFrame.lua:68-70` guards its level line on it. - pub family: Option, /// `GetPetIcon()`: the family row's `CreatureFamily.dbc` icon, `None` on the family word's /// misses. Ungated like the family word; whether the reference gates it (`0x4beb10`) is /// untraced, and no warlock reaches its stock callers (`PetStable.lua:51`, `161-162`). @@ -342,6 +338,11 @@ impl super::UiScript { self.model_mut().player_control = in_control; } + /// Set the player's far-sight bit (`+0x1c70` bit 0), which `HasFullControl` reads. + pub fn set_far_sight_on(&mut self, on: bool) { + self.model_mut().far_sight_on = on; + } + /// The drag's bar writes a harness VM kept, already applied here: one `Vec` per /// `CMSG_PET_SET_ACTION` of one or two `(0-based position, packed word)` pairs. pub fn take_pet_set_actions(&mut self) -> Vec> { @@ -467,23 +468,30 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // An elapsed or absent cooldown answers (0, 0, 1), so a re-feed never replays the sweep. + // All three outputs start at 0 (`0x4be006`-`0x4be00c`); only a pet's slot holding an action of + // type 1 to 5 (`0x4be017`-`0x4be023`) asks the list (`0x4be03c` → `0x6e2ea0`), which sets the + // enable output to 1. A command, a reaction, an empty or absent slot and no pet answer + // (0, 0, 0). An elapsed cooldown answers (0, 0, 1), so a re-feed never replays the sweep. g.set( "GetPetActionCooldown", lua.create_function(|lua, i: Value| { // `is-number` else `Usage: GetPetActionCooldown(index)` (`0x8478d8`, `0x4bdfb7`). let i = binding_abi::index_arg32(lua, i, "Usage: GetPetActionCooldown(index)")?; let now = crate::script::clock::now(lua); - let spell = slot_now(lua, i).and_then(|v| v.spell_id); + let spell = slot_now(lua, i) + .filter(|_| flags_now(lua).has_bar) + .filter(|v| matches!((v.packed >> 24) & 0x3F, 1..=5)) + .map(|v| v.packed & 0xFFFF); + let Some(spell) = spell else { + return Ok((0.0, 0.0, 0)); + }; let snapshot = { let model = lua.app_data_ref::().expect("model app_data"); slot_at(&model, i).and_then(|s| s.cooldown) }; - // A spell slot's query reads the pet's list inside the call (`0x4be03c` → - // `0x6e2ea0`), through the lent host; with none, the frame's snapshot answers. - let live = spell.and_then(|spell| { - super::cooldown_read::read_live(lua, super::CooldownQuery::PetSpell(spell)) - }); + // The query reads the pet's list inside the call, through the lent host; with none, + // the frame's snapshot answers. + let live = super::cooldown_read::read_live(lua, super::CooldownQuery::PetSpell(spell)); Ok(match live.unwrap_or(snapshot) { Some((start, duration, enabled)) if start + duration > now || !enabled => ( crate::script::clock::held_start(start, enabled, now), @@ -678,16 +686,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // One return on every path, with no class gate (`0x51a310`). Answers the `"pet"` token only: - // the reference resolves any unit off its cached creature record (`[[unit+0xb30]+0x1c]`), so - // a wild boar's `"target"` answers "Boar" there and nil here. The pet page is the only stock - // caller. + // `UnitCreatureFamily("unit")` (`0x51a310`): one return on every path, with no class gate. A + // missing or non-string argument raises the usage line (`0x51a318`, `0x51a321`); the token goes + // through the resolver (`0x51a341` → `0x515940`), which raises for an unknown one, and the + // family word is read off the unit (`0x51a34c` → `0x6055e0`); any unit answers the word of its + // cached creature record (`[[unit+0xb30]+0x1c]`), so a wild boar's `"target"` is "Boar". g.set( "UnitCreatureFamily", - lua.create_function(move |lua, token: Option| { - let family = token - .filter(|t| t.eq_ignore_ascii_case("pet")) - .and_then(|_| stats_now(lua).1.family); + lua.create_function(move |lua, token: Value| { + let token = + binding_abi::string_arg(lua, token, r#"Usage: UnitCreatureFamily("unit")"#)?; + super::unit::check_unit_token(&Some(token.clone()))?; + let family = super::unit::read_identity(lua, &token, |_, id| id.family()).flatten(); Ok(match family.as_deref() { Some(name) => Value::String(lua.create_string(name)?), None => Value::Nil, @@ -867,6 +877,7 @@ mod tests { is_token: true, active: true, attack_active: true, + packed: super::order(super::PET_ACT_COMMAND, super::PET_COMMAND_ATTACK), ..Default::default() }, PetActionView { @@ -876,6 +887,7 @@ mod tests { autocast_allowed: true, autocast_enabled: true, cooldown: Some((9400, 1500, true)), + packed: 0xC100_0000 | 1767, ..Default::default() }, PetActionView::default(), @@ -947,12 +959,16 @@ mod tests { s.tick(10.0); // GetTime == 10 s.set_pet_actions(true, true, true, slots()); - assert_eq!( - s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(1)") - .unwrap(), - (0.0, 0.0, 1), - "no cooldown reads cold" - ); + // Only a spell action on a slot with a pet asks the list, which sets the enable output + // (`0x4be017`-`0x4be03c`); a command, an empty slot and one past the bar answer zeros. + for slot in [1, 3, 11] { + assert_eq!( + s.eval::<(f64, f64, i32)>(&format!("return GetPetActionCooldown({slot})")) + .unwrap(), + (0.0, 0.0, 0), + "slot {slot} is no spell" + ); + } let (start, duration, enable) = s .eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)") .unwrap(); @@ -964,7 +980,16 @@ mod tests { assert_eq!( s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)") .unwrap(), - (0.0, 0.0, 1) + (0.0, 0.0, 1), + "a spell slot with no cooldown reads cold" + ); + + // No pet: the guid test comes first (`0x4bdfde`), so even a spell slot answers zeros. + s.set_pet_actions(false, true, true, slots()); + assert_eq!( + s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)") + .unwrap(), + (0.0, 0.0, 0) ); } @@ -1241,14 +1266,32 @@ mod tests { ); } + /// `HasFullControl` (`0x51a140`) answers 1 only with a player object, far sight off + /// (`+0x1c70` bit 0), health above 0 and the control flag set (`0x51a158`). #[test] - fn has_full_control_follows_the_hosts_flag() { + fn has_full_control_needs_the_player_alive_in_control_and_off_far_sight() { let mut s = UiScript::new().unwrap(); s.keep_calls(); + let nil = |s: &UiScript| s.eval::("return HasFullControl() == nil").unwrap(); + assert!(nil(&s), "no player object"); + let player = |health: u32| { + Some(crate::script::UnitState { + exists: true, + health, + ..Default::default() + }) + }; + s.set_unit("player", player(100)); assert_eq!(s.eval::("return HasFullControl()").unwrap(), 1); s.set_player_control(false); - assert!(s.eval::("return HasFullControl() == nil").unwrap()); + assert!(nil(&s), "control lost"); s.set_player_control(true); + s.set_far_sight_on(true); + assert!(nil(&s), "far sight or possession"); + s.set_far_sight_on(false); + s.set_unit("player", player(0)); + assert!(nil(&s), "a corpse"); + s.set_unit("player", player(1)); assert_eq!(s.eval::("return HasFullControl()").unwrap(), 1); } @@ -1277,7 +1320,6 @@ mod tests { loyalty: Some("(Loyalty Level 6) Best Friend".into()), training_points: (170, 130), experience: (4200, 8000), - family: Some("Boar".into()), food_types: vec![ "Meat".into(), "Fish".into(), @@ -1384,47 +1426,66 @@ mod tests { .unwrap()); } + /// `UnitCreatureFamily("unit")` (`0x51a310`) answers the family word of any unit's cached + /// creature record, nil for a unit with none, a missing or non-string argument raises its + /// usage line (`0x51a318`, `0x51a321`) and an unknown token the resolver's error + /// (`0x51a341`). #[test] - fn unit_creature_family_is_nil_on_every_absent_path() { + fn unit_creature_family_reads_any_unit_and_raises_on_a_bad_token() { let mut s = UiScript::new().unwrap(); s.keep_calls(); - // No pet pushed. + // No unit at all, or one with no family: nil, never "" (a truthy empty string would print + // a bare 'Level 58 ' on the pet page). + assert!(s + .eval::(r#"return UnitCreatureFamily("pet") == nil"#) + .unwrap()); + let unit = |family: Option<&str>| { + Some(crate::script::UnitState { + exists: true, + family: family.map(str::to_string), + ..Default::default() + }) + }; + s.set_unit("pet", unit(None)); assert!(s .eval::(r#"return UnitCreatureFamily("pet") == nil"#) .unwrap()); - // Family 0 and an unanswered creature query both arrive as `None`. - s.set_pet_stats( - true, - PetStats { - family: None, - ..hunter_stats() - }, - ); - assert!(s - .eval::(r#"return UnitCreatureFamily("pet") == nil"#) - .unwrap()); - assert!( - s.eval::(r#"return UnitCreatureFamily("pet") ~= ''"#) - .unwrap(), - "nil, never an empty string — '' is TRUTHY in Lua, so it would pass the ref's guard \ - and print a bare 'Level 58 ' with a trailing space" - ); - - // With a family: `"pet"` answers it, any other token nil. - s.set_pet_stats(true, hunter_stats()); - assert_eq!( - s.eval::(r#"return UnitCreatureFamily("pet")"#) - .unwrap(), - "Boar" - ); - for token in [r#""target""#, r#""player""#, "nil"] { - assert!( - s.eval::(&format!("return UnitCreatureFamily({token}) == nil")) + s.set_unit("pet", unit(Some("Imp"))); + s.set_unit("target", unit(Some("Boar"))); + for (token, want) in [("pet", "Imp"), ("target", "Boar"), ("TARGET", "Boar")] { + assert_eq!( + s.eval::(&format!(r#"return UnitCreatureFamily("{token}")"#)) .unwrap(), - "{token} must answer nil" + want, + "{token}" ); } + assert!(s + .eval::(r#"return UnitCreatureFamily("player") == nil"#) + .unwrap()); + + for (call, want) in [ + ( + "UnitCreatureFamily()", + r#"Usage: UnitCreatureFamily("unit")"#, + ), + ( + "UnitCreatureFamily(nil)", + r#"Usage: UnitCreatureFamily("unit")"#, + ), + ( + "UnitCreatureFamily({})", + r#"Usage: UnitCreatureFamily("unit")"#, + ), + ( + r#"UnitCreatureFamily("nonsense")"#, + "Unknown unit name: nonsense", + ), + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains(want), "{call}: {err}"); + } } #[test] @@ -1458,23 +1519,17 @@ mod tests { /// The diet's hunter gate is the app's to apply, so here a non-hunter's diet is just empty. #[test] - fn the_family_word_answers_for_a_non_hunter_pet() { + fn a_non_hunter_pet_has_no_diet() { let mut s = UiScript::new().unwrap(); s.keep_calls(); s.set_pet_stats( true, PetStats { hunter_pet: false, - family: Some("Imp".into()), food_types: vec![], ..PetStats::default() }, ); - assert_eq!( - s.eval::(r#"return UnitCreatureFamily("pet")"#) - .unwrap(), - "Imp" - ); assert!(s.eval::("return GetPetLoyalty() == nil").unwrap()); assert_eq!(s.arity("GetPetFoodTypes()").unwrap(), 0); } diff --git a/crates/benilla-ui/src/script/stable.rs b/crates/benilla-ui/src/script/stable.rs index 31058ca39..75187044b 100644 --- a/crates/benilla-ui/src/script/stable.rs +++ b/crates/benilla-ui/src/script/stable.rs @@ -373,15 +373,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "GetStablePetInfo", lua.create_function(|lua, i: Value| { // `is-number` else `Usage: GetStablePetInfo(index)` (`0x848f18`, `0x4cb25b`). - let i = i64::from(binding_abi::number_arg( - lua, - i, - "Usage: GetStablePetInfo(index)", - )?); + let i = binding_abi::number_arg(lua, i, "Usage: GetStablePetInfo(index)")?; + // The signed test on `i - 1` (`0x4cb285`-`0x4cb286`): 0 or below is the summoned pet's + // record, anything else the stabled array's `i - 1` (`0x4cb288`-`0x4cb298`). + let row = if i.wrapping_sub(1) < 0 { + Some(0) + } else { + slot_index(i64::from(i)) + }; let pet = { let model = lua.app_data_ref::().expect("model app_data"); let mut view = view(lua, &model); - slot_index(i).and_then(|i| view.state.as_mut()?.slots[i].take()) + row.and_then(|i| view.state.as_mut()?.slots[i].take()) }; if let Some(pet) = &pet { ask_creature(lua, pet, false)?; @@ -776,6 +779,27 @@ mod tests { .unwrap()); } + /// The signed test on `i - 1` (`0x4cb285`, `js 0x4cb2a0`) sends 0 and below to the summoned + /// pet's record; the stabled pets are `i - 1` of the array. + #[test] + fn an_index_of_zero_or_below_reads_the_summoned_pet() { + let mut s = UiScript::new().unwrap(); + open(&mut s); + for i in ["0", "-1", "-40"] { + assert_eq!( + s.eval::(&format!("local _, n = GetStablePetInfo({i}) return n")) + .unwrap(), + "Rex", + "index {i}" + ); + } + assert_eq!( + s.eval::("local _, n = GetStablePetInfo(1) return n") + .unwrap(), + "Bruiser" + ); + } + #[test] fn a_missing_pet_still_answers_five_values() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 5dda37128..9b059d51b 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -176,18 +176,18 @@ fn the_instruction_budget_counts_lua_a_library_function_runs() { assert!(err.contains("instruction budget exhausted"), "{err}"); } -/// The hearth location's name, `""` before the packet; `StaticPopup.lua:1742` formats it. +/// The hearth location's name; before the packet, or for a bind zone with no AreaTable row, the +/// `HOME_INN` text (`0x48db19`), never nil. `StaticPopup.lua:1742` formats it. #[test] -fn get_bind_location_answers_the_pushed_name_and_never_nil() { +fn get_bind_location_answers_the_pushed_name_and_the_inn_text_otherwise() { let mut s = crate::script::UiScript::new().unwrap(); - assert_eq!(s.eval::("return GetBindLocation()").unwrap(), ""); + s.eval::<()>(r#"HOME_INN = "your inn""#).unwrap(); assert_eq!( - s.eval::("return type(GetBindLocation())").unwrap(), - "string", - "never nil — a consumer concatenates it" + s.eval::("return GetBindLocation()").unwrap(), + "your inn" ); - s.set_bind_location("Stormwind City"); + s.set_bind_location(Some("Stormwind City")); assert_eq!( s.eval::("return GetBindLocation()").unwrap(), "Stormwind City" @@ -197,11 +197,16 @@ fn get_bind_location_answers_the_pushed_name_and_never_nil() { .unwrap(), "Bound: Stormwind City" ); - s.set_bind_location("Ironforge"); + s.set_bind_location(Some("Ironforge")); assert_eq!( s.eval::("return GetBindLocation()").unwrap(), "Ironforge" ); + s.set_bind_location(None); + assert_eq!( + s.eval::("return GetBindLocation()").unwrap(), + "your inn" + ); } /// `RequestTimePlayed()` is a call the host sends inside the verb; `TIME_PLAYED_MSG` carries the diff --git a/crates/benilla-ui/src/script/tests/worldmap.rs b/crates/benilla-ui/src/script/tests/worldmap.rs index f54406bca..2703a2225 100644 --- a/crates/benilla-ui/src/script/tests/worldmap.rs +++ b/crates/benilla-ui/src/script/tests/worldmap.rs @@ -327,13 +327,47 @@ fn worldmap_navigation_and_map_info() { ); } +/// `GetMapInfo`'s second value is `ftol(H * 1002 / W)` (`0x4a6d50`), the span the +/// `WorldMapContinent` tile counts at continent level and the loc rect at zone level; the third +/// rounds its low byte up to a power of two (`0x4a7e69`). The world sheet answers 0, 0. +#[test] +fn get_map_info_answers_the_texture_height_and_its_power_of_two_round() { + let mut s = script(); + push_catalog(&mut s); + let info = |s: &UiScript| { + s.eval::<(i64, i64)>("local _, h, p = GetMapInfo() return h, p") + .unwrap() + }; + assert_eq!(info(&s), (0, 0), "the world sheet"); + // Kalimdor's tiles are 26 wide and 44 high: 1695, low byte 159 rounds to 256. + s.run("SetMapZoom(1)").unwrap(); + assert_eq!(info(&s), (1695, 1792)); + // Eastern Kingdoms: 25 by 47 tiles, 1883, low byte 91 rounds to 128. + s.run("SetMapZoom(2)").unwrap(); + assert_eq!(info(&s), (1883, 1920)); + // Durotar: a 5000 by 4700 rect, 941, low byte 173 rounds to 256. + s.run("SetMapZoom(1, 1)").unwrap(); + assert_eq!(info(&s), (941, 1024)); + // The Barrens: 10000 by 11500, 1152, whose low byte 128 is a power of two already. + s.run("SetMapZoom(1, 2)").unwrap(); + assert_eq!(info(&s), (1152, 1152)); +} + /// `SetMapToCurrentZone` lands on the app-fed player zone, and the feed surfaces through -/// `GetPlayerMapPosition` for the player and `party1..4`. A `raid` token -/// answers the off-map sentinel; the reference reads raid positions (`WorldMapFrame.lua:379`). +/// `GetPlayerMapPosition` for the player, `party1..4` and the raid rows, by the token's guid +/// (`0x4a8658` → `0x515970`): any case, a `target` that names a member, and "Unknown unit name" +/// for a token the resolver does not know. A string or number is required (`0x4a861e`). #[test] fn worldmap_current_zone_and_player_feed() { let mut s = script(); push_catalog(&mut s); + s.set_unit_guids(&crate::script::UnitGuids { + player: 1, + target: 10, + party: [10, 11, 0, 0], + raid: vec![20, 1], + ..Default::default() + }); s.set_world_map_feed( Some((1, 2)), @@ -341,7 +375,7 @@ fn worldmap_current_zone_and_player_feed() { 1.5, None, vec![Some((0.1, 0.2)), None], - Vec::new(), + vec![Some((0.3, 0.4)), Some((0.25, 0.75))], ); s.run("SetMapToCurrentZone()").unwrap(); assert_eq!( @@ -349,24 +383,41 @@ fn worldmap_current_zone_and_player_feed() { .unwrap(), (1, 2) ); - let (x, y) = s - .eval::<(f64, f64)>(r#"return GetPlayerMapPosition("player")"#) - .unwrap(); - assert!((x - 0.25).abs() < 1e-6 && (y - 0.75).abs() < 1e-6); - let (px, py) = s - .eval::<(f64, f64)>(r#"return GetPlayerMapPosition("party1")"#) - .unwrap(); - assert!( - (px - 0.1).abs() < 1e-6 && (py - 0.2).abs() < 1e-6, - "a party slot reads its own projection" - ); - for token in ["party2", "party5", "party0", "raid1", "nonsense"] { - assert_eq!( - s.eval::<(f64, f64)>(&format!(r#"return GetPlayerMapPosition("{token}")"#)) - .unwrap(), - (0.0, 0.0), - "{token} answers the off-map sentinel" + let near = |token: &str, want: (f64, f64)| { + let (x, y) = s + .eval::<(f64, f64)>(&format!(r#"return GetPlayerMapPosition("{token}")"#)) + .unwrap(); + assert!( + (x - want.0).abs() < 1e-6 && (y - want.1).abs() < 1e-6, + "{token}: {x},{y}" ); + }; + near("player", (0.25, 0.75)); + near("Player", (0.25, 0.75)); + near("party1", (0.1, 0.2)); + near("PARTY1", (0.1, 0.2)); + near("target", (0.1, 0.2)); + near("raid1", (0.3, 0.4)); + near("raid2", (0.25, 0.75)); + for token in ["party2", "party5", "party0", "raid3", "pet", "mouseover"] { + near(token, (0.0, 0.0)); + } + for (call, want) in [ + ( + r#"GetPlayerMapPosition("nonsense")"#, + "Unknown unit name: nonsense", + ), + ( + "GetPlayerMapPosition()", + r#"Usage: GetPlayerMapPosition("player")"#, + ), + ( + "GetPlayerMapPosition(nil)", + r#"Usage: GetPlayerMapPosition("player")"#, + ), + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains(want), "{call}: {err}"); } // No feed → the world sheet (never an error). diff --git a/crates/benilla-ui/src/script/trainer/mod.rs b/crates/benilla-ui/src/script/trainer/mod.rs index 8d1282a7a..7583ccdd4 100644 --- a/crates/benilla-ui/src/script/trainer/mod.rs +++ b/crates/benilla-ui/src/script/trainer/mod.rs @@ -69,8 +69,8 @@ fn filter_bit_named(name: &str) -> Option { /// The state mask `"all"` sets (`0x4da2a9`): the three states a filter can name. const TRAINER_FILTER_ALL: u32 = 7; -/// A service's skill requirement (`GetTrainerServiceSkillReq`). The wire has no per-gate bit, so -/// `met` comes from the service's `category`. +/// A service's skill requirement (`GetTrainerServiceSkillReq`). `met` is the snapshot's answer to +/// the player's skill; the getter reads it again at the call ([`TrainerServiceLive::skill_met`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TrainerSkillReq { pub name: String, @@ -182,6 +182,10 @@ pub struct TrainerService { /// `GetTrainerSkillLines` (`0x4da660`) lists per header; `None` for a key with no row, the /// known-talents group's -1 and a tradeskill trainer's 1 and 2 included. pub line_name: Option, + /// `GetTrainerServiceSkillLine`'s name (`0x4d91dc`): the `SkillLine` of the ability row of the + /// spell the row teaches, else of the sent spell, which differs from the group's name at a + /// tradeskill trainer; `None` answers nil. + pub skill_line: Option, } /// One header and its services, built by [`UiScript::set_trainer`]. @@ -234,6 +238,9 @@ pub struct TrainerServiceLive { pub description: Option, pub description_wait: Option, pub ability_reqs: Vec, + /// `GetTrainerServiceSkillReq`'s third value as the call finds it (`0x4d9570`): the player's + /// skill reaches the required value, 1 whenever nothing asks it. + pub skill_met: bool, } impl super::UiScript { @@ -682,6 +689,7 @@ fn live(lua: &Lua, model: &Model, index: usize) -> Option { description: s.description.clone().map(TooltipText::from), description_wait: s.description_wait, ability_reqs: s.ability_reqs.clone(), + skill_met: s.skill_req.as_ref().is_none_or(|r| r.met), }) }) } @@ -719,19 +727,18 @@ fn raise_trainer_update(model: &mut Model) { model.raise("TRAINER_UPDATE", Vec::new()); } -fn set_collapsed(model: &mut Model, id: usize, collapse: bool) { - if id == 0 && !collapse { - model.trainer_collapsed.clear(); - return; - } - let targets: Vec = if id == 0 { - model +/// The fold verbs' write on the row `n - 1` signed (`0x4da6f4`, `0x4da774`): below 0 every group +/// at once (`0x4da6f5`, `0x4da775`), else the row must be a header of the array (`0x4d8be0` answers +/// -1 for a service or a row past the total) whose group's bit is set or cleared. `false` is the +/// bad skill line, which raises with nothing touched and no `TRAINER_UPDATE` (`0x4d8cd0`). +fn set_collapsed(model: &mut Model, n: i32, collapse: bool) -> bool { + let targets: Vec = match usize::try_from(n.wrapping_sub(1)) { + Err(_) => model .trainer .as_ref() .map(|t| t.groups.iter().map(|g| g.key).collect()) - .unwrap_or_default() - } else { - match rows(model).0.get(id - 1) { + .unwrap_or_default(), + Ok(row) => match rows(model).0.get(row) { Some(Row::Header(gi)) => model .trainer .as_ref() @@ -739,8 +746,8 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) { .map(|g| g.key) .into_iter() .collect(), - _ => Vec::new(), - } + _ => return false, + }, }; for sl in targets { if collapse { @@ -749,6 +756,21 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) { model.trainer_collapsed.remove(&sl); } } + true +} + +/// A fold verb past its usage check: the write, then `TRAINER_UPDATE`, or the `bad` line raised. +fn fold(lua: &Lua, n: i32, collapse: bool, bad: &'static str) -> mlua::Result<()> { + let folded = super::tick::verb(lua, |model| { + let ok = set_collapsed(model, n, collapse); + if ok { + raise_trainer_update(model); + } + ok + }); + folded + .then_some(()) + .ok_or_else(|| mlua::Error::RuntimeError(bad.into())) } fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result { @@ -816,8 +838,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetTrainerServiceSkillLine(index): the group name, nil for a header. The reference's return - // (`0x4d9160`) is untraced; the stock `CONFIRM_PROFESSION` dialog shows it as the profession. + // GetTrainerServiceSkillLine(index) (`0x4d9160`): the name of the skill line of the row's + // ability (see [`TrainerService::skill_line`]), nil for a header; the stock `CONFIRM_PROFESSION` + // dialog shows it as the profession. g.set( "GetTrainerServiceSkillLine", lua.create_function(|lua, index: Value| { @@ -825,19 +848,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let index = binding_abi::index_arg(lua, index, "Usage: GetTrainerServiceSkillLine(index)")?; let model = lua.app_data_ref::().expect("model app_data"); - let Some(n) = index.checked_sub(1) else { - return Ok(Value::Nil); - }; - let Some(Row::Service(si)) = rows(&model).0.get(n).copied() else { - return Ok(Value::Nil); - }; - let t = model - .trainer - .as_ref() - .expect("a resolved row ⇒ an open trainer"); - Ok(Value::String( - lua.create_string(&t.services[si].group_name)?, - )) + opt_str( + lua, + service(&model, index).and_then(|s| s.skill_line.as_ref()), + ) })?, )?; @@ -924,10 +938,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let Some(req) = service(&model, index).and_then(|s| s.skill_req.as_ref()) else { return Ok(binding_abi::tuple(&[None, Some(0), Some(1)])); }; + // The flag compares the player's skill at the call, not the row's colour (`0x4d9570`). + let met = live(lua, &model, index).map_or(req.met, |l| l.skill_met); + // An empty name answers `nil, 0` and the flag (`0x4d9589`-`0x4d9599`). + if req.name.is_empty() { + return Ok(binding_abi::tuple(&[None, Some(0), met.then_some(1)])); + } Ok(MultiValue::from_vec(vec![ Value::String(lua.create_string(&req.name)?), Value::Integer(i64::from(req.rank)), - binding_abi::flag(req.met), + binding_abi::flag(met), ])) })?, )?; @@ -1247,30 +1267,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // Collapse/ExpandTrainerSkillLine(id): fold the group headed by row `id`, or all at 0. `id` - // spans the whole row array like the getters' gate (`0x4d89b0`); their own bound is untraced. + // Collapse/ExpandTrainerSkillLine(n): fold the group headed by row `n`, or every group for + // `n - 1` below 0; a row that is no header raises (`0x4da6c0`, `0x4da740`). `n` spans the + // whole row array like the getters' gate (`0x4d89b0`). g.set( "CollapseTrainerSkillLine", - lua.create_function(|lua, id: Value| { + lua.create_function(|lua, n: Value| { // `is-number` else `Usage: CollapseTrainerSkillLine(index)` (`0x84ad24`, `0x4da6d1`). - let id = binding_abi::index_arg(lua, id, "Usage: CollapseTrainerSkillLine(index)")?; - super::tick::verb(lua, |model| { - set_collapsed(model, id, true); - raise_trainer_update(model); - }); - Ok(()) + let n = binding_abi::number_arg(lua, n, "Usage: CollapseTrainerSkillLine(index)")?; + fold(lua, n, true, "Bad skill line in CollapseTrainerSkillLine") })?, )?; g.set( "ExpandTrainerSkillLine", - lua.create_function(|lua, id: Value| { + lua.create_function(|lua, n: Value| { // `is-number` else `Usage: ExpandTrainerSkillLine(index)` (`0x84ad78`, `0x4da751`). - let id = binding_abi::index_arg(lua, id, "Usage: ExpandTrainerSkillLine(index)")?; - super::tick::verb(lua, |model| { - set_collapsed(model, id, false); - raise_trainer_update(model); - }); - Ok(()) + let n = binding_abi::number_arg(lua, n, "Usage: ExpandTrainerSkillLine(index)")?; + fold(lua, n, false, "Bad skill line in ExpandTrainerSkillLine") })?, )?; diff --git a/crates/benilla-ui/src/script/trainer/tests.rs b/crates/benilla-ui/src/script/trainer/tests.rs index af093634d..58a00edea 100644 --- a/crates/benilla-ui/src/script/trainer/tests.rs +++ b/crates/benilla-ui/src/script/trainer/tests.rs @@ -35,6 +35,7 @@ fn svc( skill_step: false, step_increase: None, line_name: Some(group_name.into()), + skill_line: None, } } @@ -266,6 +267,44 @@ fn service_getters_read_the_row_at_a_visible_index() { assert!(s .eval::("return GetTrainerServiceSkillReq(1) == nil") .unwrap()); + + // An empty name answers `nil, 0` and the flag (`0x4d9589`). + let mut t = trainer(); + t.services[0].skill_req = Some(TrainerSkillReq { + name: String::new(), + rank: 100, + met: false, + }); + s.set_trainer(Some(t)); + assert!(s + .eval::( + "local n, r, m = GetTrainerServiceSkillReq(2) return n == nil and r == 0 and m == nil" + ) + .unwrap()); +} + +/// `GetTrainerServiceSkillLine` answers the service's own skill line (`0x4d92bb`), never its +/// group's name, and nil for a header, a service with none and a row past the list. +#[test] +fn the_skill_line_is_the_services_own_not_its_groups() { + let mut s = UiScript::new().unwrap(); + let mut t = trainer(); + t.services[0].skill_line = Some("Alchemy".into()); + s.set_trainer(Some(t)); + // Row 1 is Arms' header; rows 2 and 3 are its services. + assert_eq!( + s.eval::("return GetTrainerServiceSkillLine(2)") + .unwrap(), + "Alchemy", + "not the group's \"Arms\"" + ); + for row in [1, 3, 99] { + assert!( + s.eval::(&format!("return GetTrainerServiceSkillLine({row}) == nil")) + .unwrap(), + "row {row}" + ); + } } /// The reference's filter hide exempts no header (`0x4d8528`, `0x4d8535`). @@ -866,6 +905,47 @@ fn the_filter_and_collapse_verbs_fire_trainer_update_in_place() { assert_eq!(seen, ["TRAINER_UPDATE 4", "TRAINER_UPDATE 3"]); } +/// The fold verbs take `n - 1` signed (`0x4da6f4`, `0x4da774`): below 0 they fold every group, a +/// row that is no header (`0x4d8be0` answers -1 for a service or a row past the total) raises +/// "Bad skill line in ..." and fires no `TRAINER_UPDATE`. +#[test] +fn the_fold_verbs_take_a_negative_index_for_all_and_raise_on_a_bad_row() { + let mut s = UiScript::new().unwrap(); + s.set_trainer(Some(trainer())); + s.run("CollapseTrainerSkillLine(-1)").unwrap(); + assert_eq!(s.eval::("return GetNumTrainerServices()").unwrap(), 2); + s.run("ExpandTrainerSkillLine(-5)").unwrap(); + assert_eq!(s.eval::("return GetNumTrainerServices()").unwrap(), 5); + + crate::script::tick::heard::listen(&s, &["TRAINER_UPDATE"], None); + for (call, want) in [ + ( + "CollapseTrainerSkillLine(2)", + "Bad skill line in CollapseTrainerSkillLine", + ), + ( + "CollapseTrainerSkillLine(6)", + "Bad skill line in CollapseTrainerSkillLine", + ), + ( + "ExpandTrainerSkillLine(3)", + "Bad skill line in ExpandTrainerSkillLine", + ), + ( + "ExpandTrainerSkillLine(99)", + "Bad skill line in ExpandTrainerSkillLine", + ), + ] { + let err = s.run(call).unwrap_err().to_string(); + assert!(err.contains(want), "{call}: {err}"); + } + assert!( + crate::script::tick::heard::take(&s).is_empty(), + "a raise fires no update" + ); + assert_eq!(s.eval::("return GetNumTrainerServices()").unwrap(), 5); +} + /// `OpenTrainer` (`0x4d8cf0`) asks for the player's own list: it hands the host its call, which /// holds the player, and takes no argument. #[test] diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index a71072087..59a0b8569 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -465,14 +465,22 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // HasFullControl(): the flag `SMSG_CLIENT_CONTROL_UPDATE` writes for the local player - // (`0xb4b3e4`, read at `0x51a158`), which the stock unit menu greys Trade and Duel on - // (`UnitPopup.lua:474-477`, `:502-505`). + // HasFullControl() (`0x51a140`): 1 only with a player object, far sight off (`+0x1c70` bit 0, + // `0x51a142`), `UNIT_FIELD_HEALTH` above 0 (`0x51a151`-`0x51a156`) and the flag + // `SMSG_CLIENT_CONTROL_UPDATE` writes (`0xb4b3e4`, `0x51a158`), else nil. The stock unit menu + // greys Trade and Duel on it (`UnitPopup.lua:474-477`, `:502-505`). g.set( "HasFullControl", lua.create_function(|lua, ()| { - let model = lua.app_data_ref::().expect("model app_data"); - Ok(flag(model.player_control)) + let ours = { + let model = lua.app_data_ref::().expect("model app_data"); + model.player_control && !model.far_sight_on + }; + let alive = ours + && with_object(lua, &Some("player".to_string()), false, |u, o| { + u.exists && o.health() > 0 + })?; + Ok(flag(alive)) })?, )?; diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index b26bce18c..1df3edf88 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -114,6 +114,10 @@ pub struct UnitState { pub class: Option, /// `UnitClass`'s second return, the uppercase token (`"WARRIOR"`). pub class_file: Option, + /// `UnitCreatureFamily`'s word (`0x6055e0`, `0x51a372`): the localized `CreatureFamily.dbc` name of the + /// family word in the unit's cached creature record (`[[unit+0xb30]+0x1c]`); `None` without a + /// record, with family 0, or for a family the table lacks. Players have no record. + pub family: Option, /// `UnitHasRelicSlot` (`0x519e50`): INVSLOT 17 is a relic slot, from `ChrClasses.dbc` field /// 16. Players only, true for Paladin, Shaman and Druid. pub has_relic_slot: bool, @@ -755,6 +759,11 @@ impl UnitIdentityRead for LiveOr<'_> { .class() .or_else(|| UnitIdentityRead::class(self.unit)) } + fn family(&self) -> Option { + self.live + .family() + .or_else(|| UnitIdentityRead::family(self.unit)) + } } /// A unit predicate's body: the number 1 or nil through [`super::binding_abi::flag`], never a Lua diff --git a/crates/benilla-ui/src/script/unit/object.rs b/crates/benilla-ui/src/script/unit/object.rs index c0d7d6884..e75552a58 100644 --- a/crates/benilla-ui/src/script/unit/object.rs +++ b/crates/benilla-ui/src/script/unit/object.rs @@ -112,6 +112,8 @@ pub trait UnitIdentityRead { fn race(&self) -> Option<(String, String)>; /// [`UnitState::class`] and [`UnitState::class_file`]. fn class(&self) -> Option<(String, String)>; + /// [`UnitState::family`]. + fn family(&self) -> Option; } impl UnitIdentityRead for UnitState { @@ -124,4 +126,7 @@ impl UnitIdentityRead for UnitState { fn class(&self) -> Option<(String, String)> { self.class.clone().zip(self.class_file.clone()) } + fn family(&self) -> Option { + self.family.clone() + } } diff --git a/crates/benilla-ui/src/script/worldmap.rs b/crates/benilla-ui/src/script/worldmap.rs index 3e551d106..a6ac4e9e1 100644 --- a/crates/benilla-ui/src/script/worldmap.rs +++ b/crates/benilla-ui/src/script/worldmap.rs @@ -211,6 +211,56 @@ fn grid_area(state: &WorldMapState, u: f32, v: f32) -> Option { } } +/// `GetMapInfo`'s second value (`0x4a6d50`): `ftol(H * 1002 / W)` of the displayed map's span, the +/// `WorldMapContinent` tile counts for a continent, the loc rect for a zone; 0 for a span of zero +/// width or height (`0x4a6e2e`-`0x4a6e3e`), the world sheet and an instance map, whose map id no +/// `WorldMapContinent` row carries (`0x4a6dc2`-`0x4a6df2`). +fn texture_height(wm: &WorldMapState) -> u32 { + let (c, z) = wm.selection; + let Some(cont) = wm + .direct_area + .is_none() + .then_some(c) + .and_then(|c| wm.continents.get(usize::try_from(c).ok()?.checked_sub(1)?)) + else { + return 0; + }; + let (w, h) = match z.checked_sub(1).and_then(|n| cont.zones.get(n as usize)) { + Some(zone) => { + let (left, right, top, bottom) = zone.loc_rect; + ( + f64::from(left) - f64::from(right), + f64::from(top) - f64::from(bottom), + ) + } + None => { + let Some(sheet) = cont.sheet else { + return 0; + }; + let (left, right, top, bottom) = sheet.tiles; + let tiles = + |lo: u32, hi: u32| f64::from((hi as i32).wrapping_sub(lo as i32).wrapping_add(1)); + (tiles(left, right), tiles(top, bottom)) + } + }; + if w == 0.0 || h == 0.0 { + return 0; + } + binding_abi::ftol(h * 1002.0 / w) as u32 +} + +/// `GetMapInfo`'s third value (`0x4a7e69`-`0x4a7e9a`): the height with its low byte rounded up +/// to a power of two, the bytes above kept; a low byte of 0 or a power of two stays. +fn pot_height(height: u32) -> u32 { + let low = height & 0xff; + let rounded = if low.is_power_of_two() || low == 0 { + low + } else { + low.next_power_of_two() + }; + height - low + rounded +} + /// The client's power-of-two round-up (`0x4a833c`). fn next_pow2(n: i64) -> i64 { let mut p = 1i64; @@ -790,8 +840,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetMapInfo() → mapFileName and two texture dimensions (`0x4a7e30`, `mov eax,3`): nil, 0, 0 - // at world level. The name is the unlocalized art folder (`0x4a6cf0` reads `[row+0xc]`). + // GetMapInfo() → mapFileName, textureHeight and its power-of-two round (`0x4a7e30`, `mov + // eax,3`): nil, 0, 0 at world level. The name is the unlocalized art folder (`0x4a6cf0` reads + // `[row+0xc]`). g.set( "GetMapInfo", lua.create_function(|lua, ()| { @@ -811,13 +862,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { .unwrap_or_else(|| cont.map_file.clone()), }), }; - // No caller reads the dimensions, 0 here on every map; the reference computes them - // from `WorldMapContinent.dbc` (`0x4a6d50`), which lacks an instance map's mapID. let file = match file { Some(f) => Value::String(lua.create_string(&f)?), None => Value::Nil, }; - Ok((file, 0i64, 0i64)) + let height = texture_height(wm); + Ok((file, i64::from(height), i64::from(pot_height(height)))) })?, )?; @@ -861,23 +911,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // GetPlayerMapPosition(unit) → map UV, or (0, 0) off the map; `player`, `party1`..`party4` - // and `raid1`..`raid40` resolve, anything else is (0, 0). + // GetPlayerMapPosition(unit) → map UV, or (0, 0) off the map (`0x4a8610`): a string or number + // else the usage line (`0x4a861e`, `0x4a8627`), the token's guid through `GetGUIDFromName` + // (`0x4a8658` → `0x515970`: any case, the `target` hops, "Unknown unit name" raised), and the + // position by guid only for the player (`0x4a8668`), a party member (`0x4a8675`) or a raid + // member (`0x4a8681`); anyone else leaves (0, 0). g.set( "GetPlayerMapPosition", - lua.create_function(|lua, unit: String| { + lua.create_function(|lua, unit: Value| { + let token = + binding_abi::string_arg(lua, unit, r#"Usage: GetPlayerMapPosition("player")"#)?; let model = lua.app_data_ref::().expect("model app_data"); - let slot = |prefix: &str, list: &[Option<(f32, f32)>]| { - unit.strip_prefix(prefix) - .and_then(|n| n.parse::().ok()) - .filter(|n| *n >= 1) - .and_then(|n| list.get(n - 1).copied().flatten()) + let guid = crate::script::unit::guid_of(lua, &model, &token)?.unwrap_or(0); + let (guids, wm) = (&model.unit_guids, &model.worldmap); + let at = |members: &[u64], uv: &[Option<(f32, f32)>]| { + members + .iter() + .position(|g| *g == guid) + .and_then(|n| uv.get(n).copied().flatten()) }; - let (x, y) = match unit.as_str() { - "player" => model.worldmap.player_uv, - u if u.starts_with("party") => slot("party", &model.worldmap.party_uv), - u if u.starts_with("raid") => slot("raid", &model.worldmap.raid_uv), - _ => None, + let (x, y) = if guid == 0 { + None + } else if guid == guids.player { + wm.player_uv + } else { + at(&guids.party, &wm.party_uv).or_else(|| at(&guids.raid, &wm.raid_uv)) } .unwrap_or((0.0, 0.0)); Ok((f64::from(x), f64::from(y))) diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 28b8abc93..87e22e98c 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -279,7 +279,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### spell - An out-of-range byte drops the packet, because the reference's unchecked write overruns the table (`(64, 19)` lands on the class family at `0xcecaac`). ([`src/spell/mods.rs:132`](../crates/benilla-app/src/spell/mods.rs#L132)) -- The fallback is the packet's target guid, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:1306`](../crates/benilla-app/src/spell/net.rs#L1306)) +- The fallback is the packet's target guid, never the nearest-hostile acquire, because that would attack something the player never named. ([`src/spell/net.rs:1321`](../crates/benilla-app/src/spell/net.rs#L1321)) - A range failure leaves targeting armed and working. The reference's report cancels the cast (`0x6e2218` → `0x6e4940`): its line, `SPELLCAST_FAILED` and the tradeskill disarm, which benilla keeps; with the word the bind emptied it also sends `CMSG_CANCEL_CAST` for the unsent spell (`0x6e4970`) and clears the caster, then restores the pre-click word (`0x6e6096`), so every later click fails the caster test (`0x6e5b93`) and the cursor is stranded until a cancel. ([`src/spell/targeting/mod.rs:624`](../crates/benilla-app/src/spell/targeting/mod.rs#L624)) #### target @@ -412,7 +412,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### ui_pet_stats -- Fired when the bucket or its two tooltip figures change, not on every change of the happiness field as in the reference, because no stock consumer reads the raw value. ([`src/ui_pet_stats.rs:170`](../crates/benilla-app/src/ui_pet_stats.rs#L170)) +- Fired when the bucket or its two tooltip figures change, not on every change of the happiness field as in the reference, because no stock consumer reads the raw value. ([`src/ui_pet_stats.rs:168`](../crates/benilla-app/src/ui_pet_stats.rs#L168)) #### ui_quest @@ -588,7 +588,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### spells -- Every token keys the `$l` plural. The reference's `$a`, `$d`, `$t`, `$e`, `$c`, `$p`, `$f`, `$F` and `$z` arms never write `[0xbe0b84]`, so its `$l` keys on the number before them, and Blizzard's "$s1 … every $t1 $lsecond:seconds;" reads "every 1 seconds" in 1.12.1. ([`src/spells/tokens.rs:687`](../crates/benilla-formats/src/spells/tokens.rs#L687)) +- Every token keys the `$l` plural. The reference's `$a`, `$d`, `$t`, `$e`, `$c`, `$p`, `$f`, `$F` and `$z` arms never write `[0xbe0b84]`, so its `$l` keys on the number before them, and Blizzard's "$s1 … every $t1 $lsecond:seconds;" reads "every 1 seconds" in 1.12.1. ([`src/spells/tokens.rs:690`](../crates/benilla-formats/src/spells/tokens.rs#L690)) #### tga @@ -708,7 +708,7 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it #### script -- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:425`](../crates/benilla-ui/src/script/action.rs#L425)) +- An out-of-range slot answers nil, because the reference indexes `0xbc6980` with no bounds check and reads the memory beside its 120 slots. ([`src/script/action.rs:441`](../crates/benilla-ui/src/script/action.rs#L441)) - The files the handler writes beside the install (`.pub` with the key, `.url`; `0x51dce0`, `0x51dde0`) are not written, as the install is read-only; the key is never read either, as there is no signature to verify with it. ([`src/script/addon.rs:207`](../crates/benilla-ui/src/script/addon.rs#L207)) - Holding the file, so the shutdown write keeps it. ([`src/script/addon.rs:787`](../crates/benilla-ui/src/script/addon.rs#L787)) - The reference drops a send that resolves to PARTY while party slot 0's guid (`0xbc6f48`) is zero (`0x49fa9f`-`0x49faab`); benilla sends it, because `PartyState` is empty until the first `SMSG_GROUP_LIST` and the gate would swallow a real broadcast. vmangos drops a group-less party line anyway (`ChatHandler.cpp:476`). ([`src/script/addon_message.rs:7`](../crates/benilla-ui/src/script/addon_message.rs#L7)) @@ -733,8 +733,8 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - A row the session owns (an env lever) is passed, because the env chose its value for this run and the file never holds it, so a reset would undo the lever and save nothing. ([`src/script/console_exec.rs:204`](../crates/benilla-ui/src/script/console_exec.rs#L204)) - The backpack is bounded by its own 16 slots, where `0x4f990f` bounds bag 0 by the player list's count (0x71), a reference bug that lets the backpack index read other containers. ([`src/script/container.rs:327`](../crates/benilla-ui/src/script/container.rs#L327)) - A row index the list no longer holds buys nothing and drops, where this path reads through a null row (`0x4f9f35`). ([`src/script/container.rs:825`](../crates/benilla-ui/src/script/container.rs#L825)) -- 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:201`](../crates/benilla-ui/src/script/craft.rs#L201)) -- The reference reads past the table unbounded (`0x4f6c90`), so type 5 would answer "CloseCraft"; no shipped opener carries a type past 3, and benilla answers nil rather than read past it. ([`src/script/craft.rs:775`](../crates/benilla-ui/src/script/craft.rs#L775)) +- 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:202`](../crates/benilla-ui/src/script/craft.rs#L202)) +- The reference reads past the table unbounded (`0x4f6c90`), so type 5 would answer "CloseCraft"; no shipped opener carries a type past 3, and benilla answers nil rather than read past it. ([`src/script/craft.rs:788`](../crates/benilla-ui/src/script/craft.rs#L788)) - 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:739`](../crates/benilla-ui/src/script/cursor.rs#L739)) - 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:948`](../crates/benilla-ui/src/script/cursor.rs#L948)) - Returns nothing. The handler ends `mov eax,1` with nothing pushed (`0x4895bc`), so Lua 5.0 hands back the stack top, the last argument or the function itself: an accident of the handler, not an API, and copying a stack-top leak would copy a bug. ([`src/script/cursor.rs:1188`](../crates/benilla-ui/src/script/cursor.rs#L1188)) @@ -808,11 +808,11 @@ Each entry is the first sentence of a `Deviation:` comment, linking to where it - The reference recurses without a bound (`0x52dbce` builds the product whatever the depth), so a product that teaches a recipe for itself would recurse until the stack overflows; benilla stops here, so a cyclic `Spell.dbc` chain cannot crash the client. ([`src/script/tooltip_item/render.rs:199`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L199)) - 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:568`](../crates/benilla-ui/src/script/tooltip_item/render.rs#L568)) - 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:126`](../crates/benilla-ui/src/script/tooltip_unit.rs#L126)) -- 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:306`](../crates/benilla-ui/src/script/trainer/mod.rs#L306)) -- 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:1278`](../crates/benilla-ui/src/script/trainer/mod.rs#L1278)) -- Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:1302`](../crates/benilla-ui/src/script/trainer/mod.rs#L1302)) +- 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:313`](../crates/benilla-ui/src/script/trainer/mod.rs#L313)) +- 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:1291`](../crates/benilla-ui/src/script/trainer/mod.rs#L1291)) +- Index 0 is a no-op, because one stray call would spend the purse; the reference buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`). ([`src/script/trainer/mod.rs:1315`](../crates/benilla-ui/src/script/trainer/mod.rs#L1315)) - Kept apart from `Self::vertex_color`, which the reference's `Texture:SetAlpha` (`0x79b580`) rewrites, because where stock sets both on one region (`PlayerFrame.lua:25-26`, `TargetFrame.lua:408` with `420-426`) the drawn product differs for about one frame, and a FontString's `SetAlpha` then keeps its font colour. ([`src/script/types.rs:428`](../crates/benilla-ui/src/script/types.rs#L428)) -- Among whole-name matches ours picks the nearest, because the reference's first-walked pick is order-dependent and reads as a bug. ([`src/script/unit/bindings.rs:1066`](../crates/benilla-ui/src/script/unit/bindings.rs#L1066)) +- Among whole-name matches ours picks the nearest, because the reference's first-walked pick is order-dependent and reads as a bug. ([`src/script/unit/bindings.rs:1074`](../crates/benilla-ui/src/script/unit/bindings.rs#L1074)) - The reference's matcher takes a whole-string hit at once only when it is nearer than the best so far (`0x493c88`-`0x493ca2`); an exact hit that is not nearer competes on prefix length alone (`0x493cf8`-`0x493d09`), so a nearer "Bobby" walked first beats an exact "Bob", and the pick follows the object list's order. ([`src/script/unit/by_name.rs:19`](../crates/benilla-ui/src/script/unit/by_name.rs#L19)) - A full tie goes to the lower guid, where the reference keeps the first unit its walk (`0x493aa0`) reaches, an order neither of ours has; a guid keeps the two picks equal. ([`src/script/unit/by_name.rs:61`](../crates/benilla-ui/src/script/unit/by_name.rs#L61)) - `0x4e81d0` has no bound and reads past its four-entry table, which is stale memory, not a mechanism; past it, nobody. ([`src/script/unit/resolve.rs:85`](../crates/benilla-ui/src/script/unit/resolve.rs#L85))