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:
co-authored by
Claude Opus 5.5
parent
602c30389a
commit
0a920b52fe
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")"#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user