the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect - both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list - the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand - the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
//! The game-state aura bindings — **two dialects over one list** (decisions 0255, 0257).
|
||||
//!
|
||||
//! Same engine-free seam as [`super::unit`]: the app pushes a per-token, **already ordered** list of
|
||||
//! [`AuraState`] via [`UiScript::set_auras`], and these globals index it. This module deliberately
|
||||
//! knows nothing about slots, caches, or descriptors — the *order* of the list is the app's to decide,
|
||||
//! because the two orderings the reference uses are not derivable from a snapshot:
|
||||
//!
|
||||
//! - the player's own bar walks a densely packed cache in **insertion order** (`GetPlayerBuff`), and
|
||||
//! - any other unit is read straight off its aura array, ascending slot (`UnitBuff`/`UnitDebuff`).
|
||||
//!
|
||||
//! Decision 0257 resolves that split toward the player-bar law for the local player under every token,
|
||||
//! since the Era API has one aura function where 1.12 had two.
|
||||
//!
|
||||
//! # The two dialects
|
||||
//!
|
||||
//! **Era** — `UnitAura`/`UnitBuff`/`UnitDebuff`/`CancelUnitBuff`: a token plus a **1-based** index
|
||||
//! *within the sign-filtered list*, terminated by a bare `nil`. This is the shape our own transcribed
|
||||
//! frames use.
|
||||
//!
|
||||
//! **1.12** — the `GetPlayerBuff*` family plus `CancelPlayerBuff`: the player only, no token, a
|
||||
//! **0-based** index, and — the part no documentation gets right — `GetPlayerBuff` does not return the
|
||||
//! aura. It returns a **handle**: the aura's *physical position* in the cache, `-1` past the end. Every
|
||||
//! sibling (`GetPlayerBuffTexture`, `…TimeLeft`, `…DispelType`, `…Applications`, `CancelPlayerBuff`,
|
||||
//! and `GameTooltip:SetPlayerBuff`) takes that position, **not** the loop counter. So the family is a
|
||||
//! two-step: enumerate to get a position, then read the position.
|
||||
//!
|
||||
//! That two-step is what makes the index space *absolute*: the position a filtered enumeration hands
|
||||
//! back is the same number an unfiltered one would, which is exactly what the corpus relies on
|
||||
//! (`CT_BuffMod/CT_BuffFrame.lua:73-79` obtains an index under `"HARMFUL"` and compares it for
|
||||
//! equality against one obtained under `"HELPFUL|HARMFUL"`; `Zorlen/Zorlen.lua:2798` intersects the
|
||||
//! filtered and unfiltered walks at the same counter).
|
||||
//!
|
||||
//! Because [`AuraState`] lists are pushed in the app's display order and the player's list **is** the
|
||||
//! reference's `0xbc6040` insertion-ordered cache (`benilla::ui_aura`), a cache position is simply a
|
||||
//! 0-based index into `model.auras["player"]`. No second structure exists, and none is needed.
|
||||
//!
|
||||
//! # Byte-verified signatures (`~/dev/wow-5875-re/WoW/WoW.exe`, `system/ui/ledger.tsv`)
|
||||
//!
|
||||
//! Read off the disassembly, not off a wiki — a later client changed every one of these shapes, and a
|
||||
//! wrong shape fails *silently*.
|
||||
//!
|
||||
//! | verb | addr | returns | miss |
|
||||
//! |---|---|---|---|
|
||||
//! | `GetPlayerBuff(index [, "filter"])` | `0x4e45d0` | **always 2** numbers: `pos`, `untilCancelled` | `(-1, 0)` |
|
||||
//! | `GetPlayerBuffTexture(pos)` | `0x4e4740` | 1: icon path or nil | nil |
|
||||
//! | `GetPlayerBuffDispelType(pos)` | `0x4e4800` | 1: dispel-class name or nil | nil |
|
||||
//! | `GetPlayerBuffApplications(pos)` | `0x4e48b0` | 1: number | **`1`** |
|
||||
//! | `GetPlayerBuffTimeLeft(pos)` | `0x4e4930` | 1: **seconds** | `0` |
|
||||
//! | `CancelPlayerBuff(pos)` | `0x4e49a0` | **0** values | — |
|
||||
//!
|
||||
//! The traps, each with the site that pins it:
|
||||
//!
|
||||
//! - **`-1`, never nil.** `GetPlayerBuff` seeds its out-parameter to `-1` (`mov DWORD PTR [ebp-4],
|
||||
//! 0xffffffff`, `0x4e46ea`) and pushes it unconditionally, then pushes `record+0xc` or `0`
|
||||
//! (`0x4e46f6`/`0x4e4728`), returning `mov eax,0x2` on **both** paths. The corpus terminates on the
|
||||
//! number — `while GetPlayerBuff(counter) >= 0 do` (`_LazyPig/LazyPig.lua:1174`) — so a nil past the
|
||||
//! end is `attempt to compare nil with number`, and *zero* returns is the same crash.
|
||||
//! - **0-based.** `lua_tonumber` → `__ftol` → used raw, with **no `dec`** (`0x4e460a`-`0x4e4616`);
|
||||
//! contrast `UnitBuff 0x519500`, which does `dec eax` at `0x519579`. Every enumerating call site in
|
||||
//! the corpus starts at `0`; the reference's own buttons are `BuffButton0`..`BuffButton23` with
|
||||
//! `id="0"` upward (`ref-BuffFrame.xml:159`).
|
||||
//! - **The default filter is `HELPFUL|HARMFUL`, not `HELPFUL`.** `mov esi,0x3` at `0x4e4618`, kept
|
||||
//! when the `lua_isstring` check on arg 2 fails. This is the one that would have failed silently:
|
||||
//! `Zorlen/Zorlen.lua:2797-2801` walks *unfiltered* and feeds each index to
|
||||
//! `GetPlayerBuffDispelType` looking for `"Poison"`/`"Disease"` (`Zorlen_Other.lua:94,98`) — a
|
||||
//! debuff-only concept. Under a `HELPFUL` default that check never matches, and nothing errors.
|
||||
//! - **`untilCancelled` is the number `0`/`1`.** Five corpus sites compare it to the *number* `1`
|
||||
//! (`CT_BuffMod/CT_BuffFrame.lua:175,232`; `_LazyPig/LazyPig.lua:2071`; `Zorlen/Zorlen.lua:2800`;
|
||||
//! `ElkBuffBar/ElkBuffBar.lua:502`), as does `ref-BuffFrame.lua:124`. A boolean breaks all six:
|
||||
//! `true ~= 1`.
|
||||
//! - **`PASSIVE` is not a token here.** The parser knows exactly four (`stricmp` against `0x84bc34`
|
||||
//! `HELPFUL`=`0x1`, `0x84bc2c` `HARMFUL`=`0x2`, `0x84bc20` `CANCELABLE`=`0x10`, `0x84bc10`
|
||||
//! `NOT_CANCELABLE`=`0x20`; `0x4e4661`-`0x4e46cf`). A `"PASSIVE"` string does exist in the binary at
|
||||
//! `0x806a30`, but `0x4e45d0` never references it — and no corpus addon passes it.
|
||||
//! - **The mask starts at ZERO once a filter string is given** (`xor esi,esi`, `0x4e4639`). So
|
||||
//! `GetPlayerBuff(0, "CANCELABLE")` matches *nothing* — neither sign bit is set — where
|
||||
//! [`super::unit`]-style parsing would have defaulted the sign to helpful. Delimiters are
|
||||
//! `" |"` (`0x84bc3c`), i.e. space **or** pipe.
|
||||
//!
|
||||
//! **Stated gaps, not hidden.** `source` (the caster) is always nil and `duration`/`expirationTime`
|
||||
//! are `0` for every unit but the player: the 1.12 wire carries no aura caster at all, and no duration
|
||||
//! for anyone but yourself (byte-verified, decision 0257 B6/B10). The reference's own target and party
|
||||
//! frames show no timers for the same reason.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::Model;
|
||||
|
||||
mod player_buff;
|
||||
|
||||
/// One aura on one unit, as the Era `UnitAura` family reports it. Plain data (no mlua handles, no ECS
|
||||
/// types), pushed by the app's [`crate::script::UiScript::set_auras`] feed each frame.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AuraState {
|
||||
/// `Spell.dbc` id (`UnitAura`'s `spellId`).
|
||||
pub spell_id: u32,
|
||||
/// The spell's name, or `None` when the catalog doesn't know the id (`UnitAura` returns nil).
|
||||
pub name: Option<String>,
|
||||
/// The icon's extensionless MPQ path (`Interface\Icons\…`).
|
||||
pub icon: Option<String>,
|
||||
/// Stack count, ≥ 1 (`UnitAura`'s `count`). The reference shows the number only above 1.
|
||||
pub count: u8,
|
||||
/// `"Magic"`/`"Curse"`/`"Disease"`/`"Poison"`, or `None` for an undispellable class — the
|
||||
/// `debuffType` return, which the debuff border tints by.
|
||||
pub debuff_type: Option<String>,
|
||||
/// The full duration in seconds as the last apply/refresh reported it; `0.0` = no duration
|
||||
/// (a permanent aura, or any aura on a unit that isn't the player).
|
||||
pub duration: f64,
|
||||
/// The `GetTime()`-based instant this aura runs out; `0.0` when [`Self::duration`] is `0.0`.
|
||||
/// Lua counts down against it, exactly as `CastingBar.xml` does for a cast.
|
||||
pub expiration_time: f64,
|
||||
/// A buff (helpful) rather than a debuff — the `HELPFUL`/`HARMFUL` filter's discriminator.
|
||||
pub helpful: bool,
|
||||
/// Whether right-clicking it will be honoured (the wire's `AFLAG_CANCELABLE`) — the
|
||||
/// `CANCELABLE`/`NOT_CANCELABLE` filter's discriminator.
|
||||
pub cancelable: bool,
|
||||
/// **`GetPlayerBuff`'s second return** — the reference's cache record `+0xc`: "this aura has no
|
||||
/// finite duration to display."
|
||||
///
|
||||
/// DBC-derived at cache-build time, and deliberately **not** the same question as
|
||||
/// `expiration_time == 0.0`: it is computed from the spell, so it is right on the frame an aura
|
||||
/// appears, before any `SMSG_UPDATE_AURA_DURATION` has arrived. That is the whole point of it —
|
||||
/// `ref-BuffFrame.lua:124` returns *before* calling `GetPlayerBuffTimeLeft` when it is `1`, so a
|
||||
/// permanent aura never flickers a `0 s` timer. The app fills it (`benilla::ui_aura`) from the
|
||||
/// byte-verified derivation at `0x4e452e`-`0x4e45c5`; see there for the two clauses.
|
||||
pub until_cancelled: bool,
|
||||
/// `Spell.dbc` `AttributesEx & 0x4` (`SPELL_ATTR_EX_IS_CHANNELED`) — the **second** arm of the
|
||||
/// cancel gate, and the only one that can authorise cancelling a *negative* aura. See
|
||||
/// [`cancel_authorized`].
|
||||
pub channeled: bool,
|
||||
}
|
||||
|
||||
/// The player's active tracking aura — the engine mirror of the reference's tracking global
|
||||
/// (`DAT_00bc6378`): during the aura-cache rebuild the client *excludes* a tracking-effect spell
|
||||
/// (Find Minerals, Track Beasts, …) from the display cache and records it here instead;
|
||||
/// `GetTrackingTexture` is the one reader (wow-re `aura-display-pipeline.md` §3 — the
|
||||
/// `{0x2c,0x2d,0x97}` effect test). Pushed by the app's aura feed via [`UiScript::set_tracking`];
|
||||
/// `None` = no tracking active (the minimap frame hides).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TrackingState {
|
||||
/// `Spell.dbc` id — `CancelTrackingBuff`'s `CMSG_CANCEL_AURA` payload.
|
||||
pub spell_id: u32,
|
||||
/// The spell's name, or `None` when the catalog doesn't know the id (tooltip fallback line).
|
||||
pub name: Option<String>,
|
||||
/// The icon's extensionless MPQ path — `GetTrackingTexture`'s return (`nil` when unknown).
|
||||
pub icon: Option<String>,
|
||||
/// The wire's `AFLAG_CANCELABLE` on the tracking aura's slot — `CancelTrackingBuff`'s gate,
|
||||
/// same client-side check as `CancelUnitBuff`'s.
|
||||
pub cancelable: bool,
|
||||
}
|
||||
|
||||
/// The parsed `filter` argument: a `|`-separated token set. Absent or sign-less defaults to
|
||||
/// `HELPFUL`, matching the live API (`UnitAura(unit, i)` enumerates buffs).
|
||||
struct Filter {
|
||||
helpful: bool,
|
||||
cancelable: Option<bool>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn parse(spec: Option<&str>) -> Self {
|
||||
let spec = spec.unwrap_or("");
|
||||
let has = |t: &str| spec.split('|').any(|s| s.trim().eq_ignore_ascii_case(t));
|
||||
Self {
|
||||
// HARMFUL wins only if named; everything else (including "CANCELABLE" alone) is HELPFUL.
|
||||
helpful: !has("HARMFUL"),
|
||||
cancelable: match (has("CANCELABLE"), has("NOT_CANCELABLE")) {
|
||||
(true, false) => Some(true),
|
||||
(false, true) => Some(false),
|
||||
// Both or neither: no cancelable constraint (the reference never passes both).
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, a: &AuraState) -> bool {
|
||||
a.helpful == self.helpful && self.cancelable.is_none_or(|c| a.cancelable == c)
|
||||
}
|
||||
}
|
||||
|
||||
/// May this aura be cancelled? **One law, two entry points** — `CancelUnitBuff` (the Era name) and
|
||||
/// `CancelPlayerBuff` (the 1.12 name) are the same verb over the same cache, so they share the gate
|
||||
/// and the queue rather than growing a second mechanism.
|
||||
///
|
||||
/// Byte-verified, `CancelPlayerBuff 0x4e49a0` (`0x4e49fb`-`0x4e4a14`) — an **OR** of two arms:
|
||||
///
|
||||
/// 1. the **wire** arm: the aura sits in the positive half (`record.slot < 0x20`, i.e. helpful) AND
|
||||
/// carries `AFLAG_CANCELABLE` (`record+0xa & 0x1`), which vmangos sets iff the aura is positive
|
||||
/// and the spell lacks `SPELL_ATTR_NO_AURA_CANCEL`; OR
|
||||
/// 2. the **DBC** arm: the spell has `AttributesEx & 0x4` (`SPELL_ATTR_EX_IS_CHANNELED`) — the only
|
||||
/// way a *negative* aura becomes cancellable, which is how you break a channel being held on you.
|
||||
///
|
||||
/// Neither arm alone is the gate; reading only the first (the shape `CancelUnitBuff` shipped with)
|
||||
/// silently refuses the channeled case.
|
||||
pub(super) fn cancel_authorized(a: &AuraState) -> bool {
|
||||
(a.helpful && a.cancelable) || a.channeled
|
||||
}
|
||||
|
||||
/// `UnitAura`'s return tuple for a hit. Order and arity follow the Classic Era signature:
|
||||
/// `name, icon, count, debuffType, duration, expirationTime, source, isStealable,
|
||||
/// nameplateShowPersonal, spellId`. The three we cannot know on this wire (`source`, `isStealable`,
|
||||
/// `nameplateShowPersonal`) return nil — an addon that reads them sees "unknown", which is honest,
|
||||
/// rather than a fabricated value.
|
||||
fn returns(lua: &Lua, a: &AuraState) -> mlua::Result<MultiValue> {
|
||||
let s = |v: &Option<String>| -> mlua::Result<Value> {
|
||||
Ok(match v {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
})
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
s(&a.name)?,
|
||||
s(&a.icon)?,
|
||||
Value::Integer(i64::from(a.count)),
|
||||
s(&a.debuff_type)?,
|
||||
Value::Number(a.duration),
|
||||
Value::Number(a.expiration_time),
|
||||
Value::Nil, // source — no aura caster exists on the 1.12 wire, for any unit (0257 B10)
|
||||
Value::Nil, // isStealable — Spellsteal is TBC
|
||||
Value::Nil, // nameplateShowPersonal
|
||||
Value::Integer(i64::from(a.spell_id)),
|
||||
]))
|
||||
}
|
||||
|
||||
/// The shared body of `UnitAura`/`UnitBuff`/`UnitDebuff`: take the `index`-th (1-based) aura of
|
||||
/// `unit` that passes `filter`, in the order the app pushed them. Out of range → a bare nil, the
|
||||
/// live API's "no more auras" terminator that every `for i = 1, N` loop breaks on.
|
||||
fn nth_aura(
|
||||
lua: &Lua,
|
||||
token: &Option<String>,
|
||||
index: i64,
|
||||
filter: &Filter,
|
||||
) -> mlua::Result<MultiValue> {
|
||||
if index < 1 {
|
||||
return Ok(MultiValue::new());
|
||||
}
|
||||
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 hit {
|
||||
Some(a) => returns(lua, &a),
|
||||
None => Ok(MultiValue::new()),
|
||||
}
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push (or clear) a unit token's aura list, **in display order** — the app decides that order
|
||||
/// (decision 0257): the maintained insertion-order cache for the local player, ascending aura
|
||||
/// slot for anyone else. `None` (or an empty list) makes every `UnitAura(token, i)` return nil.
|
||||
pub fn set_auras(&mut self, token: &str, auras: Option<Vec<AuraState>>) {
|
||||
let mut model = self.model_mut();
|
||||
match auras {
|
||||
Some(a) => {
|
||||
model.auras.insert(token.to_string(), a);
|
||||
}
|
||||
None => {
|
||||
model.auras.remove(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push (or clear) the player's active tracking aura ([`TrackingState`] doc) — the app's aura
|
||||
/// feed derives it from the same walk that excludes tracking spells from the display lists.
|
||||
pub fn set_tracking(&mut self, tracking: Option<TrackingState>) {
|
||||
self.model_mut().tracking = tracking;
|
||||
}
|
||||
|
||||
/// Drain the spell ids `CancelUnitBuff` queued since the last call — the app sends one
|
||||
/// `CMSG_CANCEL_AURA` per id (the server cancels by spell, never by slot; decision 0257 B8).
|
||||
pub fn take_cancel_aura_requests(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().cancel_aura_requests)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register `UnitAura`, `UnitBuff`, `UnitDebuff` and `CancelUnitBuff`, plus the 1.12 `GetPlayerBuff*`
|
||||
/// family ([`player_buff`]).
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// UnitAura(unit, index [, filter]) — filter defaults to HELPFUL, like the live API.
|
||||
g.set(
|
||||
"UnitAura",
|
||||
lua.create_function(
|
||||
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
|
||||
nth_aura(lua, &token, index, &Filter::parse(filter.as_deref()))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// UnitBuff/UnitDebuff(unit, index [, filter]) — the live API pins the sign and folds any extra
|
||||
// filter tokens (CANCELABLE, …) in alongside it.
|
||||
g.set(
|
||||
"UnitBuff",
|
||||
lua.create_function(
|
||||
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
|
||||
let spec = match filter {
|
||||
Some(f) => format!("HELPFUL|{f}"),
|
||||
None => "HELPFUL".to_string(),
|
||||
};
|
||||
nth_aura(lua, &token, index, &Filter::parse(Some(&spec)))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
g.set(
|
||||
"UnitDebuff",
|
||||
lua.create_function(
|
||||
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
|
||||
let spec = match filter {
|
||||
Some(f) => format!("HARMFUL|{f}"),
|
||||
None => "HARMFUL".to_string(),
|
||||
};
|
||||
nth_aura(lua, &token, index, &Filter::parse(Some(&spec)))
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
// CancelUnitBuff(unit, index [, filter]) — the Era name for the reference's
|
||||
// `CancelPlayerBuff(buffIndex)`. Resolves the aura the same way the getters do, then queues its
|
||||
// SPELL ID for the app: the server's `CMSG_CANCEL_AURA` carries a spell, not a slot, and refuses
|
||||
// anything not cancelable. A non-cancelable aura (or a missing one) is a silent no-op, as in the
|
||||
// reference, where the client's own `AFLAG_CANCELABLE` gate never sends the packet.
|
||||
g.set(
|
||||
"CancelUnitBuff",
|
||||
lua.create_function(
|
||||
|lua, (token, index, filter): (Option<String>, i64, Option<String>)| {
|
||||
let spec = match filter {
|
||||
Some(f) => format!("HELPFUL|{f}"),
|
||||
None => "HELPFUL".to_string(),
|
||||
};
|
||||
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()
|
||||
})
|
||||
};
|
||||
if let Some(a) = hit.filter(cancel_authorized) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.cancel_aura_requests.push(a.spell_id);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
player_buff::install(lua)?;
|
||||
|
||||
// GetTrackingTexture() — the reference's `0x4e4a20`: the icon path of the active tracking
|
||||
// spell (the tracking global's occupant), or nil when none — `MiniMapTrackingFrame`'s
|
||||
// show/hide + SetTexture driver on PLAYER_AURAS_CHANGED (ref-Minimap.xml l.150-159).
|
||||
g.set(
|
||||
"GetTrackingTexture",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.tracking.as_ref().and_then(|t| t.icon.clone()))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CancelTrackingBuff() — the reference's `0x4e4a80` (the tracking frame's right-click): cancel
|
||||
// the active tracking aura by SPELL id through the same `CMSG_CANCEL_AURA` queue as
|
||||
// `CancelUnitBuff`, behind the same client-side `AFLAG_CANCELABLE` gate. No tracking active
|
||||
// is a silent no-op.
|
||||
g.set(
|
||||
"CancelTrackingBuff",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if let Some(t) = model.tracking.as_ref().filter(|t| t.cancelable) {
|
||||
let id = t.spell_id;
|
||||
model.cancel_aura_requests.push(id);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::{AuraState, TrackingState, UiScript};
|
||||
|
||||
fn aura(spell_id: u32, name: &str, helpful: bool, cancelable: bool) -> AuraState {
|
||||
AuraState {
|
||||
spell_id,
|
||||
name: Some(name.into()),
|
||||
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
|
||||
count: 1,
|
||||
debuff_type: None,
|
||||
duration: 0.0,
|
||||
expiration_time: 0.0,
|
||||
helpful,
|
||||
cancelable,
|
||||
until_cancelled: false,
|
||||
channeled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The list the app pushes IS the enumeration order — the binding never re-sorts it. This is the
|
||||
/// contract decision 0257 rests on: the player's insertion-ordered cache survives the seam.
|
||||
#[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),
|
||||
]),
|
||||
);
|
||||
// Buffs, in the pushed order (not ascending spell id).
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitAura("player", 1))"#)
|
||||
.unwrap(),
|
||||
"Battle Stance"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitBuff("player", 2))"#)
|
||||
.unwrap(),
|
||||
"Mark of the Wild"
|
||||
);
|
||||
// The debuff is index 1 of its own filter, not index 3.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitDebuff("player", 1))"#)
|
||||
.unwrap(),
|
||||
"Shadow Word: Pain"
|
||||
);
|
||||
// Past the end → nil, the loop terminator.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitBuff("player", 3) == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitDebuff("player", 2) == nil"#)
|
||||
.unwrap());
|
||||
// An unknown token, and a zero/negative index, are the same "no aura" shape.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitAura("target", 1) == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitAura("player", 0) == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[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 but not cancelable
|
||||
aura(589, "Pain", false, false),
|
||||
]),
|
||||
);
|
||||
// No filter ⇒ HELPFUL: two hits, the debuff invisible.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
r#"local n = 0 for i=1,10 do if UnitAura("player", i) then n = n + 1 end end return n"#
|
||||
)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
// HARMFUL names the sign explicitly.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitAura("player", 1, "HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
"Pain"
|
||||
);
|
||||
// CANCELABLE / NOT_CANCELABLE partition the helpful set.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitAura("player", 1, "HELPFUL|CANCELABLE"))"#)
|
||||
.unwrap(),
|
||||
"Battle Stance"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitAura("player", 1, "HELPFUL|NOT_CANCELABLE"))"#)
|
||||
.unwrap(),
|
||||
"Sealed"
|
||||
);
|
||||
// A bare CANCELABLE still means helpful (the sign defaults, it isn't cleared).
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return (UnitAura("player", 1, "CANCELABLE"))"#)
|
||||
.unwrap(),
|
||||
"Battle Stance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_aura_returns_the_era_tuple_with_the_unknowable_fields_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
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]));
|
||||
|
||||
let (name, icon, count, dtype, dur, expiry, spell) = s
|
||||
.eval::<(String, String, i64, String, f64, f64, i64)>(
|
||||
r#"local n, i, c, d, du, e, src, st, np, sid = UnitDebuff("target", 1)
|
||||
assert(src == nil and st == nil and np == nil, "unknowable fields must be nil")
|
||||
return n, i, c, d, du, e, sid"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(name, "Shadow Word: Pain");
|
||||
assert_eq!(icon, "Interface\\Icons\\Spell_589");
|
||||
assert_eq!((count, dtype.as_str()), (3, "Magic"));
|
||||
assert_eq!((dur, expiry), (18.0, 1042.5));
|
||||
assert_eq!(spell, 589);
|
||||
}
|
||||
|
||||
#[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),
|
||||
]),
|
||||
);
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
|
||||
// A cancelable buff queues its SPELL id (not its index).
|
||||
s.eval::<()>(r#"CancelUnitBuff("player", 1)"#).unwrap();
|
||||
// A non-cancelable one is a silent no-op, as in the reference.
|
||||
s.eval::<()>(r#"CancelUnitBuff("player", 2)"#).unwrap();
|
||||
// So is an out-of-range index, and a debuff reached through the helpful filter.
|
||||
s.eval::<()>(r#"CancelUnitBuff("player", 9)"#).unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![2457]);
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
}
|
||||
|
||||
/// The tracking seam (the reference's `GetTrackingTexture`/`CancelTrackingBuff` pair over the
|
||||
/// tracking global): the pushed state is what the bindings read, the cancel queues the SPELL
|
||||
/// id behind the same `AFLAG_CANCELABLE` gate as `CancelUnitBuff`, and no tracking is nil +
|
||||
/// no-op.
|
||||
#[test]
|
||||
fn tracking_bindings_read_the_pushed_state_and_cancel_by_spell_id() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
// No tracking pushed yet: nil texture, cancel is a silent no-op.
|
||||
assert!(s
|
||||
.eval::<bool>("return GetTrackingTexture() == nil")
|
||||
.unwrap());
|
||||
s.eval::<()>("CancelTrackingBuff()").unwrap();
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
|
||||
s.set_tracking(Some(TrackingState {
|
||||
spell_id: 2580,
|
||||
name: Some("Find Minerals".into()),
|
||||
icon: Some("Interface\\Icons\\Trade_Mining".into()),
|
||||
cancelable: true,
|
||||
}));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetTrackingTexture()").unwrap(),
|
||||
"Interface\\Icons\\Trade_Mining"
|
||||
);
|
||||
s.eval::<()>("CancelTrackingBuff()").unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![2580]);
|
||||
|
||||
// A non-cancelable tracking aura refuses, exactly like CancelUnitBuff's gate.
|
||||
s.set_tracking(Some(TrackingState {
|
||||
spell_id: 2580,
|
||||
cancelable: false,
|
||||
..Default::default()
|
||||
}));
|
||||
s.eval::<()>("CancelTrackingBuff()").unwrap();
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
|
||||
// Cleared: back to nil (the minimap frame's hide branch).
|
||||
s.set_tracking(None);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetTrackingTexture() == nil")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[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)]));
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitAura("player", 1) ~= nil"#)
|
||||
.unwrap());
|
||||
s.set_auras("player", None);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return UnitAura("player", 1) == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
//! **The 1.12 dialect** of the aura API — `GetPlayerBuff` and the five verbs that consume the cache
|
||||
//! position it hands back, plus `CancelPlayerBuff`.
|
||||
//!
|
||||
//! The index law, the byte-verified signature table, and the traps each shape hides are in the
|
||||
//! parent module's header ([`super`]); this file is the implementation. Everything here reads the
|
||||
//! *same* `model.auras["player"]` list the Era verbs read — the player's list **is** the reference's
|
||||
//! `0xbc6040` insertion-ordered cache — so a "cache position" needs no structure of its own.
|
||||
//!
|
||||
//! The one thing that is genuinely not shared with [`super`] is the **filter**: see
|
||||
//! [`PlayerBuffFilter`] for why reusing the Era parser would be a silent bug rather than a tidy-up.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::{cancel_authorized, AuraState, Model};
|
||||
|
||||
/// `GetPlayerBuff`'s filter, as a **bitmask** — deliberately *not* [`Filter`].
|
||||
///
|
||||
/// The two parsers are not the same function and sharing one would be the silent-shape bug this
|
||||
/// module's header is about. `UnitAura`'s filter defaults its *sign* to helpful and treats a bare
|
||||
/// `"CANCELABLE"` as `HELPFUL|CANCELABLE`; `GetPlayerBuff`'s mask starts at **zero** the moment a
|
||||
/// filter string is supplied (`xor esi,esi`, `0x4e4639`), so a bare `"CANCELABLE"` sets no sign bit
|
||||
/// and matches nothing at all. And with **no** filter argument the mask is `HELPFUL|HARMFUL`
|
||||
/// (`mov esi,0x3`, `0x4e4618`), where `UnitAura`'s default is helpful only.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PlayerBuffFilter(u32);
|
||||
|
||||
impl PlayerBuffFilter {
|
||||
/// `stricmp` against the four `.rdata` tokens (`0x4e4661`-`0x4e46cf`). Anything else — including
|
||||
/// `"PASSIVE"` — contributes no bit, exactly as the reference's `strtok`/`stricmp` chain falls
|
||||
/// through. Delimiters are `" |"` (`0x84bc3c`): space or pipe, runs collapsed like `strtok`'s.
|
||||
fn parse(spec: Option<&str>) -> Self {
|
||||
const HELPFUL: u32 = 0x1;
|
||||
const HARMFUL: u32 = 0x2;
|
||||
const CANCELABLE: u32 = 0x10;
|
||||
const NOT_CANCELABLE: u32 = 0x20;
|
||||
|
||||
let Some(spec) = spec else {
|
||||
// No filter argument at all: the preloaded `HELPFUL|HARMFUL`, both halves.
|
||||
return Self(HELPFUL | HARMFUL);
|
||||
};
|
||||
let mut mask = 0;
|
||||
for token in spec.split([' ', '|']).filter(|t| !t.is_empty()) {
|
||||
for (name, bit) in [
|
||||
("HELPFUL", HELPFUL),
|
||||
("HARMFUL", HARMFUL),
|
||||
("CANCELABLE", CANCELABLE),
|
||||
("NOT_CANCELABLE", NOT_CANCELABLE),
|
||||
] {
|
||||
if token.eq_ignore_ascii_case(name) {
|
||||
mask |= bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self(mask)
|
||||
}
|
||||
|
||||
/// The enumerator's per-record test (`0x4e43c9`-`0x4e43f7`): the sign bit for this record's half
|
||||
/// must be set, then `CANCELABLE`/`NOT_CANCELABLE` (if named) must agree with `record+0xa & 1`.
|
||||
fn matches(self, a: &AuraState) -> bool {
|
||||
const HELPFUL: u32 = 0x1;
|
||||
const HARMFUL: u32 = 0x2;
|
||||
const CANCELABLE: u32 = 0x10;
|
||||
const NOT_CANCELABLE: u32 = 0x20;
|
||||
|
||||
let sign = if a.helpful { HELPFUL } else { HARMFUL };
|
||||
self.0 & sign != 0
|
||||
&& !(self.0 & CANCELABLE != 0 && !a.cancelable)
|
||||
&& !(self.0 & NOT_CANCELABLE != 0 && a.cancelable)
|
||||
}
|
||||
}
|
||||
|
||||
/// `lua_isnumber`-then-`lua_tonumber`-then-`__ftol`, with the reference's own usage message.
|
||||
///
|
||||
/// Every verb in the family opens with exactly this (`0x4e45d6`, `0x4e4748`, `0x4e4808`, `0x4e48bc`,
|
||||
/// `0x4e493e`, `0x4e49a8`): a non-number argument raises through `luaL_error` (`0x6f4940` =
|
||||
/// `luaL_where` + `lua_pushvfstring` + `lua_concat` + `lua_error`) rather than returning nil, so an
|
||||
/// addon sees the same "Usage:" line the reference prints. Lua 5.1's `lua_isnumber` accepts a numeric
|
||||
/// *string*, and [`Lua::coerce_number`] is that same coercion.
|
||||
fn buff_index_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Result<i64> {
|
||||
match lua.coerce_number(v)? {
|
||||
// `__ftol` truncates toward zero; `as i64` on f64 does the same (and saturates).
|
||||
Some(n) => Ok(n as i64),
|
||||
None => Err(mlua::Error::RuntimeError(usage.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's display cache — the reference's `0xbc6040`, which for us **is** the pushed
|
||||
/// `"player"` list (decision 0257; `benilla::ui_aura` maintains the insertion order across frames).
|
||||
/// `pos` is a physical cache position, 0-based.
|
||||
///
|
||||
/// Out of range is `None`, covering both of the reference's miss shapes: `0x4e4430` hands back NULL
|
||||
/// for `pos < 0` and `pos >= 0x30`, and a position inside the array but past the packed prefix
|
||||
/// resolves to a **cleared** record (`slot = -1`, `spellId = 0`), which every sibling then treats as
|
||||
/// absent anyway — `GetPlayerBuffTexture` fails its `Spell.dbc` lookup on id 0 and pushes nil,
|
||||
/// `…TimeLeft` returns 0 on `slot < 0`, `CancelPlayerBuff` finds no spell and no-ops. (One reference
|
||||
/// quirk is deliberately *not* reproduced: `GetPlayerBuffApplications` on a cleared record reads
|
||||
/// `+0x9`, which `BuildBuffRecord` never rewrites when it clears — so the real client hands back the
|
||||
/// previous occupant's stack count. It is unreachable from any guarded call site, and stale memory is
|
||||
/// not a mechanism worth mirroring; we answer with the absent-record default, `1`.)
|
||||
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()
|
||||
}
|
||||
|
||||
/// The enumerator, `0x4e43b0`: walk the cache by ascending physical position, counting only records
|
||||
/// that pass `filter`, and stop when that count reaches `index`. Returns the **position** and the
|
||||
/// record — the position is what every sibling verb consumes.
|
||||
///
|
||||
/// A negative `index` can never match: the reference's counter starts at 0 and only increments, and
|
||||
/// the hit test is an equality (`cmp ebx,[ebp-4]; je`), so it walks the whole cache and falls out
|
||||
/// with `*outPos` still at its pre-seeded `-1`.
|
||||
fn enumerate_player_buff(
|
||||
lua: &Lua,
|
||||
index: i64,
|
||||
filter: PlayerBuffFilter,
|
||||
) -> Option<(usize, AuraState)> {
|
||||
if index < 0 {
|
||||
return None;
|
||||
}
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
model
|
||||
.auras
|
||||
.get("player")?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, a)| filter.matches(a))
|
||||
.nth(usize::try_from(index).ok()?)
|
||||
.map(|(pos, a)| (pos, a.clone()))
|
||||
}
|
||||
|
||||
/// Register the six 1.12 globals. Signatures are byte-verified — see [`super`]'s header table for
|
||||
/// the addresses and the corpus/FrameXML site that pins each shape.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetPlayerBuff(index [, "filter"]) — 0x4e45d0. ALWAYS two numbers: the physical cache position
|
||||
// (-1 past the end) and untilCancelled (0/1). Never nil, never a different arity: both the hit
|
||||
// path (0x4e471e) and the miss path (0x4e4733) end `mov eax,0x2`.
|
||||
//
|
||||
// With the default HELPFUL|HARMFUL mask every live record passes the sign test, so the match
|
||||
// counter advances in lockstep with the position and `GetPlayerBuff(i)` returns exactly `i` over
|
||||
// the packed prefix — which `_LazyPig/LazyPig.lua:1174-1181` relies on, enumerating with
|
||||
// `counter` and then cancelling `counter` rather than the returned index.
|
||||
g.set(
|
||||
"GetPlayerBuff",
|
||||
lua.create_function(|lua, (index, filter): (Value, Option<String>)| {
|
||||
let index = buff_index_arg(lua, index, r#"Usage: GetPlayerBuff(index [, "filter"])"#)?;
|
||||
let hit = enumerate_player_buff(lua, index, PlayerBuffFilter::parse(filter.as_deref()));
|
||||
let (pos, until_cancelled) = match hit {
|
||||
Some((pos, a)) => (pos as i64, i64::from(a.until_cancelled)),
|
||||
None => (-1, 0),
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::Integer(pos),
|
||||
Value::Integer(until_cancelled),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPlayerBuffTexture(buffIndex) — 0x4e4740. One value: the icon path, or nil when the position
|
||||
// is absent or the spell/icon row is missing (three separate `lua_pushnil; mov eax,0x1` exits).
|
||||
// Nil is load-bearing: `SnaFu/SnaFu.lua:317` uses this call *as its loop condition*
|
||||
// (`while GetPlayerBuffTexture(i) do`), so an empty string past the end would spin forever.
|
||||
//
|
||||
// The extra argument `CT_BuffMod/CT_BuffFrame.lua:148` passes
|
||||
// (`GetPlayerBuffTexture(buffIndex, "HELPFUL|HARMFUL")`) is simply ignored — the reference reads
|
||||
// argument 1 and nothing else, and mlua drops the surplus for the same reason.
|
||||
g.set(
|
||||
"GetPlayerBuffTexture",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let pos = buff_index_arg(lua, index, "Usage: GetPlayerBuffTexture(buffIndex)")?;
|
||||
Ok(player_buff_record(lua, pos).and_then(|a| a.icon))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPlayerBuffDispelType(buffIndex) — 0x4e4800. One value: SpellDispelType.dbc's name for the
|
||||
// spell's `Dispel` column, or nil (dispel id 0, or any missing row — gated at 0x4e485e before
|
||||
// the DBC is touched). The vocabulary is the DBC's, shared with `UnitDebuff`'s third return.
|
||||
//
|
||||
// Nil, never `""` and never `"none"`: the corpus indexes `DebuffTypeColor[debuffType]` behind an
|
||||
// `if ( debuffType )` and falls back to the `"none"` key itself
|
||||
// (`ElkBuffBar/ElkBuffBar.lua:324-329`, `CT_BuffMod/CT_BuffFrame.lua:137-142`). An empty string
|
||||
// is truthy in Lua, so it would take the wrong branch and then index a nil colour.
|
||||
//
|
||||
// It must also tolerate `-1` directly: `ref-BuffFrame.lua:83` calls
|
||||
// `GetPlayerBuffDispelType(GetPlayerBuff(this:GetID(), "HARMFUL"))` with no guard between them.
|
||||
g.set(
|
||||
"GetPlayerBuffDispelType",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let pos = buff_index_arg(lua, index, "Usage: GetPlayerBuffDispelType(buffIndex)")?;
|
||||
Ok(player_buff_record(lua, pos).and_then(|a| a.debuff_type))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPlayerBuffApplications(buffIndex) — 0x4e48b0. One value, always a number: the record's
|
||||
// stack count, and **1** for an absent position (`push 0x3ff00000; push 0x0` = the double 1.0,
|
||||
// 0x4e4917). Never nil — `CT_BuffMod/CT_BuffFrame.lua:216` and `ElkBuffBar/ElkBuffBar.lua:278`
|
||||
// both do `if ( count > 1 )` with no guard, which nil turns into a hard error.
|
||||
//
|
||||
// (The reference's usage string here is `0x84bcc0`, "Usage: GetPlayerBuffTimeLeft(buffIndex)" —
|
||||
// the same constant 0x4e4930 pushes. A copy-paste in the shipped binary; we name the verb the
|
||||
// caller actually called, since nothing can depend on the wrong name and a debugging addon
|
||||
// author would be misled by it.)
|
||||
g.set(
|
||||
"GetPlayerBuffApplications",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let pos = buff_index_arg(lua, index, "Usage: GetPlayerBuffApplications(buffIndex)")?;
|
||||
Ok(player_buff_record(lua, pos).map_or(1, |a| i64::from(a.count)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPlayerBuffTimeLeft(buffIndex) — 0x4e4930 over the reader 0x4e4450. One value, always a
|
||||
// number in **seconds** (the reader returns ms and 0x4e4986 multiplies by the 0.001 at
|
||||
// 0x801608): `max(0, expiry - now)`, and **0** for an absent position, a cleared record, or an
|
||||
// aura with no expiry at all.
|
||||
//
|
||||
// Never nil: `oRA2/Participant/Buff.lua:286` computes `floor(GetPlayerBuffTimeLeft(index) + .5)`
|
||||
// with no guard whatsoever, and `PowerAuras/PowerAuras.lua:593` feeds the result of a *possibly
|
||||
// -1* `GetPlayerBuff` straight in and then compares `> 0`.
|
||||
//
|
||||
// The clock is the VM's own `GetTime()` session clock, the same one `expiration_time` is
|
||||
// expressed on (decision 0257) — so this is a live subtraction per call, exactly as
|
||||
// `ref-BuffFrame.lua:130` re-reads it every frame from `OnUpdate` rather than caching it on the
|
||||
// event.
|
||||
g.set(
|
||||
"GetPlayerBuffTimeLeft",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let pos = buff_index_arg(lua, index, "Usage: GetPlayerBuffTimeLeft(buffIndex)")?;
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
// The `max(0, …)` covers both of the reference's zero cases at once: an expired aura,
|
||||
// and a permanent one, whose expiry array slot holds 0 so that `now - 0` is positive.
|
||||
Ok(player_buff_record(lua, pos).map_or(0.0, |a| (a.expiration_time - now).max(0.0)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CancelPlayerBuff(buffIndex) — 0x4e49a0. **Zero** return values on every path, including the
|
||||
// successful one (`xor eax,eax; ret`). Resolves the position to a record, applies
|
||||
// [`cancel_authorized`], and queues the SPELL id onto the same drain `CancelUnitBuff` uses —
|
||||
// there is one queue because there is one packet: `Spell_C::CancelAura 0x6e7040` sends
|
||||
// `CMSG_CANCEL_AURA` (0x136) with a single u32 spell id (decision 0257 B8). A refused or absent
|
||||
// aura is a silent no-op, as in the reference.
|
||||
g.set(
|
||||
"CancelPlayerBuff",
|
||||
lua.create_function(|lua, index: Value| {
|
||||
let pos = buff_index_arg(lua, index, "Usage: CancelPlayerBuff(buffIndex)")?;
|
||||
if let Some(a) = player_buff_record(lua, pos).filter(cancel_authorized) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.cancel_aura_requests.push(a.spell_id);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::{AuraState, UiScript};
|
||||
|
||||
/// A plain aura as the app's feed would push it. Local to this module's fixtures: the 1.12
|
||||
/// verbs read fields (`until_cancelled`, `channeled`) the Era tests have no use for.
|
||||
fn aura(spell_id: u32, name: &str, helpful: bool, cancelable: bool) -> AuraState {
|
||||
AuraState {
|
||||
spell_id,
|
||||
name: Some(name.into()),
|
||||
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
|
||||
count: 1,
|
||||
debuff_type: None,
|
||||
duration: 0.0,
|
||||
expiration_time: 0.0,
|
||||
helpful,
|
||||
cancelable,
|
||||
until_cancelled: false,
|
||||
channeled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's cache as the app pushes it: buffs and debuffs in ONE insertion-ordered list,
|
||||
/// which is what makes a cache position an absolute handle across filters.
|
||||
fn player_cache() -> Vec<AuraState> {
|
||||
let mut mark = aura(1126, "Mark of the Wild", true, true);
|
||||
mark.count = 1;
|
||||
mark.expiration_time = 100.0;
|
||||
let mut stance = aura(2457, "Battle Stance", true, false);
|
||||
stance.until_cancelled = true; // permanent: no SpellDuration row to count down
|
||||
let mut pain = aura(589, "Shadow Word: Pain", false, false);
|
||||
pain.count = 3;
|
||||
pain.debuff_type = Some("Magic".into());
|
||||
pain.expiration_time = 18.0;
|
||||
// position: 0 = Mark (buff), 1 = Stance (buff), 2 = Pain (debuff)
|
||||
vec![mark, stance, pain]
|
||||
}
|
||||
|
||||
fn with_player_cache() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_auras("player", Some(player_cache()));
|
||||
s
|
||||
}
|
||||
|
||||
/// **The sentinel, and the arity that carries it.** `GetPlayerBuff` returns two numbers on every
|
||||
/// path — `(-1, 0)` past the end, never nil and never a different count.
|
||||
///
|
||||
/// The `select('#')` assertions are the half that matters, exactly as in
|
||||
/// [`super::super::item_stats`]'s `get_item_info_tests`: an implementation that returned `nil`
|
||||
/// for a miss still "works" for every individual read, and only the arity notices. Here it is
|
||||
/// worse than cosmetic — the corpus's termination test is `>= 0` on the *first* value, so nil is
|
||||
/// `attempt to compare nil with number` and zero returns is the same crash.
|
||||
#[test]
|
||||
fn get_player_buff_returns_two_numbers_and_minus_one_past_the_end() {
|
||||
let mut s = with_player_cache();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetPlayerBuff(0))").unwrap(),
|
||||
2,
|
||||
"1.12 pushes exactly two values (`mov eax,0x2` at 0x4e471e) — a hit must not be a tuple \
|
||||
of aura fields, which is the modern UnitAura shape"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetPlayerBuff(99))")
|
||||
.unwrap(),
|
||||
2,
|
||||
"and a MISS pushes two as well (0x4e4733), not zero and not nil"
|
||||
);
|
||||
|
||||
// 0-based: position 0 is the first aura, not the second.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetPlayerBuff(0)").unwrap(),
|
||||
(0, 0)
|
||||
);
|
||||
// Past the end: the -1 sentinel, and untilCancelled 0 beside it.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetPlayerBuff(3)").unwrap(),
|
||||
(-1, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetPlayerBuff(99)").unwrap(),
|
||||
(-1, 0)
|
||||
);
|
||||
// A negative index can never match the counter, so it is a miss, not an error.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetPlayerBuff(-1)").unwrap(),
|
||||
(-1, 0)
|
||||
);
|
||||
// untilCancelled is the NUMBER 1 — five corpus sites compare it to `1`, so a boolean
|
||||
// (`true ~= 1`) would silently invert every one of them.
|
||||
assert!(s
|
||||
.eval::<bool>("local _, uc = GetPlayerBuff(1) return uc == 1")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("local _, uc = GetPlayerBuff(0) return uc == 0")
|
||||
.unwrap());
|
||||
// An empty cache is the same miss shape as an over-long index (not an error, not nil).
|
||||
s.set_auras("player", None);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("return GetPlayerBuff(0)").unwrap(),
|
||||
(-1, 0)
|
||||
);
|
||||
}
|
||||
|
||||
/// **The corpus's own loop.** `while GetPlayerBuff(counter) >= 0 do … end` over a real cache
|
||||
/// must visit every aura once and then *stop* — `_LazyPig/LazyPig.lua:1174`,
|
||||
/// `Zorlen/Zorlen.lua:2797`. Not terminating is the failure mode that matters: it is an infinite
|
||||
/// loop inside a `PLAYER_AURAS_CHANGED` handler, i.e. a frozen client, and no gate but this one
|
||||
/// would see it.
|
||||
///
|
||||
/// The loop also pins LazyPig's assumption that `counter` and the returned index are
|
||||
/// interchangeable under the default filter, and that the walk covers **debuffs too** (the
|
||||
/// `HELPFUL|HARMFUL` default).
|
||||
#[test]
|
||||
fn the_corpus_while_ge_zero_loop_terminates_and_visits_every_aura() {
|
||||
let mut s = with_player_cache();
|
||||
|
||||
let (visited, names) = s
|
||||
.eval::<(i64, String)>(
|
||||
r#"
|
||||
local counter = 0
|
||||
local visited = 0
|
||||
local names = ""
|
||||
while GetPlayerBuff(counter) >= 0 do
|
||||
local index, untilCancelled = GetPlayerBuff(counter)
|
||||
-- LazyPig's assumption: the ordinal IS the returned position, unfiltered.
|
||||
assert(index == counter, "unfiltered enumeration must be position-identical")
|
||||
names = names .. GetPlayerBuffTexture(index) .. ";"
|
||||
visited = visited + 1
|
||||
counter = counter + 1
|
||||
assert(counter < 100, "GetPlayerBuff loop did not terminate")
|
||||
end
|
||||
return visited, names
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(visited, 3, "all three auras, buffs AND the debuff");
|
||||
assert_eq!(
|
||||
names,
|
||||
"Interface\\Icons\\Spell_1126;Interface\\Icons\\Spell_2457;Interface\\Icons\\Spell_589;"
|
||||
);
|
||||
|
||||
// The same loop over an EMPTY cache terminates immediately rather than spinning.
|
||||
s.set_auras("player", Some(vec![]));
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
r#"local c = 0
|
||||
while GetPlayerBuff(c) >= 0 do c = c + 1 assert(c < 100, "did not terminate") end
|
||||
return c"#
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// **The default filter is `HELPFUL|HARMFUL`.** The trap that fails silently: under a `HELPFUL`
|
||||
/// default (which is what `UnitAura` uses, one function away in this file)
|
||||
/// `Zorlen/Zorlen.lua:2797-2801` — an unfiltered walk feeding `GetPlayerBuffDispelType` and
|
||||
/// matching `"Poison"`/`"Disease"` — simply never finds anything, and nothing errors.
|
||||
///
|
||||
/// Also pins the absolute index space: a `"HARMFUL"` enumeration hands back the same position an
|
||||
/// unfiltered one does, which `CT_BuffMod/CT_BuffFrame.lua:73-79` compares for equality across
|
||||
/// the two filters.
|
||||
#[test]
|
||||
fn get_player_buff_defaults_to_both_halves_and_indexes_absolutely() {
|
||||
let s = with_player_cache();
|
||||
|
||||
// No filter: three matches — the debuff included.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("local n = 0 while GetPlayerBuff(n) >= 0 do n = n + 1 end return n")
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
// The debuff is reachable unfiltered, and its dispel class comes back.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetPlayerBuffDispelType(GetPlayerBuff(2))")
|
||||
.unwrap(),
|
||||
"Magic"
|
||||
);
|
||||
// "HARMFUL" alone finds it at ordinal 0 — and hands back position 2, the SAME number the
|
||||
// unfiltered walk used.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(0, "HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(1, "HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
-1
|
||||
);
|
||||
// "HELPFUL" alone: two, at positions 0 and 1.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>(
|
||||
r#"return (GetPlayerBuff(0, "HELPFUL")), (GetPlayerBuff(1, "HELPFUL"))"#
|
||||
)
|
||||
.unwrap(),
|
||||
(0, 1)
|
||||
);
|
||||
// CT_BuffMod's own literal, and the space-delimited spelling the reference's strtok accepts.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(2, "HELPFUL|HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(2, "HELPFUL HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
// The zero-mask rule: a filter STRING with no sign token matches nothing at all, because the
|
||||
// mask starts at 0 (`xor esi,esi`) rather than defaulting the sign the way UnitAura's does.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(0, "CANCELABLE"))"#)
|
||||
.unwrap(),
|
||||
-1
|
||||
);
|
||||
// "PASSIVE" is not a token in this parser: it contributes no bit, so it is a zero mask too.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(0, "PASSIVE"))"#)
|
||||
.unwrap(),
|
||||
-1
|
||||
);
|
||||
// CANCELABLE / NOT_CANCELABLE partition a named sign.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(0, "HELPFUL|CANCELABLE"))"#)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return (GetPlayerBuff(0, "HELPFUL|NOT_CANCELABLE"))"#)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// **The accessors' empty-slot answers**, each of which is a different shape, and each of which
|
||||
/// a corpus site would crash on if it were nil.
|
||||
#[test]
|
||||
fn the_player_buff_accessors_answer_an_absent_position_without_nil_arithmetic() {
|
||||
let mut s = with_player_cache();
|
||||
s.tick(0.0); // GetTime() = 0, the clock GetPlayerBuffTimeLeft counts against
|
||||
|
||||
// Every accessor returns exactly ONE value, hit or miss — the reference's `mov eax,0x1`.
|
||||
for verb in [
|
||||
"GetPlayerBuffTexture",
|
||||
"GetPlayerBuffDispelType",
|
||||
"GetPlayerBuffApplications",
|
||||
"GetPlayerBuffTimeLeft",
|
||||
] {
|
||||
for arg in ["0", "-1", "99"] {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!("return select('#', {verb}({arg}))"))
|
||||
.unwrap(),
|
||||
1,
|
||||
"{verb}({arg}) must push one value, not zero and not a tuple"
|
||||
);
|
||||
}
|
||||
}
|
||||
// CancelPlayerBuff pushes NONE (`xor eax,eax; ret`), on every path.
|
||||
for arg in ["0", "-1", "99"] {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!("return select('#', CancelPlayerBuff({arg}))"))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
s.take_cancel_aura_requests();
|
||||
|
||||
// Texture: nil past the end — SnaFu's `while GetPlayerBuffTexture(i) do` loop condition.
|
||||
assert!(s
|
||||
.eval::<bool>("return GetPlayerBuffTexture(3) == nil")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return GetPlayerBuffTexture(-1) == nil")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
r#"local i = 0
|
||||
while GetPlayerBuffTexture(i) do i = i + 1 assert(i < 100, "no nil terminator") end
|
||||
return i"#
|
||||
)
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
|
||||
// DispelType: nil, never "" and never "none" — the corpus branches on truthiness and then
|
||||
// indexes DebuffTypeColor with the value, so "" would take the wrong branch.
|
||||
assert!(s
|
||||
.eval::<bool>("return GetPlayerBuffDispelType(0) == nil")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return GetPlayerBuffDispelType(99) == nil")
|
||||
.unwrap());
|
||||
// ref-BuffFrame.lua:83 nests the calls with no guard, so -1 must pass straight through.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return GetPlayerBuffDispelType(GetPlayerBuff(9, "HARMFUL")) == nil"#)
|
||||
.unwrap());
|
||||
|
||||
// Applications: a NUMBER always, and 1 (not 0, not nil) for an absent position — the
|
||||
// reference's `push 0x3ff00000`. `count > 1` is unguarded in CT_BuffMod and ElkBuffBar.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetPlayerBuffApplications(2)")
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetPlayerBuffApplications(99)")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetPlayerBuffApplications(-1)")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetPlayerBuffApplications(-1) > 1 == false")
|
||||
.unwrap());
|
||||
|
||||
// TimeLeft: a NUMBER always, in seconds, 0 for absent/permanent — oRA2 does
|
||||
// `floor(GetPlayerBuffTimeLeft(index) + .5)` with no guard at all.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetPlayerBuffTimeLeft(2)").unwrap(),
|
||||
18.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetPlayerBuffTimeLeft(1)").unwrap(),
|
||||
0.0,
|
||||
"a permanent aura has no expiry stamp: max(0, 0 - now) = 0"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetPlayerBuffTimeLeft(99)").unwrap(),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetPlayerBuffTimeLeft(-1)").unwrap(),
|
||||
0.0
|
||||
);
|
||||
// The unguarded corpus arithmetic, run for real.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return math.floor(GetPlayerBuffTimeLeft(-1) + .5)")
|
||||
.unwrap(),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>(r#"return GetPlayerBuffTimeLeft(GetPlayerBuff(9, "HARMFUL"))"#)
|
||||
.unwrap(),
|
||||
0.0
|
||||
);
|
||||
|
||||
// It counts down against the VM's GetTime clock, live, per call.
|
||||
s.tick(5.0);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetPlayerBuffTimeLeft(2)").unwrap(),
|
||||
13.0
|
||||
);
|
||||
// And floors at 0 rather than going negative once the aura is past its expiry.
|
||||
s.tick(20.0);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return GetPlayerBuffTimeLeft(2)").unwrap(),
|
||||
0.0
|
||||
);
|
||||
|
||||
// A surplus argument is ignored, not an error — CT_BuffMod/CT_BuffFrame.lua:148 passes a
|
||||
// filter string to GetPlayerBuffTexture, which takes none.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return GetPlayerBuffTexture(0, "HELPFUL|HARMFUL")"#)
|
||||
.unwrap(),
|
||||
"Interface\\Icons\\Spell_1126"
|
||||
);
|
||||
|
||||
// A non-number argument raises with the reference's own usage line, rather than answering.
|
||||
let err = s
|
||||
.eval::<()>("GetPlayerBuff({})")
|
||||
.expect_err("a table index must raise");
|
||||
assert!(
|
||||
format!("{err}").contains(r#"Usage: GetPlayerBuff(index [, "filter"])"#),
|
||||
"expected the reference usage message, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `CancelPlayerBuff` is `CancelUnitBuff` under its 1.12 name: the same gate, the same queue,
|
||||
/// the same `CMSG_CANCEL_AURA` spell id — never a second mechanism. It differs only in how the
|
||||
/// aura is addressed (a cache position, not a token plus a filtered ordinal).
|
||||
#[test]
|
||||
fn cancel_player_buff_shares_the_gate_and_the_queue_with_cancel_unit_buff() {
|
||||
let mut s = with_player_cache();
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
|
||||
// Position 0 is a cancelable buff: its SPELL id is queued, not its index.
|
||||
s.eval::<()>("CancelPlayerBuff(0)").unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![1126]);
|
||||
|
||||
// Position 1 is a buff without AFLAG_CANCELABLE, position 2 a plain debuff, and 99/-1 are
|
||||
// absent — all silent no-ops, exactly as the reference's gate falls through.
|
||||
for arg in ["1", "2", "99", "-1"] {
|
||||
s.eval::<()>(&format!("CancelPlayerBuff({arg})")).unwrap();
|
||||
}
|
||||
assert!(s.take_cancel_aura_requests().is_empty());
|
||||
|
||||
// Both names reach the same queue for the same aura: CancelUnitBuff addresses it as the
|
||||
// 1st helpful aura of "player", CancelPlayerBuff as cache position 0.
|
||||
s.eval::<()>(r#"CancelUnitBuff("player", 1)"#).unwrap();
|
||||
s.eval::<()>("CancelPlayerBuff(0)").unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![1126, 1126]);
|
||||
|
||||
// The channeled arm — the only way a NEGATIVE aura is cancelable (DBC AttributesEx & 0x4,
|
||||
// 0x4e4a10). Without it the debuff below is refused, which is the shape CancelUnitBuff
|
||||
// shipped with.
|
||||
let mut channeled = aura(689, "Drain Life", false, false);
|
||||
channeled.channeled = true;
|
||||
s.set_auras("player", Some(vec![channeled]));
|
||||
s.eval::<()>("CancelPlayerBuff(0)").unwrap();
|
||||
assert_eq!(s.take_cancel_aura_requests(), vec![689]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user