From 010e41e600a510c1db55553f53fdb57477f2a71d Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:19:48 +0700 Subject: [PATCH] unit getters: chained tokens like raid3target answer their unit (#716) The `Unit*` getters and `SpellCanTargetUnit` answered nil for chained tokens like `raid3target` or `party1target`, so a raid-assist addon's main-tank frame stayed empty. Every getter and `SpellCanTargetUnit` resolves its token through `0x515970` and then reads the guid's object (`0x468460`); see `UnitHealth 0x517511`/`0x51752d` and `SpellCanTargetUnit 0x6e6d3a`. A token with target hops now resolves to a guid in the VM and reads a per-guid snapshot. A new feed pushes one for each held unit a chain ends on. `SpellCanTargetUnit` keeps its verdicts by guid. The three target, target-of-target and NPC snapshot builders are folded into one. Tests: `UnitName`/`UnitHealth("raid3target")` and `UnitExists("pettarget")` answer the chained unit, `SpellCanTargetUnit("party1target")` answers its verdict, and plain tokens are unchanged. Fixes #693 Fixes #694 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/spell/mod.rs | 6 +- crates/benilla-app/src/spell/targeting/mod.rs | 112 +++- crates/benilla-app/src/ui_unit.rs | 133 +---- crates/benilla-app/src/ui_unit/held.rs | 491 ++++++++++++++++++ crates/benilla-ui/src/script/cursor.rs | 9 +- crates/benilla-ui/src/script/model.rs | 45 +- crates/benilla-ui/src/script/spellbook.rs | 22 +- crates/benilla-ui/src/script/tooltip_unit.rs | 2 +- crates/benilla-ui/src/script/unit/bindings.rs | 16 +- .../benilla-ui/src/script/unit/chain_tests.rs | 327 ++++++++++++ crates/benilla-ui/src/script/unit/mod.rs | 38 +- crates/benilla-ui/src/script/unit/resolve.rs | 29 +- crates/benilla-ui/src/script/unit/tests.rs | 10 +- 13 files changed, 1077 insertions(+), 163 deletions(-) create mode 100644 crates/benilla-app/src/ui_unit/held.rs create mode 100644 crates/benilla-ui/src/script/unit/chain_tests.rs diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 08f74602..edfc1637 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -88,10 +88,12 @@ impl Plugin for SpellPlugin { // The state push runs before the input pass's `ToggleGameMenu`, whose // `SpellStopTargeting` lands with the script calls after it. After the // old-target clear, the pet bar's writer in the feed: `"pet"` resolves off - // the bar. + // the bar. After the aura feed, whose resolver inputs list the units the + // hover verdict is asked for. targeting::feed_targeting_to_vm .in_set(UnitFeed) - .after(crate::ui_pet::pet_stop_on_old_target_clear), + .after(crate::ui_pet::pet_stop_on_old_target_clear) + .after(crate::ui_aura::AuraEvents), ( targeting::publish_picks_self, targeting::publish_corpse_pick, diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 272b2554..c55db2ed 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -369,7 +369,6 @@ pub(crate) fn feed_targeting_to_vm( targeting: Res, checks: BindChecks, tokens: crate::ui_unit::UnitTokens, - selection: Res, mut last: Local>>, script: Option>, ) { @@ -377,12 +376,19 @@ pub(crate) fn feed_targeting_to_vm( let last = last.get(&script); script.set_spell_targeting(targeting.active()); script.set_item_pick_armed(targeting.wants(TargetingWants::Item)); - // `SpellCanTargetUnit(unit)`: resolve each token and ask `0x6e6460`'s unit leg. - script.set_spell_targetable_units(crate::ui_unit::reach_tokens().filter(|token| { - tokens - .resolve(token, &selection) - .is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks)) - })); + // `SpellCanTargetUnit(unit)`: the VM resolves the token to a guid (`0x6e6d3a`), and + // `0x6e6460`'s unit leg answers for that guid. The units to ask are the held ones the + // resolver's inputs list, the `target` chains' included. + let targetable: Vec = script + .held_unit_guids() + .into_iter() + .filter(|&guid| { + tokens + .held(guid) + .is_some_and(|(entity, _)| targeting.can_target_unit(entity, &checks)) + }) + .collect(); + script.set_spell_targetable_units(targetable); if *last != targeting.spell() { *last = targeting.spell(); script.fire_event("CURRENT_SPELL_CAST_CHANGED", vec![]); @@ -565,6 +571,33 @@ mod tests { ); } + /// What the aura feed pushes each frame for the units this world holds, us and the selection: + /// the resolver's inputs, and the object manager's index of them. `SpellCanTargetUnit` + /// resolves its token through the inputs, and the feed asks the verdict of each unit they list. + fn publish_guids(world: &mut World) { + let me = world + .query_filtered::>() + .single(world) + .expect("the player"); + let selection = world.resource::(); + let selected = selection.target.zip(selection.guid); + let mut guids = benilla_ui::script::UnitGuids { + player: ME, + held: HashMap::from([(ME, 0)]), + ..Default::default() + }; + let mut index = world.resource_mut::(); + index.0.insert(ME, me); + if let Some((entity, guid)) = selected { + index.0.insert(guid, entity); + guids.target = guid; + guids.held.insert(guid, 0); + } + world + .non_send_resource_mut::() + .set_unit_guids(&guids); + } + /// Queue `SpellTargetUnit(token)` in the VM, then apply it. fn spell_target_unit(world: &mut World, token: &str) { world @@ -717,6 +750,68 @@ mod tests { } } + /// `SpellCanTargetUnit` resolves a chain as `SpellTargetUnit` does and answers the verdict of + /// the unit it ends on (`0x6e6d3a` into `0x515970`, then `0x6e6460`): the ally we and our pet + /// target, in range of the spell or not, and nobody past a unit with no target. + #[test] + fn spell_can_target_unit_follows_a_target_chain() { + const PET: u64 = 0xF140_0000_0000_0077; + // `OBJECT_FIELD_TYPE`, `UNIT_FIELD_HEALTH` and `UNIT_FIELD_TARGET`, the last a 2-field guid. + let unit = |kind: u32, target: u64| { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (2, kind), + (22, 100), + (16, target as u32), + (17, (target >> 32) as u32), + ])) + }; + let can = |distance: f32, token: &str| { + let (mut world, _rx, ally) = unit_world(distance); + // We and our pet target the ally; the ally targets nobody. + let me = world + .query_filtered::>() + .single(&world) + .unwrap(); + world.entity_mut(me).insert(unit(0x19, ALLY)); + world.entity_mut(ally).insert(unit(0x09, 0)); + let pet = world + .spawn((Guid(PET), GlobalTransform::default(), unit(0x09, ALLY))) + .id(); + let mut bar = crate::ui_pet::PetBar::default(); + bar.spells.pet_guid = PET; + world.insert_resource(bar); + let mut index = world.resource_mut::(); + index.0.insert(PET, pet); + arm(&mut world, HEAL, 0x0002); + // The index of us and the ally, then the aura feed's inputs with the chain's units: + // each held unit and the target it names. + publish_guids(&mut world); + world.non_send_resource_mut::().set_unit_guids( + &benilla_ui::script::UnitGuids { + player: ME, + pet: PET, + target: ALLY, + held: HashMap::from([(ME, ALLY), (PET, ALLY), (ALLY, 0)]), + ..Default::default() + }, + ); + world + .run_system_cached(feed_targeting_to_vm) + .expect("the feed runs"); + world + .non_send_resource::() + .eval::(&format!("return SpellCanTargetUnit({token:?}) == true")) + .expect("a boolean") + }; + for token in ["pettarget", "playertarget", "PETTARGET", "target"] { + assert!(can(10.0, token), "{token}: the ally in range"); + assert!(!can(40.0, token), "{token}: the ally out of range"); + } + for token in ["pettargettarget", "playertargettarget", "party1target"] { + assert!(!can(10.0, token), "{token}: past the ally, nobody"); + } + } + /// 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 @@ -887,6 +982,7 @@ mod tests { arm(&mut world, SPELL, word); // The hover verdict. + publish_guids(&mut world); world .run_system_cached(feed_targeting_to_vm) .expect("the feed runs"); @@ -1124,6 +1220,7 @@ mod tests { arm(&mut world, SPELL, word); // The hover verdict. + publish_guids(&mut world); world .run_system_cached(feed_targeting_to_vm) .expect("the feed runs"); @@ -1176,6 +1273,7 @@ mod tests { let can = |distance: f32, spell: u32, token: &str| { let (mut world, _rx, _) = unit_world(distance); arm(&mut world, spell, 0x0002); + publish_guids(&mut world); world .run_system_cached(feed_targeting_to_vm) .expect("the feed runs"); diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index 6c3e5fee..e5b45051 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -21,6 +21,8 @@ use crate::net::{ use crate::target::{ring_reaction, Factions, Selection}; use crate::ui_script::{gate, UiInput}; +mod held; + /// The unit-feed pass, gated so none of its login one-shots or per-VM memos runs before the in-game /// interface exists; the demo override orders itself after it. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -152,6 +154,8 @@ impl Plugin for UiUnitPlugin { feed_units, // After the aura feed, whose resolver inputs it measures. feed_unit_reach.after(crate::ui_aura::AuraEvents), + // After the same feed, whose guids it covers. + held::feed_chain_units.after(crate::ui_aura::AuraEvents), feed_player_control, feed_farsight_focus, melee_unit_combat, @@ -683,17 +687,6 @@ impl UnitTokens<'_, '_> { } } -/// The tokens the `SpellCanTargetUnit` feed answers for, `"player"` included: the bases and one -/// hop off the target, not the chains a script can spell. -pub(crate) fn reach_tokens() -> impl Iterator { - ["player", "target", "targettarget", "mouseover", "pet"] - .into_iter() - .chain(crate::ui_party::PARTY_TOKENS) - .chain(crate::ui_party::PARTY_PET_TOKENS) - .chain(crate::ui_party::RAID_TOKENS) - .chain(crate::ui_party::RAID_PET_TOKENS) -} - /// Squared distance as the reference sums it: `f32` widened to `f64`, `(dz² + dx²) + dy²` /// (`0x48a26f..0x48a27d`, shared by `CanInspect` `0x48a1b0` and `CheckInteractDistance` /// `0x48ba00`). Bevy's axes change it by at most a last ulp, which matters only on a threshold. @@ -1297,37 +1290,21 @@ fn feed_units( } feed.warned_sideless = sideless; } + // What the three snapshots below (target, target-of-target, NPC) read besides a descriptor. + let held = held::HeldUnits { + names: &names, + commands: &commands, + factions: factions.as_deref(), + reputations: &reputations, + group: &group, + self_store: self_pair.map(|(s, _)| s), + classes: chr, + types, + }; let target = selection.target.zip(selection.guid).and_then(|(e, guid)| { let store = stores.all.get(e).ok()?; - let name = names - .resolve_unit(guid, Some(store), &commands) - .map(str::to_string); - let reaction = unit_reaction( - factions.as_deref(), - &reputations, - store, - self_pair.map(|(s, _)| s), - ); - let mut s = snapshot(store, guid, name, reaction, chr, types); - s.raid_target = group.raid_target_index(guid); - s.faction_group = faction_group(store, factions.as_deref()); - s.faction_group_localized = faction_group_localized(store, factions.as_deref()); - // `CanAttack` (`0x606980`); `UnitCanAttack` gates the target frame's level colour. - s.can_attack = crate::target::can_attack( - Some(store), - factions.as_deref(), - &reputations, - self_pair.map(|(s, _)| s), - ); + let mut s = held.state(store, guid); s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands); - enrich_unit( - &mut s, - guid, - &names, - store, - factions.as_deref(), - self_pair.map(|(s, _)| s), - ); Some(s) }); @@ -1339,37 +1316,7 @@ fn feed_units( .and_then(|s| s.0.unit_target()) .filter(|guid| *guid != 0) .and_then(|guid| Some((*index.as_ref()?.0.get(&guid)?, guid))) - .and_then(|(entity, guid)| { - let store = stores.all.get(entity).ok()?; - let name = names - .resolve_unit(guid, Some(store), &commands) - .map(str::to_string); - let reaction = unit_reaction( - factions.as_deref(), - &reputations, - store, - self_pair.map(|(s, _)| s), - ); - let mut s = snapshot(store, guid, name, reaction, chr, types); - s.raid_target = group.raid_target_index(guid); - s.faction_group = faction_group(store, factions.as_deref()); - s.faction_group_localized = faction_group_localized(store, factions.as_deref()); - s.can_attack = crate::target::can_attack( - Some(store), - factions.as_deref(), - &reputations, - self_pair.map(|(s, _)| s), - ); - enrich_unit( - &mut s, - guid, - &names, - store, - factions.as_deref(), - self_pair.map(|(s, _)| s), - ); - Some(s) - }); + .and_then(|(entity, guid)| Some(held.state(stores.all.get(entity).ok()?, guid))); // `"player"` is pushed only while its descriptor exists: the roster seat stands in before // arrival, and `PLAYER_LOGOUT` handlers still read `UnitName("player")`, as the reference's do. @@ -1405,37 +1352,7 @@ fn feed_units( let npc = interact .as_deref() .and_then(|i| Some((i.0?, i.1?))) - .and_then(|(entity, guid)| { - let store = stores.all.get(entity).ok()?; - let name = names - .resolve_unit(guid, Some(store), &commands) - .map(str::to_string); - let reaction = unit_reaction( - factions.as_deref(), - &reputations, - store, - self_pair.map(|(s, _)| s), - ); - let mut s = snapshot(store, guid, name, reaction, chr, types); - s.raid_target = group.raid_target_index(guid); - s.faction_group = faction_group(store, factions.as_deref()); - s.faction_group_localized = faction_group_localized(store, factions.as_deref()); - s.can_attack = crate::target::can_attack( - Some(store), - factions.as_deref(), - &reputations, - self_pair.map(|(s, _)| s), - ); - enrich_unit( - &mut s, - guid, - &names, - store, - factions.as_deref(), - self_pair.map(|(s, _)| s), - ); - Some(s) - }); + .and_then(|(entity, guid)| Some(held.state(stores.all.get(entity).ok()?, guid))); // Closing the window must clear the token, so the memo is written here, not only read. No // `fire_transitions`: nothing draws `"npc"` as a unit frame. let npc_dirty = match (&npc, memo.last.get("npc")) { @@ -2008,20 +1925,6 @@ mod tests { assert_eq!(resolve(&mut app, "partypet1"), None); } - /// 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(); - assert_eq!(tokens.len(), 5 + 4 + 4 + 40 + 40); - for i in 1..=4 { - assert!(tokens.contains(&format!("partypet{i}").as_str())); - } - for i in 1..=40 { - assert!(tokens.contains(&format!("raidpet{i}").as_str())); - } - } - /// 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] diff --git a/crates/benilla-app/src/ui_unit/held.rs b/crates/benilla-app/src/ui_unit/held.rs new file mode 100644 index 00000000..5242357f --- /dev/null +++ b/crates/benilla-app/src/ui_unit/held.rs @@ -0,0 +1,491 @@ +//! The snapshot of a held unit that a token names other than the player, and the per-guid feed of +//! the units a `target` chain ends on. +//! +//! The reference resolves a token to a guid (`0x515970`) and reads that guid's object, so a unit it +//! holds answers a `Unit*` getter however the token spelled it. The app pushes a snapshot +//! for each base token by name ([`UiScript::set_unit`]); a chain such as `party1target` names a +//! unit no base does, so the VM resolves it to a guid and reads that guid's snapshot, which +//! [`feed_chain_units`] pushes for the guids [`UiScript::chain_end_guids`] lists. + +use std::collections::HashMap; + +use bevy::prelude::*; + +use benilla_formats::ChrClasses; +use benilla_ui::script::{UiScript, UnitState}; + +use super::{enrich_unit, faction_group, faction_group_localized, snapshot, unit_reaction}; +use crate::creature_type::CreatureTypeSources; +use crate::names::NameCache; +use crate::net::{Guid, NetCommands, ObjectStore, Reputations, SelfPlayer}; +use crate::target::Factions; +use crate::ui_script::{gate, VmMemo}; + +/// What a held unit's snapshot reads beside its descriptor. +pub(super) struct HeldUnits<'a> { + pub(super) names: &'a NameCache, + pub(super) commands: &'a NetCommands, + pub(super) factions: Option<&'a Factions>, + pub(super) reputations: &'a Reputations, + pub(super) group: &'a crate::ui_party::GroupState, + /// Our own descriptor, which reaction, attackability and the faction line read. + pub(super) self_store: Option<&'a ObjectStore>, + pub(super) classes: Option<&'a ChrClasses>, + pub(super) types: CreatureTypeSources<'a>, +} + +impl HeldUnits<'_> { + /// The snapshot of the held unit `guid`, whose descriptor is `store`, as a token naming a unit + /// other than the player carries it. It has no guild leg, so `GetGuildInfo` on a chain answers + /// nothing, where the reference resolves the token and reads the object's guild (`0x4c9330`). + pub(super) fn state(&self, store: &ObjectStore, guid: u64) -> UnitState { + let name = self + .names + .resolve_unit(guid, Some(store), self.commands) + .map(str::to_string); + let reaction = unit_reaction(self.factions, self.reputations, store, self.self_store); + let mut s = snapshot(store, guid, name, reaction, self.classes, self.types); + s.raid_target = self.group.raid_target_index(guid); + s.faction_group = faction_group(store, self.factions); + s.faction_group_localized = faction_group_localized(store, self.factions); + // `CanAttack` (`0x606980`); `UnitCanAttack` gates the target frame's level colour. + s.can_attack = crate::target::can_attack( + Some(store), + self.factions, + self.reputations, + self.self_store, + ); + enrich_unit( + &mut s, + guid, + self.names, + store, + self.factions, + self.self_store, + ); + s + } +} + +/// [`feed_chain_units`]' per-VM memory. +#[derive(Default)] +pub(super) struct ChainMemo { + names_generation: gate::Watch, + /// The guids the last build covered, sorted. + ends: Vec, + /// What each was last pushed as. + pushed: HashMap, +} + +/// Push the snapshot of each held unit a `target` chain can end on, by guid, so `UnitName`, +/// `UnitHealth` and the other getters answer for `party1target`, `raid3target` and `pettarget` +/// (`0x515970` resolves the token, then each getter reads the guid's object, `0x468460`). The +/// guids are the resolver's own inputs ([`UiScript::chain_end_guids`]), which the aura feed pushes +/// each frame, so this runs after it. A guid the object manager does not hold, or holds as +/// anything but a unit, gets no snapshot. The reference still answers such a guid: `UnitExists` +/// from the roster (`0x491900`), `UnitHealth` from the roster or pet record (`0x496400`, +/// `0x496420`), and `UnitName` from the pet record or the name cache, else `UNKNOWNOBJECT` +/// (`0x5171da`-`0x517216`); those legs are not built for a chain's end. A token without a hop reads its own push, not these. +pub(super) fn feed_chain_units( + script: Option>, + tables: super::SnapshotTables, + // The object manager's lookup (`0x468460`) on its own, not through [`super::UnitTokens`], + // whose pet-bar read would order this feed against the pet feed it does not depend on. + index: Option>, + stores: Query<&ObjectStore>, + names: Res, + commands: Res, + factions: Option>, + reputations: Res, + group: Res, + self_q: Query<&ObjectStore, With>, + changed: Query<(), (Changed, Without)>, + mut removed: RemovedComponents, + mut memo: Local>, +) { + let Some(mut script) = script else { + return; + }; + let (memo, vm_reset) = memo.get_reset(&script); + let ends = script.chain_end_guids(); + + // The gate: every input read below, despawns included (invisible to `Changed`), and the + // guids themselves, which move with the selection, the hover, the group and every held + // unit's target. + let names_moved = memo.names_generation.moved(names.generation()); + let ends_moved = memo.ends != ends; + let stores_changed = !changed.is_empty(); + let stores_removed = !removed.is_empty(); + let group_changed = group.is_changed(); + let reps_changed = reputations.is_changed(); + let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed()); + removed.clear(); + let gate = gate::Gate::new( + vm_reset + || names_moved + || ends_moved + || stores_changed + || stores_removed + || group_changed + || reps_changed + || factions_changed, + ); + if gate.skip() { + return; + } + + let held = HeldUnits { + names: &names, + commands: &commands, + factions: factions.as_deref(), + reputations: &reputations, + group: &group, + self_store: self_q.iter().next(), + classes: tables.classes(), + types: tables.types(&names), + }; + let built: HashMap = ends + .iter() + .filter_map(|&guid| { + let entity = *index.as_ref()?.0.get(&guid)?; + let store = stores.get(entity).ok().filter(|s| s.is_unit())?; + Some((guid, held.state(store, guid))) + }) + .collect(); + + memo.pushed.retain(|guid, _| { + let keep = built.contains_key(guid); + if !keep { + gate.audit("feed_chain_units", "a chain end's clear"); + script.set_unit_by_guid(*guid, None); + } + keep + }); + for (guid, state) in built { + if memo.pushed.get(&guid) != Some(&state) { + gate.audit("feed_chain_units", "a chain end's snapshot"); + script.set_unit_by_guid(guid, Some(state.clone())); + memo.pushed.insert(guid, state); + } + } + memo.ends = ends; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::names::CreatureRecord; + use crate::net::GuidIndex; + use crate::target::Selection; + use crate::ui_party::{GroupState, GROUPTYPE_RAID}; + use benilla_protocol::messages::{member_status, GroupMemberEntry}; + use benilla_protocol::ObjectFields; + + 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; + /// Our target, which targets party1. + const MOB: u64 = 0xF130_0000_4500_0001; + /// What party1 targets. + const BOSS: u64 = 0xF130_0000_4600_0001; + /// What raid3 targets, and a unit that targets nobody. + const ADD: u64 = 0xF130_0000_4700_0001; + const MY_PET: u64 = 0xF140_0000_0000_0010; + /// What our pet targets. + const FOE: u64 = 0xF130_0000_4800_0001; + /// What `BOSS` targets, and nobody holds. + const GONE: u64 = 0xF130_0000_4900_0001; + /// Held from the start, and named by no token until it is selected. + const LONER: u64 = 0xF130_0000_4A00_0001; + /// What `LONER` targets. + const EXTRA: u64 = 0xF130_0000_4B00_0001; + + /// `OBJECT_FIELD_TYPE`, `UNIT_FIELD_TARGET`, `_HEALTH`, `_MAXHEALTH` and `_LEVEL`. + const OBJECT_TYPE: u16 = 2; + const TARGET: u16 = 16; + const HEALTH: u16 = 22; + const MAXHEALTH: u16 = 28; + const LEVEL: u16 = 34; + + fn unit(kind: u32, target: u64, health: u32) -> ObjectStore { + ObjectStore(ObjectFields::from_pairs(&[ + (OBJECT_TYPE, kind), + (TARGET, target as u32), + (TARGET + 1, (target >> 32) as u32), + (HEALTH, health), + (MAXHEALTH, 5000), + (LEVEL, 60), + ])) + } + + fn player(target: u64, health: u32) -> ObjectStore { + unit(0x19, target, health) + } + + fn creature(target: u64, health: u32) -> ObjectStore { + unit(0x09, target, health) + } + + fn spawn(app: &mut App, guid: u64, store: ObjectStore) -> Entity { + let e = app.world_mut().spawn((Guid(guid), store)).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + e + } + + fn group_entry(guid: u64) -> GroupMemberEntry { + GroupMemberEntry { + name: format!("M{guid:x}"), + guid, + status: member_status::ONLINE, + flags: 0, + } + } + + /// The world: we target `MOB`, which targets party1 (`A`), who targets `BOSS`, which targets + /// `GONE`; raid3 (`B`) targets `ADD`, our pet `FOE`, and `C` nobody. `LONER` is held and + /// targets `EXTRA`. 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. The aura feed, whose resolver inputs the chain feed + /// reads, and the chain feed run. + fn app() -> App { + let (tx, _rx) = crossbeam_channel::unbounded(); + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) + .insert_state(crate::char_select::ClientState::InWorld) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_resource(NetCommands(tx)) + .add_plugins(crate::ui_aura::UiAuraPlugin) + .add_systems(Update, feed_chain_units.after(crate::ui_aura::AuraEvents)); + app.insert_non_send_resource(UiScript::new().unwrap()); + { + let mut names = app.world_mut().resource_mut::(); + for (guid, name) in [(ME, "Meg"), (A, "Alia"), (B, "Bram"), (C, "Cole")] { + names.insert_player(guid, name.to_string(), None); + } + for (guid, name) in [ + (MOB, "Mob"), + (BOSS, "Boss"), + (ADD, "Add"), + (FOE, "Foe"), + (LONER, "Loner"), + (EXTRA, "Extra"), + ] { + names.insert_creature( + benilla_protocol::guid::entry(guid).unwrap(), + Some(CreatureRecord { + name: name.to_string(), + subname: None, + creature_type: 0, + pet_family: 0, + rank: 0, + type_flags: 0, + civilian: false, + racial_leader: false, + display_id: 0, + }), + ); + } + } + let me = spawn(&mut app, ME, player(MOB, 900)); + app.world_mut().entity_mut(me).insert(SelfPlayer); + spawn(&mut app, A, player(BOSS, 800)); + spawn(&mut app, B, player(ADD, 700)); + spawn(&mut app, C, player(0, 600)); + let mob = spawn(&mut app, MOB, creature(A, 5000)); + spawn(&mut app, BOSS, creature(GONE, 4000)); + spawn(&mut app, ADD, creature(0, 250)); + spawn(&mut app, MY_PET, creature(FOE, 300)); + spawn(&mut app, FOE, creature(0, 120)); + spawn(&mut app, LONER, creature(EXTRA, 90)); + spawn(&mut app, EXTRA, creature(0, 45)); + app.world_mut().resource_mut::().apply_list( + GROUPTYPE_RAID, + 0, + vec![group_entry(A), group_entry(B), group_entry(C)], + ME, + None, + Some(ME), + ); + app.insert_resource(Selection { + target: Some(mob), + guid: Some(MOB), + ..Default::default() + }); + let mut bar = crate::ui_pet::PetBar::default(); + bar.spells.pet_guid = MY_PET; + app.insert_resource(bar); + app + } + + fn script(app: &App) -> &UiScript { + app.world().non_send_resource::() + } + + fn name(app: &App, token: &str) -> Option { + script(app) + .eval(&format!("return UnitName({token:?})")) + .unwrap() + } + + fn number(app: &App, call: &str, token: &str) -> i64 { + script(app) + .eval(&format!("return {call}({token:?})")) + .unwrap() + } + + fn health(app: &App, token: &str) -> i64 { + number(app, "UnitHealth", token) + } + + /// Whether the Lua expression holds. + fn holds(app: &App, expr: &str) -> bool { + script(app).eval(&format!("return {expr}")).unwrap() + } + + /// The getters answer for a chain through the feeds that run in the client: the aura feed's + /// resolver inputs name the guid, and this feed's snapshot of it answers, so `party1target`, + /// `raid3target` and `pettarget` read the unit at the end of the chain and any break in the + /// chain reads nobody. No token names `ADD` or `FOE` but a chain. + #[test] + fn the_getters_answer_for_the_unit_a_chain_ends_on() { + let mut app = app(); + app.update(); + for (token, want, hp) in [ + ("party1target", "Boss", 4000), + ("raid2target", "Boss", 4000), + ("PARTY1TARGET", "Boss", 4000), + ("raid3target", "Add", 250), + ("pettarget", "Foe", 120), + ("playertarget", "Mob", 5000), + ("targettarget", "Alia", 800), + ("targettargettarget", "Boss", 4000), + ] { + assert_eq!( + name(&app, token).as_deref(), + Some(want), + "UnitName({token})" + ); + assert_eq!(health(&app, token), hp, "UnitHealth({token})"); + assert_eq!(number(&app, "UnitHealthMax", token), 5000, "{token}"); + assert_eq!(number(&app, "UnitLevel", token), 60, "{token}"); + assert!( + holds(&app, &format!("UnitExists({token:?}) == 1")), + "UnitExists({token})" + ); + } + assert!(holds( + &app, + r#"UnitIsUnit("raid2target", "party1target") == 1"# + )); + assert!(holds( + &app, + r#"UnitIsUnit("raid2target", "raid3target") == nil"# + )); + // `BOSS` targets `GONE`, which nobody holds: 0 and nil, and a name the unbuilt name-cache leg + // would give (`0x5171eb`), so it is not asserted. + assert_eq!(health(&app, "party1targettarget"), 0); + assert!(holds(&app, r#"UnitExists("party1targettarget") == nil"#)); + // `C` targets nobody; party4 is nobody. + for token in ["raid4target", "party4target", "raid9target"] { + assert_eq!(name(&app, token), None, "UnitName({token})"); + assert_eq!(health(&app, token), 0, "UnitHealth({token})"); + assert!( + holds(&app, &format!("UnitExists({token:?}) == nil")), + "UnitExists({token})" + ); + } + // The plain tokens read their own push and nothing else: this world pushes none, so + // `raid2` answers nil though a chain ends on it, and a token pushed by name wins. + assert_eq!(name(&app, "raid2"), None); + assert_eq!(name(&app, "target"), None); + app.world_mut() + .non_send_resource_mut::() + .set_unit( + "target", + Some(UnitState { + exists: true, + name: Some("Pushed".into()), + ..Default::default() + }), + ); + assert_eq!(name(&app, "target").as_deref(), Some("Pushed")); + } + + /// Each input of the feed's gate reaches the getters: a descriptor that moves, a chain that + /// gains an end with no descriptor moving, and a unit that leaves the object manager. + #[test] + fn the_feed_follows_the_world() { + let mut app = app(); + app.update(); + assert_eq!(health(&app, "party1target"), 4000); + + // A descriptor moves. + let boss = app.world().resource::().0[&BOSS]; + app.world_mut() + .entity_mut(boss) + .insert(creature(GONE, 1000)); + app.update(); + assert_eq!(health(&app, "party1target"), 1000); + + // Selecting `LONER` gives `targettarget` a unit that no chain ended on, and moves no + // descriptor and no name. + assert_eq!(name(&app, "targettarget").as_deref(), Some("Alia")); + let loner = app.world().resource::().0[&LONER]; + app.insert_resource(Selection { + target: Some(loner), + guid: Some(LONER), + ..Default::default() + }); + app.update(); + assert_eq!(name(&app, "targettarget").as_deref(), Some("Extra")); + assert_eq!(health(&app, "targettarget"), 45); + + // `ADD` leaves the object manager, and `raid3target` with it. The chain is still + // `raid3`'s target, so the resolver's guid holds; the snapshot is what goes. + assert_eq!(name(&app, "raid3target").as_deref(), Some("Add")); + let add = app.world_mut().resource_mut::().0.remove(&ADD); + app.world_mut().despawn(add.unwrap()); + app.update(); + // The name would be the name cache's (`0x5171eb`), which a chain's end does not read yet. + assert_eq!(health(&app, "raid3target"), 0); + } + + /// The chain feed reads the resolver inputs the aura feed pushes in the same frame, so the + /// declared order puts the feed after it: read off the schedule graph. + #[test] + fn the_chain_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"); + for feed in [ + "::ui_unit::held::feed_chain_units", + "::targeting::feed_targeting_to_vm", + ] { + assert!( + c.dependencies.contains(&(aura, one(feed))), + "{feed} is declared after feed_auras" + ); + } + } +} diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 2d75ffcc..e11b6194 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -403,10 +403,11 @@ impl super::UiScript { self.model_mut().item_pick_armed = armed; } - /// Push the unit tokens for which the armed word's unit checks all pass. - pub fn set_spell_targetable_units<'a>(&mut self, tokens: impl IntoIterator) { - self.model_mut().spell_targetable_units = - tokens.into_iter().map(str::to_ascii_lowercase).collect(); + /// Push the guids of the units for which the armed word's unit checks all pass. + /// `SpellCanTargetUnit` resolves its token to a guid, as `0x515970` does, and asks this set, + /// so a chain such as `"party1target"` reads the unit it ends on. + pub fn set_spell_targetable_units(&mut self, guids: impl IntoIterator) { + self.model_mut().spell_targetable_units = guids.into_iter().collect(); } /// Drain the `(bag, slot)` picks since the last call; a paper-doll pick reports as diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 5a0d4b2e..ca4f7d36 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -6,10 +6,10 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena}; use super::{ auction, backdrop, bank, bind_confirm, camera_view, char_stats, chat_window, colorselect, container, craft, cursor, death, duel, follow, gossip, guild, inspect, item_text, loot, - loot_roll, macros, mail, merchant, party, petition, pvp, quest, quest_log, reputation, session, - simplehtml, skills, slider, social, spellbook, stable, taxi, trade, tradeskill, trainer, - weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, MusicRequest, - PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState, + loot_roll, macros, mail, merchant, parse_unit_token, party, petition, pvp, quest, quest_log, + reputation, session, simplehtml, skills, slider, social, spellbook, stable, taxi, trade, + tradeskill, trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, + MusicRequest, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState, UnitTokenParse, }; // ── The Rust-side model ── @@ -238,6 +238,10 @@ pub(crate) struct Model { /// Each unit token's state as pushed this frame, keyed lowercased because 1.12's resolver /// (`0x515970`) matches with `SStrCmpI`: read it only through `unit`, where the fold lives. pub(crate) units_by_lower: HashMap, + /// The snapshot of each unit a `target` chain can end on, by guid, which the app pushes for + /// [`super::UnitGuids::chain_ends`]: a token with a hop that no push names reads its + /// resolved guid's entry here. + pub(crate) units_by_guid: HashMap, /// The player's auras in the reference cache's insertion order (`0xbc6040`), durations joined: /// the `GetPlayerBuff` family's list, and any token naming the player's. pub(crate) player_auras: Vec, @@ -453,9 +457,10 @@ pub(crate) struct Model { /// Spell targeting is on (`SpellIsTargeting`, `0x6e6cd0`); it gates `SpellStopTargeting()`, /// whose nil the ESC chain falls through on (`UIParent.lua:1490`). pub(crate) spell_targeting: bool, - /// Tokens for which `SpellCanTargetUnit`'s armed unit word clears fully, used by stock unit - /// frames before they call `SpellTargetUnit`. - pub(crate) spell_targetable_units: HashSet, + /// The units, by guid, for which `SpellCanTargetUnit`'s armed unit word clears fully, which + /// stock unit frames ask before they call `SpellTargetUnit`; a token resolves to its guid + /// through [`Self::unit_guids`]. + pub(crate) spell_targetable_units: HashSet, pub(crate) talents: super::talent::TalentUiState, /// `LearnTalent(tab, index)` calls queued. @@ -999,9 +1004,16 @@ impl Model { self.warnings.push(msg); } - /// A unit token's state, folded as 1.12's resolver `0x515970` folds (`_strnicmp`: `A`..`Z` - /// only, never a byte of 0x80 or more), so `to_ascii_lowercase`, never `to_lowercase`. + /// The state of the unit `token` names, as the `Unit*` getters read it: the snapshot the app + /// pushed under the token, else, for a token with a `target` hop, the chain's end. pub(crate) fn unit(&self, token: &str) -> Option<&UnitState> { + self.pushed_unit(token).or_else(|| self.chained_unit(token)) + } + + /// The snapshot pushed under `token` itself, folded as 1.12's resolver `0x515970` folds + /// (`_strnicmp`: `A`..`Z` only, never a byte of 0x80 or more), so `to_ascii_lowercase`, never + /// `to_lowercase`. + pub(crate) fn pushed_unit(&self, token: &str) -> Option<&UnitState> { if token.bytes().any(|b| b.is_ascii_uppercase()) { self.units_by_lower.get(&token.to_ascii_lowercase()) } else { @@ -1009,6 +1021,20 @@ impl Model { } } + /// The unit a token with a `target` hop and no push of its own names: the resolver's guid + /// (`0x515970`, which the getters call before their own object lookup, `0x468460`), then that + /// guid's snapshot. A token without a hop is the app's to push, base by base, and reads + /// nothing here. + fn chained_unit(&self, token: &str) -> Option<&UnitState> { + match parse_unit_token(token) { + UnitTokenParse::Unit { base, hops } if hops > 0 => { + let guid = self.unit_guids.resolve_unit(base, hops)?; + self.units_by_guid.get(&guid) + } + _ => None, + } + } + pub(crate) fn new() -> Model { Model { addons: Vec::new(), @@ -1083,6 +1109,7 @@ impl Model { // 1024x768 until the host calls `set_screen_size`; y-up `[bottom, left, top, right]`. screen: Rect::new(0.0, 0.0, 768.0, 1024.0), units_by_lower: HashMap::new(), + units_by_guid: HashMap::new(), player_auras: Vec::new(), unit_auras: HashMap::new(), unit_guids: Default::default(), diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index e78a832a..8f9ac223 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -617,17 +617,23 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the standing - // word clears against that resolved unit. + // SpellCanTargetUnit("unit") (`0x6e6d00`): the argument is a string or a number (`0x6e6d0e`, + // else `Usage:`), resolved through `0x515970` (`0x6e6d3a`), which raises for a token it does + // not know; a token naming nobody answers nil (`0x6e6d43`), and a guid asks `0x6e6460`'s unit + // leg whether the standing word clears against it. g.set( "SpellCanTargetUnit", - lua.create_function(|lua, unit: Option| { + lua.create_function(|lua, unit: Value| { + let token = crate::script::binding_abi::string_arg( + lua, + unit, + r#"Usage: SpellCanTargetUnit("unit")"#, + )?; let model = lua.app_data_ref::().expect("model app_data"); - let can = unit.is_some_and(|unit| { - model - .spell_targetable_units - .contains(&unit.to_ascii_lowercase()) - }); + let can = model + .unit_guids + .guid_of(&token)? + .is_some_and(|guid| model.spell_targetable_units.contains(&guid)); if can { Ok(Value::Boolean(true)) } else { diff --git a/crates/benilla-ui/src/script/tooltip_unit.rs b/crates/benilla-ui/src/script/tooltip_unit.rs index 3c2c5b8b..4786c9b2 100644 --- a/crates/benilla-ui/src/script/tooltip_unit.rs +++ b/crates/benilla-ui/src/script/tooltip_unit.rs @@ -235,7 +235,7 @@ fn update_bar(lua: &Lua, h: FrameHandle, unit: Option<&UnitState>) { pub(super) fn on_unit_push(lua: &Lua, token: &str) { let (unit, hits): (UnitState, Vec) = { let model = lua.app_data_mut::().expect("model app_data"); - let Some(unit) = model.unit(token).filter(|u| u.exists && u.guid != 0) else { + let Some(unit) = model.pushed_unit(token).filter(|u| u.exists && u.guid != 0) else { return; }; // Registered frames only, as `update_bar` writes layout. diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index c54d9b18..13bf261e 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -9,8 +9,9 @@ use super::super::binding_abi::flag; use super::super::calls::ScriptCall; use super::super::Model; use super::{ - check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token, - unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest, + check_unit_token, classification_word, grey_band, level_reads_unknown, parse_unit_token, + pick_unit_token, unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest, + UnitBase, UnitTokenParse, }; /// The `"player"` fast path of `UnitName`, `UnitRace`, `UnitClass` and `UnitSex`, never @@ -523,7 +524,16 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { if !u.exists || !grouped { return Ok(Value::Nil); } - let hit = (t.starts_with("party") && !t.starts_with("partypet")) + // A `partyN` token itself names a member; a chain off one (`party1target`) names + // whoever that member targets, which the guid arms below judge. + let party_slot = matches!( + parse_unit_token(&t), + UnitTokenParse::Unit { + base: UnitBase::Party(_), + hops: 0 + } + ); + let hit = party_slot || t.eq_ignore_ascii_case("player") || (u.guid != 0 && (model.unit("player").is_some_and(|p| p.guid == u.guid) diff --git a/crates/benilla-ui/src/script/unit/chain_tests.rs b/crates/benilla-ui/src/script/unit/chain_tests.rs new file mode 100644 index 00000000..07311fc7 --- /dev/null +++ b/crates/benilla-ui/src/script/unit/chain_tests.rs @@ -0,0 +1,327 @@ +//! The getters and `SpellCanTargetUnit` over a `target` chain: the token resolves to a guid +//! through [`UnitGuids`] (`0x515970`), and the guid's snapshot answers. + +use std::collections::HashMap; + +use crate::script::{UiScript, UnitGuids, UnitState}; + +const ME: u64 = 0x10; +const PET: u64 = 0xF140_0000_0000_0077; +/// Our target. +const MOB: u64 = 0xF130_0000_0000_0001; +/// party1 and raid2, who targets `BOSS`. +const P1: u64 = 0x21; +/// party2, held by no one. +const FAR: u64 = 0x22; +/// raid3, who targets `ADD`. +const R3: u64 = 0x33; +/// What party1 targets, and the unit `MOB`'s hop lands on party1 from. +const BOSS: u64 = 0xF130_0000_0000_00B0; +/// What raid3 targets: held, and never named by a base token. +const ADD: u64 = 0xF130_0000_0000_00AD; +/// What our pet targets. +const PET_FOE: u64 = 0xF130_0000_0000_00F0; +/// What `BOSS` targets, and nobody holds. +const GONE: u64 = 0xF130_0000_0000_0099; +/// Held, targets nobody, and no snapshot is pushed for it. +const SILENT: u64 = 0xF130_0000_0000_0055; +/// raid4, who targets `SILENT`. +const R4: u64 = 0x44; + +fn state(name: &str, guid: u64, health: u32) -> UnitState { + UnitState { + exists: true, + has_object: true, + guid, + name: Some(name.into()), + health, + max_health: 1000, + level: 60, + ..Default::default() + } +} + +/// We target the mob, which targets party1, who targets the boss, which targets a unit nobody +/// holds. raid3 targets the add, raid4 a held unit with no snapshot, our pet its foe, and party2 +/// is out of range. Pushed by name: the bases. Pushed by guid: the units a chain ends on. +fn world() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_unit_guids(&UnitGuids { + player: ME, + pet: PET, + target: MOB, + party: [P1, FAR, 0, 0], + raid: vec![ME, P1, R3, R4], + held: HashMap::from([ + (ME, MOB), + (PET, PET_FOE), + (MOB, P1), + (P1, BOSS), + (BOSS, GONE), + (R3, ADD), + (R4, SILENT), + (ADD, 0), + (PET_FOE, 0), + (SILENT, 0), + ]), + ..Default::default() + }); + for (token, unit) in [ + ("player", state("Me", ME, 900)), + ("target", state("Mob", MOB, 500)), + ("party1", state("Pia", P1, 800)), + ("pet", state("Pet", PET, 300)), + ] { + s.set_unit(token, Some(unit)); + } + for unit in [ + state("Boss", BOSS, 4000), + state("Add", ADD, 250), + state("Foe", PET_FOE, 120), + // The hops that land on a unit a base names. + state("Mob", MOB, 500), + state("Pia", P1, 800), + ] { + s.set_unit_by_guid(unit.guid, Some(unit)); + } + s +} + +fn eval(s: &UiScript, chunk: &str) -> T { + s.eval(chunk).unwrap_or_else(|e| panic!("{chunk}: {e}")) +} + +/// The getters read the snapshot of the unit the chain ends on, whatever the base is, and however +/// many hops the token takes. +#[test] +fn a_chain_answers_the_snapshot_of_the_unit_it_ends_on() { + let s = world(); + for (chunk, want) in [ + (r#"return UnitName("party1target")"#, "Boss"), + (r#"return UnitName("raid2target")"#, "Boss"), + (r#"return UnitName("raid3target")"#, "Add"), + (r#"return UnitName("pettarget")"#, "Foe"), + (r#"return UnitName("playertarget")"#, "Mob"), + (r#"return UnitName("targettarget")"#, "Pia"), + (r#"return UnitName("targettargettarget")"#, "Boss"), + (r#"return UnitName("playertargettargettarget")"#, "Boss"), + // The compares fold case, the hops' too. + (r#"return UnitName("PARTY1TARGET")"#, "Boss"), + (r#"return UnitName("Raid3Target")"#, "Add"), + ] { + assert_eq!(eval::(&s, chunk), want, "{chunk}"); + } + assert_eq!(eval::(&s, r#"return UnitHealth("raid3target")"#), 250); + assert_eq!( + eval::(&s, r#"return UnitHealth("party1target")"#), + 4000 + ); + assert_eq!( + eval::(&s, r#"return UnitHealthMax("party1target")"#), + 1000 + ); + assert_eq!(eval::(&s, r#"return UnitLevel("pettarget")"#), 60); + assert!(eval::(&s, r#"return UnitExists("pettarget") == 1"#)); + assert!(eval::( + &s, + r#"return UnitExists("party1target") == 1"# + )); + assert!(eval::( + &s, + r#"return UnitIsUnit("targettarget", "party1") == 1"# + )); +} + +/// A chain that names nobody answers what the getter answers for no unit: nil, 0 and nil. A chain +/// to a guid nothing holds reads 0 and nil too, but its name is the reference's name-cache leg +/// (`0x5171eb`, else `UNKNOWNOBJECT` at `0x517216`), which is not built, so it is not asserted. +#[test] +fn a_chain_to_a_unit_nobody_holds_answers_nothing() { + let s = world(); + // `BOSS` targets `GONE`, which is not held. + for (lua, want) in [ + (r#"UnitHealth("party1targettarget")"#, "0"), + (r#"UnitExists("party1targettarget")"#, "nil"), + ] { + assert_eq!( + eval::(&s, &format!("return tostring({lua})")), + want, + "{lua}" + ); + } + for token in [ + // `party2` is not held, so a hop off it names nobody. + "party2target", + // `SILENT` is held, and nothing pushed its snapshot. + "raid4target", + // A hop off a unit with no target, and a base past the roster. + "raid3targettarget", + "raid9target", + "partypet1target", + ] { + assert!( + eval::(&s, &format!(r#"return UnitName("{token}") == nil"#)), + "{token}: name" + ); + assert_eq!( + eval::(&s, &format!(r#"return UnitHealth("{token}")"#)), + 0, + "{token}: health" + ); + assert!( + eval::(&s, &format!(r#"return UnitExists("{token}") == nil"#)), + "{token}: exists" + ); + } +} + +/// The plain tokens read the snapshot the app pushed under them, never a guid's: a base with no +/// push answers nothing even where its guid has an entry, and `"target"` is not `"party1target"`. +#[test] +fn a_token_without_a_hop_still_reads_its_own_push() { + let mut s = world(); + for (chunk, want) in [ + (r#"return UnitName("player")"#, "Me"), + (r#"return UnitName("target")"#, "Mob"), + (r#"return UnitName("party1")"#, "Pia"), + (r#"return UnitName("pet")"#, "Pet"), + (r#"return UnitName("TARGET")"#, "Mob"), + ] { + assert_eq!(eval::(&s, chunk), want, "{chunk}"); + } + assert_eq!(eval::(&s, r#"return UnitHealth("target")"#), 500); + + // A guid entry for the unit a plain token resolves to is not that token's snapshot: the + // app pushes each base by name. + s.set_unit_by_guid(R3, Some(state("Raider", R3, 700))); + assert!(eval::(&s, r#"return UnitName("raid3") == nil"#)); + assert_eq!(eval::(&s, r#"return UnitHealth("raid3")"#), 0); + // A push under the token wins over a guid entry, which is what `targettarget` is. + s.set_unit("targettarget", Some(state("Tot", P1, 1))); + assert_eq!( + eval::(&s, r#"return UnitName("targettarget")"#), + "Tot" + ); + // Cleared, it falls to the chain. + s.set_unit("targettarget", None); + assert_eq!( + eval::(&s, r#"return UnitName("targettarget")"#), + "Pia" + ); + // And the entry is dropped with `None`. + s.set_unit_by_guid(P1, None); + assert!(eval::( + &s, + r#"return UnitName("targettarget") == nil"# + )); +} + +/// The junk after a hop and an unknown token behave as the resolver does: the first names +/// nobody, the second raises. +#[test] +fn a_malformed_chain_is_nobody_and_an_unknown_token_raises() { + let s = world(); + for token in [ + "party1targetfoo", + "party1 target", + "party1targettarge", + "targetfoo", + ] { + assert!( + eval::(&s, &format!(r#"return UnitName("{token}") == nil"#)), + "{token}" + ); + } + for chunk in [ + r#"return UnitName("npctarget")"#, + r#"return UnitHealth("bogustarget")"#, + ] { + let err = s.eval::(chunk).expect_err(chunk).to_string(); + assert!(err.contains("Unknown unit name"), "{chunk}: {err}"); + } +} + +/// `UnitInParty` judges a chain by the unit it ends on, not by the `party` in its text. +#[test] +fn unit_in_party_judges_the_unit_a_party_chain_ends_on() { + let mut s = world(); + s.set_party(crate::script::PartyState { + members: vec![crate::script::PartyMemberInfo { + guid: P1, + ..Default::default() + }], + ..Default::default() + }); + assert!(eval::(&s, r#"return UnitInParty("party1") == 1"#)); + // The boss is no member. + assert!(eval::( + &s, + r#"return UnitInParty("party1target") == nil"# + )); + // `MOB` targets party1, so the chain ends on a member. + assert!(eval::( + &s, + r#"return UnitInParty("targettarget") == 1"# + )); +} + +/// `SpellCanTargetUnit` resolves its token to a guid and asks the verdicts by guid: a chain reads +/// the unit it ends on, and two tokens naming one unit answer alike. +#[test] +fn spell_can_target_unit_answers_the_verdict_of_the_unit_the_token_names() { + let mut s = world(); + s.set_spell_targetable_units([BOSS, P1]); + let can = |s: &UiScript, token: &str| { + s.eval::(&format!("return SpellCanTargetUnit({token:?}) == true")) + .unwrap() + }; + let refused = |s: &UiScript, token: &str| { + s.eval::(&format!("return SpellCanTargetUnit({token:?}) == nil")) + .unwrap() + }; + assert!(can(&s, "party1target"), "the boss"); + assert!(can(&s, "raid2target"), "the boss by another base"); + assert!(can(&s, "PARTY1TARGET"), "case folds"); + assert!(can(&s, "targettarget"), "party1, by a hop off the target"); + assert!(can(&s, "party1"), "a base, by its guid"); + assert!(can(&s, "raid2"), "another token, one unit"); + assert!(refused(&s, "raid3target"), "the add is not cleared"); + assert!(refused(&s, "pettarget")); + assert!(refused(&s, "target"), "the mob is not cleared"); + assert!(refused(&s, "party1targettarget"), "a unit nobody holds"); + assert!(refused(&s, "party2target"), "a hop off a unit not held"); + assert!(refused(&s, "party1targetfoo"), "junk after a hop"); + assert!(refused(&s, ""), "no token names nobody"); + + // The set moves with the push, and clearing it clears every token. + s.set_spell_targetable_units([ADD]); + assert!(can(&s, "raid3target")); + assert!(refused(&s, "party1target")); + s.set_spell_targetable_units([]); + assert!(refused(&s, "raid3target")); +} + +/// The reference gates the argument (`0x6e6d0e`) and resolves through `0x515970`, which raises for +/// a token it does not know. +#[test] +fn spell_can_target_unit_raises_as_the_resolver_and_the_gate_do() { + let s = world(); + let err = |chunk: &str| s.eval::(chunk).expect_err(chunk).to_string(); + for chunk in [ + "return SpellCanTargetUnit()", + "return SpellCanTargetUnit(nil)", + "return SpellCanTargetUnit({})", + ] { + assert!( + err(chunk).contains(r#"Usage: SpellCanTargetUnit("unit")"#), + "{chunk}" + ); + } + for chunk in [ + r#"return SpellCanTargetUnit("bogus")"#, + r#"return SpellCanTargetUnit("npctarget")"#, + "return SpellCanTargetUnit(5)", + ] { + assert!(err(chunk).contains("Unknown unit name"), "{chunk}"); + } +} diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 1a9838a4..40e0d26d 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -1,5 +1,6 @@ //! The `Unit*` bindings. The app pushes each token's [`UnitState`] every frame through -//! [`UiScript::set_unit`], and the globals read that plain data, which keeps this crate free of +//! [`UiScript::set_unit`], and the units a `target` chain ends on by guid through +//! [`UiScript::set_unit_by_guid`]; the globals read that plain data, which keeps this crate free of //! the ECS. Every predicate answers the number `1` or nil, never a Lua boolean. use mlua::Lua; @@ -46,8 +47,9 @@ pub struct PlayerRecord { pub sex: u8, } -/// One unit token's snapshot, pushed by the app each frame and read by the `Unit*` bindings; -/// plain data, with no mlua handles or ECS types. +/// One unit's snapshot, pushed by the app each frame under a token, or under a guid for a unit a +/// `target` chain ends on, and read by the `Unit*` bindings; plain data, with no mlua handles or +/// ECS types. #[derive(Clone, Debug, Default, PartialEq)] pub struct UnitState { /// `UnitExists`, true for an out-of-range party member through the roster fallback. The @@ -190,7 +192,7 @@ pub struct UnitState { pub raid_target: u8, /// Whether the player can attack the unit, `UnitCanAttack("player", unit)`, which the binding /// (`0x516c50`, delegating to `CanAttack` `0x606980`) answers for both argument orders. Fed for - /// `target`, `targettarget` and `npc`; other tokens read false. + /// `target`, `targettarget`, `npc` and the units a chain ends on; other tokens read false. pub can_attack: bool, /// `UnitIsCorpse` (`0x5161c0`): the token names a `TYPEID_CORPSE` object, which a dead unit /// is not. No feed sets it. @@ -315,6 +317,32 @@ impl super::UiScript { super::tooltip_unit::on_unit_push(&self.lua, token); } + /// Push, or clear with `None`, the snapshot of a unit a `target` chain can end on, by guid. A + /// token with a hop that no push names (`"party1target"`, `"raid3targettarget"`) resolves + /// through [`UnitGuids`] to a guid and reads that guid's entry, the reference's order + /// (`0x515970`, then each getter's object lookup `0x468460`); the guids to cover are + /// [`Self::chain_end_guids`]. A token without a hop reads its own push, not this. + pub fn set_unit_by_guid(&mut self, guid: u64, state: Option) { + let mut model = self.model_mut(); + match state { + Some(s) => { + model.units_by_guid.insert(guid, s); + } + None => { + model.units_by_guid.remove(&guid); + } + } + } + + /// The guids the chains a script can spell end on ([`UnitGuids::chain_ends`]), each once: the + /// units whose snapshots [`Self::set_unit_by_guid`] serves. + pub fn chain_end_guids(&self) -> Vec { + let mut ends: Vec = self.model_ref().unit_guids.chain_ends().collect(); + ends.sort_unstable(); + ends.dedup(); + ends + } + /// Push the player's copper (`PLAYER_FIELD_COINAGE`), read by `GetMoney`. pub fn set_money(&mut self, copper: u64) { self.model_mut().money = copper; @@ -492,6 +520,8 @@ fn unit_predicate( /// The `Unit*` and `GetQuestGreenRange` registrations. mod bindings; +#[cfg(test)] +mod chain_tests; mod resolve; #[cfg(test)] mod tests; diff --git a/crates/benilla-ui/src/script/unit/resolve.rs b/crates/benilla-ui/src/script/unit/resolve.rs index d93f14ff..5d0ad2fe 100644 --- a/crates/benilla-ui/src/script/unit/resolve.rs +++ b/crates/benilla-ui/src/script/unit/resolve.rs @@ -172,11 +172,15 @@ impl UnitGuids { /// match, where the reference raises `Unknown unit name: %s` (`0x515c14`). An empty token is /// nobody (`0x51599d`). pub(crate) fn resolve(&self, token: &str) -> Result, ()> { - let (base, hops) = match parse_unit_token(token) { - UnitTokenParse::Unknown => return Err(()), - UnitTokenParse::Nobody => return Ok(None), - UnitTokenParse::Unit { base, hops } => (base, hops), - }; + match parse_unit_token(token) { + UnitTokenParse::Unknown => Err(()), + UnitTokenParse::Nobody => Ok(None), + UnitTokenParse::Unit { base, hops } => Ok(self.resolve_unit(base, hops)), + } + } + + /// The guid a parsed token names: its base's guid, then each `target` hop. `None` is nobody. + pub(crate) fn resolve_unit(&self, base: UnitBase, hops: usize) -> Option { // The indexed bases: the table's row for the parsed number. let at = |table: &[u64], row: u32| { usize::try_from(row) @@ -187,7 +191,7 @@ impl UnitGuids { }; let guid = match base { UnitBase::Player => self.player, - UnitBase::Pet if self.player == 0 => return Ok(None), + UnitBase::Pet if self.player == 0 => return None, UnitBase::Pet => self.pet, UnitBase::Target => self.target, UnitBase::Npc => self.npc, @@ -199,10 +203,19 @@ impl UnitGuids { // 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 if !self.held.contains_key(&self.mouseover) => return None, UnitBase::Mouseover => self.mouseover, }; - Ok(self.follow(guid, hops)) + self.follow(guid, hops) + } + + /// The guids a `target` hop can end on: each held unit's target, when it names one. `held` + /// holds the bases' units and each unit a hop reaches from them, so a token with a hop names + /// one of these or nobody, and a per-guid feed over them covers the chains a script can spell. + /// A guid repeats when two units share a target, and the last hop's unit need not be held: the + /// lookup that finds its object is the reader's. + pub(crate) fn chain_ends(&self) -> impl Iterator + '_ { + self.held.values().copied().filter(|&guid| guid != 0) } /// [`Self::resolve`] with the raise as the Lua error it is. diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 184bde29..c68f3c1f 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -1,6 +1,6 @@ //! The `Unit*` binding tests. -use crate::script::{PartyState, PlayerRecord, UiScript, UnitState}; +use crate::script::{PartyState, PlayerRecord, UiScript, UnitGuids, UnitState}; fn player() -> UnitState { UnitState { @@ -61,7 +61,13 @@ fn spell_target_unit_checks_in_the_references_order() { #[test] fn spell_can_target_unit_answers_per_validated_token() { let mut s = UiScript::new().unwrap(); - s.set_spell_targetable_units(["player", "party1"]); + s.set_unit_guids(&UnitGuids { + player: 0x10, + party: [0x21, 0, 0, 0], + target: 0x30, + ..Default::default() + }); + s.set_spell_targetable_units([0x10, 0x21]); assert!(s .eval::(r#"return SpellCanTargetUnit("player") == true"#) .unwrap());