From 65a5e44d488579c65fbcb8de61ece6b3fdab80cc Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:46:33 +0700 Subject: [PATCH] Unit aura bindings resolve their token as 1.12.1 does, so mouseover, party1target and any casing read the unit they name (#497) `UnitBuff`, `UnitDebuff`, `UnitAura`, `CancelUnitBuff` and the tooltip's `SetUnitBuff`/`SetUnitDebuff` now resolve their unit token as 1.12.1's `0x515970` does before reading any aura, so `mouseover`, `party1target`, `raid3target`, `pettarget`, `targettargettarget` and any casing (`"PLAYER"`) answer for the unit they name, where before they answered nil. - The resolver matches the nine base names as prefixes in the reference's order, folding ASCII case (`SStrCmpI 0x64a4c0`), with `npc` whole; the number after `party`/`raid` wraps at 32 bits and counts from 1; `mouseover` counts only while the unit is loaded (`0x515bd2`); each `target` suffix hops to that unit's `UNIT_FIELD_TARGET` (`0x5159d3`-`0x515a2c`); a name none of the nine match raises `Unknown unit name: %s` (`0x515c14`), before any index check, as `UnitBuff` calls it at `0x519542`. - The VM holds one aura list per guid, plus the player's own in cache order (`0xbc6040`); any token naming the player reads that list, so its order and durations are unchanged. - The feed builds lists only for guids some token can name, through the existing per-guid cache. `UNIT_AURA` fires for the same tokens as before. Fixes #488 Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/addon_harness/mod.rs | 26 +- crates/benilla-app/src/ui_aura.rs | 228 ++++------ .../src/ui_aura/{group.rs => units.rs} | 400 +++++++++++++----- .../benilla-app/src/ui_script/buff_tests.rs | 27 +- .../src/ui_script/pet_frame_tests.rs | 13 +- .../benilla-app/src/ui_script/quiver_tests.rs | 33 +- .../src/ui_script/target_aura_tests.rs | 18 +- .../src/ui_script/tooltip_anchor_tests.rs | 29 +- .../src/ui_script/tot_frame_tests.rs | 22 +- crates/benilla-ui/src/script/aura/mod.rs | 356 +++++++++++++--- .../benilla-ui/src/script/aura/player_buff.rs | 18 +- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/script/model.rs | 14 +- .../src/script/tests/tooltip_spell.rs | 88 ++-- crates/benilla-ui/src/script/tooltip_spell.rs | 9 +- crates/benilla-ui/src/script/unit/mod.rs | 31 +- crates/benilla-ui/src/script/unit/resolve.rs | 298 +++++++++++++ 17 files changed, 1141 insertions(+), 471 deletions(-) rename crates/benilla-app/src/ui_aura/{group.rs => units.rs} (63%) create mode 100644 crates/benilla-ui/src/script/unit/resolve.rs diff --git a/crates/benilla-app/src/addon_harness/mod.rs b/crates/benilla-app/src/addon_harness/mod.rs index 4fccd95f..bf503e1d 100644 --- a/crates/benilla-app/src/addon_harness/mod.rs +++ b/crates/benilla-app/src/addon_harness/mod.rs @@ -1088,18 +1088,20 @@ fn seat_a_session(script: &mut UiScript) { // One buff, one target and one action with a running cooldown, so an addon that only draws when // there is content (a buff bar, a target frame, cooldown text) is separated from one that // failed. - script.set_auras( - "player", - Some(vec![benilla_ui::script::AuraState { - spell_id: 1243, - name: Some("Power Word: Fortitude".into()), - icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()), - count: 1, - helpful: true, - cancelable: true, - ..Default::default() - }]), - ); + // The player's guid, which a token resolves to before its aura list is read. + script.set_unit_guids(&benilla_ui::script::UnitGuids { + player: 1, + ..Default::default() + }); + script.set_player_auras(vec![benilla_ui::script::AuraState { + spell_id: 1243, + name: Some("Power Word: Fortitude".into()), + icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()), + count: 1, + helpful: true, + cancelable: true, + ..Default::default() + }]); script.set_unit( "target", Some(benilla_ui::script::UnitState { diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 383dee34..575fc7a4 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -1,10 +1,11 @@ -//! The aura feed: the `UNIT_FIELD_AURA` blocks of the player, the target, the target's target, the -//! pet and the party and raid and their pets as the ordered [`AuraState`] lists the aura bindings -//! read, with the player's durations. The player's list keeps the reference cache's insertion order -//! (`0xbc6040`, repacked by `PlayerAuras_Update 0x4e4170`); any other unit's reads its descriptor -//! by ascending slot, or, for a group member or pet with no live object, its roster record. +//! The aura feed: the `UNIT_FIELD_AURA` blocks of the player and of every unit a token can name as +//! the ordered [`AuraState`] lists the aura bindings read, with the player's durations, and the +//! unit-token resolver's inputs the bindings reach them through ([`units`]). The player's list +//! keeps the reference cache's insertion order (`0xbc6040`, repacked by `PlayerAuras_Update +//! 0x4e4170`); any other unit's reads its descriptor by ascending slot, or, for a group member or +//! pet with no live object, its roster record. -mod group; +mod units; use std::collections::HashMap; @@ -157,13 +158,8 @@ struct AuraFeedMemo { last: Vec, /// The tracking spell: a tracking switch changes no list, but the minimap needs the event. tracking_last: Option, - /// The target's guid and projection: the guid makes a switch between equal lists re-fire. - /// The pet's and the target's target's are keyed the same way. - target_last: Option<(u64, Vec)>, - pet_last: Option<(u64, Vec)>, - tot_last: Option<(u64, Vec)>, - /// The `partyN`, `partypetN`, `raidN` and `raidpetN` lists and their pushes. - group: group::GroupAuras, + /// Every other unit's list, by guid, the resolver's inputs and their pushes. + units: units::UnitAuras, } /// The `BENILLA_AURA_TRACE` period in seconds; a set value that is not a positive number means 1 s. @@ -230,19 +226,6 @@ fn projection_of(list: &[AuraState]) -> Vec { .collect() } -/// A token's `UNIT_AURA` edge: its unit or its list moved since the last push. Keyed by guid too, -/// so a switch between equal lists re-fires; a token clearing fires nothing, since the frames -/// react to their own events (`PLAYER_TARGET_CHANGED`, `UNIT_PET`, `PARTY_MEMBERS_CHANGED`). -fn aura_edge( - last: &mut Option<(u64, Vec)>, - cur: Option<(u64, &[AuraState])>, -) -> bool { - let cur = cur.map(|(guid, l)| (guid, projection_of(l))); - let changed = cur.is_some() && cur != *last; - *last = cur; - changed -} - /// One aura as `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` return it for a unit not the player: /// no duration, which the 1.12 wire carries for the player alone. fn unit_aura_state( @@ -300,16 +283,6 @@ fn other_unit_inputs<'a>( .map(|a| (a.slot, a.spell_id, a.stacks, a.flags)) } -/// [`other_unit_inputs`] as the list. -fn other_unit_auras( - store: &ObjectStore, - catalog: Option<&SpellCatalog>, - buffs_visible: bool, -) -> Vec { - let inputs: Vec = other_unit_inputs(store, catalog, buffs_visible).collect(); - aura_list(catalog, &inputs) -} - /// A roster record's aura block as the two bindings' roster walk reads it (`0x519763`-`0x5197d2`, /// `0x519b53`-`0x519bc5`): ascending slot, the display filter alone (`0x51979f`, `0x519b8f`), no /// unit gate and no `AURAFLAGS`, which the record does not hold. The stack count is always 1 @@ -411,15 +384,24 @@ fn tracking_state_of( }) } +/// The units the resolver's guid globals name, from `UnitTokens`' own sources. +#[derive(bevy::ecs::system::SystemParam)] +struct AuraUnits<'w> { + selection: Res<'w, Selection>, + pet: Res<'w, crate::ui_pet::PetBar>, + // `Option`: a UI-only harness lacks their plugins; without the party's no group token names + // anyone. + group: Option>, + hovered: Option>, + interact: Option>, +} + fn feed_auras( script: Option>, self_q: Query<(&ObjectStore, &Guid), With>, - selection: Res, + units: AuraUnits, stores: Query<&ObjectStore>, spells: Option>, - pet: Res, - // `Option`: a UI-only harness has no party plugin; without it no party token has a list. - group: Option>, index: Res, // The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use. factions: Option>, @@ -537,65 +519,52 @@ fn feed_auras( } let held = |guid: u64| index.0.get(&guid).and_then(|&e| stores.get(e).ok()); + let pet_guid = units.pet.spells.pet_guid; // `UnitBuff`'s unit gate on a live unit. `unit` is never `store`, the player's, which is the - // gate's second argument. - let buffs_visible = |unit: &ObjectStore| { - buffs_visible_on( - unit, - Some(store), - factions.as_deref(), - &reputations, - |owner| held(owner).cloned(), - ) + // gate's second argument. The pet is ungated; the reference gates a pet too, but `CanAssist`'s + // player-controlled arm (`0x60673e`-`0x60679f`, the controlling players' duel and FFA-PvP + // state) passes for your own pet, leaving only the charm, selectable and reaction clauses to + // hide its buffs. + let buffs_visible = |guid: u64, unit: &ObjectStore| { + guid == pet_guid + || buffs_visible_on( + unit, + Some(store), + factions.as_deref(), + &reputations, + |owner| held(owner).cloned(), + ) }; - let gated = |unit: &ObjectStore| other_unit_auras(unit, catalog, buffs_visible(unit)); - - // The target's rows. A self-target mirrors the player list in cache order, where the - // reference's `UnitBuff` reads by slot. - let target_list: Option> = - selection.target.zip(selection.guid).and_then(|(e, guid)| { - if guid == self_guid.0 { - return Some(list.clone()); - } - Some(gated(stores.get(e).ok()?)) - }); - - // The pet's list: the stock pet frame's four buttons draw its buffs (`PetFrame.lua:37,56`). - let pet_guid = pet.spells.pet_guid; - let pet_list: Option> = (pet_guid != 0) - .then(|| held(pet_guid)) - .flatten() - // Ungated; the reference gates a pet too, but `CanAssist`'s player-controlled arm - // (`0x60673e`-`0x60679f`, the controlling players' duel and FFA-PvP state) passes for - // your own pet, leaving only the charm, selectable and reaction clauses to hide its buffs. - .map(|store| other_unit_auras(store, catalog, true)); - - // The target's target's list, for the ToT frame's four debuff buttons, through the one-hop - // `UNIT_FIELD_TARGET` read. When that is you, it is the player list, as for a self-target. - let tot_guid = selection - .target - .and_then(|e| stores.get(e).ok()) - .and_then(|s| s.0.unit_target()) - .filter(|g| *g != 0) - .unwrap_or(0); - let tot_list: Option> = - (tot_guid != 0) - .then(|| held(tot_guid)) - .flatten() - .map(|tot_store| { - if tot_guid == self_guid.0 { - return list.clone(); - } - gated(tot_store) - }); - - let target_changed = aura_edge( - &mut memo.target_last, - selection.guid.zip(target_list.as_deref()), + let target_guid = units.selection.guid.unwrap_or(0); + let catalog_moved = spells.as_ref().is_some_and(|s| s.is_changed()); + let unit_changed = memo.units.feed( + &mut script, + units::Inputs { + group: units.group.as_deref(), + me: (self_guid.0, changed), + pet: pet_guid, + target: target_guid, + // The world pick, as `UnitTokens` resolves `"mouseover"`. + mouseover: units + .hovered + .as_deref() + .and_then(|h| h.target.and(h.guid)) + .unwrap_or(0), + npc: units.interact.as_deref().and_then(|i| i.1).unwrap_or(0), + held, + buffs_visible, + catalog, + catalog_moved, + }, ); - if target_changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() { - let l = target_list.as_deref().unwrap_or_default(); + if unit_changed.contains(&"target") && std::env::var_os("BENILLA_AURA_DUMP").is_some() { + let l = if target_guid == self_guid.0 { + Some(list.as_slice()) + } else { + memo.units.list(target_guid) + }; + let l = l.unwrap_or_default(); info!("aura dump: {} aura(s) on the target rows", l.len()); for a in l { info!( @@ -608,39 +577,7 @@ fn feed_auras( } } - let pet_changed = aura_edge( - &mut memo.pet_last, - (pet_guid != 0) - .then_some(pet_list.as_deref()) - .flatten() - .map(|l| (pet_guid, l)), - ); - let tot_changed = aura_edge( - &mut memo.tot_last, - (tot_guid != 0) - .then_some(tot_list.as_deref()) - .flatten() - .map(|l| (tot_guid, l)), - ); - // `party1..4`, `partypet1..4`, `raid1..40` and `raidpet1..40`: the live unit's rows, else its - // roster record's, each pushed only as it moves. - let catalog_moved = spells.as_ref().is_some_and(|s| s.is_changed()); - let group_changed = memo.group.feed( - &mut script, - group::Inputs { - group: group.as_deref(), - me: (self_guid.0, &list, changed), - held, - buffs_visible, - catalog, - catalog_moved, - }, - ); - script.set_auras("player", Some(list)); - // Clearing a token fires nothing: the frames react to `PLAYER_TARGET_CHANGED` and `UNIT_PET`. - script.set_auras("target", target_list); - script.set_auras("pet", pet_list); - script.set_auras("targettarget", tot_list); + script.set_player_auras(list); script.set_tracking(tracking); if changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]); @@ -648,18 +585,11 @@ fn feed_auras( // `MiniMapTrackingFrame` register it, the unit frames `UNIT_AURA`. script.fire_event("PLAYER_AURAS_CHANGED", vec![]); } - if pet_changed { - script.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]); - } - if target_changed { - script.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]); - } - if tot_changed { - script.fire_event("UNIT_AURA", vec![ScriptValue::Str("targettarget".into())]); - } - // The reference fires these from the record's aura bits (`0x5e57d8`, the pet's `0x5e58f1`) - // and from the live descriptor's aura fields, to every token naming the guid (`0x515e50`). - for token in group_changed { + // The reference fires these from the live descriptor's aura fields and the record's aura bits + // (`0x5e57d8`, the pet's `0x5e58f1`), to every token naming the guid (`0x515e50`); a token + // naming a new unit fires too, and a token clearing fires nothing, since the frames react to + // their own events (`PLAYER_TARGET_CHANGED`, `UNIT_PET`, `PARTY_MEMBERS_CHANGED`). + for token in unit_changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str(token.into())]); } } @@ -694,12 +624,8 @@ fn end_session_aura_state( mut cache: ResMut, ) { if let Some(mut script) = script { - script.set_auras("player", None); - script.set_auras("target", None); - script.set_auras("targettarget", None); - for token in group::tokens() { - script.set_auras(token, None); - } + script.clear_auras(); + script.set_unit_guids(&Default::default()); script.set_tracking(None); } cache.auras.clear(); @@ -721,6 +647,16 @@ fn drain_aura_cancels(script: Option>, net: Res, + buffs_visible: bool, + ) -> Vec { + let inputs: Vec = other_unit_inputs(store, catalog, buffs_visible).collect(); + aura_list(catalog, &inputs) + } + /// The plugin on a bare app: without a `UiScript` the systems that need one skip. fn aura_app() -> App { let (tx, _) = crossbeam_channel::unbounded(); diff --git a/crates/benilla-app/src/ui_aura/group.rs b/crates/benilla-app/src/ui_aura/units.rs similarity index 63% rename from crates/benilla-app/src/ui_aura/group.rs rename to crates/benilla-app/src/ui_aura/units.rs index e51c2636..afb3cf90 100644 --- a/crates/benilla-app/src/ui_aura/group.rs +++ b/crates/benilla-app/src/ui_aura/units.rs @@ -1,13 +1,16 @@ -//! The group tokens' aura lists: `partyN`, `partypetN`, `raidN` and `raidpetN`. `UnitBuff -//! 0x519500` and `UnitDebuff 0x5198f0` read a token's live object, gated as any unit's; a guid -//! with no live object diverts to the roster record's aura block instead (`0x519592`-`0x5195a4` -//! to `0x519735`, `0x519b25`). A list is built once per guid and shared by every token naming it, -//! and rebuilt only when what it is built from moves: a 40-man raid names up to 80 units. +//! Every unit's aura list but the player's, one per guid, and the unit-token resolver's inputs +//! (`0x515970`, [`UnitGuids`]) that `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read them through: +//! the guid globals, the group tables, and each held unit a `target` chain can reach. A list is the +//! live unit's slots, gated as any unit's; a group member or pet with no live object diverts to its +//! roster record's block (`0x519592`-`0x5195a4` to `0x519735`, `0x519b25`). Lists are built only for +//! guids some token can name, once per guid whatever names it, and rebuilt only when what they are +//! built from moves: a 40-man raid names up to 80 units, and their targets. use std::collections::hash_map::{Entry, HashMap}; use benilla_formats::SpellCatalog; -use benilla_ui::script::{AuraState, UiScript}; +use benilla_protocol::messages::ObjectType; +use benilla_ui::script::{AuraState, UiScript, UnitGuids}; use super::{aura_list, other_unit_inputs, record_inputs, AuraInput}; use crate::net::ObjectStore; @@ -36,52 +39,87 @@ const RAID_PET_TOKENS: [&str; 40] = [ "raidpet36", "raidpet37", "raidpet38", "raidpet39", "raidpet40", ]; -const TOKEN_COUNT: usize = PARTY_AURA_TOKENS.len() + RAID_TOKENS.len() + RAID_PET_TOKENS.len(); +/// The tokens `UNIT_AURA` fires for besides `"player"`, in firing order: [`EVENT_UNITS`], then the +/// group's, each resolved by [`event_guid`]. +const EVENT_UNITS: [&str; 3] = ["pet", "target", "targettarget"]; -/// Every token this feeds, in [`token_guids`]' order. -pub(super) fn tokens() -> impl Iterator { - PARTY_AURA_TOKENS +const EVENT_TOKEN_COUNT: usize = + EVENT_UNITS.len() + PARTY_AURA_TOKENS.len() + RAID_TOKENS.len() + RAID_PET_TOKENS.len(); + +fn event_tokens() -> impl Iterator { + EVENT_UNITS .into_iter() + .chain(PARTY_AURA_TOKENS) .chain(RAID_TOKENS) .chain(RAID_PET_TOKENS) } -/// The guid each of [`tokens`] names, `None` for nobody. `party1..4` are the own subgroup's slots -/// and `partypetN` their pets (`0x4e81d0`); `raidN` is `GetRaidRosterInfo` row N, as every other -/// raid-token reader resolves it, and `raidpetN` that row's pet (`0x491960`). `held` is the object -/// manager's lookup (`0x468460`). -fn token_guids<'a>( - group: &GroupState, - me: u64, - held: impl Fn(u64) -> Option<&'a ObjectStore>, -) -> Vec> { - let live = |g: u64| held(g).map(|s| &s.0); - let members: Vec = group.party_slots().map(|m| m.guid).collect(); - let rows = raid_row_guids(group, Some(me)); - let mut out = Vec::with_capacity(TOKEN_COUNT); - out.extend((0..4).map(|i| members.get(i).copied())); - out.extend((0..4).map(|i| { - members - .get(i) - .and_then(|&m| group.party_pet_guid(m, live(m))) - })); - out.extend((0..RAID_TOKENS.len()).map(|i| rows.get(i).copied())); - out.extend( - (0..RAID_PET_TOKENS.len()) - .map(|i| rows.get(i).and_then(|&m| group.raid_pet_guid(m, live(m)))), - ); - out +/// The guid [`event_tokens`]' `i`-th names, 0 for nobody. +fn event_guid(g: &UnitGuids, i: usize) -> u64 { + let at = |t: &[u64], i: usize| t.get(i).copied().unwrap_or(0); + match i { + 0 => g.pet, + 1 => g.target, + // One hop, as the resolver's `target` loop makes it: off a held target only. + 2 => g.held.get(&g.target).copied().unwrap_or(0), + 3..=6 => g.party[i - 3], + 7..=10 => g.party_pets[i - 7], + 11..=50 => at(&g.raid, i - 11), + _ => at(&g.raid_pets, i - 51), + } } -/// What [`GroupAuras::feed`] reads this frame. +/// The group tables of [`UnitGuids`]: `party1..4` are the own subgroup's slots and `partypetN` +/// their pets (`0x4e81d0`); `raidN` is `GetRaidRosterInfo` row N, as every other raid-token reader +/// resolves it, and `raidpetN` that row's pet (`0x491960`). +fn fill_group<'a>( + g: &mut UnitGuids, + group: Option<&GroupState>, + me: u64, + held: impl Fn(u64) -> Option<&'a ObjectStore>, +) { + g.party = [0; 4]; + g.party_pets = [0; 4]; + g.raid.clear(); + g.raid_pets.clear(); + let Some(group) = group else { + return; + }; + let live = |m: u64| held(m).map(|s| &s.0); + for (i, m) in group.party_slots().take(4).enumerate() { + g.party[i] = m.guid; + g.party_pets[i] = group.party_pet_guid(m.guid, live(m.guid)).unwrap_or(0); + } + g.raid = raid_row_guids(group, Some(me)); + g.raid_pets.extend( + g.raid + .iter() + .map(|&m| group.raid_pet_guid(m, live(m)).unwrap_or(0)), + ); +} + +/// 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>, - /// Our guid, the player's list, and whether that list moved this frame: a raid row naming us - /// mirrors the player's list, as a self-target does. - pub(super) me: (u64, &'g [AuraState], bool), + /// Our guid, and whether the player's list moved this frame: a token naming us reads the + /// player's list, which the caller pushes. + pub(super) me: (u64, bool), + /// `pet`, `target`, `mouseover` and `npc`'s guids, 0 for none. + pub(super) pet: u64, + pub(super) target: u64, + pub(super) mouseover: u64, + pub(super) npc: u64, /// The object manager's lookup (`0x468460`). pub(super) held: H, - /// `UnitBuff`'s unit gate on a live unit. + /// `UnitBuff`'s unit gate on a live unit, by guid. pub(super) buffs_visible: V, pub(super) catalog: Option<&'a SpellCatalog>, /// The spell catalog was replaced, so every list is rebuilt. @@ -93,32 +131,38 @@ struct GuidList { /// What the list was built from; the list is rebuilt only when these move. inputs: Vec, list: Vec, - /// Taken from [`GroupAuras::versions`] each time the list changes. + /// Taken from [`UnitAuras::versions`] each time the list changes. version: u64, - /// The frame that last read it; an entry no token read this frame is dropped. + /// The frame that last read it; an entry no token can name this frame is dropped. frame: u64, } -/// The group tokens' feed state for one VM. +/// The per-guid lists and the resolver's inputs, for one VM. #[derive(Default)] -pub(super) struct GroupAuras { - /// Each token's last push, by [`tokens`] index: the guid and that guid's list version. - pushed: Vec>, +pub(super) struct UnitAuras { + /// The resolver's inputs as last built. + guids: UnitGuids, by_guid: HashMap, + /// Each list version the VM holds, by guid. + pushed: HashMap, + /// Each event token's last `(guid, version)`, by [`event_tokens`] index. + fired: Vec>, /// The last version handed out; every list change takes the next, so none repeats. versions: u64, - /// The player's list version, for a raid row naming us. + /// The player's list version, for a token naming us. player_version: u64, catalog_present: bool, frame: u64, - /// The inputs being read, kept to save the allocation. + /// Kept to save the allocations: the inputs being read, and the walk over the guids a token + /// can name, each with whether its roster record may stand in for it. scratch: Vec, + walk: Vec<(u64, bool)>, } -impl GroupAuras { - /// Push each token whose unit or list moved, clear each that stopped naming anyone, and - /// return the tokens `UNIT_AURA` fires for: every token naming a unit whose list moved - /// (`0x515e50`), or that names a new unit. Clearing a token fires nothing. +impl UnitAuras { + /// Build the resolver's inputs and every nameable guid's list, push what moved, and return + /// the tokens `UNIT_AURA` fires for: every event token naming a unit whose list moved + /// (`0x515e50`), or that names a new unit. A token clearing fires nothing. pub(super) fn feed<'a, H, V>( &mut self, script: &mut UiScript, @@ -126,11 +170,15 @@ impl GroupAuras { ) -> Vec<&'static str> where H: Fn(u64) -> Option<&'a ObjectStore>, - V: Fn(&ObjectStore) -> bool, + V: Fn(u64, &ObjectStore) -> bool, { let Inputs { group, - me: (me, player_list, player_moved), + me: (me, player_moved), + pet, + target, + mouseover, + npc, held, buffs_visible, catalog, @@ -145,66 +193,109 @@ impl GroupAuras { self.versions += 1; self.player_version = self.versions; } - let guids = match group { - Some(group) => token_guids(group, me, &held), - None => vec![None; TOKEN_COUNT], - }; - self.pushed.resize(TOKEN_COUNT, None); - let mut fire = Vec::new(); - for (i, (token, guid)) in tokens().zip(guids).enumerate() { - let cur = guid.map(|g| { - let version = if g == me { - // Every frame, for the durations the player's list carries. - script.set_auras(token, Some(player_list.to_vec())); - self.player_version - } else { - self.refresh(g, group, &held, &buffs_visible, catalog) - }; - (g, version) - }); - if cur == self.pushed[i] { + + // The resolver's inputs and the lists, in one walk over every guid a token can name: the + // bases, then each held unit their `target` chains reach. A group token's guid lists off + // its roster record while out of view; any other lists only while held. + let mut g = std::mem::take(&mut self.guids); + let mut walk = std::mem::take(&mut self.walk); + (g.player, g.pet, g.target, g.mouseover, g.npc) = (me, pet, target, mouseover, npc); + fill_group(&mut g, group, me, &held); + g.held.clear(); + walk.clear(); + walk.extend([me, pet, target, mouseover, npc].map(|g| (g, false))); + walk.extend(g.party.iter().chain(&g.party_pets).map(|&g| (g, true))); + walk.extend(g.raid.iter().chain(&g.raid_pets).map(|&g| (g, true))); + while let Some((guid, record_ok)) = walk.pop() { + if guid == 0 || g.held.contains_key(&guid) { continue; } - if let Some((g, _)) = cur.filter(|&(g, _)| g != me) { - script.set_auras(token, Some(self.by_guid[&g].list.clone())); - } else if cur.is_none() { - script.set_auras(token, None); + let store = held(guid); + if let Some(unit) = store.filter(|s| is_unit(s)) { + let next = unit.0.unit_target().unwrap_or(0); + g.held.insert(guid, next); + walk.push((next, false)); } - if cur.is_some() { - fire.push(token); + if guid != me { + self.refresh(guid, store, record_ok, group, &buffs_visible, catalog); } - self.pushed[i] = cur; } + script.set_unit_guids(&g); + self.guids = g; + self.walk = walk; let frame = self.frame; self.by_guid.retain(|_, e| e.frame == frame); + + for (&guid, e) in &self.by_guid { + if self.pushed.insert(guid, e.version) != Some(e.version) { + script.set_unit_auras(guid, Some(e.list.clone())); + } + } + let by_guid = &self.by_guid; + self.pushed.retain(|guid, _| { + let keep = by_guid.contains_key(guid); + if !keep { + script.set_unit_auras(*guid, None); + } + keep + }); + + self.fired.resize(EVENT_TOKEN_COUNT, None); + let mut fire = Vec::new(); + for (i, token) in event_tokens().enumerate() { + let guid = event_guid(&self.guids, i); + let cur = match guid { + 0 => None, + g if g == me => Some((g, self.player_version)), + g => self.by_guid.get(&g).map(|e| (g, e.version)), + }; + if cur != self.fired[i] && cur.is_some() { + fire.push(token); + } + self.fired[i] = cur; + } fire } - /// `guid`'s list version this frame, rebuilding the list if what it is built from moved: the - /// live unit's gated slots, else the roster record's block. - fn refresh<'a>( + /// `guid`'s list as last built, `None` for a unit with none. + pub(super) fn list(&self, guid: u64) -> Option<&[AuraState]> { + self.by_guid.get(&guid).map(|e| e.list.as_slice()) + } + + /// Rebuild `guid`'s list this frame if what it is built from moved: the live unit's gated + /// slots (`store`, the object manager's answer), else, for a guid a group token names, the + /// roster record's block. A guid neither held nor a group token's gets no list, as + /// `UnitBuff`'s roster walk finds nothing for it. + fn refresh( &mut self, guid: u64, + store: Option<&ObjectStore>, + record_ok: bool, group: Option<&GroupState>, - held: &impl Fn(u64) -> Option<&'a ObjectStore>, - buffs_visible: &impl Fn(&ObjectStore) -> bool, + buffs_visible: &impl Fn(u64, &ObjectStore) -> bool, catalog: Option<&SpellCatalog>, - ) -> u64 { - if let Some(e) = self.by_guid.get(&guid).filter(|e| e.frame == self.frame) { - return e.version; + ) { + if self + .by_guid + .get(&guid) + .is_some_and(|e| e.frame == self.frame) + { + return; } self.scratch.clear(); - match held(guid) { - Some(store) => { - self.scratch - .extend(other_unit_inputs(store, catalog, buffs_visible(store))) - } - None => { + match store { + Some(store) => self.scratch.extend(other_unit_inputs( + store, + catalog, + buffs_visible(guid, store), + )), + None if record_ok => { if let Some(group) = group { self.scratch .extend(record_inputs(group.roster_aura_slots(guid), catalog)); } } + None => return, } match self.by_guid.entry(guid) { Entry::Occupied(mut e) => { @@ -219,7 +310,6 @@ impl GroupAuras { e.version = self.versions; } } - e.version } Entry::Vacant(e) => { self.versions += 1; @@ -230,8 +320,7 @@ impl GroupAuras { list, version: self.versions, frame: self.frame, - }) - .version + }); } } } @@ -756,7 +845,7 @@ mod tests { app.world_mut() .resource_scope(|world, mut mem: Mut| { let script = world.non_send_resource::(); - mem.vm.get(script).group.versions + mem.vm.get(script).units.versions }) }; let before = versions(&mut app); @@ -772,4 +861,121 @@ mod tests { "one list moved, one rebuilt" ); } + + // == Any token the resolver parses == + + /// `OBJECT_FIELD_TYPE`; a unit's typemask is `0x9`, a player's `0x19`. + const TYPE: u16 = 2; + /// `UNIT_FIELD_TARGET`'s low dword; the high half is the next index. + const TARGET: u16 = 16; + const MOB: u64 = 0xF130_0000_0000_0001; + const ADD: u64 = 0xF130_0000_0000_0002; + + /// A unit of typemask `ty` targeting `target`, with one debuff in slot 32. + fn unit_fields(ty: u32, target: u64, debuff: u32) -> Vec<(u16, u32)> { + vec![ + (TYPE, ty), + (TARGET, target as u32), + (TARGET + 1, (target >> 32) as u32), + (AURA + 32, debuff), + (AURAFLAGS + 4, 0x0000_0008), + ] + } + + /// We target `MOB`, which targets party1, who targets `ADD`, which targets us; the mouse is over + /// `ADD`, and our pet targets `MOB`. Each carries its own debuff: us 6788, `MOB` 589, party1 + /// 11976, `ADD` 770. + fn chain_app() -> App { + let mut app = party_app(); + app.init_resource::(); + let me = app.world_mut().resource::().0[&ME]; + *app.world_mut().get_mut::(me).unwrap() = + ObjectStore(ObjectFields::from_pairs(&unit_fields(0x19, MOB, 6788))); + stream(&mut app, MOB, &unit_fields(0x9, MEMBER, 589)); + stream(&mut app, MEMBER, &unit_fields(0x19, ADD, 11976)); + stream(&mut app, ADD, &unit_fields(0x9, ME, 770)); + stream(&mut app, PET, &unit_fields(0x9, MOB, 1)); + app.world_mut() + .resource_mut::() + .spells + .pet_guid = PET; + let (mob, add) = { + let index = &app.world().resource::().0; + (index[&MOB], index[&ADD]) + }; + let mut selection = app.world_mut().resource_mut::(); + (selection.target, selection.guid) = (Some(mob), Some(MOB)); + let mut hovered = app.world_mut().resource_mut::(); + (hovered.target, hovered.guid) = (Some(add), Some(ADD)); + app.update(); + app + } + + /// `UnitDebuff` reads the list of whatever unit the token names, through the resolver + /// (`0x515970`, called at `0x519542`): the mouseover, a member's target, any depth of `target` + /// chain, in any case, and the player's own list at the end of a chain that comes back to us. + #[test] + fn every_token_reads_the_list_of_the_unit_it_names() { + let mut app = chain_app(); + for (token, want) in [ + ("mouseover", 770), + ("MouseOver", 770), + ("party1target", 770), + ("PARTY1TARGET", 770), + ("target", 589), + ("targettarget", 11976), + ("targettargettarget", 770), + ("targettargettargettarget", 6788), + ("pettarget", 589), + ("playertarget", 589), + ("mouseovertargettarget", 589), + ("PLAYER", 6788), + ("Player", 6788), + ] { + assert_eq!(listed(&mut app, token, true), [want], "{token}"); + } + assert_eq!( + listed(&mut app, "PLAYER", true), + listed(&mut app, "player", true) + ); + } + + /// `UNIT_AURA` fires for the tokens it fired for before, never for a chain or the mouseover, + /// which the reference's fan-out (`0x515c50`) does not list. + #[test] + fn unit_aura_still_fires_for_the_fixed_tokens_alone() { + let mut app = chain_app(); + let mut first = seen(&mut app); + first.sort(); + assert_eq!(first, ["party1", "pet", "target", "targettarget"]); + + // `ADD`'s list moves: it is the mouseover, party1's target and three hops off ours, and + // no token that fires names it. + let add = app.world().resource::().0[&ADD]; + let mut fields = unit_fields(0x9, ME, 770); + fields.push((AURA + 33, 589)); + fields[4].1 = 0x0000_0088; + *app.world_mut().get_mut::(add).unwrap() = + ObjectStore(ObjectFields::from_pairs(&fields)); + app.update(); + assert!(seen(&mut app).is_empty()); + assert_eq!(listed(&mut app, "mouseover", true), [770, 589]); + assert_eq!(listed(&mut app, "party1target", true), [770, 589]); + } + + /// A raid member's target that is out of view but on the roster reads its record, as + /// `UnitDebuff`'s roster walk finds it (`0x519b25`); a hop off a unit not held is nobody. + #[test] + fn a_raid_members_target_reads_whatever_the_guid_holds() { + let mut app = raid_app(); + stats_of(&mut app, FAR, true, online(vec![(32, 589)])); + // `raid3` is 0x2002, in view and targeting `FAR`, which is not. + stream(&mut app, 0x2002, &unit_fields(0x19, FAR, 770)); + app.update(); + assert_eq!(listed(&mut app, "raid3", true), [770]); + assert_eq!(listed(&mut app, "raid3target", true), [589]); + assert_eq!(listed(&mut app, "raid5", true), [589]); + assert!(listed(&mut app, "raid5target", true).is_empty()); + assert!(listed(&mut app, "raid3targettarget", true).is_empty()); + } } diff --git a/crates/benilla-app/src/ui_script/buff_tests.rs b/crates/benilla-app/src/ui_script/buff_tests.rs index 6aa7bea1..9eb0af37 100644 --- a/crates/benilla-app/src/ui_script/buff_tests.rs +++ b/crates/benilla-app/src/ui_script/buff_tests.rs @@ -69,7 +69,7 @@ fn aura( /// Push the list and fire `PLAYER_AURAS_CHANGED`, as the app's feed does (`BuffFrame.lua:113`). fn push(s: &mut UiScript, auras: Vec) { - s.set_auras("player", Some(auras)); + s.set_player_auras(auras); s.fire_event("PLAYER_AURAS_CHANGED", vec![]); } @@ -351,7 +351,7 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() { assert_eq!(text(&s, "BuffButton0Duration"), "19 s"); // The refresh: a new expiry and no event. - s.set_auras("player", Some(vec![mark(300.0)])); + s.set_player_auras(vec![mark(300.0)]); frame(&mut s, 0.1); assert_eq!( text(&s, "BuffButton0Duration"), @@ -367,14 +367,14 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() { // timed aura with no stamp yet shows "0 s" and pulses until the stamp joins. let mut pending = mark(0.0); pending.until_cancelled = false; // a timed spell, stamp not yet arrived - s.set_auras("player", Some(vec![pending])); + s.set_player_auras(vec![pending]); frame(&mut s, 0.1); assert_eq!( text(&s, "BuffButton0Duration"), "0 s", "the reference draws the floor of a timed aura it has no stamp for, not a blank" ); - s.set_auras("player", Some(vec![mark(60.0)])); // the stamp joins, still no event + s.set_player_auras(vec![mark(60.0)]); // the stamp joins, still no event frame(&mut s, 0.1); assert_eq!( text(&s, "BuffButton0Duration"), @@ -885,17 +885,14 @@ fn the_duration_line_reads_the_real_global_strings() { // `secs_left` seconds remaining at GetTime = 10, read back as the tooltip's last line. let mut line = |secs_left: f64| -> String { - s.set_auras( - "player", - Some(vec![AuraState { - spell_id: 1459, - name: Some("Arcane Intellect".into()), - duration: 86_400.0, - expiration_time: 10.0 + secs_left, - helpful: true, - ..Default::default() - }]), - ); + s.set_player_auras(vec![AuraState { + spell_id: 1459, + name: Some("Arcane Intellect".into()), + duration: 86_400.0, + expiration_time: 10.0 + secs_left, + helpful: true, + ..Default::default() + }]); s.run( r#" BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT") diff --git a/crates/benilla-app/src/ui_script/pet_frame_tests.rs b/crates/benilla-app/src/ui_script/pet_frame_tests.rs index ae4e79df..66ab52a5 100644 --- a/crates/benilla-app/src/ui_script/pet_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/pet_frame_tests.rs @@ -260,8 +260,15 @@ fn the_debuff_row_fills_from_the_pets_own_auras() { s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0))); s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]); - s.set_auras( - "pet", + // `"pet"` resolves to the pet's guid, whose list the row reads. + const PET: u64 = 0xF140_0000_0000_0077; + s.set_unit_guids(&benilla_ui::script::UnitGuids { + player: 1, + pet: PET, + ..Default::default() + }); + s.set_unit_auras( + PET, Some(vec![pet_buff(1000, "Rend", 1), pet_buff(1001, "Sunder", 3)]), ); s.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]); @@ -289,7 +296,7 @@ fn the_debuff_row_fills_from_the_pets_own_auras() { ); assert!(draws(&mut s, "Interface\\Icons\\Spell_1001")); - s.set_auras("pet", Some(vec![])); + s.set_unit_auras(PET, Some(vec![])); s.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]); assert!(!s .eval::("return PetFrameDebuff1:IsVisible()") diff --git a/crates/benilla-app/src/ui_script/quiver_tests.rs b/crates/benilla-app/src/ui_script/quiver_tests.rs index 006ad2d1..2351dbea 100644 --- a/crates/benilla-app/src/ui_script/quiver_tests.rs +++ b/crates/benilla-app/src/ui_script/quiver_tests.rs @@ -323,22 +323,23 @@ fn auto_shot_bar_drains_the_moment_an_ammo_lock_event_arrives() { /// Seat one active player buff by name and announce it the way the app's aura feed does. fn seat_a_buff(s: &mut UiScript, spell_id: u32, name: &str, icon: &str) { use benilla_ui::script::AuraState; - s.set_auras( - "player", - Some(vec![AuraState { - spell_id, - name: Some(name.into()), - icon: Some(icon.into()), - count: 0, - debuff_type: None, - duration: 0.0, - expiration_time: 0.0, - helpful: true, - cancelable: true, - until_cancelled: true, - channeled: false, - }]), - ); + s.set_unit_guids(&benilla_ui::script::UnitGuids { + player: 1, + ..Default::default() + }); + s.set_player_auras(vec![AuraState { + spell_id, + name: Some(name.into()), + icon: Some(icon.into()), + count: 0, + debuff_type: None, + duration: 0.0, + expiration_time: 0.0, + helpful: true, + cancelable: true, + until_cancelled: true, + channeled: false, + }]); s.fire_event("PLAYER_AURAS_CHANGED", vec![]); } diff --git a/crates/benilla-app/src/ui_script/target_aura_tests.rs b/crates/benilla-app/src/ui_script/target_aura_tests.rs index 07769353..5f76d2b9 100644 --- a/crates/benilla-app/src/ui_script/target_aura_tests.rs +++ b/crates/benilla-app/src/ui_script/target_aura_tests.rs @@ -1,7 +1,7 @@ //! The stock target frame's aura rows (`TargetFrame.lua:263`) under a stubbed feed. //! BuffFrame.xml loads first for the `DebuffTypeColor` and `RefreshBuffs` the frames use. -use benilla_ui::script::{AuraState, QuadContent, ScriptValue, UiScript, UnitState}; +use benilla_ui::script::{AuraState, QuadContent, ScriptValue, UiScript, UnitGuids, UnitState}; use super::test_ui::load_ui as load_xml; @@ -56,10 +56,18 @@ fn target(s: &mut UiScript, reaction: u8, auras: Vec) { ..UnitState::default() }), ); - s.set_auras("target", Some(auras)); + s.set_unit_guids(&UnitGuids { + player: 1, + target: TARGET, + ..Default::default() + }); + s.set_unit_auras(TARGET, Some(auras)); s.fire_event("PLAYER_TARGET_CHANGED", vec![]); } +/// The target's guid, which `"target"` resolves to. +const TARGET: u64 = 0xF130_0000_0000_0001; + fn debuff(spell_id: u32, name: &str, count: u8, debuff_type: Option<&str>) -> AuraState { AuraState { spell_id, @@ -235,7 +243,7 @@ fn reaching_the_wrap_shrinks_the_first_row_to_17px() { assert_eq!(size(&s, "TargetFrameBuff1"), (17.0, 17.0)); // Below the wrap they grow back; the feed re-fires UNIT_AURA on the change. - s.set_auras("target", Some(vec![debuff(1000, "D0", 1, None)])); + s.set_unit_auras(TARGET, Some(vec![debuff(1000, "D0", 1, None)])); s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]); assert_eq!(size(&s, "TargetFrameDebuff1"), (21.0, 21.0)); assert_eq!(size(&s, "TargetFrameDebuff1Border"), (23.0, 23.0)); @@ -249,7 +257,7 @@ fn clearing_the_list_or_the_target_hides_the_buttons() { target(&mut s, 2, vec![debuff(589, "Pain", 1, Some("Magic"))]); assert!(shown(&s, "TargetFrameDebuff1")); - s.set_auras("target", Some(vec![])); + s.set_unit_auras(TARGET, Some(vec![])); s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]); assert!( !shown(&s, "TargetFrameDebuff1"), @@ -260,7 +268,7 @@ fn clearing_the_list_or_the_target_hides_the_buttons() { target(&mut s, 2, vec![debuff(589, "Pain", 1, Some("Magic"))]); assert!(shown(&s, "TargetFrameDebuff1")); s.set_unit("target", None); - s.set_auras("target", None); + s.set_unit_auras(TARGET, None); s.fire_event("PLAYER_TARGET_CHANGED", vec![]); assert!(!shown(&s, "TargetFrameDebuff1"), "no target, no buttons"); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); diff --git a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs index d7f6938b..71f5b1ed 100644 --- a/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_anchor_tests.rs @@ -432,22 +432,19 @@ fn buff_hover_hangs_below_left_of_the_button() { "Interface\\FrameXML\\TextStatusBar.xml", "Interface\\FrameXML\\BuffFrame.xml", ]); - s.set_auras( - "player", - Some(vec![AuraState { - spell_id: 1459, - name: Some("Arcane Intellect".into()), - icon: Some("Interface\\Icons\\Spell_Holy_MagicalSentry".into()), - count: 1, - debuff_type: None, - duration: 1800.0, - expiration_time: 1800.0, - helpful: true, - cancelable: true, - until_cancelled: false, - channeled: false, - }]), - ); + s.set_player_auras(vec![AuraState { + spell_id: 1459, + name: Some("Arcane Intellect".into()), + icon: Some("Interface\\Icons\\Spell_Holy_MagicalSentry".into()), + count: 1, + debuff_type: None, + duration: 1800.0, + expiration_time: 1800.0, + helpful: true, + cancelable: true, + until_cancelled: false, + channeled: false, + }]); // The event the stock buff buttons register (`BuffFrame.lua:113`). s.fire_event("PLAYER_AURAS_CHANGED", vec![]); s.resolve(); diff --git a/crates/benilla-app/src/ui_script/tot_frame_tests.rs b/crates/benilla-app/src/ui_script/tot_frame_tests.rs index 0da18977..34dd77d8 100644 --- a/crates/benilla-app/src/ui_script/tot_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/tot_frame_tests.rs @@ -402,8 +402,9 @@ fn the_debuff_row_draws_what_the_unit_carries() { benilla_formats::wow_data_or_skip!(); let mut s = load_tot(); switch_on(&mut s); - s.set_auras( - "targettarget", + s.set_unit_guids(&unit_guids()); + s.set_unit_auras( + TOT, Some(vec![ debuff(1000, "Rend", None), debuff(1001, "Curse of Agony", Some("Curse")), @@ -437,7 +438,8 @@ fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() { benilla_formats::wow_data_or_skip!(); let mut s = load_tot(); // A hostile target, so the debuffs lead and the buffs hang off them. - s.set_auras("target", Some(vec![debuff(2000, "Sunder", None)])); + s.set_unit_guids(&unit_guids()); + s.set_unit_auras(TARGET, Some(vec![debuff(2000, "Sunder", None)])); s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]); let anchor = |s: &mut UiScript, frame: &str| -> String { @@ -498,3 +500,17 @@ fn the_reconcile_takes_the_frame_down_when_the_token_goes_silent() { assert!(shown(&mut s), "and brings it back"); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +const TARGET: u64 = 0xF130_0000_0000_0001; +const TOT: u64 = 0xF130_0000_0000_0002; + +/// The resolver's inputs: `"target"` is `TARGET`, held and targeting `TOT`, so `"targettarget"` +/// is `TOT`. +fn unit_guids() -> benilla_ui::script::UnitGuids { + benilla_ui::script::UnitGuids { + player: 1, + target: TARGET, + held: [(TARGET, TOT)].into_iter().collect(), + ..Default::default() + } +} diff --git a/crates/benilla-ui/src/script/aura/mod.rs b/crates/benilla-ui/src/script/aura/mod.rs index db930c69..659c55bf 100644 --- a/crates/benilla-ui/src/script/aura/mod.rs +++ b/crates/benilla-ui/src/script/aura/mod.rs @@ -1,7 +1,9 @@ -//! The aura bindings, over the per-token lists the app pushes through [`UiScript::set_auras`] in -//! display order: the player's in the reference's insertion-ordered cache (`0xbc6040`), kept by -//! `benilla::ui_aura`, any other unit's by ascending aura slot. The player's list keeps cache order -//! under every token, where the reference's `UnitBuff("player", i)` reads by slot. +//! The aura bindings, over the lists the app pushes in display order: the player's in the +//! reference's insertion-ordered cache (`0xbc6040`), kept by `benilla::ui_aura`, every other unit's +//! by guid, in ascending aura slot. A token resolves to its guid as the reference's resolver +//! `0x515970` does ([`super::UnitGuids`]), so `"mouseover"`, `"party1target"` and `"PLAYER"` read +//! the list of the unit they name, and a token naming the player reads the player's list in cache +//! order, where the reference's `UnitBuff("player", i)` reads by slot. //! //! `UnitAura`, `UnitBuff`, `UnitDebuff` and `CancelUnitBuff` take a token and a 1-based index into //! the sign-filtered list; the getters answer nil past the end. The 1.12 `GetPlayerBuff` @@ -17,7 +19,8 @@ use super::Model; mod player_buff; -/// One aura on one unit, as the app's [`crate::script::UiScript::set_auras`] feed pushes it. +/// One aura on one unit, as the app's feed pushes it ([`crate::script::UiScript::set_unit_auras`], +/// [`crate::script::UiScript::set_player_auras`]). #[derive(Clone, Debug, Default, PartialEq)] pub struct AuraState { /// `Spell.dbc` id, `UnitAura`'s `spellId`. @@ -144,6 +147,20 @@ fn returns(lua: &Lua, a: &AuraState) -> mlua::Result { ])) } +/// The list of the unit `token` names, through the resolver (`0x515970`, which `UnitBuff` calls at +/// `0x519542`): the player's cache list for the player, else that guid's; `None` for nobody or a +/// unit with no list. A token the resolver does not recognise raises `Unknown unit name`. +pub(crate) fn auras_of<'m>(model: &'m Model, token: &str) -> mlua::Result> { + let guids = &model.unit_guids; + Ok(guids.guid_of(token)?.and_then(|g| { + if g == guids.player { + Some(model.player_auras.as_slice()) + } else { + model.unit_auras.get(&g).map(Vec::as_slice) + } + })) +} + /// The `index`-th (1-based) aura of `token` passing `filter`, in pushed order; out of range is a /// bare nil, the loop terminator. fn nth_aura( @@ -153,20 +170,21 @@ fn nth_aura( filter: &Filter, shape: Shape, ) -> mlua::Result { - if index < 1 { - return Ok(MultiValue::new()); - } + // The token resolves before the index is read (`0x519542`, then `0x51957a`), so a bad token + // raises at any index. let hit = { let model = lua.app_data_ref::().expect("model app_data"); - token - .as_ref() - .and_then(|t| model.auras.get(t)) - .and_then(|list| { - list.iter() - .filter(|a| filter.matches(a)) - .nth((index - 1) as usize) - .cloned() - }) + match token { + Some(t) => auras_of(&model, t)? + .filter(|_| index >= 1) + .and_then(|list| { + list.iter() + .filter(|a| filter.matches(a)) + .nth((index - 1) as usize) + .cloned() + }), + None => None, + } }; match hit { Some(a) => match shape { @@ -179,20 +197,40 @@ fn nth_aura( } impl super::UiScript { - /// Push or clear a token's aura list, in display order: cache order for the player, ascending - /// aura slot for anyone else. - pub fn set_auras(&mut self, token: &str, auras: Option>) { + /// Push the player's aura list in the cache's order (`0xbc6040`), durations joined. + pub fn set_player_auras(&mut self, auras: Vec) { + self.model_mut().player_auras = auras; + } + + /// Push or clear a unit's aura list by guid, in ascending aura slot; any token naming the unit + /// reads it. The player's is [`Self::set_player_auras`]. + pub fn set_unit_auras(&mut self, guid: u64, auras: Option>) { let mut model = self.model_mut(); match auras { Some(a) => { - model.auras.insert(token.to_string(), a); + model.unit_auras.insert(guid, a); } None => { - model.auras.remove(token); + model.unit_auras.remove(&guid); } } } + /// Drop every aura list, the player's with the rest: the session's end. + pub fn clear_auras(&mut self) { + let mut model = self.model_mut(); + model.player_auras.clear(); + model.unit_auras.clear(); + } + + /// Push the unit-token resolver's inputs, copied only when they moved. + pub fn set_unit_guids(&mut self, guids: &super::UnitGuids) { + let mut model = self.model_mut(); + if model.unit_guids != *guids { + model.unit_guids.clone_from(guids); + } + } + /// Push or clear the player's active [`TrackingState`]. pub fn set_tracking(&mut self, tracking: Option) { self.model_mut().tracking = tracking; @@ -274,16 +312,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { let f = Filter::parse(Some(&spec)); let hit = { let model = lua.app_data_ref::().expect("model app_data"); - token - .as_ref() - .filter(|_| index >= 1) - .and_then(|t| model.auras.get(t)) - .and_then(|list| { - list.iter() - .filter(|a| f.matches(a)) - .nth((index - 1) as usize) - .cloned() - }) + match token.as_deref() { + Some(t) => auras_of(&model, t)? + .filter(|_| index >= 1) + .and_then(|list| { + list.iter() + .filter(|a| f.matches(a)) + .nth((index - 1) as usize) + .cloned() + }), + None => None, + } }; if let Some(a) = hit.filter(cancel_authorized) { let mut model = lua.app_data_mut::().expect("model app_data"); @@ -325,7 +364,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { #[cfg(test)] mod tests { - use crate::script::{AuraState, TrackingState, UiScript}; + use crate::script::{AuraState, TrackingState, UiScript, UnitGuids}; + + const ME: u64 = 0x10; + const TARGET: u64 = 0xF130_0000_0000_0001; + + /// A VM whose resolver names us as `player` and `TARGET` as `target`. + fn script() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_unit_guids(&UnitGuids { + player: ME, + target: TARGET, + ..Default::default() + }); + s + } fn aura(spell_id: u32, name: &str, helpful: bool, cancelable: bool) -> AuraState { AuraState { @@ -345,15 +398,12 @@ mod tests { #[test] fn unit_aura_enumerates_the_pushed_order_not_the_spell_id_order() { - let mut s = UiScript::new().unwrap(); - s.set_auras( - "player", - Some(vec![ - aura(2457, "Battle Stance", true, true), - aura(1126, "Mark of the Wild", true, true), - aura(589, "Shadow Word: Pain", false, false), - ]), - ); + let mut s = script(); + s.set_player_auras(vec![ + aura(2457, "Battle Stance", true, true), + aura(1126, "Mark of the Wild", true, true), + aura(589, "Shadow Word: Pain", false, false), + ]); assert_eq!( s.eval::(r#"return (UnitAura("player", 1))"#) .unwrap(), @@ -387,15 +437,12 @@ mod tests { #[test] fn unit_aura_defaults_to_helpful_and_honours_the_cancelable_tokens() { - let mut s = UiScript::new().unwrap(); - s.set_auras( - "player", - Some(vec![ - aura(2457, "Battle Stance", true, true), - aura(9999, "Sealed", true, false), // helpful, not cancelable - aura(589, "Pain", false, false), - ]), - ); + let mut s = script(); + s.set_player_auras(vec![ + aura(2457, "Battle Stance", true, true), + aura(9999, "Sealed", true, false), // helpful, not cancelable + aura(589, "Pain", false, false), + ]); // No filter means HELPFUL. assert_eq!( s.eval::( @@ -429,13 +476,13 @@ mod tests { #[test] fn unit_aura_returns_the_era_tuple_with_the_unknowable_fields_nil() { - let mut s = UiScript::new().unwrap(); + let mut s = script(); let mut a = aura(589, "Shadow Word: Pain", false, false); a.count = 3; a.debuff_type = Some("Magic".into()); a.duration = 18.0; a.expiration_time = 1042.5; - s.set_auras("target", Some(vec![a])); + s.set_unit_auras(TARGET, Some(vec![a])); let (name, icon, count, dtype, dur, expiry, spell) = s .eval::<(String, String, i64, String, f64, f64, i64)>( @@ -453,13 +500,13 @@ mod tests { #[test] fn unit_buff_and_unit_debuff_return_the_1121_tuple_not_the_era_one() { - let mut s = UiScript::new().unwrap(); + let mut s = script(); let mut buff = aura(1126, "Mark of the Wild", true, true); buff.count = 1; let mut debuff = aura(589, "Shadow Word: Pain", false, false); debuff.count = 3; debuff.debuff_type = Some("Magic".into()); - s.set_auras("target", Some(vec![buff, debuff])); + s.set_unit_auras(TARGET, Some(vec![buff, debuff])); let (icon, count, third) = s .eval::<(String, i64, Option)>( @@ -500,15 +547,12 @@ mod tests { #[test] fn cancel_unit_buff_queues_the_spell_id_and_refuses_a_non_cancelable_aura() { - let mut s = UiScript::new().unwrap(); - s.set_auras( - "player", - Some(vec![ - aura(2457, "Battle Stance", true, true), - aura(9999, "Sealed", true, false), - aura(589, "Pain", false, false), - ]), - ); + let mut s = script(); + s.set_player_auras(vec![ + aura(2457, "Battle Stance", true, true), + aura(9999, "Sealed", true, false), + aura(589, "Pain", false, false), + ]); assert!(s.take_cancel_aura_requests().is_empty()); // Only the cancelable buff queues, by spell id; the others are silent no-ops. @@ -521,7 +565,7 @@ mod tests { #[test] fn tracking_bindings_read_the_pushed_state_and_cancel_by_spell_id() { - let mut s = UiScript::new().unwrap(); + let mut s = script(); assert!(s .eval::("return GetTrackingTexture() == nil") .unwrap()); @@ -556,15 +600,189 @@ mod tests { } #[test] - fn set_auras_none_clears_the_token() { - let mut s = UiScript::new().unwrap(); - s.set_auras("player", Some(vec![aura(2457, "Stance", true, true)])); + fn an_emptied_player_list_answers_nil() { + let mut s = script(); + s.set_player_auras(vec![aura(2457, "Stance", true, true)]); assert!(s .eval::(r#"return UnitAura("player", 1) ~= nil"#) .unwrap()); - s.set_auras("player", None); + s.set_player_auras(Vec::new()); assert!(s .eval::(r#"return UnitAura("player", 1) == nil"#) .unwrap()); } + + const MOB: u64 = 0xF130_0000_0000_0002; + const P1: u64 = 0x21; + const P1_TARGET: u64 = 0xF130_0000_0000_0003; + + /// We target `TARGET`, which targets party1, who targets `P1_TARGET`, which targets us; the mouse + /// is over `MOB`. Each unit's list holds a buff and a debuff named for it. + fn group_script() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_unit_guids(&UnitGuids { + player: ME, + target: TARGET, + mouseover: MOB, + party: [P1, 0, 0, 0], + held: [ + (ME, TARGET), + (TARGET, P1), + (P1, P1_TARGET), + (MOB, 0), + (P1_TARGET, ME), + ] + .into_iter() + .collect(), + ..Default::default() + }); + s.set_player_auras(vec![ + aura(2457, "Battle Stance", true, true), + aura(1126, "Mark of the Wild", true, true), + aura(11976, "Strike", false, false), + ]); + for (guid, name) in [ + (TARGET, "on the target"), + (MOB, "on the mouseover"), + (P1, "on party1"), + (P1_TARGET, "on party1's target"), + ] { + s.set_unit_auras( + guid, + Some(vec![aura(1, name, true, true), aura(2, name, false, false)]), + ); + } + s + } + + /// `UnitAura`'s name, the list's identity: the bindings' own tuples lead with the icon. + fn named(s: &UiScript, call: &str) -> Option { + s.eval::>(&format!("local a, b, c = {call} return b and a")) + .unwrap() + } + + /// Every token reads the list of the unit the resolver names (`0x515970` at `0x519542`): the + /// mouseover, a group member's target and any depth of `target` chain. + #[test] + fn a_token_reads_the_list_of_the_unit_it_names() { + let s = group_script(); + let icon = |name: &str| { + s.eval::>(&format!("return ({name})")) + .unwrap() + }; + assert_eq!( + icon(r#"UnitDebuff("mouseover", 1)"#).as_deref(), + Some("Interface\\Icons\\Spell_2") + ); + for (token, name) in [ + ("mouseover", "on the mouseover"), + ("party1target", "on party1's target"), + ("targettarget", "on party1"), + ("TargetTarget", "on party1"), + ("targettargettarget", "on party1's target"), + ("party1", "on party1"), + ("PARTY1", "on party1"), + ] { + assert_eq!( + named(&s, &format!(r#"UnitAura("{token}", 1, "HARMFUL")"#)).as_deref(), + Some(name), + "{token}" + ); + assert_eq!( + named(&s, &format!(r#"UnitAura("{token}", 1)"#)).as_deref(), + Some(name), + "{token}" + ); + } + // `UnitBuff("party1target", 1)` and `UnitDebuff("mouseover", 1)`, by their icons. + assert!(s + .eval::( + r#"return UnitBuff("party1target", 1) == "Interface\\Icons\\Spell_1" + and UnitDebuff("mouseover", 1) == "Interface\\Icons\\Spell_2" + and UnitBuff("party1target", 2) == nil"# + ) + .unwrap()); + } + + /// A token naming the player, in any case or down a chain, reads the player's list in cache + /// order, as `"player"` does. + #[test] + fn a_token_naming_the_player_reads_the_player_list() { + let s = group_script(); + for token in [ + "player", + "PLAYER", + "Player", + "targettargettargettarget", + "party1TARGETtarget", + ] { + assert_eq!( + named(&s, &format!(r#"UnitAura("{token}", 2)"#)).as_deref(), + Some("Mark of the Wild"), + "{token}" + ); + } + assert!(s + .eval::( + r#"local a, n = UnitBuff("PLAYER", 1) + local b, m = UnitBuff("player", 1) + local c = UnitDebuff("targettargettargettarget", 1) + return a == b and n == m and a == "Interface\\Icons\\Spell_2457" + and c == "Interface\\Icons\\Spell_11976""# + ) + .unwrap()); + } + + /// A recognised token naming nobody is nil; a token none of the resolver's nine compares match + /// raises `Unknown unit name: %s` (`0x515c14`), at any index, as the resolver runs first. + #[test] + fn an_unknown_token_raises_and_a_recognised_empty_one_is_nil() { + let s = group_script(); + for call in [ + r#"UnitBuff("party2", 1)"#, + r#"UnitDebuff("raid1target", 1)"#, + r#"UnitBuff("mouseovertargettarget", 1)"#, + r#"UnitBuff("playerfoo", 1)"#, + r#"UnitBuff("pet", 1)"#, + r#"UnitBuff("", 1)"#, + ] { + assert!( + s.eval::(&format!("return {call} == nil")).unwrap(), + "{call}" + ); + } + for call in [ + r#"UnitBuff("bogus", 1)"#, + r#"UnitDebuff("focus", 1)"#, + r#"UnitAura("npctarget", 1)"#, + r#"UnitBuff("bogus", 0)"#, + r#"CancelUnitBuff("bogus", 1)"#, + ] { + let err = s.eval::<()>(call).unwrap_err().to_string(); + assert!(err.contains("Unknown unit name: "), "{call}: {err}"); + } + } + + /// `GameTooltip:SetUnitBuff` resolves as `UnitBuff` does (`0x534b8b`). + #[test] + fn set_unit_buff_resolves_the_token() { + let s = group_script(); + s.run( + r#"local a = CreateFrame("Button", "TF1") + a:SetPoint("CENTER", 0, 0) a:SetWidth(10) a:SetHeight(10) + TT = CreateFrame("GameTooltip", "TT") + TT:SetOwner(a, "ANCHOR_BOTTOMRIGHT") + TT:SetUnitDebuff("MouseOver", 1)"#, + ) + .unwrap(); + assert_eq!( + s.eval::("return TTTextLeft1:GetText()").unwrap(), + "on the mouseover" + ); + let err = s + .run(r#"TT:SetUnitBuff("bogus", 1)"#) + .unwrap_err() + .to_string(); + assert!(err.contains("Unknown unit name: bogus"), "{err}"); + } } diff --git a/crates/benilla-ui/src/script/aura/player_buff.rs b/crates/benilla-ui/src/script/aura/player_buff.rs index d41eaa73..c8c73ee0 100644 --- a/crates/benilla-ui/src/script/aura/player_buff.rs +++ b/crates/benilla-ui/src/script/aura/player_buff.rs @@ -73,7 +73,7 @@ fn buff_index_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result fn player_buff_record(lua: &Lua, pos: i64) -> Option { let pos = usize::try_from(pos).ok()?; let model = lua.app_data_ref::().expect("model app_data"); - model.auras.get("player")?.get(pos).cloned() + model.player_auras.get(pos).cloned() } /// The enumerator (`0x4e43b0`): the `index`-th record passing `filter`, by ascending position, @@ -88,8 +88,7 @@ fn enumerate_player_buff( } let model = lua.app_data_ref::().expect("model app_data"); model - .auras - .get("player")? + .player_auras .iter() .enumerate() .filter(|(_, a)| filter.matches(a)) @@ -226,7 +225,12 @@ mod tests { fn with_player_cache() -> UiScript { let mut s = UiScript::new().unwrap(); - s.set_auras("player", Some(player_cache())); + s.set_player_auras(player_cache()); + // `CancelUnitBuff("player", …)` resolves the token to the player's guid. + s.set_unit_guids(&crate::script::UnitGuids { + player: 1, + ..Default::default() + }); s } @@ -271,7 +275,7 @@ mod tests { assert!(s .eval::("local _, uc = GetPlayerBuff(0) return uc == 0") .unwrap()); - s.set_auras("player", None); + s.set_player_auras(Vec::new()); assert_eq!( s.eval::<(i64, i64)>("return GetPlayerBuff(0)").unwrap(), (-1, 0) @@ -310,7 +314,7 @@ mod tests { "Interface\\Icons\\Spell_1126;Interface\\Icons\\Spell_2457;Interface\\Icons\\Spell_589;" ); - s.set_auras("player", Some(vec![])); + s.set_player_auras(vec![]); assert_eq!( s.eval::( r#"local c = 0 @@ -545,7 +549,7 @@ mod tests { // The channeled arm (`AttributesEx & 0x4`, `0x4e4a10`) cancels a negative aura. let mut channeled = aura(689, "Drain Life", false, false); channeled.channeled = true; - s.set_auras("player", Some(vec![channeled])); + s.set_player_auras(vec![channeled]); s.eval::<()>("CancelPlayerBuff(0)").unwrap(); assert_eq!(s.take_cancel_aura_requests(), vec![689]); } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 1815e210..7c99c06f 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -256,7 +256,7 @@ 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, - UnitState, + UnitGuids, UnitState, }; pub use weapon_enchant::WeaponEnchant; pub use who_sort::{WhoSortChain, WhoSortKey}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 0b709fca..adbd9c75 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -230,8 +230,14 @@ 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, - /// Per unit token, auras in display order: the player's by insertion, the rest by aura slot. - pub(crate) auras: 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, + /// Every other unit's auras by guid, ascending aura slot: one list per unit, whatever token + /// names it. + pub(crate) unit_auras: HashMap>, + /// The unit-token resolver's inputs (`0x515970`), which the aura bindings resolve through. + pub(crate) unit_guids: super::UnitGuids, /// Spell ids the cancel verbs queued (`CancelPlayerBuff`, `CancelTrackingBuff`, …), one /// `CMSG_CANCEL_AURA` each. pub(crate) cancel_aura_requests: Vec, @@ -1040,7 +1046,9 @@ 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(), - auras: HashMap::new(), + player_auras: Vec::new(), + unit_auras: HashMap::new(), + unit_guids: Default::default(), cancel_aura_requests: Vec::new(), tracking: None, script_calls: Vec::new(), diff --git a/crates/benilla-ui/src/script/tests/tooltip_spell.rs b/crates/benilla-ui/src/script/tests/tooltip_spell.rs index 123a69f2..ee671691 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_spell.rs @@ -232,17 +232,14 @@ fn player_buff_hover_is_the_aura_variant() { }, ); s.tick(10.0); // GetTime = 10 - s.set_auras( - "player", - Some(vec![AuraState { - spell_id: 1459, - name: Some("Arcane Intellect".into()), - duration: 1800.0, - expiration_time: 100.0, // 90 s left at now = 10 - helpful: true, - ..Default::default() - }]), - ); + s.set_player_auras(vec![AuraState { + spell_id: 1459, + name: Some("Arcane Intellect".into()), + duration: 1800.0, + expiration_time: 100.0, // 90 s left at now = 10 + helpful: true, + ..Default::default() + }]); s.run( r#" -- The duration line's wording comes from the VM's GlobalStrings, which the app runs off @@ -302,15 +299,12 @@ fn player_buff_hover_names_the_dispel_class_in_gold() { ..Default::default() }, ); - s.set_auras( - "player", - Some(vec![AuraState { - spell_id: 168, - name: Some("Ice Armor".into()), - helpful: true, - ..Default::default() - }]), - ); + s.set_player_auras(vec![AuraState { + spell_id: 168, + name: Some("Ice Armor".into()), + helpful: true, + ..Default::default() + }]); s.run( r#" local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) @@ -420,8 +414,13 @@ fn unit_buff_and_debuff_hover_render_the_aura_variant_without_remaining() { ..Default::default() }, ); - s.set_auras( - "target", + // The target, guid 7, through the resolver. + s.set_unit_guids(&crate::script::UnitGuids { + target: 7, + ..Default::default() + }); + s.set_unit_auras( + 7, Some(vec![ AuraState { spell_id: 1126, @@ -480,29 +479,26 @@ fn player_buff_hover_indexes_the_cache_position_not_a_filtered_ordinal() { ); } // The player's cache: two buffs then a debuff, in one insertion-ordered list. - s.set_auras( - "player", - Some(vec![ - AuraState { - spell_id: 1126, - name: Some("Mark of the Wild".into()), - helpful: true, - ..Default::default() - }, - AuraState { - spell_id: 2457, - name: Some("Battle Stance".into()), - helpful: true, - ..Default::default() - }, - AuraState { - spell_id: 589, - name: Some("Shadow Word: Pain".into()), - helpful: false, - ..Default::default() - }, - ]), - ); + s.set_player_auras(vec![ + AuraState { + spell_id: 1126, + name: Some("Mark of the Wild".into()), + helpful: true, + ..Default::default() + }, + AuraState { + spell_id: 2457, + name: Some("Battle Stance".into()), + helpful: true, + ..Default::default() + }, + AuraState { + spell_id: 589, + name: Some("Shadow Word: Pain".into()), + helpful: false, + ..Default::default() + }, + ]); s.run( r#" local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10) @@ -888,7 +884,7 @@ fn the_remaining_line_is_gated_on_until_cancelled_not_on_the_duration() { BENILLA_LAST = BENILLA_LINES == 3 and TT2TextLeft3:GetText() or "" "#; let mut show = |aura: AuraState| { - s.set_auras("player", Some(vec![aura])); + s.set_player_auras(vec![aura]); s.run(hover).unwrap(); ( s.eval::("return BENILLA_LINES").unwrap(), diff --git a/crates/benilla-ui/src/script/tooltip_spell.rs b/crates/benilla-ui/src/script/tooltip_spell.rs index e4757cd6..93d6e71a 100644 --- a/crates/benilla-ui/src/script/tooltip_spell.rs +++ b/crates/benilla-ui/src/script/tooltip_spell.rs @@ -383,7 +383,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { let model = lua.app_data_mut::().expect("model app_data"); let hit = usize::try_from(index) .ok() - .and_then(|pos| model.auras.get("player").and_then(|a| a.get(pos))); + .and_then(|pos| model.player_auras.get(pos)); match hit { Some(a) => { // The gate is `untilCancelled`, not a known duration: `0x532b00` skips @@ -442,12 +442,11 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> { .unwrap_or(0), _ => 0, }; + // The token resolves as `UnitBuff`'s does (`0x515970`, called at `0x534b8b`). let hit = { - let model = lua.app_data_mut::().expect("model app_data"); + let model = lua.app_data_ref::().expect("model app_data"); let idx = usize::try_from(index.max(1) - 1).unwrap_or(0); - model - .auras - .get(&token) + crate::script::aura::auras_of(&model, &token)? .and_then(|a| a.iter().filter(|a| a.helpful == helpful).nth(idx)) .map(|a| (a.spell_id, a.name.clone())) }; diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index b0ade537..6d314662 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -444,33 +444,7 @@ fn pick_unit_token(a: &Option, b: &Option) -> Option { } } -/// The token prefixes the resolver (`0x515970`) tests, in its order (`partypet` before `party`). -/// Each is a prefix test, so `"playerfoo"` is recognised; `npc`, its one full-string compare, is -/// tested apart. -const UNIT_TOKEN_PREFIXES: [&str; 8] = [ - "player", - "pet", - "target", - "partypet", - "party", - "raidpet", - "raid", - "mouseover", -]; - -/// Whether the resolver recognises the token, not whether it names a unit: `"party5"` solo is -/// recognised and answers nil, and only a token none of its nine compares match raises. -pub(crate) fn token_recognised(token: &str) -> bool { - // `npc` full-string, the rest prefixes, all folded as `_strnicmp` folds: ASCII only. - token.eq_ignore_ascii_case("npc") - || UNIT_TOKEN_PREFIXES - .iter() - // Bytes, not a `str` slice, which panics mid-character on a multibyte token. - .any(|p| { - token.len() >= p.len() - && token.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes()) - }) -} +use resolve::token_recognised; /// The resolver's token check: an unrecognised token raises `Unknown unit name`, as `0x515970` /// ends in `luaL_error`; `""`, an absent token and a recognised one naming nothing pass. Whether @@ -515,7 +489,10 @@ fn unit_predicate( /// The `Unit*` and `GetQuestGreenRange` registrations. mod bindings; +mod resolve; #[cfg(test)] mod tests; +pub use resolve::UnitGuids; + pub(super) use bindings::install; diff --git a/crates/benilla-ui/src/script/unit/resolve.rs b/crates/benilla-ui/src/script/unit/resolve.rs new file mode 100644 index 00000000..e7258ade --- /dev/null +++ b/crates/benilla-ui/src/script/unit/resolve.rs @@ -0,0 +1,298 @@ +//! The unit-token resolver `0x515970`, token to guid, over the state it reads, which the app pushes +//! as [`UnitGuids`]: a base (`player`, `pet`, `target`, `partyN`, `partypetN`, `raidN`, +//! `raidpetN`, `mouseover`, `npc`), then any number of `target` hops, each off the current unit's +//! `UNIT_FIELD_TARGET`. Every compare folds ASCII case (`SStrCmpI 0x64a4c0`, first at `0x5159b4`). + +use std::collections::HashMap; + +/// What the resolver reads: the guid globals, the group tables and, for each unit a token can +/// reach, whether it is held and whom it targets. A zero guid is nobody. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UnitGuids { + /// The local player's guid; 0 without the player object, when `player` and `pet` answer nil + /// (`0x5159c9`, `0x515a58`). + pub player: u64, + /// The pet: `UNIT_FIELD_CHARM`, else `UNIT_FIELD_SUMMON` (`0x515a64`). + pub pet: u64, + /// The target global `[0xb4e2d8]`. + pub target: u64, + /// The mouseover global `[0xb4e2c8]`, read only while it is a held unit (`0x515bd2`). + pub mouseover: u64, + /// The interaction NPC `[0xb4e2d0]`. + pub npc: u64, + /// `partyN`'s guid, index N-1 (`0x4e81a0`). + pub party: [u64; 4], + /// `partypetN`'s guid, index N-1 (`0x4e81d0`). + pub party_pets: [u64; 4], + /// `raidN`'s guid, one per roster row; a row past the end is nobody (`0x491940`). + pub raid: Vec, + /// `raidpetN`'s guid, row for row with [`Self::raid`] (`0x491960`). + pub raid_pets: Vec, + /// Each held unit a token can reach, to its `UNIT_FIELD_TARGET` (0 for none): the object + /// manager's lookup with the unit typemask (`0x468460`, `ecx = 8`) and the descriptor read a + /// `target` hop makes (`0x515a0f`-`0x515a25`). A guid missing here is not held. + pub held: HashMap, +} + +/// A token's base, the resolver's compares in its order (`partypet` before `party`, `raidpet` +/// before `raid`); `npc` is tested apart, as its one full-string compare. +#[derive(Clone, Copy)] +enum Base { + Player, + Pet, + Target, + PartyPet, + Party, + RaidPet, + Raid, + Mouseover, +} + +/// Each prefix and its base, in the resolver's order; a prefix test compares `strlen(literal)` +/// bytes, so `"playerfoo"` matches `player`. +const PREFIXES: [(&str, Base); 8] = [ + ("player", Base::Player), + ("pet", Base::Pet), + ("target", Base::Target), + ("partypet", Base::PartyPet), + ("party", Base::Party), + ("raidpet", Base::RaidPet), + ("raid", Base::Raid), + ("mouseover", Base::Mouseover), +]; + +/// `_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()) +} + +/// The first prefix the token matches, with its length. +fn base_of(token: &[u8]) -> Option<(Base, usize)> { + PREFIXES + .iter() + .find(|(p, _)| has_prefix(token, p)) + .map(|&(p, b)| (b, p.len())) +} + +/// Whether the resolver recognises the token, not whether it names a unit: `"party5"` solo is +/// recognised and answers nil, and only a token none of its nine compares match raises. +pub(crate) fn token_recognised(token: &str) -> bool { + token.eq_ignore_ascii_case("npc") || base_of(token.as_bytes()).is_some() +} + +/// The inline base-10 parse after `party`, `partypet`, `raid` and `raidpet`, then `dec` to a +/// 0-based index (`0x515ab1`-`0x515acc`): digits accumulate with 32-bit wrap, and a missing +/// number leaves 0, whose index `0xffffffff` every bound rejects. +fn index_of(s: &[u8]) -> (u32, usize) { + let digits = s.iter().take_while(|b| b.is_ascii_digit()).count(); + let n = s[..digits].iter().fold(0u32, |acc, &b| { + acc.wrapping_mul(10).wrapping_add(u32::from(b - b'0')) + }); + (n.wrapping_sub(1), digits) +} + +impl UnitGuids { + /// The guid `token` names, or `None` for nobody; `Err` for a token none of the nine compares + /// match, where the reference raises `Unknown unit name: %s` (`0x515c14`). An empty token is + /// nobody (`0x51599d`). + pub(crate) fn resolve(&self, token: &str) -> Result, ()> { + let bytes = token.as_bytes(); + if bytes.is_empty() { + return Ok(None); + } + let (guid, rest) = if let Some((base, len)) = base_of(bytes) { + let rest = &bytes[len..]; + // The indexed bases: the table's row for the parsed number, and what follows it. + let at = |table: &[u64]| { + let (i, digits) = index_of(rest); + let row = usize::try_from(i).ok().and_then(|i| table.get(i)); + (row.copied().unwrap_or(0), &rest[digits..]) + }; + match base { + Base::Player => (self.player, rest), + Base::Pet if self.player == 0 => return Ok(None), + Base::Pet => (self.pet, rest), + Base::Target => (self.target, rest), + // Deviation: `0x4e81d0` has no bound and reads past its four-entry table, which + // is stale memory, not a mechanism; past it, nobody. + Base::PartyPet => at(&self.party_pets), + // Capped at 4 (`0x4e81b0`). + Base::Party => at(&self.party), + // Capped at the roster count (`0x491940`, `0x491960`). + Base::RaidPet => at(&self.raid_pets), + Base::Raid => at(&self.raid), + Base::Mouseover if !self.held.contains_key(&self.mouseover) => return Ok(None), + Base::Mouseover => (self.mouseover, rest), + } + } else if token.eq_ignore_ascii_case("npc") { + // The full-string compare leaves nothing to chain (`0x515bec`): `"npctarget"` raises. + (self.npc, &bytes[bytes.len()..]) + } else { + return Err(()); + }; + Ok(self.follow(guid, rest)) + } + + /// [`Self::resolve`] with the raise as the Lua error it is. + pub(crate) fn guid_of(&self, token: &str) -> mlua::Result> { + self.resolve(token) + .map_err(|()| mlua::Error::runtime(format!("Unknown unit name: {token}"))) + } + + /// The `target` loop (`0x5159d3`-`0x515a2c`): nobody stays nobody, the end of the token answers + /// the guid, and each `target` hops to the held unit's `UNIT_FIELD_TARGET`. Anything else + /// after a base, or a hop off a unit not held, is a quiet nobody (`0x5159f8`, `0x515a16`). + fn follow(&self, mut guid: u64, mut rest: &[u8]) -> Option { + loop { + 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)?; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ME: u64 = 0x10; + const PET: u64 = 0xF140_0000_0000_0077; + const MOB: u64 = 0xF130_0000_0000_0001; + const P1: u64 = 0x21; + const P1_PET: u64 = 0xF140_0000_0000_0021; + const R3: u64 = 0x33; + const FAR: u64 = 0x44; + + /// Us targeting a mob that targets party1, who targets us; party1's pet and raid row 3 held, + /// `FAR` a roster member out of range. + fn guids() -> UnitGuids { + UnitGuids { + player: ME, + pet: PET, + target: MOB, + mouseover: MOB, + npc: MOB, + party: [P1, FAR, 0, 0], + party_pets: [P1_PET, 0, 0, 0], + raid: vec![ME, P1, R3], + raid_pets: vec![PET, P1_PET, 0], + held: HashMap::from([ + (ME, MOB), + (PET, 0), + (MOB, P1), + (P1, ME), + (P1_PET, MOB), + (R3, 0), + ]), + } + } + + fn at(token: &str) -> Option { + guids().resolve(token).expect("recognised") + } + + #[test] + fn every_base_resolves_through_its_table() { + assert_eq!(at("player"), Some(ME)); + assert_eq!(at("pet"), Some(PET)); + assert_eq!(at("target"), Some(MOB)); + assert_eq!(at("mouseover"), Some(MOB)); + assert_eq!(at("npc"), Some(MOB)); + assert_eq!(at("party1"), Some(P1)); + // Not held, but a roster member: the base needs no object. + assert_eq!(at("party2"), Some(FAR)); + assert_eq!(at("partypet1"), Some(P1_PET)); + assert_eq!(at("raid3"), Some(R3)); + assert_eq!(at("raidpet2"), Some(P1_PET)); + } + + #[test] + fn the_compares_fold_ascii_case() { + assert_eq!(at("PLAYER"), Some(ME)); + assert_eq!(at("Player"), Some(ME)); + assert_eq!(at("PartyPet1"), Some(P1_PET)); + assert_eq!(at("MouseOver"), Some(MOB)); + assert_eq!(at("NPC"), Some(MOB)); + assert_eq!(at("TargetTarget"), Some(P1)); + } + + #[test] + fn target_is_a_loop_on_every_base_but_npc() { + assert_eq!(at("targettarget"), Some(P1)); + assert_eq!(at("targettargettarget"), Some(ME)); + assert_eq!(at("targettargettargettarget"), Some(MOB)); + assert_eq!(at("playertarget"), Some(MOB)); + assert_eq!(at("party1target"), Some(ME)); + assert_eq!(at("partypet1target"), Some(MOB)); + assert_eq!(at("raid2target"), Some(ME)); + assert_eq!(at("raid2targettarget"), Some(MOB)); + assert_eq!(at("mouseovertarget"), Some(P1)); + // A hop to nobody ends the chain (`0x515a2e`), as does a hop off a unit not held. + assert_eq!(at("pettarget"), None); + assert_eq!(at("party2target"), None); + assert!(guids().resolve("npctarget").is_err()); + } + + #[test] + fn a_recognised_token_naming_nobody_is_quiet() { + for t in [ + "", + "party", + "party0", + "party5", + "partyX", + "raid0", + "raid4", + "raidpet3", + "playerfoo", + "targetfoo", + "partypet5", + ] { + assert_eq!(guids().resolve(t), Ok(None), "{t}"); + } + let solo = UnitGuids { + player: 0, + pet: PET, + ..guids() + }; + assert_eq!(solo.resolve("pet"), Ok(None), "no player object, no pet"); + let gone = UnitGuids { + held: HashMap::new(), + ..guids() + }; + assert_eq!(gone.resolve("mouseover"), Ok(None), "a mouseover not held"); + assert_eq!( + gone.resolve("target"), + Ok(Some(MOB)), + "the target global is not gated" + ); + } + + #[test] + fn an_unrecognised_token_is_the_raise() { + for t in ["bogus", "focus", "none", "5", "npc1", "tar"] { + assert_eq!(guids().resolve(t), Err(()), "{t}"); + } + } + + #[test] + fn the_index_parse_wraps_and_stops_at_the_first_non_digit() { + assert_eq!(at("party01"), Some(P1)); + assert_eq!(at("raid03"), Some(R3)); + assert_eq!(at("raid03target"), None, "raid3 targets nobody"); + assert_eq!( + index_of(b"4294967297"), + (0, 10), + "2^32 + 1 wraps to 1, index 0" + ); + assert_eq!(index_of(b"x"), (u32::MAX, 0)); + } +}