diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 9975565f..ac22edc0 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -226,6 +226,18 @@ pub(crate) fn current_speed(s: &MoveSpeeds, flags: u32) -> f32 { #[derive(Component, Clone, Default)] pub(crate) struct ObjectStore(pub(crate) ObjectFields); +impl ObjectStore { + /// The object manager's unit lookup (`0x468460`, `ecx = 8`): a unit or a player, which share + /// the unit block; a game object, corpse or item is not one. + pub(crate) fn is_unit(&self) -> bool { + use benilla_protocol::messages::ObjectType; + matches!( + self.0.object_type(), + Some(ObjectType::Unit | ObjectType::Player) + ) + } +} + /// One descriptor dword moved on a streamed object, the reference's `CMirrorHandler` edge: the /// values notifier (`0x465330`) diffs live against a shadow copy and passes the old value /// (`0x465570`). A first create fires nothing; a re-create of a live guid fires like a delta. diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 2a85140f..272b2554 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -655,6 +655,68 @@ mod tests { assert_eq!((selection.target, selection.guid), (Some(ally), Some(ALLY))); } + /// `SpellTargetUnit` takes a `target` chain as the resolver does (`0x6e6de1` into `0x515970`): + /// `"pettarget"` and `"playertarget"` bind the unit our pet and we target, where a chain that + /// names no unit still ends the cast with "Out of range.". + #[test] + fn spell_target_unit_follows_a_target_chain() { + const PET: u64 = 0xF140_0000_0000_0077; + // `OBJECT_FIELD_TYPE` and `UNIT_FIELD_TARGET`, the latter a 2-field guid. + let unit = |kind: u32, target: u64| { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, kind), + (16, target as u32), + (17, (target >> 32) as u32), + ])) + }; + let seat = |world: &mut World, ally: Entity| { + // We and our pet target the ally; the ally targets nobody. + let me = world + .query_filtered::>() + .single(world) + .unwrap(); + world.entity_mut(me).insert(unit(0x19, ALLY)); + let pet = world.spawn((Guid(PET), unit(0x09, ALLY))).id(); + let mut bar = crate::ui_pet::PetBar::default(); + bar.spells.pet_guid = PET; + world.insert_resource(bar); + let mut index = world.resource_mut::(); + index.0.insert(ME, me); + index.0.insert(ALLY, ally); + index.0.insert(PET, pet); + }; + let fail = vec![crate::ui_action::CastFail::local(HEAL, 0x59)]; + for token in ["pettarget", "playertarget", "PETTARGET"] { + let (mut world, rx, ally) = unit_world(10.0); + seat(&mut world, ally); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, token); + assert!( + matches!( + rx.try_recv(), + Ok(ClientCommand::CastSpell { + spell_id: HEAL, + target: Some(ALLY), + }) + ), + "{token} binds the ally" + ); + assert!(errors(&mut world).is_empty(), "{token}"); + assert!(!world.resource::().active(), "{token}"); + } + + // The ally targets nobody, so a hop off it names no unit: the cast ends. + for token in ["pettargettarget", "party1target", "pettargetfoo"] { + let (mut world, rx, ally) = unit_world(10.0); + seat(&mut world, ally); + arm(&mut world, HEAL, 0x0002); + spell_target_unit(&mut world, token); + assert!(rx.try_recv().is_err(), "{token}: no send"); + assert_eq!(errors(&mut world), fail, "{token}"); + assert!(!world.resource::().active(), "{token}"); + } + } + /// The press (`resolve_cast_target`), the click and `SpellTargetUnit` ([`bind_target_unit`]) and /// the hover verdict (`SpellCanTargetUnit`) all ask [`crate::spell::cast_target::unit_binds`], /// so one table of units decides all three: `BindTarget`'s gates refuse the same units at diff --git a/crates/benilla-app/src/target/script.rs b/crates/benilla-app/src/target/script.rs index bb92fb82..9aca36fb 100644 --- a/crates/benilla-app/src/target/script.rs +++ b/crates/benilla-app/src/target/script.rs @@ -11,8 +11,9 @@ use super::scan; /// Everything the selection calls read and write. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct ScriptSelect<'w, 's> { - /// The one unit-token resolver, shared with the reach feed so `TargetUnit("target")` and - /// `CheckInteractDistance("target", …)` name the same unit. + /// The app's unit-token resolver. The VM's own reads the same grammar + /// ([`benilla_ui::script::parse_unit_token`]), so `TargetUnit("party1target")` and + /// `CheckInteractDistance("party1target", …)` name the same unit. tokens: crate::ui_unit::UnitTokens<'w, 's>, /// `TargetLastEnemy`'s memory (`[0xb4e2e8]`), stamped by `scan::remember_last_enemy`. last_enemy: Res<'w, scan::LastEnemy>, @@ -304,4 +305,142 @@ mod tests { assert_eq!(run(&mut world, r#"TargetUnit("party1")"#), Some(MEMBER)); assert_eq!(run(&mut world, r#"TargetUnit("partypet1")"#), Some(MEMBER)); } + + /// `TargetUnit` and `AssistUnit` take the tokens the reference's resolver takes (`0x515970`, + /// from `0x489a08` and `0x489ba9`): a base then a hop off each held unit's target, so + /// `party1target` names the unit party1 targets and `AssistUnit("party1target")` its target's. + /// A chain that breaks, and text that is not a hop, is a no-op, never a deselect. + #[test] + fn target_and_assist_unit_follow_a_target_chain() { + use crate::net::{Guid, ObjectStore, SelfPlayer}; + use benilla_protocol::messages::{member_status, GroupMemberEntry}; + use benilla_ui::script::UiScript; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 1; + const MEMBER: u64 = 0x1234; + const PET: u64 = 0xF140_0000_0000_0077; + const VICTIM: u64 = 0xC0DE; + const THIRD: u64 = 0xF130_0000_0000_0003; + // `OBJECT_FIELD_TYPE` 2 (a hop is off a unit, typemask 8), `UNIT_FIELD_SUMMON` 8 and + // `UNIT_FIELD_TARGET` 16, the latter two each a 2-field guid. + let unit = |kind: u32, summon: u64, target: u64| { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, kind), + (8, summon as u32), + (9, (summon >> 32) as u32), + (16, target as u32), + (17, (target >> 32) as u32), + ])) + }; + const PLAYER: u32 = 0x19; + const CREATURE: u32 = 0x09; + + let mut world = script_world(); + // We target the victim, party1 targets the victim, the victim targets `THIRD`, our pet + // targets `THIRD`; `THIRD` targets nobody. + world.spawn((SelfPlayer, Guid(ME), unit(PLAYER, PET, VICTIM))); + let member = world.spawn((Guid(MEMBER), unit(PLAYER, 0, VICTIM))).id(); + let victim = world.spawn((Guid(VICTIM), unit(CREATURE, 0, THIRD))).id(); + let third = world.spawn((Guid(THIRD), unit(CREATURE, 0, 0))).id(); + let pet = world.spawn((Guid(PET), unit(CREATURE, 0, THIRD))).id(); + let index = &mut world.resource_mut::().0; + index.insert(MEMBER, member); + index.insert(VICTIM, victim); + index.insert(THIRD, third); + index.insert(PET, pet); + world + .resource_mut::() + .apply_list( + 0, + 0, + vec![GroupMemberEntry { + name: "Brisca".into(), + guid: MEMBER, + status: member_status::ONLINE, + flags: 0, + }], + ME, + None, + Some(ME), + ); + let mut bar = crate::ui_pet::PetBar::default(); + bar.spells.pet_guid = PET; + world.insert_resource(bar); + + let run = |world: &mut World, lua: &str| { + world + .non_send_resource_mut::() + .eval::<()>(lua) + .expect("the binding runs"); + world + .run_system_once( + |mut script: NonSendMut, mut select: ScriptSelect| { + for request in script.take_selection_requests() { + select.select(request); + } + }, + ) + .expect("the applier runs as a one-shot system"); + world.resource::().guid + }; + let reset = |world: &mut World| { + let mut sel = world.resource_mut::(); + sel.target = None; + sel.guid = None; + }; + + assert_eq!(run(&mut world, r#"TargetUnit("party1")"#), Some(MEMBER)); + assert_eq!( + run(&mut world, r#"TargetUnit("party1target")"#), + Some(VICTIM) + ); + assert_eq!( + run(&mut world, r#"TargetUnit("party1targettarget")"#), + Some(THIRD) + ); + // `THIRD` targets nobody: the chain ends there, and the selection stays. + for token in [ + "party1targettargettarget", + "party1targetfoo", + "party1foo", + "party2target", + "npctarget", + ] { + assert_eq!( + run(&mut world, &format!(r#"TargetUnit("{token}")"#)), + Some(THIRD), + "{token}" + ); + } + + reset(&mut world); + assert_eq!( + run(&mut world, r#"TargetUnit("PARTY1TARGET")"#), + Some(VICTIM) + ); + reset(&mut world); + assert_eq!( + run(&mut world, r#"TargetUnit("playertarget")"#), + Some(VICTIM) + ); + reset(&mut world); + assert_eq!(run(&mut world, r#"TargetUnit("pettarget")"#), Some(THIRD)); + reset(&mut world); + assert_eq!(run(&mut world, r#"TargetUnit("partypet1target")"#), None); + + // Assisting a unit selects its target; its target is a chain's end, so one hop further. + reset(&mut world); + assert_eq!(run(&mut world, r#"AssistUnit("party1")"#), Some(VICTIM)); + reset(&mut world); + assert_eq!( + run(&mut world, r#"AssistUnit("party1target")"#), + Some(THIRD) + ); + assert_eq!( + run(&mut world, r#"AssistUnit("party1targettarget")"#), + Some(THIRD), + "THIRD targets nobody: silent, nothing deselected" + ); + } } diff --git a/crates/benilla-app/src/ui_aura/units.rs b/crates/benilla-app/src/ui_aura/units.rs index a7131783..560bcafa 100644 --- a/crates/benilla-app/src/ui_aura/units.rs +++ b/crates/benilla-app/src/ui_aura/units.rs @@ -9,7 +9,6 @@ use std::collections::hash_map::{Entry, HashMap}; use benilla_formats::SpellCatalog; -use benilla_protocol::messages::ObjectType; use benilla_ui::script::{AuraState, UiScript, UnitGuids}; use super::{aura_list, other_unit_inputs, record_inputs, AuraInput}; @@ -81,14 +80,6 @@ fn fill_group<'a>( ); } -/// The object manager's unit lookup (`0x468460`, `ecx = 8`): a held unit or player. -fn is_unit(store: &ObjectStore) -> bool { - matches!( - store.0.object_type(), - Some(ObjectType::Unit | ObjectType::Player) - ) -} - /// What [`UnitAuras::feed`] reads this frame. pub(super) struct Inputs<'g, 'a, H, V> { pub(super) group: Option<&'g GroupState>, @@ -194,7 +185,7 @@ impl UnitAuras { continue; } let store = held(guid); - if let Some(unit) = store.filter(|s| is_unit(s)) { + if let Some(unit) = store.filter(|s| s.is_unit()) { let next = unit.0.unit_target().unwrap_or(0); g.held.insert(guid, next); walk.push((next, false)); diff --git a/crates/benilla-app/src/ui_loot/mod.rs b/crates/benilla-app/src/ui_loot/mod.rs index 99c2ef21..f7e8e682 100644 --- a/crates/benilla-app/src/ui_loot/mod.rs +++ b/crates/benilla-app/src/ui_loot/mod.rs @@ -649,17 +649,11 @@ struct LootSourceObjects<'w, 's> { impl LootSourceObjects<'_, '_> { fn is_unit(&self, guid: u64) -> bool { - use benilla_protocol::messages::ObjectType; self.index .as_ref() .and_then(|index| index.0.get(&guid)) .and_then(|&e| self.stores.get(e).ok()) - .is_some_and(|s| { - matches!( - s.0.object_type(), - Some(ObjectType::Unit | ObjectType::Player) - ) - }) + .is_some_and(|s| s.is_unit()) } } diff --git a/crates/benilla-app/src/ui_script/honor_frame_tests.rs b/crates/benilla-app/src/ui_script/honor_frame_tests.rs index d0cb2c1f..c164c821 100644 --- a/crates/benilla-app/src/ui_script/honor_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/honor_frame_tests.rs @@ -304,6 +304,9 @@ fn the_real_global_strings_name_the_rank() { use benilla_ui::script::InspectHonorData; use std::collections::HashMap; +/// The inspected player's guid, which `"target"` resolves to and the reach map is keyed by. +const TARGET: u64 = 0x0000_0001_0000_2AB3; + /// A live, inspectable unit at squared distance `d2`. fn reach(d2: f64) -> benilla_ui::script::UnitReach { benilla_ui::script::UnitReach { @@ -366,11 +369,16 @@ fn shown_inspect_honor_page() -> UiScript { super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI"); s.run("InspectFrame_LoadUI()").unwrap(); s.set_unit("target", Some(alliance_player())); + s.set_unit_guids(&benilla_ui::script::UnitGuids { + target: TARGET, + held: HashMap::from([(TARGET, 0)]), + ..Default::default() + }); // The player is needed too: the page titles the target through `GetPVPRankInfo` // (`InspectHonorFrame.lua:50`), which keys off the local player's side and sex. s.set_unit("player", Some(alliance_player())); // 4 yards (d² = 16), inside `CanInspect`'s d² of 100; the window refuses to open otherwise. - s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))])); + s.set_unit_reach(HashMap::from([(TARGET, reach(16.0))])); s.set_inspect_honor(Some(inspect_reply())); s.run(r#"InspectUnit("target")"#).unwrap(); s.run(r#"ToggleInspect("InspectHonorFrame")"#).unwrap(); diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs index e5a1bbe6..0dfc3175 100644 --- a/crates/benilla-app/src/ui_script/inspect_tests.rs +++ b/crates/benilla-app/src/ui_script/inspect_tests.rs @@ -5,10 +5,22 @@ use std::collections::HashMap; use benilla_ui::script::{ - InspectView, InvSlotView, InventorySlots, QuadContent, SoundRequest, UiScript, UnitReach, - UnitState, + InspectView, InvSlotView, InventorySlots, QuadContent, SoundRequest, UiScript, UnitGuids, + UnitReach, UnitState, }; +/// The inspected player's guid, which `"target"` resolves to and the reach map is keyed by. +const TARGET: u64 = 0x0000_0001_0000_2AB3; + +/// The resolver's inputs with `"target"` a held unit, so a verb's token reaches [`TARGET`]'s entry. +fn target_guids() -> UnitGuids { + UnitGuids { + target: TARGET, + held: HashMap::from([(TARGET, 0)]), + ..Default::default() + } +} + /// A live, inspectable unit at squared distance `dist_sq`. `inspectable` carries the server's /// non-distance refusals and only `CanInspect` reads it; these tests are about distance. fn reach(dist_sq: f64) -> UnitReach { @@ -117,9 +129,10 @@ fn armed() -> UiScript { super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI"); s.run("InspectFrame_LoadUI()").unwrap(); s.set_unit("target", Some(target_unit())); + s.set_unit_guids(&target_guids()); s.set_inspect(Some(inspect_view("target"))); // 4 yards away (d² = 16), inside the 10-yard gate (100.0). - s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))])); + s.set_unit_reach(HashMap::from([(TARGET, reach(16.0))])); s } @@ -169,7 +182,7 @@ fn inspect_unit_refuses_out_of_range() { let _data = benilla_formats::wow_data_or_skip!(); let mut s = armed(); // 11 yards (d² = 121), past the 100.0 threshold. - s.set_unit_reach(HashMap::from([("target".to_string(), reach(121.0))])); + s.set_unit_reach(HashMap::from([(TARGET, reach(121.0))])); s.run(r#"InspectUnit("target")"#).unwrap(); assert!(s.errors().is_empty(), "errors: {:?}", s.errors()); @@ -182,7 +195,7 @@ fn inspect_unit_refuses_out_of_range() { "out of range: no CMSG_INSPECT request is queued" ); - s.set_unit_reach(HashMap::from([("target".to_string(), reach(99.9))])); + s.set_unit_reach(HashMap::from([(TARGET, reach(99.9))])); s.run(r#"InspectUnit("target")"#).unwrap(); assert!( s.eval::("return InspectFrame:IsVisible()").unwrap(), @@ -206,7 +219,7 @@ fn range_predicates_transcribe_the_verified_thresholds() { (100.0, true, false), (100.1, false, false), ] { - s.set_unit_reach(HashMap::from([("target".to_string(), reach(d2))])); + s.set_unit_reach(HashMap::from([(TARGET, reach(d2))])); assert_eq!( s.eval::(r#"return CanInspect("target") ~= nil"#) .unwrap(), @@ -221,7 +234,7 @@ fn range_predicates_transcribe_the_verified_thresholds() { ); } // Type 4 is the 30-yard row (900.0). - s.set_unit_reach(HashMap::from([("target".to_string(), reach(899.0))])); + s.set_unit_reach(HashMap::from([(TARGET, reach(899.0))])); assert!(s .eval::(r#"return CheckInteractDistance("target", 4) ~= nil"#) .unwrap()); @@ -244,7 +257,7 @@ fn range_predicates_transcribe_the_verified_thresholds() { ); // The `type` argument's degenerate arms, each the reference's answer. - s.set_unit_reach(HashMap::from([("target".to_string(), reach(1.0))])); + s.set_unit_reach(HashMap::from([(TARGET, reach(1.0))])); for bad in ["0", "5", "-1", "0.5"] { assert!( s.eval::(&format!( diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 4437a3a3..6c3e5fee 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -8,7 +8,10 @@ use bevy::prelude::*; use benilla_formats::ChrClasses; use benilla_protocol::messages::ObjectType; -use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay}; +use benilla_ui::script::{ + parse_unit_token, power_token, ScriptValue, UiScript, UnitBase, UnitState, UnitTokenParse, + WornDisplay, +}; use crate::creature_type::CreatureTypeSources; use crate::names::NameCache; @@ -147,7 +150,8 @@ impl Plugin for UiUnitPlugin { // raises for the same port. fire_leaving_world_on_worldport, feed_units, - feed_unit_reach, + // After the aura feed, whose resolver inputs it measures. + feed_unit_reach.after(crate::ui_aura::AuraEvents), feed_player_control, feed_farsight_focus, melee_unit_combat, @@ -599,59 +603,67 @@ impl UnitTokens<'_, '_> { Some((*self.index.as_ref()?.0.get(&guid)?, guid)) } - /// Resolve `token` case-insensitively (`_strnicmp`); the first matching prefix wins. + /// Resolve `token` as `0x515970` does: its base, then a hop for each `target` after it. The + /// text is read by [`parse_unit_token`], which the VM's resolver shares, so both take the same + /// tokens; a token that names nobody, or a base or hop the object manager does not hold, is + /// `None`. pub(crate) fn resolve(&self, token: &str, selection: &Selection) -> Option<(Entity, u64)> { - // Only a token with a capital pays for the fold: the reach feeds resolve every group token - // each frame, all of them already lowercase. - let folded; - let token = if token.bytes().any(|b| b.is_ascii_uppercase()) { - folded = token.to_ascii_lowercase(); - folded.as_str() - } else { - token + let UnitTokenParse::Unit { base, hops } = parse_unit_token(token) else { + return None; }; - match token { - "player" => self.me(), - "target" => selection.target.zip(selection.guid), - // One hop off the target's `UNIT_FIELD_TARGET`, the read `/assist` runs. - "targettarget" => selection - .target - .and_then(|e| self.stores.get(e).ok()) - .and_then(|s| s.0.unit_target()) - .filter(|g| *g != 0) - .and_then(|g| self.held(g)), + let mut unit = self.base(base, selection)?; + for _ in 0..hops { + unit = self.target_of(unit.0)?; + } + Some(unit) + } + + /// The held unit a token's base names, before any `target` hop. + fn base(&self, base: UnitBase, selection: &Selection) -> Option<(Entity, u64)> { + match base { + UnitBase::Player => self.me(), + UnitBase::Target => selection.target.zip(selection.guid), // The same pick `ui_tooltip` pushes `"mouseover"` from. - "mouseover" => self + UnitBase::Mouseover => self .hovered .as_ref()? .mouseover(&self.hovered_go.as_deref().copied().unwrap_or_default()), // Off the pet bar's cached guid, as the `"pet"` snapshot reads it. - "pet" => { + UnitBase::Pet => { let guid = self.pet.as_ref()?.spells.pet_guid; (guid != 0).then(|| self.held(guid)).flatten() } - // `partypet` and `raidpet` before `party` and `raid` (`0x515a9a`, `0x515b1c`): the - // shorter literal is a prefix of the longer. - t if t.starts_with("partypet") => slot_number(&t["partypet".len()..], 4) - .and_then(|n| self.group.party_slots().nth(n - 1).map(|m| m.guid)) + UnitBase::PartyPet(row) => self + .party_member(row) .and_then(|member| self.pet_of(member, false)), - t if t.starts_with("raidpet") => slot_number(&t["raidpet".len()..], 40) - .and_then(|n| self.raid_member(n)) + UnitBase::RaidPet(row) => self + .raid_member(row) .and_then(|member| self.pet_of(member, true)), - t if t.starts_with("raid") => slot_number(&t["raid".len()..], 40) - .and_then(|n| self.raid_member(n)) - .and_then(|g| self.held(g)), - t => t - .strip_prefix("party") - .and_then(|n| slot_number(n, 4)) - .and_then(|n| self.group.party_slots().nth(n - 1).map(|m| m.guid)) - .and_then(|g| self.held(g)), + UnitBase::Raid(row) => self.raid_member(row).and_then(|guid| self.held(guid)), + UnitBase::Party(row) => self.party_member(row).and_then(|guid| self.held(guid)), + // The interaction NPC is unresolved here, a quiet nil. + UnitBase::Npc => None, } } - /// The guid on 1-based raid row `n`, the one `raidN` and `GetRaidRosterInfo` share. - fn raid_member(&self, n: usize) -> Option { - crate::ui_party::raid_row_guid(&self.group, self.me().map(|(_, g)| g), n) + /// One `target` hop (`0x515a0f`-`0x515a25`): the held unit's `UNIT_FIELD_TARGET`, held in turn. + /// The lookup takes the unit typemask, so a hop off any other object, or off a unit with no + /// target, is nobody. + fn target_of(&self, unit: Entity) -> Option<(Entity, u64)> { + let store = self.stores.get(unit).ok().filter(|s| s.is_unit())?; + self.held(store.0.unit_target()?) + } + + /// The guid in 0-based party slot `row`, the one `partyN` and `partypetN` share. + fn party_member(&self, row: u32) -> Option { + let slot = usize::try_from(row).ok()?; + self.group.party_slots().nth(slot).map(|m| m.guid) + } + + /// The guid on 0-based raid row `row`, the one `raidN` and `GetRaidRosterInfo` share. + fn raid_member(&self, row: u32) -> Option { + let index = usize::try_from(row).ok()?.checked_add(1)?; + crate::ui_party::raid_row_guid(&self.group, self.me().map(|(_, g)| g), index) } /// A group member's pet, held: `CHARM`, else `SUMMON`, off the member's descriptor while it is @@ -671,18 +683,8 @@ impl UnitTokens<'_, '_> { } } -/// The number after a group token's prefix, `0x515970`'s inline digit parse read 1-based: -/// `1..=max`, so `"partypet"`, `"partypet0"`, `"partypetX"` and a number past the table name no -/// slot. Digits only, as the reference's parse: `"raid+3"` is no row. The reference's own -/// `partypetN` has no bound and reads past its four-slot table; here it is nobody, as `partyN`. -fn slot_number(digits: &str, max: usize) -> Option { - if !digits.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - digits.parse().ok().filter(|n| (1..=max).contains(n)) -} - -/// Every token [`UnitTokens`] resolves, `"player"` included (the reference answers d² = 0). +/// The tokens the `SpellCanTargetUnit` feed answers for, `"player"` included: the bases and one +/// hop off the target, not the chains a script can spell. pub(crate) fn reach_tokens() -> impl Iterator { ["player", "target", "targettarget", "mouseover", "pet"] .into_iter() @@ -747,14 +749,16 @@ fn feed_farsight_focus( } } -/// Feed the unit reach map: per token naming a live unit, its squared distance and whether it +/// Feed the unit reach map: per held unit a token can name, its squared distance and whether it /// passes inspect's two other refusals, a non-player and an attackable one (vmangos /// `MiscHandler.cpp:945-956`; that the client checks them is inferred, `0x48a1b0` being partly -/// undecoded). Ungated: distances move every frame, and no event keys off the map. +/// undecoded). The guids are the resolver's own inputs, which the aura feed pushes each frame +/// ([`UiScript::held_unit_guids`], so this runs after it): the bases and each unit a `target` +/// chain reaches, and the VM resolves a verb's token to one of them or to a guid with no entry. Ungated: distances move every +/// frame, and no event keys off the map. fn feed_unit_reach( script: Option>, tokens: UnitTokens, - selection: Res, self_q: Query<(&Transform, &ObjectStore), With>, transforms: Query<&Transform>, factions: Option>, @@ -765,8 +769,8 @@ fn feed_unit_reach( }; let mut reach = HashMap::new(); if let Some((self_tf, self_store)) = self_q.iter().next() { - for token in reach_tokens() { - let Some((entity, guid)) = tokens.resolve(token, &selection) else { + for guid in script.held_unit_guids() { + let Some((entity, _)) = tokens.held(guid) else { continue; }; let Ok(tf) = transforms.get(entity) else { @@ -781,7 +785,7 @@ fn feed_unit_reach( Some(self_store), ); reach.insert( - token.to_string(), + guid, benilla_ui::script::UnitReach { dist_sq: dist_sq(tf.translation, self_tf.translation), inspectable, @@ -2004,20 +2008,8 @@ mod tests { assert_eq!(resolve(&mut app, "partypet1"), None); } - #[test] - fn a_slot_number_is_digits_within_the_table() { - assert_eq!(slot_number("1", 4), Some(1)); - assert_eq!(slot_number("4", 4), Some(4)); - assert_eq!(slot_number("40", 40), Some(40)); - assert_eq!(slot_number("41", 40), None); - assert_eq!(slot_number("0", 4), None); - for bad in ["", "x", "+1", "-1", "1 ", "1x"] { - assert_eq!(slot_number(bad, 40), None, "{bad:?}"); - } - } - - /// The reach and `SpellCanTargetUnit` feeds walk [`reach_tokens`], so a token the - /// resolver names must be in it, or `CheckInteractDistance("partypet1", …)` answers nil. + /// The `SpellCanTargetUnit` feed walks [`reach_tokens`], so a token it should answer for + /// must be in it, or `SpellCanTargetUnit("partypet1")` answers nil. #[test] fn the_reach_tokens_cover_every_group_pet() { let tokens: Vec<_> = reach_tokens().collect(); @@ -2030,7 +2022,8 @@ mod tests { } } - /// A party pet in view gets a distance, so the range verbs answer for it. + /// A party pet in view gets a distance, so the range verbs answer for it: the feed measures + /// the held units the resolver's inputs list, and the VM resolves the token to one of them. #[test] fn the_reach_feed_measures_a_party_pet() { let mut app = party(1, true, false); @@ -2046,6 +2039,15 @@ mod tests { app.init_resource::(); app.insert_resource(Selection::default()); app.insert_non_send_resource(UiScript::new().unwrap()); + app.world_mut() + .non_send_resource_mut::() + .set_unit_guids(&benilla_ui::script::UnitGuids { + player: ME, + party: [member(1), 0, 0, 0], + party_pets: [pet(1), 0, 0, 0], + held: HashMap::from([(ME, 0), (pet(1), 0)]), + ..Default::default() + }); app.world_mut().run_system_once(feed_unit_reach).unwrap(); let answer = |app: &App, expr: &str| { app.world() @@ -2074,6 +2076,437 @@ mod tests { } } + /// The `target` chain through the one resolver (`0x5159d3`-`0x515a2c`): a base, then a hop off + /// each held unit's `UNIT_FIELD_TARGET`, so `party1target`, `pettarget` and `raid3target` name + /// the unit at the end of the chain and any break in it names nobody. + mod target_chain_tokens { + use super::*; + use crate::net::GuidIndex; + use crate::target::{Hovered, HoveredObject}; + use crate::ui_party::{GroupState, GROUPTYPE_RAID}; + use benilla_protocol::messages::{member_status, GroupMemberEntry}; + use benilla_protocol::ObjectFields; + use bevy::ecs::system::RunSystemOnce; + + const ME: u64 = 0x10; + /// party1 and raid2, party2 and raid3, party3 and raid4. + const A: u64 = 0x1001; + const B: u64 = 0x1002; + const C: u64 = 0x1003; + const MOB: u64 = 0xF130_0000_0000_0001; + /// A unit with no target, the mouseover. + const LONELY: u64 = 0xF130_0000_0000_0002; + const MY_PET: u64 = 0xF140_0000_0000_0010; + const A_PET: u64 = 0xF140_0000_0000_1001; + /// A game object that still carries a value where a unit keeps its target. + const CHEST: u64 = 0xF110_0000_0000_0003; + /// Named by `C`'s target and held by no one. + const GONE: u64 = 0xF130_0000_0000_0099; + + /// `OBJECT_FIELD_TYPE`, and the masks the object manager's typemask test reads. + const OBJECT_TYPE: u16 = 2; + const PLAYER: u32 = 0x19; + const UNIT: u32 = 0x09; + const GAME_OBJECT: u32 = 0x21; + /// `UNIT_FIELD_SUMMON` and `UNIT_FIELD_TARGET`, each a two-field guid. + const SUMMON: u16 = 8; + const TARGET: u16 = 16; + + fn fields(kind: u32, summon: u64, target: u64) -> ObjectStore { + let mut pairs = vec![(OBJECT_TYPE, kind)]; + for (field, guid) in [(SUMMON, summon), (TARGET, target)] { + pairs.push((field, guid as u32)); + pairs.push((field + 1, (guid >> 32) as u32)); + } + ObjectStore(ObjectFields::from_pairs(&pairs)) + } + + fn entry(guid: u64) -> GroupMemberEntry { + GroupMemberEntry { + name: format!("M{guid:x}"), + guid, + status: member_status::ONLINE, + flags: 0, + } + } + + /// The world's targets: we target the mob and so does `A`, the mob targets `B`, `B` targets + /// `A`, our pet targets the mob and `A`'s targets `B`, `C` targets a unit nobody holds, and + /// `LONELY` targets nobody. We select the mob and hover `LONELY`. The group is a raid of + /// us, `A`, `B` and `C`, all in our subgroup, so the three are also party slots 1 to 3. + fn app() -> App { + let mut app = App::new(); + app.init_resource::() + .init_resource::() + .init_resource::(); + let spawn = |app: &mut App, guid: u64, store: ObjectStore| { + let e = app.world_mut().spawn((Guid(guid), store)).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + e + }; + let me = spawn(&mut app, ME, fields(PLAYER, MY_PET, MOB)); + app.world_mut().entity_mut(me).insert(SelfPlayer); + spawn(&mut app, A, fields(PLAYER, A_PET, MOB)); + spawn(&mut app, B, fields(PLAYER, 0, A)); + spawn(&mut app, C, fields(PLAYER, 0, GONE)); + let mob = spawn(&mut app, MOB, fields(UNIT, 0, B)); + let lonely = spawn(&mut app, LONELY, fields(UNIT, 0, 0)); + spawn(&mut app, MY_PET, fields(UNIT, 0, MOB)); + spawn(&mut app, A_PET, fields(UNIT, 0, B)); + app.world_mut().resource_mut::().apply_list( + GROUPTYPE_RAID, + 0, + vec![entry(A), entry(B), entry(C)], + ME, + None, + Some(ME), + ); + app.insert_resource(Selection { + target: Some(mob), + guid: Some(MOB), + ..Default::default() + }); + app.insert_resource(Hovered { + target: Some(lonely), + guid: Some(LONELY), + distance: 5.0, + ..Default::default() + }); + let mut bar = crate::ui_pet::PetBar::default(); + bar.spells.pet_guid = MY_PET; + app.insert_resource(bar); + app + } + + fn resolve(app: &mut App, token: &str) -> Option<(Entity, u64)> { + let token = token.to_string(); + app.world_mut() + .run_system_once(move |tokens: UnitTokens, selection: Res| { + tokens.resolve(&token, &selection) + }) + .unwrap() + } + + fn guid_of(app: &mut App, token: &str) -> Option { + resolve(app, token).map(|(_, guid)| guid) + } + + /// Every base takes hops, and each hop reads the current unit's own target, so the answer + /// is the unit the chain ends on, its entity the one the object manager holds. + #[test] + fn a_target_suffix_names_the_unit_at_the_end_of_the_chain() { + let mut app = app(); + for (token, want) in [ + ("party1target", MOB), + ("party2target", A), + ("party1targettarget", B), + ("party1targettargettarget", A), + ("playertarget", MOB), + ("playertargettarget", B), + ("partypet1target", B), + ("pettarget", MOB), + ("pettargettarget", B), + ("target", MOB), + ("targettarget", B), + ("targettargettarget", A), + ("raid1target", MOB), + ("raid2target", MOB), + ("raid3target", A), + ("raid2targettarget", B), + ("raidpet1target", MOB), + ("raidpet2target", B), + ("mouseover", LONELY), + ] { + assert_eq!(guid_of(&mut app, token), Some(want), "{token}"); + } + let (entity, guid) = resolve(&mut app, "party2targettarget").unwrap(); + assert_eq!(guid, MOB); + assert_eq!( + app.world().get::(entity).map(|g| g.0), + Some(MOB), + "the entity is the mob's" + ); + } + + /// A unit with no target ends the chain (`0x515a2e`), as does a target the object manager + /// does not hold (`0x515a16`), a hop off anything but a unit (`0x515a0f`, typemask 8) and + /// a base that names no one. + #[test] + fn a_chain_that_breaks_names_nobody() { + let mut app = app(); + for token in [ + // The mouseover targets nobody, and so nothing follows it. + "mouseovertarget", + "mouseovertargettarget", + // `C` targets a guid nobody holds. + "party3target", + "raid4target", + // Nor does a further hop go past the break. + "party3targettarget", + // A base no one holds: party4 is nobody, raid5 is past the roster, a missing or + // zero number names no row, and `B` has no pet. + "party4target", + "raid5target", + "raid0target", + "partytarget", + "partypet2target", + "raidpet3target", + ] { + assert_eq!(resolve(&mut app, token), None, "{token}"); + } + + // A hop off a game object is nobody, whatever its descriptor holds where a unit + // keeps its target. + let chest = app + .world_mut() + .spawn((Guid(CHEST), fields(GAME_OBJECT, 0, A))) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(CHEST, chest); + app.insert_resource(Selection { + target: Some(chest), + guid: Some(CHEST), + ..Default::default() + }); + assert_eq!(guid_of(&mut app, "target"), Some(CHEST), "the base is held"); + assert_eq!(resolve(&mut app, "targettarget"), None); + } + + /// Text after the base, or after a hop, that is not `target` names nobody, and `npc` is + /// an exact compare with no chain. + #[test] + fn text_that_is_not_a_target_hop_names_nobody() { + let mut app = app(); + for token in [ + "party1foo", + "party1targetfoo", + "party1targettarge", + "party1 target", + "playerfoo", + "playertargets", + "pettarge", + "targetfoo", + "mouseoverx", + "raid3targettargetfoo", + "npc", + "npctarget", + "bogus", + "", + ] { + assert_eq!(resolve(&mut app, token), None, "{token}"); + } + // The control: the same tokens without the tail resolve. + assert_eq!(guid_of(&mut app, "party1"), Some(A)); + assert_eq!(guid_of(&mut app, "player"), Some(ME)); + } + + /// The compares fold ASCII case, the hops' included (`_strnicmp`, `0x5159f1`), and the + /// chain has no depth limit: three hops round `A`, the mob and `B` come back to `A`. + #[test] + fn a_chain_folds_case_and_has_no_depth_limit() { + let mut app = app(); + for token in [ + "PARTY1TARGET", + "Party1Target", + "party1TARGET", + "PartY1tArGeT", + ] { + assert_eq!(guid_of(&mut app, token), Some(MOB), "{token}"); + } + assert_eq!(guid_of(&mut app, "PlayerTarget"), Some(MOB)); + assert_eq!(guid_of(&mut app, "TargetTargetTarget"), Some(A)); + assert_eq!(guid_of(&mut app, "RaidPet1Target"), Some(MOB)); + for hops in [3, 30, 3000] { + let token = format!("party1{}", "target".repeat(hops)); + assert_eq!(guid_of(&mut app, &token), Some(A), "{hops} hops round A"); + } + let token = format!("party1{}", "target".repeat(3001)); + assert_eq!(guid_of(&mut app, &token), Some(MOB)); + } + + /// A number wraps in 32 bits as the reference's inline parse does (`0x515af4`): 2^32 + 1 + /// is row 1, and digits stop at the first letter. + #[test] + fn a_row_number_wraps_as_the_reference_parse_does() { + let mut app = app(); + assert_eq!(guid_of(&mut app, "party4294967297target"), Some(MOB)); + assert_eq!(guid_of(&mut app, "party01target"), Some(MOB)); + assert_eq!(guid_of(&mut app, "party1x"), None); + } + } + + /// The range verbs end to end, with the aura feed that pushes the resolver's inputs and the + /// reach feed that measures the units they hold: `CheckInteractDistance` and `CanInspect` + /// answer for a `target` chain (`0x48ba00` and `0x48a1b0` resolve their token through + /// `0x515970`), on the unit the chain names and not on the base. + #[test] + fn the_range_verbs_answer_for_a_target_chain() { + use benilla_protocol::messages::{member_status, GroupMemberEntry, ObjectFields}; + + const ME: u64 = 0x10; + const A: u64 = 0x1001; + const B: u64 = 0x1002; + const MOB: u64 = 0xF130_0000_0000_0001; + const A_PET: u64 = 0xF140_0000_0000_1001; + /// `OBJECT_FIELD_TYPE`, `UNIT_FIELD_SUMMON`, `UNIT_FIELD_TARGET` and `UNIT_FIELD_FLAGS`. + const OBJECT_TYPE: u16 = 2; + const SUMMON: u16 = 8; + const TARGET: u16 = 16; + const FLAGS: u16 = 46; + /// `UNIT_FIELD_FLAGS` bit 1 (NON_ATTACKABLE), which `can_attack` refuses. + const NON_ATTACKABLE: u32 = 1 << 1; + + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) + .insert_state(crate::char_select::ClientState::InWorld) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .add_plugins(crate::ui_aura::UiAuraPlugin) + .add_systems(Update, feed_unit_reach.after(crate::ui_aura::AuraEvents)); + app.insert_non_send_resource(UiScript::new().unwrap()); + + // We at the origin, party1 (`A`) 5 yards off with a pet 15 yards off, the mob 20 yards + // off, and `B` 3 yards off. We and `A` target the mob, the mob targets `B`, the pet + // targets `B`, and `B` targets nobody. The players are not attackable, so inspectable. + let mut spawn = |guid: u64, kind: u32, summon: u64, target: u64, yards: f32| { + let mut pairs = vec![(OBJECT_TYPE, kind), (FLAGS, NON_ATTACKABLE)]; + for (field, guid) in [(SUMMON, summon), (TARGET, target)] { + pairs.push((field, guid as u32)); + pairs.push((field + 1, (guid >> 32) as u32)); + } + let e = app + .world_mut() + .spawn(( + Guid(guid), + ObjectStore(ObjectFields::from_pairs(&pairs)), + // Along one axis, so d² is exactly yards². + Transform::from_xyz(yards, 0.0, 0.0), + )) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + e + }; + let me = spawn(ME, 0x19, 0, MOB, 0.0); + spawn(A, 0x19, A_PET, MOB, 5.0); + spawn(A_PET, 0x09, 0, B, 15.0); + let mob = spawn(MOB, 0x09, 0, B, 20.0); + spawn(B, 0x19, 0, 0, 3.0); + app.world_mut().entity_mut(me).insert(SelfPlayer); + app.world_mut() + .resource_mut::() + .apply_list( + 0, + 0, + vec![GroupMemberEntry { + name: "Brisca".into(), + guid: A, + status: member_status::ONLINE, + flags: 0, + }], + ME, + None, + Some(ME), + ); + app.insert_resource(Selection { + target: Some(mob), + guid: Some(MOB), + ..Default::default() + }); + app.update(); + + let answers = |app: &App, expr: &str| { + app.world() + .non_send_resource::() + .eval::(&format!("return {expr} ~= nil")) + .unwrap() + }; + // The base and each hop, at their own distances: the 10-yard row is `1`, the 30-yard `4`. + for (token, kind, want) in [ + ("player", 1, true), + ("target", 1, false), + ("target", 4, true), + ("playertarget", 1, false), + ("playertarget", 4, true), + ("party1target", 1, false), + ("party1target", 4, true), + ("PARTY1TARGET", 4, true), + ("party1targettarget", 1, true), + ("targettarget", 1, true), + ("partypet1", 1, false), + ("partypet1", 4, true), + ("partypet1target", 1, true), + // `B` targets nobody, so the chain ends there. + ("party1targettargettarget", 4, false), + ("party2target", 4, false), + ("party1foo", 4, false), + ("npctarget", 4, false), + ] { + assert_eq!( + answers( + &app, + &format!(r#"CheckInteractDistance("{token}", {kind})"#) + ), + want, + "CheckInteractDistance({token}, {kind})" + ); + } + // `CanInspect`: a player within 10 yards, so `B` and not the mob 20 yards off. + for (token, want) in [ + ("party1targettarget", true), + ("targettarget", true), + ("party1", true), + ("party1target", false), + ("target", false), + ("party1targettargettarget", false), + ] { + assert_eq!( + answers(&app, &format!(r#"CanInspect("{token}")"#)), + want, + "CanInspect({token})" + ); + } + } + + /// The reach feed measures the guids the aura feed pushes, so a frame's feeds must run in that + /// order: read off the declared graph, the aura feed's set comes before the reach feed. + #[test] + fn the_reach_feed_runs_after_the_aura_feed_it_reads() { + use crate::game_plugins::schedule_tests::{census, headless_client, SyncPoints}; + + let mut app = headless_client(); + let c = census(&mut app, Update, SyncPoints::Declared); + if !c.systems.values().any(|s| s.name.contains("benilla_app::")) { + eprintln!("skipped: this build carries no type names"); + return; + } + let one = |suffix: &str| { + let mut hits = c.systems.iter().filter(|(_, s)| s.name.ends_with(suffix)); + match (hits.next(), hits.next()) { + (Some((k, _)), None) => *k, + _ => panic!("exactly one system named `…{suffix}` in Update"), + } + }; + let aura = one("::ui_aura::feed_auras"); + let reach = one("::ui_unit::feed_unit_reach"); + assert!( + c.dependencies.contains(&(aura, reach)), + "feed_unit_reach is declared after feed_auras" + ); + } + /// The team digit comes off the race byte, whatever template sits beside it (a GM's 35). #[test] fn the_team_digit_comes_off_the_race_byte_not_the_faction_template() { @@ -2644,6 +3077,15 @@ mod tests { guid: Some(guid), ..Default::default() }); + // The resolver's inputs the aura feed would push: us and the target, both held. + app.world_mut() + .non_send_resource_mut::() + .set_unit_guids(&benilla_ui::script::UnitGuids { + player: ME, + target: guid, + held: HashMap::from([(ME, 0), (guid, 0)]), + ..Default::default() + }); app.world_mut().run_system_once(feed_unit_reach).unwrap(); app.world_mut() diff --git a/crates/benilla-ui/src/script/inspect.rs b/crates/benilla-ui/src/script/inspect.rs index 8bdce4ec..7e34a533 100644 --- a/crates/benilla-ui/src/script/inspect.rs +++ b/crates/benilla-ui/src/script/inspect.rs @@ -1,7 +1,7 @@ //! The inspect bindings: `NotifyInspect` and `ClearInspectPlayer` queue intents, the app pushes //! the inspected unit's equipment ([`UiScript::set_inspect`]) for the unit-keyed -//! `GetInventoryItem*` verbs, and `CanInspect` and `CheckInteractDistance` read a per-token -//! distance map ([`UiScript::set_unit_reach`]). +//! `GetInventoryItem*` verbs, and `CanInspect` and `CheckInteractDistance` read a per-guid +//! distance map ([`UiScript::set_unit_reach`]) through the token resolver ([`super::UnitGuids`]). //! //! Worn gear is public descriptor state (`PLAYER_VISIBLE_ITEM__0`), so the window paints from //! what the client holds: `SMSG_INSPECT` echoes only the guid, `InspectFrame_Show` calls @@ -25,10 +25,10 @@ pub const INTERACT_DIST_SQ: [f64; 4] = [100.0, 123.45678, 100.0, 900.0]; /// `0x48a1b0`); vmangos checks the same 10 yards (`INSPECT_DISTANCE`, `ObjectDefines.h:26`). pub const CAN_INSPECT_DIST_SQ: f64 = 100.0; -/// One token's unit this frame, as the two range verbs read it. An entry exists only when the -/// token resolved to a live unit object: the reference maps the token to a GUID (`0x515940`) and -/// answers nil when the object manager has no such unit, as for a party member out of the area, -/// whose GUID comes from the roster (`0x4e81a0`). +/// One unit this frame, as the two range verbs read it. An entry exists only for a live unit +/// object: the reference maps the token to a GUID (`0x515940`) and answers nil when the object +/// manager has no such unit, as for a party member out of the area, whose GUID comes from the +/// roster (`0x4e81a0`). #[derive(Clone, Copy, Debug, PartialEq)] pub struct UnitReach { /// Squared distance from the player. @@ -53,15 +53,23 @@ pub struct InspectView { } impl super::UiScript { - /// Push the squared distance of every token that resolved to a live unit this frame, creature - /// included; both range verbs read the same d². A token absent from the map answers nil from - /// both, the reference's null-object arm (`0x48babe`). It changes every frame, so it stays off - /// [`super::UnitState`], whose diffs fire `UNIT_*` events. Keys are lowercase: the token - /// resolver folds case (`_strnicmp`). - pub fn set_unit_reach(&mut self, reach: HashMap) { + /// Push the squared distance of every live unit a token can name this frame, creature + /// included; both range verbs read the same d². A verb resolves its token to a guid through + /// [`super::UnitGuids`], so `"party1target"` and `"PLAYER"` read the unit they name, and a + /// guid absent from the map answers nil from both, the reference's null-object arms + /// (`0x48babe` for `CheckInteractDistance`, `0x48a1fa` for `CanInspect`). It changes every frame, so it stays off [`super::UnitState`], whose diffs + /// fire `UNIT_*` events. + pub fn set_unit_reach(&mut self, reach: HashMap) { self.model_mut().unit_reach = reach; } + /// The guids of the held units the resolver's inputs reach ([`Self::set_unit_guids`]): every + /// unit a token can name, the `target` chains included, that the object manager holds. These + /// are the guids [`Self::set_unit_reach`] is measured for. + pub fn held_unit_guids(&self) -> Vec { + self.model_ref().unit_guids.held.keys().copied().collect() + } + /// Push (or clear, with `None`) the inspected unit's equipment. It fires nothing: the app fires /// `UNIT_INVENTORY_CHANGED` for the token, which the stock slot buttons listen for. pub fn set_inspect(&mut self, view: Option) { @@ -80,16 +88,14 @@ impl super::UiScript { } } -/// The reach pushed for `token`, case-folded: both verbs resolve the token through `0x515970`, -/// whose compares are `_strnicmp`. +/// The reach of the unit `token` names, through the resolver (`0x515970`, which both verbs call via +/// `0x515940`): the guid it answers, then that guid's entry. A token the resolver does not +/// recognise answers nobody here. fn reach(lua: &Lua, token: &Option) -> Option { let token = token.as_deref()?; let model = lua.app_data_ref::().expect("model app_data"); - if token.bytes().any(|b| b.is_ascii_uppercase()) { - model.unit_reach.get(&token.to_ascii_lowercase()).copied() - } else { - model.unit_reach.get(token).copied() - } + let guid = model.unit_guids.resolve(token).ok().flatten()?; + model.unit_reach.get(&guid).copied() } pub(super) fn install(lua: &Lua) -> mlua::Result<()> { @@ -169,3 +175,127 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::script::{UiScript, UnitGuids}; + + const ME: u64 = 0x10; + const MOB: u64 = 0xF130_0000_0000_0001; + const P1: u64 = 0x21; + const P1_PET: u64 = 0xF140_0000_0000_0021; + const OUT: u64 = 0x44; + + fn reach(dist_sq: f64, inspectable: bool) -> UnitReach { + UnitReach { + dist_sq, + inspectable, + } + } + + /// Us at the origin targeting a mob 20 yards off, which targets party1 5 yards off, who + /// targets us; party1's pet targets nobody. `OUT` is a roster member with no object. + fn seeded() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_unit_guids(&UnitGuids { + player: ME, + pet: P1_PET, + target: MOB, + mouseover: MOB, + party: [P1, OUT, 0, 0], + party_pets: [P1_PET, 0, 0, 0], + raid: vec![ME, P1], + held: HashMap::from([(ME, MOB), (MOB, P1), (P1, ME), (P1_PET, 0)]), + ..Default::default() + }); + s.set_unit_reach(HashMap::from([ + (ME, reach(0.0, true)), + (MOB, reach(400.0, false)), + (P1, reach(25.0, true)), + (P1_PET, reach(2500.0, false)), + ])); + s + } + + fn answers(s: &UiScript, expr: &str) -> bool { + s.eval::(&format!("return {expr} ~= nil")).unwrap() + } + + /// A token reads the reach of the unit it names, the `target` chain included: the verbs + /// resolve through [`UnitGuids`], so a chain is asked for no entry of its own. + #[test] + fn a_target_chain_reads_the_reach_of_the_unit_it_names() { + let s = seeded(); + // The base, then each hop: MOB is 20 yards off, outside the 10-yard row, inside the 30. + assert!(!answers(&s, r#"CheckInteractDistance("target", 1)"#)); + assert!(answers(&s, r#"CheckInteractDistance("target", 4)"#)); + assert!(!answers(&s, r#"CheckInteractDistance("playertarget", 1)"#)); + assert!(answers(&s, r#"CheckInteractDistance("playertarget", 4)"#)); + // party1 is 5 yards off, and so is whom the target targets. + assert!(answers(&s, r#"CheckInteractDistance("targettarget", 1)"#)); + assert!(answers(&s, r#"CheckInteractDistance("party1", 1)"#)); + // party1 targets us: distance 0, where `party1` itself is 5 yards. + assert!(answers(&s, r#"CheckInteractDistance("party1target", 1)"#)); + assert!(answers(&s, r#"CheckInteractDistance("raid2target", 1)"#)); + assert!(answers( + &s, + r#"CheckInteractDistance("targettargettarget", 1)"# + )); + // The mouseover is the mob, so its target is party1 again. + assert!(answers( + &s, + r#"CheckInteractDistance("mouseovertarget", 1)"# + )); + // Four hops come back round to the mob. + assert!(!answers( + &s, + r#"CheckInteractDistance("targettargettargettarget", 1)"# + )); + // The pet is 50 yards off, outside every row. + assert!(!answers(&s, r#"CheckInteractDistance("partypet1", 4)"#)); + } + + /// The other range verb reads the same entry, and its player-only refusal rides in it. + #[test] + fn can_inspect_reads_the_chain_too() { + let s = seeded(); + // party1 is a player in range, and the mob is a creature, which inspect refuses. + assert!(answers(&s, r#"CanInspect("party1")"#)); + assert!(answers(&s, r#"CanInspect("targettarget")"#)); + assert!(answers(&s, r#"CanInspect("party1target")"#)); + assert!(!answers(&s, r#"CanInspect("target")"#)); + assert!(!answers(&s, r#"CanInspect("party1targettarget")"#)); + } + + /// A chain that ends at nobody, a unit with no object, text after the chain and a token the + /// resolver does not read as a chain each answer nil, and the compares fold case. + #[test] + fn a_chain_that_names_nobody_answers_nil_and_case_folds() { + let s = seeded(); + for token in [ + // The pet targets nobody, so a hop off it names nobody. + "partypet1target", + "pettarget", + // A roster member with no object, and a hop off it. + "party2", + "party2target", + "party3target", + "party1foo", + "party1targetfoo", + // `npc` is an exact compare with no chain. + "npctarget", + "bogus", + "", + ] { + let call = format!(r#"CheckInteractDistance("{token}", 4)"#); + assert!(!answers(&s, &call), "{call}"); + let call = format!(r#"CanInspect("{token}")"#); + assert!(!answers(&s, &call), "{call}"); + } + assert!(answers(&s, r#"CheckInteractDistance("PARTY1TARGET", 1)"#)); + assert!(answers(&s, r#"CanInspect("Party1Target")"#)); + } +} diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 64317779..d51b1597 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -264,8 +264,8 @@ pub use types::{ }; pub(crate) use types::{FontExplicit, MeasuredText, RegionData}; pub use unit::{ - grey_band, level_reads_unknown, power_token, unit_is_grey, PlayerRecord, SelectionRequest, - UnitGuids, UnitState, + grey_band, level_reads_unknown, parse_unit_token, power_token, unit_is_grey, PlayerRecord, + SelectionRequest, UnitBase, UnitGuids, UnitState, UnitTokenParse, }; pub use weapon_enchant::WeaponEnchant; pub use who_sort::{WhoSortChain, WhoSortKey}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index d26613c7..5a0d4b2e 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -876,8 +876,8 @@ pub(crate) struct Model { pub(crate) tabard_intents: Vec, /// The `WorldFrame` type's one-shot record: true once the first is made. pub(crate) world_frame_made: bool, - /// Per lowercased live-unit token, reach for `CanInspect`/`CheckInteractDistance`; absent: nil. - pub(crate) unit_reach: HashMap, + /// Per live-unit guid, reach for `CanInspect`/`CheckInteractDistance`; absent: nil. + pub(crate) unit_reach: HashMap, /// The skills pane's snapshot and the display tree built from it. pub(crate) skills: skills::SkillsState, diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index cc47a19a..1a9838a4 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -496,6 +496,6 @@ mod resolve; #[cfg(test)] mod tests; -pub use resolve::UnitGuids; +pub use resolve::{parse_unit_token, UnitBase, UnitGuids, UnitTokenParse}; pub(super) use bindings::install; diff --git a/crates/benilla-ui/src/script/unit/resolve.rs b/crates/benilla-ui/src/script/unit/resolve.rs index e7258ade..d93f14ff 100644 --- a/crates/benilla-ui/src/script/unit/resolve.rs +++ b/crates/benilla-ui/src/script/unit/resolve.rs @@ -61,6 +61,36 @@ const PREFIXES: [(&str, Base); 8] = [ ("mouseover", Base::Mouseover), ]; +/// The unit a token starts from, before any `target` hop. A numbered base carries its 0-based +/// row, [`index_of`]'s: a table lookup that fails on the reserved `u32::MAX` of a missing or zero +/// number names nobody. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnitBase { + Player, + Pet, + Target, + Mouseover, + /// The interaction NPC; an exact compare, so it takes no `target` hop (`0x515bec`). + Npc, + Party(u32), + PartyPet(u32), + Raid(u32), + RaidPet(u32), +} + +/// What `0x515970` makes of a token's text, before it reads any state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnitTokenParse { + /// None of the nine compares match: the reference raises `Unknown unit name` (`0x515c14`). + Unknown, + /// Recognised, and names nobody whatever the state: the empty token (`0x51599d`), or text + /// after the base, and after each hop, that is not `target` (`0x515c2d`). + Nobody, + /// A base and the `target` hops after it, each off the current unit's `UNIT_FIELD_TARGET` + /// (`0x515a1c`-`0x515a25`); the loop has no depth limit. + Unit { base: UnitBase, hops: usize }, +} + /// `_strnicmp(s, lit, strlen(lit)) == 0`: ASCII fold only, bytes of 0x80 and up exact. fn has_prefix(s: &[u8], lit: &str) -> bool { s.len() >= lit.len() && s[..lit.len()].eq_ignore_ascii_case(lit.as_bytes()) @@ -91,46 +121,88 @@ fn index_of(s: &[u8]) -> (u32, usize) { (n.wrapping_sub(1), digits) } +/// Read `token`'s text as `0x515970` does, before any state: the base by the compares in their +/// order, a numbered base's row, then `target` hops to the end of the text. `"party1target"` is +/// party1 and one hop, `"targettarget"` the target and one, and `"npctarget"` is none of the nine +/// compares, as `npc` is one full-string compare. +pub fn parse_unit_token(token: &str) -> UnitTokenParse { + let bytes = token.as_bytes(); + if bytes.is_empty() { + return UnitTokenParse::Nobody; + } + let Some((base, len)) = base_of(bytes) else { + return if token.eq_ignore_ascii_case("npc") { + UnitTokenParse::Unit { + base: UnitBase::Npc, + hops: 0, + } + } else { + UnitTokenParse::Unknown + }; + }; + let mut rest = &bytes[len..]; + let mut numbered = |make: fn(u32) -> UnitBase| { + let (row, digits) = index_of(rest); + rest = &rest[digits..]; + make(row) + }; + let base = match base { + Base::Player => UnitBase::Player, + Base::Pet => UnitBase::Pet, + Base::Target => UnitBase::Target, + Base::Mouseover => UnitBase::Mouseover, + Base::PartyPet => numbered(UnitBase::PartyPet), + Base::Party => numbered(UnitBase::Party), + Base::RaidPet => numbered(UnitBase::RaidPet), + Base::Raid => numbered(UnitBase::Raid), + }; + let mut hops = 0; + while !rest.is_empty() { + if !has_prefix(rest, "target") { + return UnitTokenParse::Nobody; + } + rest = &rest[6..]; + hops += 1; + } + UnitTokenParse::Unit { base, hops } +} + impl UnitGuids { /// The guid `token` names, or `None` for nobody; `Err` for a token none of the nine compares /// match, where the reference raises `Unknown unit name: %s` (`0x515c14`). An empty token is /// nobody (`0x51599d`). pub(crate) fn resolve(&self, token: &str) -> Result, ()> { - let bytes = token.as_bytes(); - if bytes.is_empty() { - return Ok(None); - } - let (guid, rest) = if let Some((base, len)) = base_of(bytes) { - let rest = &bytes[len..]; - // The indexed bases: the table's row for the parsed number, and what follows it. - let at = |table: &[u64]| { - let (i, digits) = index_of(rest); - let row = usize::try_from(i).ok().and_then(|i| table.get(i)); - (row.copied().unwrap_or(0), &rest[digits..]) - }; - match base { - Base::Player => (self.player, rest), - Base::Pet if self.player == 0 => return Ok(None), - Base::Pet => (self.pet, rest), - Base::Target => (self.target, rest), - // Deviation: `0x4e81d0` has no bound and reads past its four-entry table, which - // is stale memory, not a mechanism; past it, nobody. - Base::PartyPet => at(&self.party_pets), - // Capped at 4 (`0x4e81b0`). - Base::Party => at(&self.party), - // Capped at the roster count (`0x491940`, `0x491960`). - Base::RaidPet => at(&self.raid_pets), - Base::Raid => at(&self.raid), - Base::Mouseover if !self.held.contains_key(&self.mouseover) => return Ok(None), - Base::Mouseover => (self.mouseover, rest), - } - } else if token.eq_ignore_ascii_case("npc") { - // The full-string compare leaves nothing to chain (`0x515bec`): `"npctarget"` raises. - (self.npc, &bytes[bytes.len()..]) - } else { - return Err(()); + let (base, hops) = match parse_unit_token(token) { + UnitTokenParse::Unknown => return Err(()), + UnitTokenParse::Nobody => return Ok(None), + UnitTokenParse::Unit { base, hops } => (base, hops), }; - Ok(self.follow(guid, rest)) + // The indexed bases: the table's row for the parsed number. + let at = |table: &[u64], row: u32| { + usize::try_from(row) + .ok() + .and_then(|row| table.get(row)) + .copied() + .unwrap_or(0) + }; + let guid = match base { + UnitBase::Player => self.player, + UnitBase::Pet if self.player == 0 => return Ok(None), + UnitBase::Pet => self.pet, + UnitBase::Target => self.target, + UnitBase::Npc => self.npc, + // Deviation: `0x4e81d0` has no bound and reads past its four-entry table, which is + // stale memory, not a mechanism; past it, nobody. + UnitBase::PartyPet(row) => at(&self.party_pets, row), + // Capped at 4 (`0x4e81b0`). + UnitBase::Party(row) => at(&self.party, row), + // Capped at the roster count (`0x491940`, `0x491960`). + UnitBase::RaidPet(row) => at(&self.raid_pets, row), + UnitBase::Raid(row) => at(&self.raid, row), + UnitBase::Mouseover if !self.held.contains_key(&self.mouseover) => return Ok(None), + UnitBase::Mouseover => self.mouseover, + }; + Ok(self.follow(guid, hops)) } /// [`Self::resolve`] with the raise as the Lua error it is. @@ -139,23 +211,16 @@ impl UnitGuids { .map_err(|()| mlua::Error::runtime(format!("Unknown unit name: {token}"))) } - /// The `target` loop (`0x5159d3`-`0x515a2c`): nobody stays nobody, the end of the token answers - /// the guid, and each `target` hops to the held unit's `UNIT_FIELD_TARGET`. Anything else - /// after a base, or a hop off a unit not held, is a quiet nobody (`0x5159f8`, `0x515a16`). - fn follow(&self, mut guid: u64, mut rest: &[u8]) -> Option { - loop { + /// The `target` loop (`0x5159d3`-`0x515a2c`): nobody stays nobody, and each hop goes to the held + /// unit's `UNIT_FIELD_TARGET`; a hop off a unit not held is a quiet nobody (`0x515a16`). + fn follow(&self, mut guid: u64, hops: usize) -> Option { + for _ in 0..hops { if guid == 0 { return None; } - if rest.is_empty() { - return Some(guid); - } - if !has_prefix(rest, "target") { - return None; - } - rest = &rest[6..]; guid = *self.held.get(&guid)?; } + Some(guid).filter(|&g| g != 0) } } @@ -241,6 +306,71 @@ mod tests { assert!(guids().resolve("npctarget").is_err()); } + /// The parse reads the text alone: the base by the resolver's compares, a numbered base's + /// 0-based row, then `target` hops to the end, whatever the depth. + #[test] + fn a_token_parses_to_a_base_and_its_hops() { + use UnitBase::*; + let unit = |base, hops| UnitTokenParse::Unit { base, hops }; + for (token, want) in [ + ("player", unit(Player, 0)), + ("pet", unit(Pet, 0)), + ("target", unit(Target, 0)), + ("mouseover", unit(Mouseover, 0)), + ("npc", unit(Npc, 0)), + ("party1", unit(Party(0), 0)), + ("partypet4", unit(PartyPet(3), 0)), + ("raid40", unit(Raid(39), 0)), + ("raidpet2", unit(RaidPet(1), 0)), + ("targettarget", unit(Target, 1)), + ("targettargettargettarget", unit(Target, 3)), + ("playertarget", unit(Player, 1)), + ("pettarget", unit(Pet, 1)), + ("mouseovertarget", unit(Mouseover, 1)), + ("party1target", unit(Party(0), 1)), + ("partypet1target", unit(PartyPet(0), 1)), + ("raid12targettargettarget", unit(Raid(11), 3)), + ("raidpet3target", unit(RaidPet(2), 1)), + // A missing or zero number is the row every bound rejects. + ("party", unit(Party(u32::MAX), 0)), + ("raid0target", unit(Raid(u32::MAX), 1)), + // The compares fold case, the hops too. + ("PARTY1TARGET", unit(Party(0), 1)), + ("PartyPet2TargetTarget", unit(PartyPet(1), 2)), + ] { + assert_eq!(parse_unit_token(token), want, "{token}"); + } + let deep = format!("player{}", "target".repeat(1000)); + assert_eq!( + parse_unit_token(&deep), + unit(Player, 1000), + "no depth limit" + ); + } + + /// Text after the base, or after any hop, that is not `target` names nobody; a token none of + /// the nine compares match is the raise, `npc` being an exact compare with no chain. + #[test] + fn a_token_that_is_not_base_then_hops_parses_to_nobody_or_the_raise() { + for token in [ + "", + "party1foo", + "playerfoo", + "player1", + "pet1", + "party1targetfoo", + "party1targettarge", + "party1 target", + "targetfoo", + "raid3x", + ] { + assert_eq!(parse_unit_token(token), UnitTokenParse::Nobody, "{token}"); + } + for token in ["npctarget", "npc1", "bogus", "tar", "focus", "5", "targe"] { + assert_eq!(parse_unit_token(token), UnitTokenParse::Unknown, "{token}"); + } + } + #[test] fn a_recognised_token_naming_nobody_is_quiet() { for t in [