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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-28 12:46:33 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8d31e9e91e
commit 65a5e44d48
17 changed files with 1141 additions and 471 deletions
+14 -12
View File
@@ -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 {
+82 -146
View File
@@ -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<AuraProjection>,
/// The tracking spell: a tracking switch changes no list, but the minimap needs the event.
tracking_last: Option<u32>,
/// 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<AuraProjection>)>,
pet_last: Option<(u64, Vec<AuraProjection>)>,
tot_last: Option<(u64, Vec<AuraProjection>)>,
/// 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<AuraProjection> {
.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<AuraProjection>)>,
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<AuraState> {
let inputs: Vec<AuraInput> = 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<Res<'w, crate::ui_party::GroupState>>,
hovered: Option<Res<'w, crate::target::Hovered>>,
interact: Option<Res<'w, crate::ui_session::InteractNpc>>,
}
fn feed_auras(
script: Option<NonSendMut<UiScript>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
selection: Res<Selection>,
units: AuraUnits,
stores: Query<&ObjectStore>,
spells: Option<Res<Spells>>,
pet: Res<crate::ui_pet::PetBar>,
// `Option`: a UI-only harness has no party plugin; without it no party token has a list.
group: Option<Res<crate::ui_party::GroupState>>,
index: Res<GuidIndex>,
// The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use.
factions: Option<Res<Factions>>,
@@ -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<Vec<AuraState>> =
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<Vec<AuraState>> = (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<Vec<AuraState>> =
(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<PlayerAuraCache>,
) {
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<NonSendMut<UiScript>>, net: Res<NetCommands
mod tests {
use super::*;
/// [`other_unit_inputs`] as the list.
fn other_unit_auras(
store: &ObjectStore,
catalog: Option<&SpellCatalog>,
buffs_visible: bool,
) -> Vec<AuraState> {
let inputs: Vec<AuraInput> = 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();
@@ -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<Item = &'static str> {
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<Item = &'static str> {
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<Option<u64>> {
let live = |g: u64| held(g).map(|s| &s.0);
let members: Vec<u64> = 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<AuraInput>,
list: Vec<AuraState>,
/// 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<Option<(u64, u64)>>,
pub(super) struct UnitAuras {
/// The resolver's inputs as last built.
guids: UnitGuids,
by_guid: HashMap<u64, GuidList>,
/// Each list version the VM holds, by guid.
pushed: HashMap<u64, u64>,
/// Each event token's last `(guid, version)`, by [`event_tokens`] index.
fired: Vec<Option<(u64, u64)>>,
/// 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<AuraInput>,
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<super::super::AuraFeedMemory>| {
let script = world.non_send_resource::<UiScript>();
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::<crate::target::Hovered>();
let me = app.world_mut().resource::<GuidIndex>().0[&ME];
*app.world_mut().get_mut::<ObjectStore>(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::<crate::ui_pet::PetBar>()
.spells
.pet_guid = PET;
let (mob, add) = {
let index = &app.world().resource::<GuidIndex>().0;
(index[&MOB], index[&ADD])
};
let mut selection = app.world_mut().resource_mut::<crate::target::Selection>();
(selection.target, selection.guid) = (Some(mob), Some(MOB));
let mut hovered = app.world_mut().resource_mut::<crate::target::Hovered>();
(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::<GuidIndex>().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::<ObjectStore>(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());
}
}
+12 -15
View File
@@ -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<AuraState>) {
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")
@@ -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::<bool>("return PetFrameDebuff1:IsVisible()")
@@ -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![]);
}
@@ -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<AuraState>) {
..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());
@@ -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();
@@ -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()
}
}
+287 -69
View File
@@ -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<MultiValue> {
]))
}
/// 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<Option<&'m [AuraState]>> {
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<MultiValue> {
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::<Model>().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<Vec<AuraState>>) {
/// Push the player's aura list in the cache's order (`0xbc6040`), durations joined.
pub fn set_player_auras(&mut self, auras: Vec<AuraState>) {
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<Vec<AuraState>>) {
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<TrackingState>) {
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::<Model>().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::<Model>().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::<String>(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::<i64>(
@@ -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<String>)>(
@@ -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::<bool>("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::<bool>(r#"return UnitAura("player", 1) ~= nil"#)
.unwrap());
s.set_auras("player", None);
s.set_player_auras(Vec::new());
assert!(s
.eval::<bool>(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<String> {
s.eval::<Option<String>>(&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::<Option<String>>(&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::<bool>(
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::<bool>(
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::<bool>(&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::<String>("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}");
}
}
@@ -73,7 +73,7 @@ fn buff_index_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<i64>
fn player_buff_record(lua: &Lua, pos: i64) -> Option<AuraState> {
let pos = usize::try_from(pos).ok()?;
let model = lua.app_data_ref::<Model>().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::<Model>().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::<bool>("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::<i64>(
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]);
}
+1 -1
View File
@@ -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};
+11 -3
View File
@@ -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<String, UnitState>,
/// Per unit token, auras in display order: the player's by insertion, the rest by aura slot.
pub(crate) auras: HashMap<String, Vec<AuraState>>,
/// 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<AuraState>,
/// Every other unit's auras by guid, ascending aura slot: one list per unit, whatever token
/// names it.
pub(crate) unit_auras: HashMap<u64, Vec<AuraState>>,
/// 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<u32>,
@@ -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(),
@@ -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::<i64>("return BENILLA_LINES").unwrap(),
@@ -383,7 +383,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
let model = lua.app_data_mut::<Model>().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::<Model>().expect("model app_data");
let model = lua.app_data_ref::<Model>().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()))
};
+4 -27
View File
@@ -444,33 +444,7 @@ fn pick_unit_token(a: &Option<String>, b: &Option<String>) -> Option<String> {
}
}
/// 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;
@@ -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<u64>,
/// `raidpetN`'s guid, row for row with [`Self::raid`] (`0x491960`).
pub raid_pets: Vec<u64>,
/// 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<u64, u64>,
}
/// 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<Option<u64>, ()> {
let bytes = token.as_bytes();
if bytes.is_empty() {
return Ok(None);
}
let (guid, rest) = if let Some((base, len)) = base_of(bytes) {
let rest = &bytes[len..];
// The indexed bases: the table's row for the parsed number, and what follows it.
let at = |table: &[u64]| {
let (i, digits) = index_of(rest);
let row = usize::try_from(i).ok().and_then(|i| table.get(i));
(row.copied().unwrap_or(0), &rest[digits..])
};
match base {
Base::Player => (self.player, rest),
Base::Pet if self.player == 0 => return Ok(None),
Base::Pet => (self.pet, rest),
Base::Target => (self.target, rest),
// Deviation: `0x4e81d0` has no bound and reads past its four-entry table, which
// is stale memory, not a mechanism; past it, nobody.
Base::PartyPet => at(&self.party_pets),
// Capped at 4 (`0x4e81b0`).
Base::Party => at(&self.party),
// Capped at the roster count (`0x491940`, `0x491960`).
Base::RaidPet => at(&self.raid_pets),
Base::Raid => at(&self.raid),
Base::Mouseover if !self.held.contains_key(&self.mouseover) => return Ok(None),
Base::Mouseover => (self.mouseover, rest),
}
} else if token.eq_ignore_ascii_case("npc") {
// The full-string compare leaves nothing to chain (`0x515bec`): `"npctarget"` raises.
(self.npc, &bytes[bytes.len()..])
} else {
return Err(());
};
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<Option<u64>> {
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<u64> {
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<u64> {
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));
}
}