unit tokens: party1target and other target chains resolve (#706)

`TargetUnit`, `AssistUnit`, `SpellTargetUnit`, `CheckInteractDistance` and `CanInspect` ignored target chains. `party1target`, `pettarget` and `raid3target` named nobody.

The reference's resolver `0x515970` takes a base token, then any number of `target` hops over held units' `UNIT_FIELD_TARGET` (`0x5159d3`-`0x515a2c`). Every one of these verbs resolves through it.

Changes:
- The app and the VM now share one parser, `parse_unit_token`.
- The reach map behind the two range verbs is keyed by guid, filled after the aura feed, and read through the VM's resolver.
- `ObjectStore::is_unit` replaces two private copies of the same test.
- Side effect: the reach feed's new order puts a few feeds after `feed_auras` where their order was undeclared before.

Tests: every chain form, a broken chain, junk after a hop and case folding. `TargetUnit("party1target")` works from Lua.

Fixes #669

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-30 10:20:16 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 602c30389a
commit 0a920b52fe
13 changed files with 1092 additions and 171 deletions
+12
View File
@@ -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.
@@ -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::<Entity, With<SelfPlayer>>()
.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::<crate::net::GuidIndex>();
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::<SpellTargeting>().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::<SpellTargeting>().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
+141 -2
View File
@@ -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::<crate::net::GuidIndex>().0;
index.insert(MEMBER, member);
index.insert(VICTIM, victim);
index.insert(THIRD, third);
index.insert(PET, pet);
world
.resource_mut::<crate::ui_party::GroupState>()
.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::<UiScript>()
.eval::<()>(lua)
.expect("the binding runs");
world
.run_system_once(
|mut script: NonSendMut<UiScript>, mut select: ScriptSelect| {
for request in script.take_selection_requests() {
select.select(request);
}
},
)
.expect("the applier runs as a one-shot system");
world.resource::<super::super::Selection>().guid
};
let reset = |world: &mut World| {
let mut sel = world.resource_mut::<super::super::Selection>();
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"
);
}
}
+1 -10
View File
@@ -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));
+1 -7
View File
@@ -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())
}
}
@@ -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();
@@ -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::<bool>("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::<bool>(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::<bool>(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::<bool>(&format!(
+515 -73
View File
@@ -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<u64> {
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<u64> {
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<u64> {
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<usize> {
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<Item = &'static str> {
["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<NonSendMut<UiScript>>,
tokens: UnitTokens,
selection: Res<Selection>,
self_q: Query<(&Transform, &ObjectStore), With<SelfPlayer>>,
transforms: Query<&Transform>,
factions: Option<Res<Factions>>,
@@ -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::<Reputations>();
app.insert_resource(Selection::default());
app.insert_non_send_resource(UiScript::new().unwrap());
app.world_mut()
.non_send_resource_mut::<UiScript>()
.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::<GuidIndex>()
.init_resource::<GroupState>()
.init_resource::<HoveredObject>();
let spawn = |app: &mut App, guid: u64, store: ObjectStore| {
let e = app.world_mut().spawn((Guid(guid), store)).id();
app.world_mut()
.resource_mut::<GuidIndex>()
.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::<GroupState>().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<Selection>| {
tokens.resolve(&token, &selection)
})
.unwrap()
}
fn guid_of(app: &mut App, token: &str) -> Option<u64> {
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::<Guid>(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::<GuidIndex>()
.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::<Selection>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::net::GuidIndex>()
.init_resource::<Reputations>()
.init_resource::<crate::ui_party::GroupState>()
.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::<crate::net::GuidIndex>()
.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::<crate::ui_party::GroupState>()
.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::<UiScript>()
.eval::<bool>(&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::<UiScript>()
.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()
+149 -19
View File
@@ -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_<n>_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<String, UnitReach>) {
/// 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<u64, UnitReach>) {
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<u64> {
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<InspectView>) {
@@ -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<String>) -> Option<UnitReach> {
let token = token.as_deref()?;
let model = lua.app_data_ref::<Model>().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::<bool>(&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")"#));
}
}
+2 -2
View File
@@ -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};
+2 -2
View File
@@ -876,8 +876,8 @@ pub(crate) struct Model {
pub(crate) tabard_intents: Vec<super::tabard::TabardIntent>,
/// 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<String, super::UnitReach>,
/// Per live-unit guid, reach for `CanInspect`/`CheckInteractDistance`; absent: nil.
pub(crate) unit_reach: HashMap<u64, super::UnitReach>,
/// The skills pane's snapshot and the display tree built from it.
pub(crate) skills: skills::SkillsState,
+1 -1
View File
@@ -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;
+176 -46
View File
@@ -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<Option<u64>, ()> {
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<u64> {
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<u64> {
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 [