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:
co-authored by
Claude Opus 5.5
parent
8d31e9e91e
commit
65a5e44d48
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+303
-97
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()))
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user